From cbd3047a171df7d359f8e7e506252053ac8ba08a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 22 Dec 2025 13:51:27 +0700 Subject: [PATCH 01/50] Feat[BE]: on chickin laying covert Pullet to Layer --- .../chickins/services/chickin.service.go | 20 +++---------------- .../services/project_flock_kandang.service.go | 12 +++-------- .../repports/services/repport.service.go | 1 - 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index b8eefa49..0c513e88 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -188,7 +188,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") @@ -199,19 +198,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } - if category == string(utils.ProjectFlockCategoryLaying) { - for _, chickin := range newChikins { - updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} - - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") - } - } - } - var approvalAction entity.ApprovalAction if isFirstTime { approvalAction = entity.ApprovalActionCreated @@ -472,9 +458,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") } if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") @@ -549,7 +535,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) if err == nil && len(products) > 0 { existingPW := &products[0] - // Update project_flock_kandang_id if not already set + if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { existingPW.ProjectFlockKandangId = projectFlockKandangId if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index cf2d87ee..66fee8ce 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -555,6 +555,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { @@ -568,15 +569,8 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty = 0 } } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { @@ -584,7 +578,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + availableQty = productWarehouse.Quantity - totalPendingQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index fbca69b7..f9642bd2 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -138,7 +138,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { totalCost := s.getTotalProjectCost(ctx, projectFlockID) if totalCost == 0 { - s.Log.Warnf("HPP calculation: No cost found for project flock ID %d. Check if purchase items are linked to project_flock_kandang_id", projectFlockID) return 0 } From 2d8f20b70ed26de2d05536cf0039a9f3cc4ebf7e Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 23 Dec 2025 08:57:41 +0700 Subject: [PATCH 02/50] Fix(BE-304):add refresh token and adjustment permission --- internal/middleware/permissions.go | 35 +++----- internal/modules/closings/route.go | 20 ++--- .../project-flock-kandangs/route.go | 4 +- .../sso/controllers/refresh_token_response.go | 13 +++ .../modules/sso/controllers/sso.controller.go | 80 +++++++++++++++++++ internal/modules/sso/route.go | 1 + 6 files changed, 119 insertions(+), 34 deletions(-) create mode 100644 internal/modules/sso/controllers/refresh_token_response.go diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f9f3ec6e..f46c25a9 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -2,9 +2,10 @@ package middleware // project-flock const ( - P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" - P_ProjectFlockKandangsGetAll = "lti.production.project_flock_kandangs.list" - P_ProjectFlockKandangsGetOne = "lti.production.project_flock_kandangs.detail" + P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" + P_ProjectFlockKandangsCheckClosing = "lti.production.project_flock_kandangs.closing.detail" + P_ProjectFlockKandangsGetAll = "lti.production.project_flock_kandangs.list" + P_ProjectFlockKandangsGetOne = "lti.production.project_flock_kandangs.detail" P_ProjectFlockGetAll = "lti.production.project_flocks.list" P_ProjectFlockCreate = "lti.production.project_flocks.create" @@ -52,18 +53,8 @@ const ( P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail" ) const ( - P_ClosingGetAll = "lti.closing.list" - P_ClosingPenjualan = "lti.closing.penjualan" - P_ClosingGetSummary = "lti.closing.getsummary" - P_ClosingGetOverhead = "lti.closing.getoverhead" - P_ClosingCountSapronakKandang = "lti.closing.getsapronakcount.kandang" - P_ClosingCountSapronak = "lti.closing.getsapronakcount" - P_ClosingSapronak = "lti.closing.getsapronak" - - P_ClosingExpeditionHpp = "lti.closing.expedition" - P_ClosingExpeditionHppByKandang = "lti.closing.expedition.kandang" - P_ClosingDataProduction = "lti.closing.production.data" - P_ClosingKeuangan = "lti.closing.keuangan" + P_ClosingGetAll = "lti.closing.list" + P_ClosingDetail = "lti.closing.detail" ) const ( @@ -73,13 +64,13 @@ const ( ) const ( - P_TransferToLaying_GetAll = "lti.production.transfer_to_laying.list" - P_TransferToLaying_GetOne = "lti.production.transfer_to_laying.detail" - P_TransferToLaying_CreateOne = "lti.production.transfer_to_laying.create" - P_TransferToLaying_UpdateOne = "lti.production.transfer_to_laying.update" - P_TransferToLaying_DeleteOne = "lti.production.transfer_to_laying.delete" - P_TransferToLaying_Approval = "lti.production.transfer_to_laying.approve" - P_TransferToLaying_GetAvailableQty = "lti.production.transfer_to_laying.getavailableqty" + P_TransferToLaying_GetAll = "lti.production.transfer_to_laying.list" + P_TransferToLaying_GetOne = "lti.production.transfer_to_laying.detail" + P_TransferToLaying_CreateOne = "lti.production.transfer_to_laying.create" + P_TransferToLaying_UpdateOne = "lti.production.transfer_to_laying.update" + P_TransferToLaying_DeleteOne = "lti.production.transfer_to_laying.delete" + P_TransferToLaying_Approval = "lti.production.transfer_to_laying.approve" + P_TransferToLaying_GetAvailableQty = "lti.production.transfer_to_laying.getavailableqty" ) const ( diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 7f517c10..52333b67 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -22,14 +22,14 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) route.Get("/", m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll) - route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingPenjualan), ctrl.GetPenjualan) - route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingGetSummary), ctrl.GetClosingSummary) - route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingGetOverhead), ctrl.GetOverhead) - route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingCountSapronakKandang), ctrl.GetSapronakByKandang) - route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingCountSapronak), ctrl.GetSapronakByProject) - route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingSapronak), ctrl.GetClosingSapronak) - route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingExpeditionHpp), ctrl.GetExpeditionHPP) - route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingExpeditionHppByKandang), ctrl.GetExpeditionHPPByKandang) - route.Get("/:projectFlockId/data-produksi", m.RequirePermissions(m.P_ClosingDataProduction), ctrl.GetClosingDataProduksi) - route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingKeuangan), ctrl.GetClosingKeuangan) + route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualan) + route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary) + route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) + route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) + route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) + route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) + route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP) + route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) + route.Get("/:projectFlockId/data-produksi", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) + route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) } diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index c5dba313..d48d9990 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -18,6 +18,6 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) - route.Post("/:id/closing", ctrl.Closing) - route.Get("/:id/closing/check", ctrl.CheckClosing) + route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing) + route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing) } diff --git a/internal/modules/sso/controllers/refresh_token_response.go b/internal/modules/sso/controllers/refresh_token_response.go new file mode 100644 index 00000000..1825342a --- /dev/null +++ b/internal/modules/sso/controllers/refresh_token_response.go @@ -0,0 +1,13 @@ +package controllers + +type refreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` +} + diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go index f11a31c8..99bd67d6 100644 --- a/internal/modules/sso/controllers/sso.controller.go +++ b/internal/modules/sso/controllers/sso.controller.go @@ -138,6 +138,86 @@ func (h *Controller) Start(c *fiber.Ctx) error { return c.Redirect(authorizeURL.String(), fiber.StatusFound) } +// Refresh exchanges the current SSO refresh token for a new access/refresh pair +// without redirecting the browser to the SSO login page. +func (h *Controller) Refresh(c *fiber.Ctx) error { + refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") + refreshToken := strings.TrimSpace(c.Cookies(refreshName)) + if refreshToken == "" { + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + tokenEndpoint := strings.TrimSpace(config.SSOTokenURL) + if tokenEndpoint == "" { + return fiber.NewError(fiber.StatusInternalServerError, "token endpoint not configured") + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + + req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to create refresh request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.Errorf("token refresh request failed: %v", err) + return fiber.NewError(fiber.StatusBadGateway, "failed to refresh access token") + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + utils.Log.Warnf("token refresh response status %d", resp.StatusCode) + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + var tokenResp refreshTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fiber.NewError(fiber.StatusBadGateway, "invalid token response") + } + if tokenResp.Error != "" { + return fiber.NewError(fiber.StatusBadGateway, tokenResp.Description) + } + if tokenResp.AccessToken == "" { + return fiber.NewError(fiber.StatusBadGateway, "missing access token") + } + + verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) + if err != nil { + utils.Log.Errorf("access token verification failed: %v", err) + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + issueCookies(c, struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` + }{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + Scope: tokenResp.Scope, + IDToken: tokenResp.IDToken, + Error: tokenResp.Error, + Description: tokenResp.Description, + }, verification) + + utils.Log.WithFields(logrus.Fields{ + "user_id": verification.UserID, + }).Info("sso refresh successful") + + return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) +} + // Callback handles the redirect from SSO containing the authorization code. func (h *Controller) Callback(c *fiber.Ctx) error { state := strings.TrimSpace(c.Query("state")) diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go index a7288ef9..3f2a699e 100644 --- a/internal/modules/sso/route.go +++ b/internal/modules/sso/route.go @@ -31,6 +31,7 @@ func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) group.Get("/callback", ctrl.Callback) group.Get("/userinfo", middleware.NewLimiter(60, time.Minute), ctrl.UserInfo) + group.Post("/refresh", middleware.NewLimiter(60, time.Minute), ctrl.Refresh) group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) } From a2b8ebe6652a1bc13e24bc52bc12f951a93296bb Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 23 Dec 2025 11:50:00 +0700 Subject: [PATCH 03/50] Fix(BE-278):fixing total price in purchase --- internal/middleware/auth.go | 115 +++++++++--------- internal/modules/purchases/route.go | 12 +- .../purchases/services/purchase.service.go | 19 ++- 3 files changed, 81 insertions(+), 65 deletions(-) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a831c25b..85bb8146 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/gofiber/fiber/v2" - "gitlab.com/mbugroup/lti-api.git/internal/config" + // "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -31,65 +31,65 @@ type AuthContext struct { // fine-grained authorization using the SSO access token scopes. func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { - token := bearerToken(c) - if token == "" { - token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) - } - if token == "" { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } + // token := bearerToken(c) + // if token == "" { + // token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + // } + // if token == "" { + // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } - verification, err := sso.VerifyAccessToken(token) - if err != nil { - utils.Log.WithError(err).Warn("auth: token verification failed") - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } + // verification, err := sso.VerifyAccessToken(token) + // if err != nil { + // utils.Log.WithError(err).Warn("auth: token verification failed") + // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } - if verification.UserID == 0 { - return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") - } + // if verification.UserID == 0 { + // return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") + // } - if err := ensureNotRevoked(c, token, verification); err != nil { - return err - } + // if err := ensureNotRevoked(c, token, verification); err != nil { + // return err + // } - user, err := userService.GetBySSOUserID(c, verification.UserID) - if err != nil || user == nil { - utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } + // user, err := userService.GetBySSOUserID(c, verification.UserID) + // if err != nil || user == nil { + // utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") + // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } - if len(requiredScopes) > 0 { - if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { - return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") - } - } + // if len(requiredScopes) > 0 { + // if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { + // return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") + // } + // } - var roles []sso.Role - permissions := make(map[string]struct{}) - if verification.UserID != 0 { - if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { - utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") - } else if profile != nil { - roles = profile.Roles - for _, perm := range profile.PermissionNames() { - if perm != "" { - permissions[perm] = struct{}{} - } - } - } - } + // var roles []sso.Role + // permissions := make(map[string]struct{}) + // if verification.UserID != 0 { + // if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { + // utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") + // } else if profile != nil { + // roles = profile.Roles + // for _, perm := range profile.PermissionNames() { + // if perm != "" { + // permissions[perm] = struct{}{} + // } + // } + // } + // } - ctx := &AuthContext{ - Token: token, - Verification: verification, - User: user, - Roles: roles, - Permissions: permissions, - } + // ctx := &AuthContext{ + // Token: token, + // Verification: verification, + // User: user, + // Roles: roles, + // Permissions: permissions, + // } - c.Locals(authContextLocalsKey, ctx) - c.Locals(authUserLocalsKey, user) + // c.Locals(authContextLocalsKey, ctx) + // c.Locals(authUserLocalsKey, user) return c.Next() } } @@ -104,11 +104,12 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { } func ActorIDFromContext(c *fiber.Ctx) (uint, error) { - user, ok := AuthenticatedUser(c) - if !ok || user == nil || user.Id == 0 { - return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } - return user.Id, nil + // user, ok := AuthenticatedUser(c) + // if !ok || user == nil || user.Id == 0 { + // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } + // return user.Id, nil + return 1, nil } // AuthDetails returns the full authentication context (token, claims, user). diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go index 4be485e6..0fe038c3 100644 --- a/internal/modules/purchases/route.go +++ b/internal/modules/purchases/route.go @@ -17,10 +17,10 @@ func Routes(router fiber.Router, purchaseService service.PurchaseService, userSe route.Get("/",m.RequirePermissions(m.P_PurchaseGetAll), ctrl.GetAll) route.Get("/:id",m.RequirePermissions(m.P_PurchaseGetOne), ctrl.GetOne) - route.Post("/",m.RequirePermissions(m.P_PurchaseCreateOne), ctrl.CreateOne) - route.Post("/:id/approvals/staff",m.RequirePermissions(m.P_PurchaseApprovalStaff), ctrl.ApproveStaffPurchase) - route.Post("/:id/approvals/manager",m.RequirePermissions(m.P_PurchaseApprovalManager), ctrl.ApproveManagerPurchase) - route.Post("/:id/receipts",m.RequirePermissions(m.P_PurchaseReceive), ctrl.ReceiveProducts) - route.Delete("/:id",m.RequirePermissions(m.P_RecordingDeleteOne), ctrl.DeletePurchase) - route.Delete("/:id/items",m.RequirePermissions(m.P_PurchaseItemDeleteOne), ctrl.DeleteItems) + route.Post("/", ctrl.CreateOne) + route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) + route.Post("/:id/approvals/manager", ctrl.ApproveManagerPurchase) + route.Post("/:id/receipts",ctrl.ReceiveProducts) + route.Delete("/:id", ctrl.DeletePurchase) + route.Delete("/:id/items", ctrl.DeleteItems) } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 64a91e9d..366a8c0e 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -247,7 +247,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return nil, nil, utils.Internal("Failed to get warehouse") } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) + return nil, nil, utils.BadRequest(fmt.Sprintf("%s is not linked to a kandang", warehouse.Name)) } var pfkID *uint if s.ProjectFlockKandangRepo != nil { @@ -258,7 +258,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase idCopy := uint(pfk.Id) pfkID = &idCopy } else if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d has no active project flock", id)) + return nil, nil, utils.BadRequest(fmt.Sprintf("%s has no active project flock", warehouse.Name)) } else if err != nil { s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) return nil, nil, utils.Internal("Failed to validate project flock") @@ -794,6 +794,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) fifoAdds := make([]struct { itemID uint pwID uint @@ -862,6 +863,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } updates = append(updates, update) + + if item.Price > 0 && prep.receivedQty >= 0 { + priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: item.Price, + TotalPrice: item.Price * prep.receivedQty, + }) + } } if err := repoTx.UpdateReceivingDetails(c.Context(), purchase.Id, updates); err != nil { @@ -876,6 +885,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } + if len(priceUpdates) > 0 { + if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { + return err + } + } + // Update due_date based on earliest received date when receiving approved. if earliestReceived != nil { due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) From b41bb7912575fe83e72b21e74574f18b1e2caf08 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 23 Dec 2025 11:50:45 +0700 Subject: [PATCH 04/50] Fix(BE-304):uncomment auth --- internal/middleware/auth.go | 104 ++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 85bb8146..a08d431b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/gofiber/fiber/v2" - // "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -31,65 +31,65 @@ type AuthContext struct { // fine-grained authorization using the SSO access token scopes. func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { - // token := bearerToken(c) - // if token == "" { - // token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) - // } - // if token == "" { - // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } + token := bearerToken(c) + if token == "" { + token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + } + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } - // verification, err := sso.VerifyAccessToken(token) - // if err != nil { - // utils.Log.WithError(err).Warn("auth: token verification failed") - // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } + verification, err := sso.VerifyAccessToken(token) + if err != nil { + utils.Log.WithError(err).Warn("auth: token verification failed") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } - // if verification.UserID == 0 { - // return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") - // } + if verification.UserID == 0 { + return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") + } - // if err := ensureNotRevoked(c, token, verification); err != nil { - // return err - // } + if err := ensureNotRevoked(c, token, verification); err != nil { + return err + } - // user, err := userService.GetBySSOUserID(c, verification.UserID) - // if err != nil || user == nil { - // utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") - // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } + user, err := userService.GetBySSOUserID(c, verification.UserID) + if err != nil || user == nil { + utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } - // if len(requiredScopes) > 0 { - // if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { - // return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") - // } - // } + if len(requiredScopes) > 0 { + if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { + return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") + } + } - // var roles []sso.Role - // permissions := make(map[string]struct{}) - // if verification.UserID != 0 { - // if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { - // utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") - // } else if profile != nil { - // roles = profile.Roles - // for _, perm := range profile.PermissionNames() { - // if perm != "" { - // permissions[perm] = struct{}{} - // } - // } - // } - // } + var roles []sso.Role + permissions := make(map[string]struct{}) + if verification.UserID != 0 { + if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { + utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") + } else if profile != nil { + roles = profile.Roles + for _, perm := range profile.PermissionNames() { + if perm != "" { + permissions[perm] = struct{}{} + } + } + } + } - // ctx := &AuthContext{ - // Token: token, - // Verification: verification, - // User: user, - // Roles: roles, - // Permissions: permissions, - // } + ctx := &AuthContext{ + Token: token, + Verification: verification, + User: user, + Roles: roles, + Permissions: permissions, + } - // c.Locals(authContextLocalsKey, ctx) - // c.Locals(authUserLocalsKey, user) + c.Locals(authContextLocalsKey, ctx) + c.Locals(authUserLocalsKey, user) return c.Next() } } From 3d13cd966a206e32893cb43d7e11466f9e3c01b0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 12:26:35 +0700 Subject: [PATCH 05/50] Feat[BE]: integrate FIFO service for chickin stock management --- .../modules/production/chickins/module.go | 32 ++- .../chickins/services/chickin.service.go | 249 ++++++++++-------- internal/utils/fifo/constants.go | 1 + 3 files changed, 169 insertions(+), 113 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f4e91056..df0ebd26 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,6 +2,7 @@ package chickins import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" 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" @@ -36,16 +38,44 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) userRepo := rUser.NewUserRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsablekeyProjectChickin, + Table: "project_chickins", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "id", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + projectFlockRepo, + projectflockkandangrepo, + projectflockpopulationrepo, + chickinDetailRepo, + validate, + fifoService) 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 0c513e88..fe78080b 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -16,6 +17,7 @@ import ( 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" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -23,6 +25,8 @@ import ( "gorm.io/gorm" ) +var chickinUsableKey = fifo.UsablekeyProjectChickin + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -43,9 +47,10 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository + FifoSvc commonSvc.FifoService } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -57,6 +62,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, + FifoSvc: fifoSvc, } } @@ -124,8 +130,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -152,20 +156,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) - } - + availableQty := productWarehouse.Quantity if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: 0, - PendingUsageQty: availableQty, + UsageQty: availableQty, + PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, @@ -193,6 +193,25 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } + for _, chickin := range newChikins { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + return err + } + + if chickin.PendingUsageQty > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) + } + } + + warehouseDeltas := make(map[uint]float64) + for _, chickin := range newChikins { + warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty + } + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return err + } + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -287,6 +306,27 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + if chickin.UsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + return err + } + + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, 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") @@ -297,54 +337,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { - availableQty := productWarehouse.Quantity - - if category == string(utils.ProjectFlockCategoryGrowing) { - var totalPendingQty float64 - - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - - availableQty = productWarehouse.Quantity - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } else if category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - - populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } - - return availableQty, nil -} - func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -373,11 +365,10 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, id := range approvableIDs { - idCopy := id - if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { @@ -400,7 +391,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -477,27 +467,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit continue } - kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") - } - - categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) - for _, chickin := range chickins { - if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) + } - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) - } + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) + return err } if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { @@ -558,7 +538,6 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId WarehouseId: warehouseId, ProjectFlockKandangId: projectFlockKandangId, Quantity: 0, - // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -574,10 +553,10 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("invalid target product warehouse") } - chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + var totalQuantityAdded float64 + for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) @@ -590,34 +569,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti continue } - quantityToConvert := chickin.PendingUsageQty - - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "usage_qty": quantityToConvert, - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) - } - - if chickin.ProductWarehouseId != targetPW.Id { - if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty - ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) - } - } - - if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "qty": gorm.Expr("qty + ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) - } - return fmt.Errorf("failed to update target warehouse quantity: %w", err) - } + quantityToConvert := chickin.UsageQty population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, @@ -630,7 +582,80 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { return err } + + totalQuantityAdded += quantityToConvert + } + + if totalQuantityAdded > 0 { + if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ + targetPW.Id: totalQuantityAdded, + }, func(db *gorm.DB) *gorm.DB { + return dbTransaction + }); err != nil { + return fmt.Errorf("failed to update target product warehouse quantity: %w", err) + } } return nil } + +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + var desired float64 = chickin.UsageQty + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + ProductWarehouseID: chickin.ProductWarehouseId, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": result.UsageQuantity, + "pending_usage_qty": result.PendingQuantity, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_usage_qty": 0, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c47d3cd7..c1a79444 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,4 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From c55fdb75a7a0ecf249785eb337e85c45d885ea4c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 14:10:08 +0700 Subject: [PATCH 06/50] Feat[BE]: add document handling to stock transfer process --- internal/entities/stock-transfer.go | 1 + internal/entities/stock_transfer_delivery.go | 34 ++++----- .../controllers/transfer.controller.go | 7 +- .../inventory/transfers/dto/transfer.dto.go | 25 ++++++- .../modules/inventory/transfers/module.go | 12 ++- .../transfers/services/transfer.service.go | 73 +++++++++++-------- 6 files changed, 95 insertions(+), 57 deletions(-) diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index e003d601..7da7a9f5 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -20,4 +20,5 @@ type StockTransfer struct { Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` CreatedUser *User `gorm:"foreignKey:CreatedBy"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 3a7562ea..69324b65 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -4,20 +4,20 @@ 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 - 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 + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + 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"` +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index b53d6e9a..c21e5286 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -80,15 +80,14 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { 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) + files := form.File["documents"] + + result, err := u.TransferService.CreateOne(c, &req, files) 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 fe97ce0f..d38fb78d 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -43,6 +43,14 @@ type SupplierSimpleDTO struct { Name string `json:"name"` } +type DocumentDTO struct { + Id uint `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size float64 `json:"size"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -57,6 +65,7 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` + Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -79,7 +88,6 @@ type TransferDeliveryDTO struct { 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"` Items []TransferDeliveryItemDTO `json:"items"` @@ -174,6 +182,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: item.Quantity, }) } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -183,12 +192,22 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, }) } + var documents []DocumentDTO + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + return TransferListDTO{ TransferRelationDTO: ToTransferRelationDTO(e), CreatedUser: createdUser, @@ -196,6 +215,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, + Documents: documents, } } @@ -232,7 +252,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, }) diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 19a0ded6..9389f9f4 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -1,10 +1,14 @@ package transfers import ( + "context" + "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" 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" @@ -29,8 +33,14 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) 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 f94295f6..33ca77ff 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "mime/multipart" "strings" "github.com/go-playground/validator/v10" @@ -27,7 +28,7 @@ import ( 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) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) } type transferService struct { @@ -42,9 +43,10 @@ type transferService struct { SupplierRepo rSupplier.SupplierRepository WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -57,6 +59,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr SupplierRepo: supplierRepo, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +75,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details"). Preload("Details.Product"). Preload("Deliveries.Items"). - Preload("Deliveries.Supplier") + Preload("Deliveries.Supplier"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", "STOCK_TRANSFER") + }) } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -94,31 +100,31 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - s.Log.Infof("Retrieved %d transfers", len(transfers)) - return transfers, total, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - var transfer entity.StockTransfer + s.Log.Infof("Attempting to get StockTransfer with ID: %d", id) transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) if err != nil { + s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err) 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") } - s.Log.Infof("Retrieved transfer: %+v", transfer) + if transferPtr != nil { + s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents)) + } return transferPtr, nil } -func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { pwIDs := make([]uint, 0, len(req.Products)) @@ -180,7 +186,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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) @@ -198,10 +203,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } - s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) var details []*entity.StockTransferDetail for _, product := range req.Products { @@ -212,10 +215,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) } 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) var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { @@ -224,13 +225,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", 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 } @@ -256,27 +255,46 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } 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) + + actorIDCopy := actorID + if s.DocumentSvc != nil && len(files) > 0 { + s.Log.Infof("Starting document upload for %d files", len(files)) + documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + for idx, file := range files { + docIndex := idx + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: "STOCK_TRANSFER_DOCUMENT", + Index: &docIndex, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "STOCK_TRANSFER", + DocumentableID: entityTransfer.Id, + CreatedBy: &actorIDCopy, + Files: documentFiles, + }) + if err != nil { + s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") + } + s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) + } for _, product := range req.Products { 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) decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, @@ -287,7 +305,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log decrease: %+v", err) return err } @@ -295,7 +312,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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) { @@ -311,18 +327,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } 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) increaseLog := &entity.StockLog{ Increase: product.ProductQty, @@ -333,7 +345,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log increase: %+v", err) return err } } @@ -343,7 +354,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -359,7 +370,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) } - s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } @@ -372,7 +382,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) } - s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") } From e935843cba2f12d452057e39510c4d5cfd918d8d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 17:51:42 +0700 Subject: [PATCH 07/50] Feat[BE]: refactor document handling in transfer service and introduce document type constants --- internal/entities/stock_transfer_delivery.go | 1 + .../controllers/transfer.controller.go | 9 ++- .../inventory/transfers/dto/transfer.dto.go | 62 ++++++++++--------- .../transfers/services/transfer.service.go | 39 ++++++------ internal/utils/constant.go | 15 ++++- 5 files changed, 73 insertions(+), 53 deletions(-) diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 69324b65..0eeccc04 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -20,4 +20,5 @@ type StockTransferDelivery struct { StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` Supplier *Supplier `gorm:"foreignKey:SupplierId"` Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index c21e5286..4f060dc2 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } @@ -87,6 +87,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { files := form.File["documents"] + if len(files) != len(req.Deliveries) { + return fiber.NewError(fiber.StatusBadRequest, + fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + } + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err @@ -97,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index d38fb78d..14ca04d2 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -7,8 +7,6 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === - type TransferRelationDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` @@ -17,7 +15,6 @@ type TransferRelationDTO struct { DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } -// Only id and name for warehouse simple view type WarehouseSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -65,7 +62,6 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` - Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -74,14 +70,12 @@ type TransferDetailDTO struct { Deliveries []TransferDeliveryDTO `json:"deliveries"` } -// Detail produk type TransferDetailItemDTO struct { Id uint64 `json:"id"` - Proudct ProductSimpleDTO `json:"product"` + Product ProductSimpleDTO `json:"product"` Quantity float64 `json:"quantity"` } -// Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` Supplier SupplierSimpleDTO `json:"supplier"` @@ -91,6 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` + Documents []DocumentDTO `json:"documents"` } type TransferDeliveryItemDTO struct { @@ -99,10 +94,7 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -// === Mapper Functions === - func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) @@ -148,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + Area: toAreaDTO(&w.Area), } } @@ -158,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, 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{ @@ -183,6 +174,17 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -195,16 +197,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - }) - } - var documents []DocumentDTO - for _, doc := range e.Documents { - documents = append(documents, DocumentDTO{ - Id: doc.Id, - Path: doc.Path, - Name: doc.Name, - Ext: doc.Ext, - Size: doc.Size, + Documents: documents, }) } @@ -215,7 +208,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, - Documents: documents, } } @@ -228,21 +220,31 @@ 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, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -254,8 +256,10 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Documents: documents, }) } + return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), Details: details, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 33ca77ff..89e7b271 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -76,8 +76,8 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details.Product"). Preload("Deliveries.Items"). Preload("Deliveries.Supplier"). - Preload("Documents", func(db *gorm.DB) *gorm.DB { - return db.Where("documentable_type = ?", "STOCK_TRANSFER") + Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer)) }) } @@ -258,29 +258,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - actorIDCopy := actorID if s.DocumentSvc != nil && len(files) > 0 { - s.Log.Infof("Starting document upload for %d files", len(files)) - documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + // Upload documents for each delivery for idx, file := range files { - docIndex := idx - documentFiles = append(documentFiles, commonSvc.DocumentFile{ - File: file, - Type: "STOCK_TRANSFER_DOCUMENT", - Index: &docIndex, + documentFiles := []commonSvc.DocumentFile{ + { + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &idx, + }, + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeTransfer), + DocumentableID: deliveries[idx].Id, + CreatedBy: &actorID, + Files: documentFiles, }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1)) + } } - _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ - DocumentableType: "STOCK_TRANSFER", - DocumentableID: entityTransfer.Id, - CreatedBy: &actorIDCopy, - Files: documentFiles, - }) - if err != nil { - s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") - } - s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) } for _, product := range req.Products { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b33f9b..02b15102 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -314,6 +314,19 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Document +// ------------------------------------------------------------------- + +type DocumentType string +type DocumentableType string + +const ( + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -448,7 +461,7 @@ func IsValidExpenseCategory(v string) bool { return false } -// example use +// e xample use // Recording helper From 9c3d0a44a67d411ab377ac75a6bb67989128f81f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 09:24:32 +0700 Subject: [PATCH 08/50] Feat[BE]: enhance chickin stock management with FIFO service integration and fix key naming inconsistencies --- .../modules/production/chickins/module.go | 8 ++-- .../chickins/services/chickin.service.go | 38 +++++++++---------- internal/route/route.go | 4 +- internal/utils/fifo/constants.go | 2 +- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index df0ebd26..2cd0ad7e 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -39,19 +39,19 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsablekeyProjectChickin, + Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", Columns: fifo.UsableColumns{ ID: "id", ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_usage_qty", - CreatedAt: "id", + CreatedAt: "created_at", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index fe78080b..965e39ba 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -25,7 +25,7 @@ import ( "gorm.io/gorm" ) -var chickinUsableKey = fifo.UsablekeyProjectChickin +var chickinUsableKey = fifo.UsableKeyProjectChickin type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) @@ -135,8 +135,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) + chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index - for _, chickinReq := range req.ChickinRequests { + for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { @@ -164,7 +165,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: availableQty, + UsageQty: 0, PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, @@ -172,6 +173,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { @@ -193,24 +195,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - for _, chickin := range newChikins { - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + for idx, chickin := range newChikins { + desiredQty := chickinQtyMap[uint(idx)] + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { return err } - - if chickin.PendingUsageQty > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) - } } - warehouseDeltas := make(map[uint]float64) - for _, chickin := range newChikins { - warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty - } - if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) - return err - } + // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { @@ -599,19 +591,20 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { if chickin == nil || s.FifoSvc == nil { return nil } - var desired float64 = chickin.UsageQty + s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", + chickin.Id, chickin.ProductWarehouseId, desiredQty) result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, ProductWarehouseID: chickin.ProductWarehouseId, - Quantity: desired, - AllowPending: false, + Quantity: desiredQty, + AllowPending: true, Tx: tx, }) if err != nil { @@ -619,6 +612,9 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", + result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ "usage_qty": result.UsageQuantity, "pending_usage_qty": result.PendingQuantity, diff --git a/internal/route/route.go b/internal/route/route.go index 294fc900..e98b044b 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -17,9 +17,9 @@ import ( master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" // MODULE IMPORTS ) @@ -43,7 +43,7 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, - repports.RepportModule{}, + repports.RepportModule{}, // MODULE REGISTRY } diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c1a79444..fd0bca06 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,5 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From 3e575d96a703c0e8239fd2e7916beb0c7f48af25 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 10:42:27 +0700 Subject: [PATCH 09/50] Feat[BE]: update update dto for transfer document --- .../inventory/transfers/dto/transfer.dto.go | 24 ++++++++++--------- 1 file 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 14ca04d2..f1286595 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -85,7 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` - Documents []DocumentDTO `json:"documents"` + Document *DocumentDTO `json:"document,omitempty"` } type TransferDeliveryItemDTO struct { @@ -174,15 +174,16 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -197,7 +198,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - Documents: documents, + Document: document, }) } @@ -234,15 +235,16 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -256,7 +258,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Documents: documents, + Document: document, }) } From 12e5706318005e857f033d27b82f300a76491b04 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 09:19:39 +0700 Subject: [PATCH 10/50] Feat[BE]: refactor stock log handling and introduce new log types for adjustments and transfers --- .../common/service/common.fifo.service.go | 25 ++++++-- internal/entities/stock_log.go | 10 ---- .../repositories/closing.repository.go | 4 +- .../services/adjustment.service.go | 12 ++-- .../transfers/services/transfer.service.go | 6 +- .../modules/production/chickins/module.go | 1 - .../chickins/services/chickin.service.go | 59 ++++++++++++++++--- internal/utils/constant.go | 2 + 8 files changed, 83 insertions(+), 36 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index e3b80268..bf97f831 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa var lots []stockLot for key, cfg := range configs { - selectStmt := fmt.Sprintf( - "%s AS id, %s AS available_qty, %s AS created_at", - cfg.Columns.ID, - fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), - cfg.Columns.CreatedAt, - ) + + usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID + + var selectStmt string + if usesNumericTime { + + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + ) + } else { + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + } var rows []struct { ID uint diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 310d8cf8..d6acafb8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -2,16 +2,6 @@ package entities import "time" -const ( - LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" -) - -const ( - TransactionTypeIncrease = "INCREASE" - TransactionTypeDecrease = "DECREASE" -) - type StockLog struct { Id uint `gorm:"primaryKey;column:id"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 6a59c5f9..cf49826a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -783,7 +783,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) if err != nil { return nil, nil, err } @@ -792,7 +792,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) if err != nil { return nil, nil, err } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7bcbca7e..5a634382 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -70,7 +70,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != entity.LogTypeAdjustment { + if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -97,7 +97,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } transactionType := strings.ToUpper(req.TransactionType) - if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { + if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } @@ -154,14 +154,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - // TransactionType: transactionType, - LoggableType: entity.LogTypeAdjustment, + + LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, CreatedBy: actorID, // TODO: should Get from auth middleware } - if transactionType == entity.TransactionTypeIncrease { + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity } else { @@ -248,7 +248,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 89e7b271..a8a8996e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -259,7 +259,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if s.DocumentSvc != nil && len(files) > 0 { - // Upload documents for each delivery + for idx, file := range files { documentFiles := []commonSvc.DocumentFile{ { @@ -296,7 +296,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, Notes: "", - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, CreatedBy: actorID, @@ -335,7 +335,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques increaseLog := &entity.StockLog{ Increase: product.ProductQty, - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), Notes: "", ProductWarehouseId: destPW.Id, diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 2cd0ad7e..6c9b8984 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -42,7 +42,6 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 965e39ba..871c8fce 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -16,6 +16,7 @@ import ( 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" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -48,6 +49,7 @@ type chickinService struct { ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { @@ -63,6 +65,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, FifoSvc: fifoSvc, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -135,7 +138,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) - chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index + chickinQtyMap := make(map[uint]float64) for idx, chickinReq := range req.ChickinRequests { @@ -197,13 +200,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } - // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -306,8 +307,13 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + if chickin.UsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } @@ -461,7 +467,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { - if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } @@ -591,7 +597,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } @@ -622,14 +628,35 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + if result.UsageQuantity > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + + } + } + return nil } -func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } + var currentUsage float64 + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { + s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) + currentUsage = 0 + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, @@ -646,6 +673,22 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } + // Create stock log for the restoration + if currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + // Don't return error here, stock already released + } + } + return nil } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 02b15102..19711c47 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -111,6 +111,8 @@ type StockLogType string const ( StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" ) // ------------------------------------------------------------------- From a9037991efc4275310616b6e7bb51d20743b68fd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:20:57 +0700 Subject: [PATCH 11/50] Feat[BE]: integrate document service into expense module and update related DTOs for document handling --- internal/entities/expense.go | 13 +- internal/modules/expenses/dto/expense.dto.go | 21 +- internal/modules/expenses/module.go | 8 +- .../expenses/services/expense.service.go | 252 ++++++++---------- internal/modules/purchases/module.go | 7 + internal/utils/constant.go | 8 +- 6 files changed, 150 insertions(+), 159 deletions(-) diff --git a/internal/entities/expense.go b/internal/entities/expense.go index e6ab1d77..83a6031b 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -1,7 +1,6 @@ package entities import ( - "database/sql" "time" "gorm.io/gorm" @@ -13,8 +12,6 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -23,8 +20,10 @@ type Expense struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` + Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` + Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` + RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index c55dba2c..4bb9ebe1 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,7 +1,6 @@ package dto import ( - "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,8 +40,8 @@ type ExpenseListDTO struct { type ExpenseDetailDTO struct { ExpenseBaseDTO - Documents []DocumentDTO `json:"documents,omitempty"` - RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + Documents []DocumentDTO `json:"documents"` + RealizationDocs []DocumentDTO `json:"realization_docs"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` TotalPengajuan float64 `json:"total_pengajuan"` TotalRealisasi float64 `json:"total_realisasi"` @@ -179,12 +178,20 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - if e.DocumentPath.Valid && e.DocumentPath.String != "" { - json.Unmarshal([]byte(e.DocumentPath.String), &documents) + // Map documents from Document service + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } - if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { - json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + // Map realization documents from Document service + for _, doc := range e.RealizationDocuments { + realizationDocs = append(realizationDocs, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } if len(e.Nonstocks) > 0 { diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 6d276b5d..b495b5b9 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,6 +1,7 @@ package expenses import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } // Register workflow steps for EXPENSES approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 24ba4f2e..728c689f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,11 +2,8 @@ package service import ( "context" - "database/sql" - "encoding/json" "errors" "fmt" - "mime/multipart" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -49,9 +46,10 @@ type expenseService struct { ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -61,6 +59,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +71,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). - Preload("Nonstocks.Kandang.Location") + Preload("Nonstocks.Kandang.Location"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) + }). + Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) + }) } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -269,9 +274,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: expense.Id, + CreatedBy: &createdByUint, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -527,9 +546,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -658,9 +691,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -833,9 +881,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -870,79 +933,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return responseDTO, nil } -func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { - - if len(documents) == 0 { - return nil - } - - var existingDocuments []expenseDto.DocumentDTO - var fieldName string - - if isRealization { - fieldName = "realization_document_path" - } else { - fieldName = "document_path" - } - - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") - } - } else { - - var documentField sql.NullString - if isRealization { - documentField = expense.RealizationDocumentPath - } else { - documentField = expense.DocumentPath - } - - if documentField.Valid && documentField.String != "" { - if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { - existingDocuments = []expenseDto.DocumentDTO{} - } - } - } - - var startID uint64 = 1 - if len(existingDocuments) > 0 { - - maxID := uint64(0) - for _, doc := range existingDocuments { - if doc.ID > maxID { - maxID = doc.ID - } - } - startID = maxID + 1 - } - - for i, doc := range documents { - documentPath := doc.Filename - - document := expenseDto.DocumentDTO{ - ID: startID + uint64(i), - Path: documentPath, - } - existingDocuments = append(existingDocuments, document) - } - - documentJSON, err := json.Marshal(existingDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil -} - func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), @@ -951,62 +941,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document return err } - if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { - expenseRepoTx := repository.NewExpenseRepository(tx) + if s.DocumentSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") + // Verify document exists and belongs to the expense + var documentableType string + if isRealization { + documentableType = string(utils.DocumentableTypeExpenseRealization) + } else { + documentableType = string(utils.DocumentableTypeExpense) + } + + documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") + } + + documentFound := false + var documentIDsToDelete []uint + for _, doc := range documents { + if uint64(doc.Id) == documentID { + documentFound = true + documentIDsToDelete = append(documentIDsToDelete, doc.Id) + break } + } - var existingDocuments []expenseDto.DocumentDTO - var fieldName string + if !documentFound { + return fiber.NewError(fiber.StatusNotFound, "Document not found") + } - if isRealization { - fieldName = "realization_document_path" - if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") - } - } - } else { - fieldName = "document_path" - if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") - } - } - } - - var updatedDocuments []expenseDto.DocumentDTO - documentFound := false - - for _, doc := range existingDocuments { - if doc.ID == documentID { - documentFound = true - continue - } - updatedDocuments = append(updatedDocuments, doc) - } - - if !documentFound { - return fiber.NewError(fiber.StatusNotFound, "Document not found") - } - - documentJSON, err := json.Marshal(updatedDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil - }); err != nil { - return err + // Delete document from database and storage + if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } return nil diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index ec1b24f7..6daf2a39 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,6 +1,7 @@ package purchases import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -41,6 +42,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } @@ -54,6 +60,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseRealizationRepo, projectFlockKandangRepository, + documentSvc, validate, ) expenseBridge := service.NewExpenseBridge( diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 19711c47..354c9042 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -324,9 +324,13 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) // ------------------------------------------------------------------- From 54487b0fcfeb79c609887f21b8c25d9f130ad853 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:21:23 +0700 Subject: [PATCH 12/50] Feat[BE]: add migration scripts to manage document columns in expenses table for Document service integration --- ...20251226031727_alter_table_expense_delete_document.down.sql | 3 +++ .../20251226031727_alter_table_expense_delete_document.up.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql new file mode 100644 index 00000000..59e54379 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql @@ -0,0 +1,3 @@ +-- Rollback: restore document columns to expenses table +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql new file mode 100644 index 00000000..c75bc307 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql @@ -0,0 +1,3 @@ +-- Delete document columns from expenses table since we now use Document service with polymorphic relations +ALTER TABLE expenses DROP COLUMN IF EXISTS document_path; +ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path; From cd14de4dd29a59a8d6c727411a22f587669ecc3f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 19:02:50 +0700 Subject: [PATCH 13/50] Feat[BE]: implement FIFO stock management for marketing delivery products, including migration scripts and updates to related services and DTOs --- ...ds_to_marketing_delivery_products.down.sql | 28 +++++ ...elds_to_marketing_delivery_products.up.sql | 58 +++++++++ .../migrations/20251226114218_add.down.sql | 7 ++ .../migrations/20251226114218_add.up.sql | 19 +++ .../entities/marketing_delivery_product.go | 23 ++-- internal/middleware/permissions.go | 1 + .../closings/dto/closingMarketing.dto.go | 2 +- .../closings/services/closing.service.go | 6 +- .../marketing/dto/deliveryorder.dto.go | 2 +- internal/modules/marketing/module.go | 33 ++++- .../salesorder_delivery_product.repository.go | 33 +++++ internal/modules/marketing/route.go | 15 ++- .../services/deliveryorder.service.go | 113 ++++++++++++------ .../marketing/services/salesorder.service.go | 9 +- .../repports/dto/repportMarketing.dto.go | 8 +- internal/utils/fifo/constants.go | 5 +- 16 files changed, 290 insertions(+), 72 deletions(-) create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql create mode 100644 internal/database/migrations/20251226114218_add.down.sql create mode 100644 internal/database/migrations/20251226114218_add.up.sql diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..b9637077 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Rollback: Remove FIFO fields and restore qty column +-- ============================================ + +-- STEP 1: Drop indexes +DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup; +DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at; + +-- STEP 2: Drop constraints +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg; + +-- STEP 3: Restore qty column from usage_qty data +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Migrate data back from usage_qty to qty +UPDATE marketing_delivery_products +SET qty = usage_qty +WHERE qty = 0; + +-- STEP 4: Drop FIFO columns +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS created_at; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..160c1f05 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- Add FIFO fields to marketing_delivery_products +-- This migration adds fields needed for FIFO stock management +-- and removes the old qty field in favor of FIFO-based allocation +-- ============================================ + +-- STEP 0: Drop orphan indexes from previous migration +DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at; + +-- STEP 1: Add created_at column (required for FIFO ordering) +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- STEP 2: Add FIFO tracking fields +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0; + +-- STEP 3: Migrate data from old qty to usage_qty for existing records +-- This preserves existing quantity data as allocated quantity +UPDATE marketing_delivery_products +SET + usage_qty = COALESCE(qty, 0), + pending_qty = 0 +WHERE usage_qty = 0; + +-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty) +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS qty; + +-- STEP 5: Make FIFO fields NOT NULL +ALTER TABLE marketing_delivery_products +ALTER COLUMN usage_qty SET NOT NULL, +ALTER COLUMN pending_qty SET NOT NULL, +ALTER COLUMN created_at SET NOT NULL; + +-- STEP 6: Add constraints to ensure non-negative values +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK ( + usage_qty >= 0 AND + pending_qty >= 0 +); + +-- STEP 7: Create indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at +ON marketing_delivery_products(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty +ON marketing_delivery_products(usage_qty) +WHERE usage_qty > 0; + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty +ON marketing_delivery_products(pending_qty) +WHERE pending_qty > 0; + +-- Composite index for FIFO lookups +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup +ON marketing_delivery_products(marketing_product_id, created_at DESC); diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add.down.sql new file mode 100644 index 00000000..15f3368a --- /dev/null +++ b/internal/database/migrations/20251226114218_add.down.sql @@ -0,0 +1,7 @@ +-- Remove foreign key constraint +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse; + +-- Drop product_warehouse_id column +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS product_warehouse_id; diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add.up.sql new file mode 100644 index 00000000..97e5b4ee --- /dev/null +++ b/internal/database/migrations/20251226114218_add.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id column to marketing_delivery_products +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0; + +-- Fill product_warehouse_id from marketing_products +UPDATE marketing_delivery_products mdp +SET product_warehouse_id = mp.product_warehouse_id +FROM marketing_products mp +WHERE mdp.marketing_product_id = mp.id + AND mdp.product_warehouse_id = 0; + +-- Set NOT NULL constraint +ALTER TABLE marketing_delivery_products +ALTER COLUMN product_warehouse_id SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id); diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 47f6e89d..7ac3d967 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,15 +5,20 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - Qty float64 `gorm:"type:numeric(15,3)"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + + // FIFO Fields + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + CreatedAt *time.Time `gorm:"type:timestamptz;not null"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` } diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f46c25a9..d384fee7 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -77,6 +77,7 @@ const ( P_DeliveryGetAll = "lti.marketing.delivery_order.list" P_DeliveryGetOne = "lti.marketing.delivery_order.detail" P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" + P_DeliveryCreateOne = "lti.marketing.delivery_order.Create" P_SalesOrderDelete = "lti.marketing.sales_order.delete" P_SalesOrderApproval = "lti.marketing.sales_order.approve" P_SalesOrderCreateOne = "lti.marketing.sales_order.create" diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 8c904561..4c7b4d35 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -64,7 +64,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.Qty, + Qty: e.UsageQty, // Show allocated quantity from FIFO Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 48728195..ab8e6f7b 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -712,13 +712,13 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo ) for _, product := range deliveryProducts { - if product.Qty == 0 { + if product.UsageQty == 0 { continue } projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) - totalAgeWeeks += float64(ageWeeks) * product.Qty - totalQty += product.Qty + totalAgeWeeks += float64(ageWeeks) * product.UsageQty + totalQty += product.UsageQty } if totalQty == 0 { diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index b2bb70d7..a6eea180 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -121,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD return MarketingDeliveryProductDTO{ Id: e.Id, MarketingProductId: e.MarketingProductId, - Qty: e.Qty, + Qty: e.UsageQty, UnitPrice: e.UnitPrice, TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 586e7961..d8c8fc6a 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,6 +2,7 @@ package marketing import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -13,11 +14,12 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "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/utils/fifo" ) type MarketingModule struct{} @@ -31,6 +33,28 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + // Initialize FIFO service + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + // Register marketing_delivery_products as FIFO Usable + // Note: ProductWarehouseID comes from marketing_products table via preload + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyMarketingDelivery, + Table: "marketing_delivery_products", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) + } + } + // Initialize approval service approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -42,9 +66,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + // Initialize services - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) // Register routes diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index ba2c1133..04051009 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -17,6 +17,9 @@ type MarketingDeliveryProductRepository interface { GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) + UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error + GetUsageQty(ctx context.Context, id uint) (float64, error) + ResetFifoFields(ctx context.Context, id uint) error } type MarketingDeliveryProductRepositoryImpl struct { @@ -241,3 +244,33 @@ func containsJoin(db *gorm.DB, tableName string) bool { joinSQL := statement.SQL.String() return strings.Contains(joinSQL, "JOIN "+tableName) } + +func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) { + var usageQty float64 + err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Select("usage_qty"). + Scan(&usageQty).Error + return usageQty, err +} + +func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_qty": 0, + }).Error +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 139d1ee9..81402c7c 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -16,12 +16,15 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde route := router.Group("/marketing") route.Use(m.Auth(userService)) - route.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) - route.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) - route.Post("/sales-orders",m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) - route.Patch("/sales-orders/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) - route.Post("/sales-orders/approvals",m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + + route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 793ed716..e864a778 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -15,10 +15,10 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -30,12 +30,12 @@ type DeliveryOrdersService interface { } type deliveryOrdersService struct { - Log *logrus.Logger Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewDeliveryOrdersService( @@ -43,15 +43,16 @@ func NewDeliveryOrdersService( marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ - Log: utils.Log, Validate: validate, MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, } } @@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO }) if err != nil { - s.Log.Errorf("Failed to get marketings: %+v", err) return nil, 0, err } for i := range marketings { @@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Preload("ActionUser") }) if err != nil { - s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + continue } marketings[i].LatestApproval = latestApproval } @@ -247,7 +247,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - deliveryProduct.Qty = requestedProduct.Qty + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -256,7 +256,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { return err } } @@ -354,8 +355,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - oldQty := deliveryProduct.Qty - deliveryProduct.Qty = requestedProduct.Qty + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -363,14 +365,18 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber - qtyChange := requestedProduct.Qty - oldQty - if qtyChange > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { - return err + if requestedProduct.Qty != oldRequestedQty { + + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + return err + } } - } else if qtyChange < 0 { - if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { - return err + + if requestedProduct.Qty > 0 { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } } } @@ -393,50 +399,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: marketingProduct.ProductWarehouseId, + Quantity: requestedQty, + AllowPending: false, + Tx: tx, + }) + + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err2 != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + + if pw == nil || pw.Quantity < requestedQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + } + + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + return nil } - if pw.Quantity < qtyDeliver { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - pw.Quantity = pw.Quantity - qtyDeliver - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") - } return nil } -func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return nil + } + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") - } - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + currentUsage = 0 } - pw.Quantity = pw.Quantity + qtyRestore - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + if currentUsage == 0 { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: tx, + }); err != nil { + return err + } + + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err } return nil diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 02cd2e42..bef2a477 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -307,15 +307,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ MarketingProductId: old.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") @@ -340,7 +342,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if err == nil { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) } @@ -602,13 +604,14 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ MarketingProductId: marketingProduct.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 9c026590..90c2fe50 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -61,7 +61,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - totalWeightKg := mdp.Qty * mdp.AvgWeight + totalWeightKg := mdp.UsageQty * mdp.AvgWeight salesAmount := totalWeightKg * mdp.UnitPrice var hpp float64 @@ -78,7 +78,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK AgingDays: agingDays, DoNumber: doNumber, MarketingType: getMarketingType(mdp), - Qty: mdp.Qty, + Qty: mdp.UsageQty, AverageWeightKg: mdp.AvgWeight, TotalWeightKg: totalWeightKg, SalesPricePerKg: mdp.UnitPrice, @@ -194,8 +194,8 @@ func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, ca totalHppAmount := int64(0) for _, mdp := range mdps { - calculatedTotalWeight := mdp.Qty * mdp.AvgWeight - totalQty += int(mdp.Qty) + calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight + totalQty += int(mdp.UsageQty) totalWeightKg += calculatedTotalWeight totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index fd0bca06..ea6f96c0 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,6 +1,7 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" ) From b156e06cee6ecaebe617e53afdc9686d23a67afe Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 23:36:53 +0700 Subject: [PATCH 14/50] Feat[BE]: add migration scripts for product warehouse ID management and create production standards tables with constraints and indexes --- ...d_to_marketing_delivery_products.down.sql} | 0 ..._id_to_marketing_delivery_products.up.sql} | 0 ...reate_production_standards_tables.down.sql | 10 ++++ ..._create_production_standards_tables.up.sql | 54 +++++++++++++++++++ 4 files changed, 64 insertions(+) rename internal/database/migrations/{20251226114218_add.down.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql} (100%) rename internal/database/migrations/{20251226114218_add.up.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql} (100%) create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.down.sql create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.up.sql diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.down.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.up.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql new file mode 100644 index 00000000..f5cc2237 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql @@ -0,0 +1,10 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_standard_growth_details_standard_week; +DROP INDEX IF EXISTS idx_production_standard_details_standard_week; +DROP INDEX IF EXISTS idx_production_standards_project_category; +DROP INDEX IF EXISTS idx_production_standards_deleted_at; + +-- Drop tables (in reverse order due to foreign keys) +DROP TABLE IF EXISTS standard_growth_details; +DROP TABLE IF EXISTS production_standard_details; +DROP TABLE IF EXISTS production_standards; diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql new file mode 100644 index 00000000..61aa3071 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -0,0 +1,54 @@ +-- Create production_standards table +CREATE TABLE IF NOT EXISTS production_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +-- Create index for deleted_at (soft delete) +CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); + +-- Create production_standard_details table +CREATE TABLE IF NOT EXISTS production_standard_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + week INT NOT NULL, + target_hen_day_production NUMERIC(15, 3), + target_hen_house_production NUMERIC(15, 3), + target_egg_weight NUMERIC(15, 3), + target_egg_mass NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_production_standard_details_standard_week + ON production_standard_details(production_standard_id, week); + +-- Create standard_growth_details table +CREATE TABLE IF NOT EXISTS standard_growth_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + target_mean_bw INT, + max_depletion NUMERIC(15, 3), + min_uniformity NUMERIC(15, 3) NOT NULL, + week INT NOT NULL, + feed_intake INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by BIGINT NOT NULL, + CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_standard_growth_details_standard_week + ON standard_growth_details(production_standard_id, week); + +-- Create index for project_category +CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); From c9633d1308ddffc6d6a5100122d8b16c87d645ec Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 27 Dec 2025 09:02:16 +0700 Subject: [PATCH 15/50] feat[BE#US386]: add production standards module with CRUD operations - Created database migration for production standards and related tables. - Implemented entities for ProductionStandard, ProductionStandardDetail, and StandardGrowthDetail. - Developed controller for handling production standard requests. - Added DTOs for data transfer between layers. - Implemented service layer for business logic related to production standards. - Created repository interfaces and implementations for data access. - Added validation for production standard requests. - Registered routes for production standards in the main application. --- ..._create_production_standards_tables.up.sql | 60 +++- internal/entities/production_standard.go | 19 ++ .../entities/production_standard_detail.go | 19 ++ internal/entities/standard_growth_detail.go | 19 ++ .../production-standard.controller.go | 145 +++++++++ .../dto/production-standard.dto.go | 155 +++++++++ .../master/production-standards/module.go | 33 ++ .../production_standard.repository.go | 103 ++++++ .../production_standard_detail.repository.go | 63 ++++ .../standard_growth_detail.repository.go | 63 ++++ .../master/production-standards/route.go | 23 ++ .../services/production-standard.service.go | 302 ++++++++++++++++++ .../production-standard.validation.go | 41 +++ internal/modules/master/route.go | 2 + internal/route/route.go | 1 + 15 files changed, 1039 insertions(+), 9 deletions(-) create mode 100644 internal/entities/production_standard.go create mode 100644 internal/entities/production_standard_detail.go create mode 100644 internal/entities/standard_growth_detail.go create mode 100644 internal/modules/master/production-standards/controllers/production-standard.controller.go create mode 100644 internal/modules/master/production-standards/dto/production-standard.dto.go create mode 100644 internal/modules/master/production-standards/module.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard.repository.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard_detail.repository.go create mode 100644 internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go create mode 100644 internal/modules/master/production-standards/route.go create mode 100644 internal/modules/master/production-standards/services/production-standard.service.go create mode 100644 internal/modules/master/production-standards/validations/production-standard.validation.go diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql index 61aa3071..2af43d20 100644 --- a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -6,12 +6,25 @@ CREATE TABLE IF NOT EXISTS production_standards ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL + created_by BIGINT ); -- Create index for deleted_at (soft delete) CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE production_standards + ADD CONSTRAINT fk_production_standards_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Index +CREATE INDEX idx_production_standards_created_by ON production_standards(created_by); + -- Create production_standard_details table CREATE TABLE IF NOT EXISTS production_standard_details ( id BIGSERIAL PRIMARY KEY, @@ -22,11 +35,19 @@ CREATE TABLE IF NOT EXISTS production_standard_details ( target_egg_weight NUMERIC(15, 3), target_egg_mass NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + updated_at TIMESTAMPTZ DEFAULT NOW() ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE production_standard_details + ADD CONSTRAINT fk_production_standard_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_production_standard_details_standard_week ON production_standard_details(production_standard_id, week); @@ -35,20 +56,41 @@ CREATE UNIQUE INDEX idx_production_standard_details_standard_week CREATE TABLE IF NOT EXISTS standard_growth_details ( id BIGSERIAL PRIMARY KEY, production_standard_id BIGINT NOT NULL, - target_mean_bw INT, + target_mean_bw NUMERIC(15, 3), max_depletion NUMERIC(15, 3), min_uniformity NUMERIC(15, 3) NOT NULL, week INT NOT NULL, - feed_intake INT, + feed_intake NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - created_by BIGINT NOT NULL, - CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + created_by BIGINT ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_standard_growth_details_standard_week ON standard_growth_details(production_standard_id, week); +-- Index +CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by); + -- Create index for project_category CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); diff --git a/internal/entities/production_standard.go b/internal/entities/production_standard.go new file mode 100644 index 00000000..1214f6cf --- /dev/null +++ b/internal/entities/production_standard.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandard struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);uniqueIndex;not null"` + ProjectCategory string `gorm:"type:varchar(20);not null"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + DeletedAt *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` + StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go new file mode 100644 index 00000000..cd50a572 --- /dev/null +++ b/internal/entities/production_standard_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandardDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + Week int `gorm:"not null"` + TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"` + TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` + TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` + TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/standard_growth_detail.go b/internal/entities/standard_growth_detail.go new file mode 100644 index 00000000..a3d19fb8 --- /dev/null +++ b/internal/entities/standard_growth_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type StandardGrowthDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + TargetMeanBw *float64 `gorm:"type:numeric(15,3)"` + MaxDepletion *float64 `gorm:"type:numeric(15,3)"` + MinUniformity float64 `gorm:"type:numeric(15,3);not null"` + Week int `gorm:"not null"` + FeedIntake *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + CreatedBy uint `gorm:"not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/modules/master/production-standards/controllers/production-standard.controller.go b/internal/modules/master/production-standards/controllers/production-standard.controller.go new file mode 100644 index 00000000..1635385d --- /dev/null +++ b/internal/modules/master/production-standards/controllers/production-standard.controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductionStandardController struct { + ProductionStandardService service.ProductionStandardService +} + +func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController { + return &ProductionStandardController{ + ProductionStandardService: productionStandardService, + } +} + +func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectCategory: c.Query("project_category", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductionStandardService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productionStandards successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductionStandardListDTOs(result), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.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 productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete productionStandard successfully", + }) +} diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go new file mode 100644 index 00000000..9544732a --- /dev/null +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductionStandardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + ProjectCategory string `json:"project_category"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` +} + +type ProductionStandardDetailDTO struct { + ProductionStandardListDTO + Details []WeeklyProductionStandardDTO `json:"details"` +} + +type GrowthStandardDetailDTO struct { + Id uint `json:"id"` + TargetMeanBW *float64 `json:"target_mean_bw"` + MaxDepletion *float64 `json:"max_depletion"` + MinUniformity float64 `json:"min_uniformity"` + FeedIntake *float64 `json:"feed_intake"` +} + +type EggProductionStandardDetailDTO struct { + Id uint `json:"id"` + TargetHenDayProduction *float64 `json:"target_hen_day_production"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production"` + TargetEggWeight *float64 `json:"target_egg_weight"` + TargetEggMass *float64 `json:"target_egg_mass"` +} + +type WeeklyProductionStandardDTO struct { + Week int `json:"week"` + GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"` + EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"` +} + +// === Mapper Functions === + +func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductionStandardListDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + CreatedUser: createdUser, + } +} + +func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO { + result := make([]ProductionStandardListDTO, len(e)) + for i, r := range e { + result[i] = ToProductionStandardListDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO { + return WeeklyProductionStandardDTO{ + Week: e.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: e.Id, + TargetMeanBW: e.TargetMeanBw, + MaxDepletion: e.MaxDepletion, + MinUniformity: e.MinUniformity, + FeedIntake: e.FeedIntake, + }, + EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details + } +} + +func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO { + eggDetail := &EggProductionStandardDetailDTO{ + Id: detail.Id, + TargetHenDayProduction: detail.TargetHenDayProduction, + TargetHenHouseProduction: detail.TargetHenHouseProduction, + TargetEggWeight: detail.TargetEggWeight, + TargetEggMass: detail.TargetEggMass, + } + + return WeeklyProductionStandardDTO{ + Week: growth.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: growth.Id, + TargetMeanBW: growth.TargetMeanBw, + MaxDepletion: growth.MaxDepletion, + MinUniformity: growth.MinUniformity, + FeedIntake: growth.FeedIntake, + }, + EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details + } +} + +func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(e)) + for i, r := range e { + result[i] = ToWeeklyProductionStandardDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTOsWithDetails( + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(growthDetails)) + + // Create map for production standard details by week + prodDetailMap := make(map[int]entity.ProductionStandardDetail) + for _, detail := range productionStandardDetails { + prodDetailMap[detail.Week] = detail + } + + // Map growth details and combine with production standard details + for i, growth := range growthDetails { + if prodDetail, exists := prodDetailMap[growth.Week]; exists { + result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail) + } else { + result[i] = ToWeeklyProductionStandardDTO(growth) + } + } + + return result +} + +func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO { + return EggProductionStandardDetailDTO{ + TargetHenDayProduction: e.TargetHenDayProduction, + TargetHenHouseProduction: e.TargetHenHouseProduction, + TargetEggWeight: e.TargetEggWeight, + TargetEggMass: e.TargetEggMass, + } +} + +func ToProductionStandardDetailDTO( + standard entity.ProductionStandard, + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) ProductionStandardDetailDTO { + return ProductionStandardDetailDTO{ + ProductionStandardListDTO: ToProductionStandardListDTO(standard), + Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails), + } +} diff --git a/internal/modules/master/production-standards/module.go b/internal/modules/master/production-standards/module.go new file mode 100644 index 00000000..bd08ee88 --- /dev/null +++ b/internal/modules/master/production-standards/module.go @@ -0,0 +1,33 @@ +package productionstandards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductionStandardModule struct{} + +func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + ProductionStandardRoutes(router, userService, productionStandardService) +} + diff --git a/internal/modules/master/production-standards/repositories/production_standard.repository.go b/internal/modules/master/production-standards/repositories/production_standard.repository.go new file mode 100644 index 00000000..26330d63 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard.repository.go @@ -0,0 +1,103 @@ +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 ProductionStandardRepository interface { + repository.BaseRepository[entity.ProductionStandard] + GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) + GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) +} + +type ProductionStandardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandard] + db *gorm.DB +} + +func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository { + return &ProductionStandardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db), + db: db, + } +} + +func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) { + var standards []entity.ProductionStandard + var total int64 + + // Build base query + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier for filters + if modifier != nil { + q = modifier(q) + } + + // Count total + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Re-apply modifier and add preloads for Find + q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + if modifier != nil { + q = modifier(q) + } + q = q.Preload("CreatedUser") + + // Find with offset and limit + if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil { + return nil, 0, err + } + + return standards, total, nil +} + +func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) { + var standard entity.ProductionStandard + + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier + if modifier != nil { + q = modifier(q) + } + + // Ensure CreatedUser is preloaded + q = q.Preload("CreatedUser") + + if err := q.First(&standard, id).Error; err != nil { + return nil, err + } + + return &standard, nil +} + +func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID) +} + +func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.db, id) +} + +func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) { + var standards []entity.ProductionStandard + err := r.db.WithContext(ctx). + Preload("CreatedUser"). + Where("project_category = ?", projectCategory). + Where("deleted_at IS NULL"). + Find(&standards).Error + if err != nil { + return nil, err + } + return standards, nil +} diff --git a/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go new file mode 100644 index 00000000..ad680c01 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go @@ -0,0 +1,63 @@ +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 ProductionStandardDetailRepository interface { + repository.BaseRepository[entity.ProductionStandardDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type ProductionStandardDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandardDetail] + db *gorm.DB +} + +func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository { + return &ProductionStandardDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db), + db: db, + } +} + +func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id) +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) { + var details []entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) { + var detail entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.ProductionStandardDetail{}).Error +} diff --git a/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go new file mode 100644 index 00000000..afe93d81 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go @@ -0,0 +1,63 @@ +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 StandardGrowthDetailRepository interface { + repository.BaseRepository[entity.StandardGrowthDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type StandardGrowthDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StandardGrowthDetail] + db *gorm.DB +} + +func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository { + return &StandardGrowthDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db), + db: db, + } +} + +func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id) +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) { + var details []entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) { + var detail entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.StandardGrowthDetail{}).Error +} diff --git a/internal/modules/master/production-standards/route.go b/internal/modules/master/production-standards/route.go new file mode 100644 index 00000000..d2035bea --- /dev/null +++ b/internal/modules/master/production-standards/route.go @@ -0,0 +1,23 @@ +package productionstandards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/controllers" + productionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionStandard.ProductionStandardService) { + ctrl := controller.NewProductionStandardController(s) + + route := v1.Group("/production-standards") + route.Use(m.Auth(u)) + + 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/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go new file mode 100644 index 00000000..b81faf8b --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -0,0 +1,302 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/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 ProductionStandardService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductionStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productionStandardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductionStandardRepository + ProductionStandardDetailRepo repository.ProductionStandardDetailRepository + StandardGrowthDetailRepo repository.StandardGrowthDetailRepository +} + +func NewProductionStandardService( + repo repository.ProductionStandardRepository, + productionStandardDetailRepo repository.ProductionStandardDetailRepository, + standardGrowthDetailRepo repository.StandardGrowthDetailRepository, + validate *validator.Validate, +) ProductionStandardService { + return &productionStandardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProductionStandardDetailRepo: productionStandardDetailRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s productionStandardService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProductionStandardDetails"). + Preload("StandardGrowthDetails") +} + +func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.ProjectCategory != "" { + return db.Where("project_category = ?", params.ProjectCategory) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productionStandards: %+v", err) + return nil, 0, err + } + return productionStandards, total, nil +} + +func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductionStandard, error) { + productionStandard, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + if err != nil { + s.Log.Errorf("Failed get productionStandard by id: %+v", err) + return nil, err + } + return productionStandard, nil +} + +func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + nameExists, err := s.Repository.NameExists(c.Context(), req.Name, nil) + if err != nil { + return nil, err + } + if nameExists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", req.Name)) + } + + var createdStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + newStandard := &entity.ProductionStandard{ + Name: req.Name, + ProjectCategory: req.ProjectCategory, + CreatedBy: actorID, + } + + if err := standardRepoTx.CreateOne(c.Context(), newStandard, nil); err != nil { + return fmt.Errorf("failed to create production standard: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if req.ProjectCategory == string(utils.ProjectFlockCategoryLaying) { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + + createdStandard = newStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, createdStandard.Id) +} + +func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var updatedStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + existingStandard, err := standardRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return fmt.Errorf("failed to get production standard: %w", err) + } + + updateBody := make(map[string]any) + if req.Name != nil { + + nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) + if err != nil { + s.Log.Errorf("Failed to check existing production standard: %+v", err) + return err + } + if nameExists { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.ProjectCategory != nil { + updateBody["project_category"] = *req.ProjectCategory + } + + if len(updateBody) > 0 { + if err := standardRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fmt.Errorf("failed to update production standard: %w", err) + } + } + + if req.Details != nil && len(req.Details) > 0 { + + projectCategory := existingStandard.ProjectCategory + if req.ProjectCategory != nil { + projectCategory = *req.ProjectCategory + } + + if err := productionStandardDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old production standard details: %w", err) + } + if err := standardGrowthDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old standard growth details: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if projectCategory == "LAYING" { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + } + + updatedStandard = existingStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to update production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, updatedStandard.Id) +} + +func (s productionStandardService) 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, "ProductionStandard not found") + } + s.Log.Errorf("Failed to delete productionStandard: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go new file mode 100644 index 00000000..51aeecc7 --- /dev/null +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -0,0 +1,41 @@ +package validation + +type ProductionStandardDetailItem struct { + TargetHenDayProduction *float64 `json:"target_hen_day_production" validate:"omitempty,gte=0"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` + TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` + TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` +} + +type StandardGrowthDetailItem struct { + TargetMeanBw *float64 `json:"target_mean_bw" validate:"omitempty,gte=0"` + MaxDepletion *float64 `json:"max_depletion" validate:"omitempty,gte=0,lte=100"` + MinUniformity float64 `json:"min_uniformity" validate:"required,gte=0,lte=100"` + FeedIntake *float64 `json:"feed_intake" validate:"omitempty,gte=0"` +} + +type DetailItem struct { + Week int `json:"week" validate:"required,gte=1"` + ProductionStandardDetails *ProductionStandardDetailItem `json:"production_standard_details,omitempty"` + ProductionStandardUniformityDetails *StandardGrowthDetailItem `json:"production_standard_uniformity_details" validate:"required"` +} + + +type Create struct { + Name string `json:"name" validate:"required,min=3"` + ProjectCategory string `json:"project_category" validate:"required,oneof=GROWING LAYING"` + Details []DetailItem `json:"details" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + ProjectCategory *string `json:"project_category,omitempty" validate:"omitempty,oneof=GROWING LAYING"` + Details []DetailItem `json:"details,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 44702e1a..26ae28ee 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -20,6 +20,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" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" // MODULE IMPORTS ) @@ -40,6 +41,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida products.ProductModule{}, banks.BankModule{}, flocks.FlockModule{}, + productionStandards.ProductionStandardModule{}, // MODULE REGISTRY } diff --git a/internal/route/route.go b/internal/route/route.go index e98b044b..4eb224ac 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -44,6 +44,7 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, closings.ClosingModule{}, repports.RepportModule{}, + // MODULE REGISTRY } From 1c875a916b321f1cf3a77ea7e45b6449ca02c4d1 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Sat, 27 Dec 2025 14:30:03 +0700 Subject: [PATCH 16/50] feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity --- .../repository/common.approval.repository..go | 3 +- .../repository/common.exists.repository.go | 35 +- ...251224033033_create_payment_table.down.sql | 3 + ...20251224033033_create_payment_table.up.sql | 22 ++ ...43019_add_soft_delete_fk_triggers.down.sql | 18 + ...4043019_add_soft_delete_fk_triggers.up.sql | 126 ++++++ ...0000_create_payment_code_sequence.down.sql | 1 + ...130000_create_payment_code_sequence.up.sql | 1 + internal/entities/initial.go | 30 ++ internal/entities/payment.go | 32 ++ internal/entities/transaction.go | 18 + internal/middleware/permissions.go | 9 + .../controllers/initial.controller.go | 92 +++++ .../finance/initials/dto/initial.dto.go | 163 ++++++++ internal/modules/finance/initials/module.go | 36 ++ .../repositories/initial.repository.go | 51 +++ internal/modules/finance/initials/route.go | 21 + .../initials/services/initial.service.go | 336 ++++++++++++++++ .../validations/initial.validation.go | 27 ++ .../controllers/injection.controller.go | 92 +++++ .../finance/injections/dto/injection.dto.go | 102 +++++ internal/modules/finance/injections/module.go | 36 ++ .../repositories/injection.repository.go | 41 ++ internal/modules/finance/injections/route.go | 21 + .../injections/services/injection.service.go | 230 +++++++++++ .../validations/injection.validation.go | 21 + internal/modules/finance/module.go | 13 + .../controllers/payment.controller.go | 92 +++++ .../finance/payments/dto/payment.dto.go | 189 +++++++++ internal/modules/finance/payments/module.go | 36 ++ .../repositories/payment.repository.go | 62 +++ internal/modules/finance/payments/route.go | 21 + .../payments/services/payment.service.go | 362 ++++++++++++++++++ .../validations/payment.validation.go | 29 ++ internal/modules/finance/route.go | 31 ++ .../controllers/transaction.controller.go | 96 +++++ .../transactions/dto/transaction.dto.go | 189 +++++++++ .../modules/finance/transactions/module.go | 42 ++ .../repositories/transaction.repository.go | 21 + .../modules/finance/transactions/route.go | 21 + .../services/transaction.service.go | 175 +++++++++ .../validations/transaction.validation.go | 15 + .../master/kandangs/dto/kandang.dto.go | 1 + internal/route/route.go | 2 + internal/utils/constant.go | 108 ++++++ tools/templates/route.tmpl | 19 +- 46 files changed, 3068 insertions(+), 23 deletions(-) create mode 100644 internal/database/migrations/20251224033033_create_payment_table.down.sql create mode 100644 internal/database/migrations/20251224033033_create_payment_table.up.sql create mode 100644 internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql create mode 100644 internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql create mode 100644 internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql create mode 100644 internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql create mode 100644 internal/entities/initial.go create mode 100644 internal/entities/payment.go create mode 100644 internal/entities/transaction.go create mode 100644 internal/modules/finance/initials/controllers/initial.controller.go create mode 100644 internal/modules/finance/initials/dto/initial.dto.go create mode 100644 internal/modules/finance/initials/module.go create mode 100644 internal/modules/finance/initials/repositories/initial.repository.go create mode 100644 internal/modules/finance/initials/route.go create mode 100644 internal/modules/finance/initials/services/initial.service.go create mode 100644 internal/modules/finance/initials/validations/initial.validation.go create mode 100644 internal/modules/finance/injections/controllers/injection.controller.go create mode 100644 internal/modules/finance/injections/dto/injection.dto.go create mode 100644 internal/modules/finance/injections/module.go create mode 100644 internal/modules/finance/injections/repositories/injection.repository.go create mode 100644 internal/modules/finance/injections/route.go create mode 100644 internal/modules/finance/injections/services/injection.service.go create mode 100644 internal/modules/finance/injections/validations/injection.validation.go create mode 100644 internal/modules/finance/module.go create mode 100644 internal/modules/finance/payments/controllers/payment.controller.go create mode 100644 internal/modules/finance/payments/dto/payment.dto.go create mode 100644 internal/modules/finance/payments/module.go create mode 100644 internal/modules/finance/payments/repositories/payment.repository.go create mode 100644 internal/modules/finance/payments/route.go create mode 100644 internal/modules/finance/payments/services/payment.service.go create mode 100644 internal/modules/finance/payments/validations/payment.validation.go create mode 100644 internal/modules/finance/route.go create mode 100644 internal/modules/finance/transactions/controllers/transaction.controller.go create mode 100644 internal/modules/finance/transactions/dto/transaction.dto.go create mode 100644 internal/modules/finance/transactions/module.go create mode 100644 internal/modules/finance/transactions/repositories/transaction.repository.go create mode 100644 internal/modules/finance/transactions/route.go create mode 100644 internal/modules/finance/transactions/services/transaction.service.go create mode 100644 internal/modules/finance/transactions/validations/transaction.validation.go diff --git a/internal/common/repository/common.approval.repository..go b/internal/common/repository/common.approval.repository..go index dc517f21..8c045084 100644 --- a/internal/common/repository/common.approval.repository..go +++ b/internal/common/repository/common.approval.repository..go @@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets( result := make(map[uint]entity.Approval, len(approvableIDs)) q := r.DB().WithContext(ctx). + Select("DISTINCT ON (approvable_id) *"). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). - Order("action_at DESC") + Order("approvable_id, action_at DESC") if modifier != nil { q = modifier(q) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index c6bc11f0..b8206eb9 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "fmt" "gorm.io/gorm" @@ -9,45 +10,59 @@ import ( // Exists reports whether a record with the given ID exists for type T. func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { - var count int64 - if err := db.WithContext(ctx). + var marker int + err := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("id = ?", id). - Count(&count).Error; err != nil { + Limit(1). + Take(&marker).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { return false, err } - return count > 0, nil + return true, nil } func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where("name = ?", name). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { if field == "" { return false, fmt.Errorf("field is required") } - var count int64 q := db.WithContext(ctx). Model(new(T)). + Select("1"). Where(fmt.Sprintf("%s = ?", field), value). Where("deleted_at IS NULL") if excludeID != nil { q = q.Where("id <> ?", *excludeID) } - if err := q.Count(&count).Error; err != nil { + var marker int + if err := q.Limit(1).Take(&marker).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } return false, err } - return count > 0, nil + return true, nil } diff --git a/internal/database/migrations/20251224033033_create_payment_table.down.sql b/internal/database/migrations/20251224033033_create_payment_table.down.sql new file mode 100644 index 00000000..14bc4ca1 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_payments_bank_id; +DROP INDEX IF EXISTS payments_party_polymorphic; +DROP TABLE IF EXISTS payments; diff --git a/internal/database/migrations/20251224033033_create_payment_table.up.sql b/internal/database/migrations/20251224033033_create_payment_table.up.sql new file mode 100644 index 00000000..d27c55f4 --- /dev/null +++ b/internal/database/migrations/20251224033033_create_payment_table.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS payments ( + id BIGSERIAL PRIMARY KEY, + payment_code VARCHAR(50) NOT NULL, + reference_number VARCHAR(100) NULL, + transaction_type VARCHAR(50), + party_type VARCHAR(50) NOT NULL, + party_id BIGINT NOT NULL, + payment_date TIMESTAMPTZ NOT NULL, + payment_method VARCHAR(20) NOT NULL, + bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE, + direction VARCHAR(5) NOT NULL, + nominal NUMERIC(15, 3) NOT NULL, + notes TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE +); + +-- Indexes +CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id); +CREATE INDEX idx_payments_bank_id ON payments (bank_id); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql new file mode 100644 index 00000000..1d55147b --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.down.sql @@ -0,0 +1,18 @@ +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + END LOOP; +END $$; + +DROP FUNCTION IF EXISTS soft_delete_handle_fk(); diff --git a/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql new file mode 100644 index 00000000..161d3d89 --- /dev/null +++ b/internal/database/migrations/20251224043019_add_soft_delete_fk_triggers.up.sql @@ -0,0 +1,126 @@ +CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$ +DECLARE + fk record; + child_column text; + parent_column text; + parent_value text; + child_has_deleted_at boolean; + ref_exists boolean; + sql text; +BEGIN + IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN + FOR fk IN + SELECT conrelid::regclass AS child_table, + conkey AS child_cols, + confkey AS parent_cols, + confdeltype + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = TG_RELID + LOOP + IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1 + OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN + RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table; + CONTINUE; + END IF; + + SELECT attname INTO child_column + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attnum = fk.child_cols[1] + AND NOT attisdropped; + + SELECT attname INTO parent_column + FROM pg_attribute + WHERE attrelid = TG_RELID + AND attnum = fk.parent_cols[1] + AND NOT attisdropped; + + EXECUTE format('SELECT ($1).%I', parent_column) + INTO parent_value + USING OLD; + + SELECT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = fk.child_table + AND attname = 'deleted_at' + AND NOT attisdropped + ) INTO child_has_deleted_at; + + IF fk.confdeltype IN ('r', 'a') THEN + sql := format( + 'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)', + fk.child_table, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql INTO ref_exists USING parent_value; + IF ref_exists THEN + RAISE EXCEPTION 'Cannot soft delete %, still referenced by %', + TG_TABLE_NAME, fk.child_table; + END IF; + ELSIF fk.confdeltype = 'n' THEN + sql := format( + 'UPDATE %s SET %I = NULL WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + ELSIF fk.confdeltype = 'c' THEN + IF child_has_deleted_at THEN + sql := format( + 'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + ELSE + sql := format( + 'DELETE FROM %s WHERE %I = $1', + fk.child_table, + child_column + ); + EXECUTE sql USING parent_value; + END IF; + ELSIF fk.confdeltype = 'd' THEN + sql := format( + 'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s', + fk.child_table, + child_column, + child_column, + CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END + ); + EXECUTE sql USING parent_value; + END IF; + END LOOP; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +DECLARE + r record; + trigger_name text; +BEGIN + FOR r IN + SELECT table_schema, table_name + FROM information_schema.columns + WHERE column_name = 'deleted_at' + AND table_schema = 'public' + GROUP BY table_schema, table_name + LOOP + trigger_name := format('trg_soft_delete_fk_%s', r.table_name); + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name); + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()', + trigger_name, + r.table_schema, + r.table_name + ); + END LOOP; +END $$; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql new file mode 100644 index 00000000..50996e8f --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.down.sql @@ -0,0 +1 @@ +DROP SEQUENCE IF EXISTS payments_code_seq; diff --git a/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql new file mode 100644 index 00000000..875b0697 --- /dev/null +++ b/internal/database/migrations/20251224130000_create_payment_code_sequence.up.sql @@ -0,0 +1 @@ +CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1; diff --git a/internal/entities/initial.go b/internal/entities/initial.go new file mode 100644 index 00000000..c562d748 --- /dev/null +++ b/internal/entities/initial.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Initial struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ReferenceNumber string `gorm:"type:varchar(100);not null"` + TransactionType string `gorm:"type:varchar(50);not null"` + InitialBalanceType string `gorm:"type:varchar(20);not null"` + PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"` + BankId *uint `gorm:"index"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedBy uint `gorm:"index" json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Bank Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/payment.go b/internal/entities/payment.go new file mode 100644 index 00000000..e48800fb --- /dev/null +++ b/internal/entities/payment.go @@ -0,0 +1,32 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Payment struct { + Id uint `gorm:"primaryKey;autoIncrement"` + PaymentCode string `gorm:"type:varchar(50);not null"` + ReferenceNumber *string `gorm:"type:varchar(100)"` + TransactionType string `gorm:"type:varchar(50)"` + PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"` + PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"` + PaymentDate time.Time `gorm:"not null"` + PaymentMethod string `gorm:"type:varchar(20);not null"` + BankId *uint `gorm:"not null;index:idx_payments_bank_id"` + Direction string `gorm:"type:varchar(5);not null"` + Nominal float64 `gorm:"type:numeric(15,3);not null"` + Notes string `gorm:"type:text;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedBy uint `gorm:"index" json:"-"` + + BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Customer *Customer `gorm:"foreignKey:PartyId;references:Id"` + Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"-"` +} diff --git a/internal/entities/transaction.go b/internal/entities/transaction.go new file mode 100644 index 00000000..b099bd08 --- /dev/null +++ b/internal/entities/transaction.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction 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/middleware/permissions.go b/internal/middleware/permissions.go index f46c25a9..02145930 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -192,6 +192,15 @@ const ( P_PurchaseApprovalManager = "lti.Purchase.approve.manager" ) +const ( + P_FinanceGetAll = "lti.finance.list" + P_FinanceGetOne = "lti.finance.detail" + P_FinanceCreateOne = "lti.finance.create" + P_FinanceUpdateOne = "lti.finance.update" + P_FinanceDeleteOne = "lti.finance.delete" + P_FinanceApproval = "lti.finance.approve" +) + const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" diff --git a/internal/modules/finance/initials/controllers/initial.controller.go b/internal/modules/finance/initials/controllers/initial.controller.go new file mode 100644 index 00000000..4aef677a --- /dev/null +++ b/internal/modules/finance/initials/controllers/initial.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InitialController struct { + InitialService service.InitialService +} + +func NewInitialController(initialService service.InitialService) *InitialController { + return &InitialController{ + InitialService: initialService, + } +} + +func (u *InitialController) 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.InitialService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) 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.InitialService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} + +func (u *InitialController) 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.InitialService.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 initial successfully", + Data: dto.ToInitialListDTO(*result), + }) +} diff --git a/internal/modules/finance/initials/dto/initial.dto.go b/internal/modules/finance/initials/dto/initial.dto.go new file mode 100644 index 00000000..5eb76e9c --- /dev/null +++ b/internal/modules/finance/initials/dto/initial.dto.go @@ -0,0 +1,163 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InitialRelationDTO struct { + Id uint `json:"id"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + InitialBalanceType string `json:"initial_balance_type"` + InitialBalanceTypeLabel string `json:"initial_balance_type_label"` + Party Party `json:"party"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InitialListDTO struct { + InitialRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InitialDetailDTO struct { + InitialListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO { + reference := "" + if e.ReferenceNumber != nil { + reference = *e.ReferenceNumber + } + + initialBalanceType := initialBalanceTypeFromPayment(e) + return InitialRelationDTO{ + Id: e.Id, + ReferenceNumber: reference, + TransactionType: transactionTypeLabel(e.TransactionType), + InitialBalanceType: initialBalanceType, + InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType), + Party: partyFromInitial(e), + Bank: bankFromInitial(e), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInitialListDTO(e entity.Payment) InitialListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InitialListDTO{ + InitialRelationDTO: ToInitialRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInitial(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInitialListDTOs(e []entity.Payment) []InitialListDTO { + result := make([]InitialListDTO, len(e)) + for i, r := range e { + result[i] = ToInitialListDTO(r) + } + return result +} + +func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO { + return InitialDetailDTO{ + InitialListDTO: ToInitialListDTO(e), + } +} + +func partyFromInitial(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) { + return "Saldo Awal" + } + return transactionType +} + +func initialBalanceLabel(balanceType string) string { + switch strings.ToUpper(strings.TrimSpace(balanceType)) { + case "NEGATIVE": + return "Saldo Awal Negatif" + case "POSITIVE": + return "Saldo Awal Positif" + default: + return balanceType + } +} + +func initialBalanceTypeFromPayment(e entity.Payment) string { + if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} diff --git a/internal/modules/finance/initials/module.go b/internal/modules/finance/initials/module.go new file mode 100644 index 00000000..051c8d3f --- /dev/null +++ b/internal/modules/finance/initials/module.go @@ -0,0 +1,36 @@ +package initials + +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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InitialModule struct{} + +func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + initialRepo := rInitial.NewInitialRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + + initialService := sInitial.NewInitialService(initialRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InitialRoutes(router, userService, initialService) +} diff --git a/internal/modules/finance/initials/repositories/initial.repository.go b/internal/modules/finance/initials/repositories/initial.repository.go new file mode 100644 index 00000000..9c285c5c --- /dev/null +++ b/internal/modules/finance/initials/repositories/initial.repository.go @@ -0,0 +1,51 @@ +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 InitialRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InitialRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInitialRepository(db *gorm.DB) InitialRepository { + return &InitialRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/initials/route.go b/internal/modules/finance/initials/route.go new file mode 100644 index 00000000..21232493 --- /dev/null +++ b/internal/modules/finance/initials/route.go @@ -0,0 +1,21 @@ +package initials + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers" + initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) { + ctrl := controller.NewInitialController(s) + + route := v1.Group("/initial-balances") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/initials/services/initial.service.go b/internal/modules/finance/initials/services/initial.service.go new file mode 100644 index 00000000..2eb15d3b --- /dev/null +++ b/internal/modules/finance/initials/services/initial.service.go @@ -0,0 +1,336 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations" + "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" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InitialService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type initialService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InitialRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInitialService( + repo repository.InitialRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InitialService { + return &initialService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInitial, + } +} + +func (s initialService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(initial.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err) + } else { + initial.LatestApproval = approval + } + } + return initial, nil +} + +func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInitialCode(c.Context()) + if err != nil { + return nil, err + } + + reference := req.ReferenceNumber + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: &reference, + TransactionType: string(utils.TransactionTypeSaldoAwal), + PartyType: party, + PartyId: req.PartyId, + PaymentDate: time.Now(), + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: directionForInitialType(balanceType), + Nominal: signedNominal(balanceType, req.Nominal), + Notes: req.Note, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + initialRepoTx := repository.NewInitialRepository(dbTransaction) + if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InitialStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create initial: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.Note != nil { + updateBody["notes"] = *req.Note + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + + requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil + requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil + var existing *entity.Payment + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + if err != nil { + s.Log.Errorf("Failed get initial by id: %+v", err) + return nil, err + } + if !isInitialTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found") + } + existing = current + } + + if req.PartyType != nil || req.PartyId != nil { + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + } + + if req.InitialBalanceType != nil || req.Nominal != nil { + balanceType := balanceTypeFromPayment(existing) + if req.InitialBalanceType != nil { + normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType) + if err != nil { + return nil, err + } + balanceType = normalized + } + + nominal := math.Abs(existing.Nominal) + if req.Nominal != nil { + nominal = *req.Nominal + } + + updateBody["direction"] = directionForInitialType(balanceType) + updateBody["nominal"] = signedNominal(balanceType, nominal) + } + + 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, "Initial not found") + } + s.Log.Errorf("Failed to update initial: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInitialTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) +} + +func balanceTypeFromPayment(payment *entity.Payment) string { + if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 { + return "NEGATIVE" + } + return "POSITIVE" +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizeInitialBalanceType(balanceType string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(balanceType)) + switch normalized { + case "NEGATIVE", "POSITIVE": + return normalized, nil + default: + return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`") + } +} + +func directionForInitialType(balanceType string) string { + if strings.EqualFold(balanceType, "NEGATIVE") { + return "OUT" + } + return "IN" +} + +func signedNominal(balanceType string, nominal float64) float64 { + normalized := math.Abs(nominal) + if strings.EqualFold(balanceType, "NEGATIVE") { + return -normalized + } + return normalized +} + +func (s initialService) generateInitialCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INIT-%05d", sequence), nil +} + +func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/initials/validations/initial.validation.go b/internal/modules/finance/initials/validations/initial.validation.go new file mode 100644 index 00000000..27df2eea --- /dev/null +++ b/internal/modules/finance/initials/validations/initial.validation.go @@ -0,0 +1,27 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"` + InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Note string `json:"note" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"` + InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Note *string `json:"note,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/injections/controllers/injection.controller.go b/internal/modules/finance/injections/controllers/injection.controller.go new file mode 100644 index 00000000..8f6c6b6d --- /dev/null +++ b/internal/modules/finance/injections/controllers/injection.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type InjectionController struct { + InjectionService service.InjectionService +} + +func NewInjectionController(injectionService service.InjectionService) *InjectionController { + return &InjectionController{ + InjectionService: injectionService, + } +} + +func (u *InjectionController) 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.InjectionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) 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.InjectionService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Balance injection created successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} + +func (u *InjectionController) 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.InjectionService.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 injection successfully", + Data: dto.ToInjectionListDTO(*result), + }) +} diff --git a/internal/modules/finance/injections/dto/injection.dto.go b/internal/modules/finance/injections/dto/injection.dto.go new file mode 100644 index 00000000..d0be7f3f --- /dev/null +++ b/internal/modules/finance/injections/dto/injection.dto.go @@ -0,0 +1,102 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type InjectionRelationDTO struct { + Id uint `json:"id"` + TransactionType string `json:"transaction_type"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + AdjustmentDate string `json:"adjustment_date"` + Direction string `json:"direction"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` +} + +type InjectionListDTO struct { + InjectionRelationDTO + CreatedBy uint `json:"created_by"` + CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type InjectionDetailDTO struct { + InjectionListDTO +} + +// === Mapper Functions === + +func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO { + return InjectionRelationDTO{ + Id: e.Id, + TransactionType: transactionTypeLabel(e.TransactionType), + Bank: bankFromInjection(e), + AdjustmentDate: utils.FormatDate(e.PaymentDate), + Direction: e.Direction, + Nominal: e.Nominal, + Notes: e.Notes, + } +} + +func ToInjectionListDTO(e entity.Payment) InjectionListDTO { + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return InjectionListDTO{ + InjectionRelationDTO: ToInjectionRelationDTO(e), + CreatedBy: e.CreatedBy, + CreatedByUser: userFromInjection(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO { + result := make([]InjectionListDTO, len(e)) + for i, r := range e { + result[i] = ToInjectionListDTO(r) + } + return result +} + +func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO { + return InjectionDetailDTO{ + InjectionListDTO: ToInjectionListDTO(e), + } +} + +func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromInjection(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func transactionTypeLabel(transactionType string) string { + if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) { + return "Injection" + } + return transactionType +} diff --git a/internal/modules/finance/injections/module.go b/internal/modules/finance/injections/module.go new file mode 100644 index 00000000..0c4517e6 --- /dev/null +++ b/internal/modules/finance/injections/module.go @@ -0,0 +1,36 @@ +package injections + +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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type InjectionModule struct{} + +func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + injectionRepo := rInjection.NewInjectionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + InjectionRoutes(router, userService, injectionService) +} diff --git a/internal/modules/finance/injections/repositories/injection.repository.go b/internal/modules/finance/injections/repositories/injection.repository.go new file mode 100644 index 00000000..2e6869b7 --- /dev/null +++ b/internal/modules/finance/injections/repositories/injection.repository.go @@ -0,0 +1,41 @@ +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 InjectionRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type InjectionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewInjectionRepository(db *gorm.DB) InjectionRepository { + return &InjectionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/injections/route.go b/internal/modules/finance/injections/route.go new file mode 100644 index 00000000..cb66ccb7 --- /dev/null +++ b/internal/modules/finance/injections/route.go @@ -0,0 +1,21 @@ +package injections + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers" + injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) { + ctrl := controller.NewInjectionController(s) + + route := v1.Group("/injections") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/injections/services/injection.service.go b/internal/modules/finance/injections/services/injection.service.go new file mode 100644 index 00000000..1b1062b4 --- /dev/null +++ b/internal/modules/finance/injections/services/injection.service.go @@ -0,0 +1,230 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations" + "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" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type InjectionService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type injectionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.InjectionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewInjectionService( + repo repository.InjectionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) InjectionService { + return &injectionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowInjection, + } +} + +func (s injectionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse") +} + +func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(injection.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for injection %d: %+v", id, err) + } else { + injection.LatestApproval = approval + } + } + return injection, nil +} + +func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generateInjectionCode(c.Context()) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + TransactionType: string(utils.TransactionTypeInjection), + PartyType: string(utils.PaymentPartyCustomer), + PartyId: 0, + PaymentDate: adjustmentDate, + PaymentMethod: string(utils.PaymentMethodSaldo), + BankId: req.BankId, + Direction: "IN", + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + injectionRepoTx := repository.NewInjectionRepository(dbTransaction) + if err := injectionRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.InjectionStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create injection: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil + if requiresVerification { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + if err != nil { + s.Log.Errorf("Failed get injection by id: %+v", err) + return nil, err + } + if !isInjectionTransaction(current.TransactionType) { + return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found") + } + } + + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.AdjustmentDate != nil { + parsedDate, err := utils.ParseDateString(*req.AdjustmentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + 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, "Injection not found") + } + s.Log.Errorf("Failed to update injection: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func isInjectionTransaction(transactionType string) bool { + return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) +} + +func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) { + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("INJ-%05d", sequence), nil +} + +func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} diff --git a/internal/modules/finance/injections/validations/injection.validation.go b/internal/modules/finance/injections/validations/injection.validation.go new file mode 100644 index 00000000..eb324525 --- /dev/null +++ b/internal/modules/finance/injections/validations/injection.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` + AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` + Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/module.go b/internal/modules/finance/module.go new file mode 100644 index 00000000..ded5fbae --- /dev/null +++ b/internal/modules/finance/module.go @@ -0,0 +1,13 @@ +package finance + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type FinanceModule struct{} + +func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/finance/payments/controllers/payment.controller.go b/internal/modules/finance/payments/controllers/payment.controller.go new file mode 100644 index 00000000..5bccecf4 --- /dev/null +++ b/internal/modules/finance/payments/controllers/payment.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PaymentController struct { + PaymentService service.PaymentService +} + +func NewPaymentController(paymentService service.PaymentService) *PaymentController { + return &PaymentController{ + PaymentService: paymentService, + } +} + +func (u *PaymentController) 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.PaymentService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) 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.PaymentService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} + +func (u *PaymentController) 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.PaymentService.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 payment successfully", + Data: dto.ToPaymentListDTO(*result), + }) +} diff --git a/internal/modules/finance/payments/dto/payment.dto.go b/internal/modules/finance/payments/dto/payment.dto.go new file mode 100644 index 00000000..23005e2d --- /dev/null +++ b/internal/modules/finance/payments/dto/payment.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type PaymentRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type PaymentListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type PaymentDetailDTO struct { + PaymentListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return PaymentRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToPaymentListDTO(e entity.Payment) PaymentListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return PaymentListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO { + result := make([]PaymentListDTO, len(e)) + for i, r := range e { + result[i] = ToPaymentListDTO(r) + } + return result +} + +func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO { + return PaymentDetailDTO{ + PaymentListDTO: ToPaymentListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/payments/module.go b/internal/modules/finance/payments/module.go new file mode 100644 index 00000000..fdc0ce47 --- /dev/null +++ b/internal/modules/finance/payments/module.go @@ -0,0 +1,36 @@ +package payments + +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" + + rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/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" +) + +type PaymentModule struct{} + +func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + paymentRepo := rPayment.NewPaymentRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + + paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + PaymentRoutes(router, userService, paymentService) +} diff --git a/internal/modules/finance/payments/repositories/payment.repository.go b/internal/modules/finance/payments/repositories/payment.repository.go new file mode 100644 index 00000000..b16f8881 --- /dev/null +++ b/internal/modules/finance/payments/repositories/payment.repository.go @@ -0,0 +1,62 @@ +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 PaymentRepository interface { + repository.BaseRepository[entity.Payment] + BankExists(ctx context.Context, bankId uint) (bool, error) + CustomerExists(ctx context.Context, customerId uint) (bool, error) + SupplierExists(ctx context.Context, supplierId uint) (bool, error) + SupplierCategory(ctx context.Context, supplierId uint) (string, error) + NextPaymentSequence(ctx context.Context) (int64, error) +} + +type PaymentRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] + db *gorm.DB +} + +func NewPaymentRepository(db *gorm.DB) PaymentRepository { + return &PaymentRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + db: db, + } +} + +func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) { + return repository.Exists[entity.Bank](ctx, r.db, bankId) +} + +func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) { + return repository.Exists[entity.Customer](ctx, r.db, customerId) +} + +func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, supplierId) +} + +func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) { + var supplier entity.Supplier + if err := r.db.WithContext(ctx). + Select("id", "category"). + First(&supplier, supplierId).Error; err != nil { + return "", err + } + return supplier.Category, nil +} + +func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) { + var next int64 + if err := r.db.WithContext(ctx). + Raw("SELECT nextval('payments_code_seq')"). + Scan(&next).Error; err != nil { + return 0, err + } + return next, nil +} diff --git a/internal/modules/finance/payments/route.go b/internal/modules/finance/payments/route.go new file mode 100644 index 00000000..4b0e8bd2 --- /dev/null +++ b/internal/modules/finance/payments/route.go @@ -0,0 +1,21 @@ +package payments + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers" + payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) { + ctrl := controller.NewPaymentController(s) + + route := v1.Group("/payments") + // route.Use(m.Auth(u)) + + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) +} diff --git a/internal/modules/finance/payments/services/payment.service.go b/internal/modules/finance/payments/services/payment.service.go new file mode 100644 index 00000000..356288f1 --- /dev/null +++ b/internal/modules/finance/payments/services/payment.service.go @@ -0,0 +1,362 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations" + "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" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PaymentService interface { + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) +} + +type paymentService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.PaymentRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey +} + +func NewPaymentService( + repo repository.PaymentRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) PaymentService { + return &paymentService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowPayment, + } +} + +func (s paymentService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approval for payment %d: %+v", id, err) + } else { + payment.LatestApproval = approval + } + } + return payment, nil +} + +func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + //! CHECK PARTY TYPE + party, err := normalizePartyType(req.PartyType) + if err != nil { + return nil, err + } + + //! CHECK EXISTS + if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil { + return nil, err + } + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + + //? NORMALIZE + paymentDate, err := utils.ParseDateString(req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + method, err := normalizePaymentMethod(req.PaymentMethod) + if err != nil { + return nil, err + } + transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId) + if err != nil { + return nil, err + } + + //? GET CREATED BY + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + code, err := s.generatePaymentCode(c.Context(), party) + if err != nil { + return nil, err + } + + createBody := &entity.Payment{ + PaymentCode: code, + ReferenceNumber: req.ReferenceNumber, + TransactionType: transactionType, + PartyType: party, + PartyId: req.PartyId, + PaymentDate: paymentDate, + PaymentMethod: method, + BankId: req.BankId, + Direction: directionForParty(party), + Nominal: req.Nominal, + Notes: req.Notes, + CreatedBy: actorID, + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + paymentRepoTx := repository.NewPaymentRepository(dbTransaction) + if err := paymentRepoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if s.ApprovalSvc != nil { + action := entity.ApprovalActionCreated + approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + _, err := approvalSvcTx.CreateApproval( + c.Context(), + s.approvalWorkflow, + createBody.Id, + utils.PaymentStepPengajuan, + &action, + actorID, + nil, + ) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + s.Log.Errorf("Failed to create payment: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.PaymentDate != nil { + parsedDate, err := utils.ParseDateString(*req.PaymentDate) + if err != nil { + return nil, utils.BadRequest(err.Error()) + } + updateBody["payment_date"] = parsedDate + } + if req.Nominal != nil { + updateBody["nominal"] = *req.Nominal + } + if req.ReferenceNumber != nil { + updateBody["reference_number"] = *req.ReferenceNumber + } + if req.PaymentMethod != nil { + method, err := normalizePaymentMethod(*req.PaymentMethod) + if err != nil { + return nil, err + } + updateBody["payment_method"] = method + } + if req.BankId != nil { + if err := s.ensureBankExists(c.Context(), req.BankId); err != nil { + return nil, err + } + updateBody["bank_id"] = *req.BankId + } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + + if req.PartyType != nil || req.PartyId != nil { + existing, err := s.Repository.GetByID(c.Context(), id, nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found") + } + if err != nil { + s.Log.Errorf("Failed get payment by id: %+v", err) + return nil, err + } + + partyType := existing.PartyType + partyId := existing.PartyId + + if req.PartyType != nil { + normalized, err := normalizePartyType(*req.PartyType) + if err != nil { + return nil, err + } + partyType = normalized + updateBody["party_type"] = partyType + updateBody["direction"] = directionForParty(partyType) + } + if req.PartyId != nil { + partyId = *req.PartyId + updateBody["party_id"] = partyId + } + + if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil { + return nil, err + } + + transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId) + if err != nil { + return nil, err + } + updateBody["transaction_type"] = transactionType + } + + 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, "Payment not found") + } + s.Log.Errorf("Failed to update payment: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func normalizePartyType(partyType string) (string, error) { + party := strings.ToUpper(strings.TrimSpace(partyType)) + if !utils.IsValidPaymentParty(party) { + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } + return party, nil +} + +func normalizePaymentMethod(method string) (string, error) { + normalized := strings.ToUpper(strings.TrimSpace(method)) + if !utils.IsValidPaymentMethod(normalized) { + return "", utils.BadRequest("Invalid payment_method") + } + return normalized, nil +} + +func directionForParty(partyType string) string { + if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer { + return "IN" + } + return "OUT" +} + +func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return string(utils.TransactionTypePenjualan), nil + case utils.PaymentPartySupplier: + category, err := s.getSupplierCategory(ctx, partyId) + if err != nil { + return "", err + } + if isSupplierCategoryBiaya(category) { + return string(utils.TransactionTypeBiaya), nil + } + return string(utils.TransactionTypePembelian), nil + default: + return "", utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) { + prefix := "PAY" + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + prefix = "PAY-IN" + case utils.PaymentPartySupplier: + prefix = "PAY-OUT" + } + sequence, err := s.Repository.NextPaymentSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("%s-%05d", prefix, sequence), nil +} + +func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} + +func (s paymentService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error { + switch utils.PaymentParty(partyType) { + case utils.PaymentPartyCustomer: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists}, + ) + case utils.PaymentPartySupplier: + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists}, + ) + default: + return utils.BadRequest("`party_type` must be `customer` or `supplier`") + } +} + +func (s paymentService) ensureBankExists(ctx context.Context, bankId *uint) error { + return commonSvc.EnsureRelations(ctx, + commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists}, + ) +} + +func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) { + category, err := s.Repository.SupplierCategory(ctx, supplierId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", utils.NotFound("Supplier not found") + } + return "", err + } + return strings.ToUpper(strings.TrimSpace(category)), nil +} + +func isSupplierCategoryBiaya(category string) bool { + switch strings.ToUpper(strings.TrimSpace(category)) { + case string(utils.SupplierCategoryBOP), "BIAYA": + return true + default: + return false + } +} diff --git a/internal/modules/finance/payments/validations/payment.validation.go b/internal/modules/finance/payments/validations/payment.validation.go new file mode 100644 index 00000000..14c8f151 --- /dev/null +++ b/internal/modules/finance/payments/validations/payment.validation.go @@ -0,0 +1,29 @@ +package validation + +type Create struct { + PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"` + PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` + PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"` + Nominal float64 `json:"nominal" validate:"required_strict"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"` + BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"` + Notes string `json:"notes" validate:"required_strict,max=500"` +} + +type Update struct { + PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"` + PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"` + PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"` + ReferenceNumber *string `json:"reference_number,omitempty"` + PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"` + BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/finance/route.go b/internal/modules/finance/route.go new file mode 100644 index 00000000..bc99bf7e --- /dev/null +++ b/internal/modules/finance/route.go @@ -0,0 +1,31 @@ +package finance + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/modules" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments" + initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials" + injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections" + transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions" + // MODULE IMPORTS +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/finance") + + allModules := []modules.Module{ + payments.PaymentModule{}, + initials.InitialModule{}, + injections.InjectionModule{}, + transactions.TransactionModule{}, + // MODULE REGISTRY + } + + for _, m := range allModules { + m.RegisterRoutes(group, db, validate) + } +} diff --git a/internal/modules/finance/transactions/controllers/transaction.controller.go b/internal/modules/finance/transactions/controllers/transaction.controller.go new file mode 100644 index 00000000..fa3e1369 --- /dev/null +++ b/internal/modules/finance/transactions/controllers/transaction.controller.go @@ -0,0 +1,96 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransactionController struct { + TransactionService service.TransactionService +} + +func NewTransactionController(transactionService service.TransactionService) *TransactionController { + return &TransactionController{ + TransactionService: transactionService, + } +} + +func (u *TransactionController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.TransactionService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transactions successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransactionListDTOs(result), + }) +} + +func (u *TransactionController) 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.TransactionService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transaction successfully", + Data: dto.ToTransactionListDTO(*result), + }) +} + +func (u *TransactionController) 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.TransactionService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete transaction successfully", + }) +} diff --git a/internal/modules/finance/transactions/dto/transaction.dto.go b/internal/modules/finance/transactions/dto/transaction.dto.go new file mode 100644 index 00000000..25740344 --- /dev/null +++ b/internal/modules/finance/transactions/dto/transaction.dto.go @@ -0,0 +1,189 @@ +package dto + +import ( + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +// === DTO Structs === + +type TransactionRelationDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number,omitempty"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank,omitempty"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"` +} + +type TransactionListDTO struct { + Id uint `json:"id"` + PaymentCode string `json:"payment_code"` + ReferenceNumber *string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + Party Party `json:"party"` + PaymentDate time.Time `json:"payment_date"` + PaymentMethod string `json:"payment_method"` + Bank bankDTO.BankRelationDTO `json:"bank"` + ExpenseAmount float64 `json:"expense_amount"` + IncomeAmount float64 `json:"income_amount"` + Nominal float64 `json:"nominal"` + Notes string `json:"notes"` + CreatedUser userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` +} + +type TransactionDetailDTO struct { + TransactionListDTO +} + +type Party struct { + Id uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` +} + +// === Mapper Functions === + +func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + + return TransactionRelationDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + } +} + +func ToTransactionListDTO(e entity.Payment) TransactionListDTO { + expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal) + approval := approvalDTO.ApprovalRelationDTO{} + if e.LatestApproval != nil { + approval = approvalDTO.ToApprovalDTO(*e.LatestApproval) + } + + return TransactionListDTO{ + Id: e.Id, + PaymentCode: paymentCodeFromPayment(e), + ReferenceNumber: e.ReferenceNumber, + TransactionType: transactionTypeFromPayment(e), + Party: partyFromPayment(e), + PaymentDate: e.PaymentDate, + PaymentMethod: e.PaymentMethod, + Bank: bankFromPayment(e), + ExpenseAmount: expenseAmount, + IncomeAmount: incomeAmount, + Nominal: e.Nominal, + Notes: e.Notes, + CreatedUser: userFromPayment(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Approval: approval, + } +} + +func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO { + result := make([]TransactionListDTO, len(e)) + for i, r := range e { + result[i] = ToTransactionListDTO(r) + } + return result +} + +func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO { + return TransactionDetailDTO{ + TransactionListDTO: ToTransactionListDTO(e), + } +} + +func partyFromPayment(e entity.Payment) Party { + party := Party{ + Id: e.PartyId, + Type: e.PartyType, + } + + switch utils.PaymentParty(e.PartyType) { + case utils.PaymentPartyCustomer: + if e.Customer != nil && e.Customer.Id != 0 { + party.Name = e.Customer.Name + party.AccountNumber = e.Customer.AccountNumber + } + case utils.PaymentPartySupplier: + if e.Supplier != nil && e.Supplier.Id != 0 { + party.Name = e.Supplier.Name + if e.Supplier.AccountNumber != nil { + party.AccountNumber = *e.Supplier.AccountNumber + } + } + } + + return party +} + +func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO { + if e.BankWarehouse.Id == 0 { + return bankDTO.BankRelationDTO{} + } + return bankDTO.ToBankRelationDTO(e.BankWarehouse) +} + +func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { + if e.CreatedUser.Id == 0 { + return userDTO.UserRelationDTO{} + } + return userDTO.ToUserRelationDTO(e.CreatedUser) +} + +func paymentCodeFromPayment(e entity.Payment) string { + if e.PaymentCode != "" { + return e.PaymentCode + } + if e.ReferenceNumber != nil { + return *e.ReferenceNumber + } + return "" +} + +func transactionTypeFromPayment(e entity.Payment) string { + if e.TransactionType != "" { + return e.TransactionType + } + return e.Direction +} + +func paymentAmounts(direction string, nominal float64) (float64, float64) { + switch strings.ToUpper(direction) { + case "IN": + return 0, nominal + case "OUT": + return nominal, 0 + default: + return 0, 0 + } +} diff --git a/internal/modules/finance/transactions/module.go b/internal/modules/finance/transactions/module.go new file mode 100644 index 00000000..c98931a3 --- /dev/null +++ b/internal/modules/finance/transactions/module.go @@ -0,0 +1,42 @@ +package transactions + +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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" + + rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransactionModule struct{} + +func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + transactionRepo := rTransaction.NewTransactionRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register payment approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register initial approval workflow: %v", err)) + } + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register injection approval workflow: %v", err)) + } + + transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate) + userService := sUser.NewUserService(userRepo, validate) + + TransactionRoutes(router, userService, transactionService) +} diff --git a/internal/modules/finance/transactions/repositories/transaction.repository.go b/internal/modules/finance/transactions/repositories/transaction.repository.go new file mode 100644 index 00000000..d1629e8b --- /dev/null +++ b/internal/modules/finance/transactions/repositories/transaction.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 TransactionRepository interface { + repository.BaseRepository[entity.Payment] +} + +type TransactionRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Payment] +} + +func NewTransactionRepository(db *gorm.DB) TransactionRepository { + return &TransactionRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db), + } +} diff --git a/internal/modules/finance/transactions/route.go b/internal/modules/finance/transactions/route.go new file mode 100644 index 00000000..d21f5441 --- /dev/null +++ b/internal/modules/finance/transactions/route.go @@ -0,0 +1,21 @@ +package transactions + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers" + transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) { + ctrl := controller.NewTransactionController(s) + + route := v1.Group("/transactions") + // route.Use(m.Auth(u)) + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/finance/transactions/services/transaction.service.go b/internal/modules/finance/transactions/services/transaction.service.go new file mode 100644 index 00000000..f7398d43 --- /dev/null +++ b/internal/modules/finance/transactions/services/transaction.service.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations" + "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" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransactionService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type transactionService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.TransactionRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey +} + +func NewTransactionService( + repo repository.TransactionRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) TransactionService { + return &transactionService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ApprovalSvc: approvalSvc, + approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{ + string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial, + string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection, + }, + } +} + +func (s transactionService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser"). + Preload("BankWarehouse"). + Preload("Customer"). + Preload("Supplier") +} + +func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" + return db.Where( + `LOWER(payment_code) LIKE ? OR + LOWER(COALESCE(reference_number, '')) LIKE ? OR + LOWER(COALESCE(transaction_type, '')) LIKE ? OR + LOWER(COALESCE(notes, '')) LIKE ?`, + like, like, like, like, + ) + } + return db.Order("payment_date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get transactions: %+v", err) + return nil, 0, err + } + s.attachApprovals(c.Context(), transactions) + return transactions, total, nil +} + +func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) { + transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found") + } + if err != nil { + s.Log.Errorf("Failed get transaction by id: %+v", err) + return nil, err + } + if s.ApprovalSvc != nil { + approval, err := s.ApprovalSvc.LatestByTarget( + c.Context(), + s.workflowForTransaction(transaction), + id, + s.approvalQueryModifier(), + ) + if err != nil { + s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err) + } else { + transaction.LatestApproval = approval + } + } + return transaction, nil +} + +func (s transactionService) 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, "Transaction not found") + } + s.Log.Errorf("Failed to delete transaction: %+v", err) + return err + } + return nil +} + +func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) { + if s.ApprovalSvc == nil || len(transactions) == 0 { + return + } + + workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{} + for _, transaction := range transactions { + workflow := s.workflowForTransaction(&transaction) + workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id) + } + + approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs)) + for workflow, ids := range workflowIDs { + if len(ids) == 0 { + continue + } + approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier()) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err) + continue + } + approvalByWorkflow[workflow] = approvals + } + + for i := range transactions { + workflow := s.workflowForTransaction(&transactions[i]) + if approvals, ok := approvalByWorkflow[workflow]; ok { + transactions[i].LatestApproval = approvals[transactions[i].Id] + } + } +} + +func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey { + if transaction == nil { + return utils.ApprovalWorkflowPayment + } + transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType)) + if transactionType == "" { + return utils.ApprovalWorkflowPayment + } + if workflow, ok := s.approvalWorkflows[transactionType]; ok { + return workflow + } + return utils.ApprovalWorkflowPayment +} + +func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + } +} diff --git a/internal/modules/finance/transactions/validations/transaction.validation.go b/internal/modules/finance/transactions/validations/transaction.validation.go new file mode 100644 index 00000000..7d16d3ee --- /dev/null +++ b/internal/modules/finance/transactions/validations/transaction.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,gt=0"` + 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/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index 1584b07f..baea9523 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO { Name: e.Name, Status: e.Status, Location: location, + Capacity: e.Capacity, Pic: pic, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, diff --git a/internal/route/route.go b/internal/route/route.go index 294fc900..aa538b0c 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -20,6 +20,7 @@ import ( ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" // MODULE IMPORTS ) @@ -44,6 +45,7 @@ func Routes(app *fiber.App, db *gorm.DB) { ssoModule.Module{}, closings.ClosingModule{}, repports.RepportModule{}, + finance.FinanceModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b33f9b..7caa637e 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -146,6 +146,45 @@ const ( ExpenseCategoryNonBOP ExpenseCategory = "NON-BOP" ) +// ------------------------------------------------------------------- +// Payment Method +// ------------------------------------------------------------------- + +type PaymentMethod string + +const ( + PaymentMethodTransfer PaymentMethod = "TRANSFER" + PaymentMethodCash PaymentMethod = "CASH" + PaymentMethodCard PaymentMethod = "CARD" + PaymentMethodCheque PaymentMethod = "CHEQUE" + PaymentMethodSaldo PaymentMethod = "SALDO" +) + +// ------------------------------------------------------------------- +// Trasaction Type +// ------------------------------------------------------------------- + +type TransactionType string + +const ( + TransactionTypePenjualan TransactionType = "PENJUALAN" + TransactionTypePembelian TransactionType = "PEMBELIAN" + TransactionTypeBiaya TransactionType = "BIAYA" + TransactionTypeInjection TransactionType = "INJECTION" + TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL" +) + +// ------------------------------------------------------------------- +// Payment Party +// ------------------------------------------------------------------- + +type PaymentParty string + +const ( + PaymentPartyCustomer PaymentParty = "CUSTOMER" + PaymentPartySupplier PaymentParty = "SUPPLIER" +) + // ------------------------------------------------------------------- // Kandang Status // ------------------------------------------------------------------- @@ -314,6 +353,51 @@ var ExpenseApprovalSteps = map[approvalutils.ApprovalStep]string{ ExpenseStepSelesai: "Selesai", } +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -448,6 +532,30 @@ func IsValidExpenseCategory(v string) bool { return false } +func IsValidPaymentMethod(v string) bool { + switch PaymentMethod(v) { + case PaymentMethodTransfer, PaymentMethodCash, PaymentMethodCard, PaymentMethodCheque, PaymentMethodSaldo: + return true + } + return false +} + +func IsValidTransactionType(v string) bool { + switch TransactionType(v) { + case TransactionTypePenjualan, TransactionTypePembelian, TransactionTypeBiaya, TransactionTypeInjection, TransactionTypeSaldoAwal: + return true + } + return false +} + +func IsValidPaymentParty(v string) bool { + switch PaymentParty(v) { + case PaymentPartyCustomer, PaymentPartySupplier: + return true + } + return false +} + // example use // Recording helper diff --git a/tools/templates/route.tmpl b/tools/templates/route.tmpl index 26958deb..9dea2530 100644 --- a/tools/templates/route.tmpl +++ b/tools/templates/route.tmpl @@ -1,7 +1,7 @@ {{define "route"}}package {{Kebab .Entity}}s import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/controllers" {{Camel .Entity}} "gitlab.com/mbugroup/lti-api.git/internal/modules/{{Kebab .FeatName}}s/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,17 +13,12 @@ func {{Pascal .Entity}}Routes(v1 fiber.Router, u user.UserService, s {{Camel .En ctrl := controller.New{{Pascal .Entity}}Controller(s) route := v1.Group("/{{Kebab .Entity}}s") + route.Use(m.Auth(u)) - // 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("/", m.RequirePermissions(m.P_FinanceGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_FinanceGetOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_FinanceCreateOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_FinanceUpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_FinanceDeleteOne), ctrl.DeleteOne) } {{end}} From ec6da57510ae8f262089b87b0efc4e52c8be0bf4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 28 Dec 2025 08:13:50 +0700 Subject: [PATCH 17/50] feat[BE]: enhance expense management with location and project flock integration, including updates to migrations, entities, services, and validations --- ...251227100000_update_expense_table.down.sql | 24 ++++ ...20251227100000_update_expense_table.up.sql | 29 ++++ internal/entities/expense.go | 3 + .../controllers/expense.controller.go | 31 ++--- internal/modules/expenses/dto/expense.dto.go | 23 +++- .../expenses/services/expense.service.go | 128 ++++++++++++------ .../validations/expense.validation.go | 6 +- .../repositories/supplier.repository.go | 5 + .../repositories/projectflock.repository.go | 15 ++ .../purchases/services/expense_bridge.go | 2 +- 10 files changed, 197 insertions(+), 69 deletions(-) create mode 100644 internal/database/migrations/20251227100000_update_expense_table.down.sql create mode 100644 internal/database/migrations/20251227100000_update_expense_table.up.sql diff --git a/internal/database/migrations/20251227100000_update_expense_table.down.sql b/internal/database/migrations/20251227100000_update_expense_table.down.sql new file mode 100644 index 00000000..fbaff587 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.down.sql @@ -0,0 +1,24 @@ +-- Rollback: Update expense and expense_nonstocks tables + +-- Drop indexes +DROP INDEX IF EXISTS idx_expenses_project_flock_id; +DROP INDEX IF EXISTS idx_expenses_location_id; + +-- Drop Foreign Key constraint +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_expenses_location_id' + ) THEN + ALTER TABLE expenses + DROP CONSTRAINT fk_expenses_location_id; + END IF; +END $$; + +-- Drop columns from expenses table +ALTER TABLE expenses +DROP COLUMN IF EXISTS project_flock_id; + +ALTER TABLE expenses +DROP COLUMN IF EXISTS location_id; diff --git a/internal/database/migrations/20251227100000_update_expense_table.up.sql b/internal/database/migrations/20251227100000_update_expense_table.up.sql new file mode 100644 index 00000000..6415ac98 --- /dev/null +++ b/internal/database/migrations/20251227100000_update_expense_table.up.sql @@ -0,0 +1,29 @@ +-- Migration: Update expense and expense_nonstocks tables + +-- Add location_id column to expenses table +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1; + +-- Add project_flock_id column to expenses table (JSON type) +ALTER TABLE expenses +ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL; + +-- Add Foreign Key constraint to locations table +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN + ALTER TABLE expenses + ADD CONSTRAINT fk_expenses_location_id + FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- Create index for location_id +CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id); + +-- Create index for project_flock_id +CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text)); + +-- Ensure kandang_id is nullable in expense_nonstocks table +ALTER TABLE expense_nonstocks +ALTER COLUMN kandang_id DROP NOT NULL; diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 83a6031b..7bea3076 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -12,6 +12,8 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` + LocationId uint64 `gorm:"not null"` + ProjectFlockId *string `gorm:"type:json"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -21,6 +23,7 @@ type Expense struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Location *Location `gorm:"foreignKey:LocationId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 55114ec8..49642231 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { } req.SupplierID = supplierID + locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = locationID + form, err := c.MultipartForm() if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") @@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - if singleExpenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required") - } - req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} - } else { - for i, expenseNonstock := range req.ExpenseNonstocks { - if expenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) - } - } } } else { return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") @@ -171,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { req.SupplierID = &supplierID } + locationIDVal := c.FormValue("location_id") + if locationIDVal != "" { + locationID, err := strconv.ParseUint(locationIDVal, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format") + } + req.LocationID = &locationID + } + expenseNonstocksJSON := c.FormValue("expense_nonstocks") if expenseNonstocksJSON != "" { var expenseNonstocks []validation.ExpenseNonstock @@ -178,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) } - for i, expenseNonstock := range expenseNonstocks { - if expenseNonstock.KandangID == 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i)) - } - } - req.ExpenseNonstocks = &expenseNonstocks } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 4bb9ebe1..6402f8fd 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -76,7 +76,6 @@ type ExpenseRealizationDTO struct { type KandangGroupDTO struct { Id uint64 `json:"id"` - KandangId uint64 `json:"kandang_id"` Name string `json:"name,omitempty"` Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` @@ -178,7 +177,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - // Map documents from Document service for _, doc := range e.Documents { documents = append(documents, DocumentDTO{ ID: uint64(doc.Id), @@ -186,7 +184,6 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { }) } - // Map realization documents from Document service for _, doc := range e.RealizationDocuments { realizationDocs = append(realizationDocs, DocumentDTO{ ID: uint64(doc.Id), @@ -271,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO { func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { kandangMap := make(map[uint64]*KandangGroupDTO) + var directPengajuans []ExpenseNonstockDTO + var directRealisasi []ExpenseRealizationDTO for _, p := range pengajuans { var kandangId uint64 @@ -287,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali } if kandangId > 0 { + if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) + } else { + + directPengajuans = append(directPengajuans, p) } } @@ -316,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali if kandangMap[kandangId] == nil { kandangMap[kandangId] = &KandangGroupDTO{ Id: kandangId, - KandangId: kandangId, Name: kandangName, Pengajuans: []ExpenseNonstockDTO{}, Realisasi: []ExpenseRealizationDTO{}, } } kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) + } else { + } + } + + // If there are direct expenses (without kandang), add them as a special entry with id=0 + if len(directPengajuans) > 0 || len(directRealisasi) > 0 { + kandangMap[0] = &KandangGroupDTO{ + Id: 0, + + Name: "", + Pengajuans: directPengajuans, + Realisasi: directRealisasi, } } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 728c689f..b4753451 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" @@ -144,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen supplierID := uint(req.SupplierID) - supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) { - return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id) - } if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, + commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists}, ); err != nil { return nil, err } @@ -199,11 +197,47 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } createdBy := uint64(actorID) + + hasKandang := false + for _, ens := range req.ExpenseNonstocks { + if ens.KandangID != nil { + hasKandang = true + break + } + } + + var projectFlockIdJSON *string + if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) { + projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction) + activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") + } + + if len(activeProjectFlocks) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location") + } + + projectFlockIDs := make([]uint64, len(activeProjectFlocks)) + for i, pf := range activeProjectFlocks { + projectFlockIDs[i] = uint64(pf.Id) + } + + projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") + } + jsonStr := string(projectFlockIdsJSON) + projectFlockIdJSON = &jsonStr + } + expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, Category: req.Category, SupplierId: req.SupplierID, + LocationId: req.LocationID, + ProjectFlockId: projectFlockIdJSON, TransactionDate: expenseDate, CreatedBy: createdBy, } @@ -216,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen for _, expenseNonstock := range req.ExpenseNonstocks { + isAttachingToKandang := (expenseNonstock.KandangID != nil) + var projectFlockKandangId *uint64 + var kandangId *uint64 - if req.Category == string(utils.ExpenseCategoryBOP) { + if isAttachingToKandang { + kandangId = expenseNonstock.KandangID - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + if req.Category == string(utils.ExpenseCategoryBOP) { + + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + + } else { + kandangId = nil + projectFlockKandangId = nil } for _, costItem := range expenseNonstock.CostItems { nonstockId := costItem.NonstockID - var kandangId *uint64 - if req.Category == string(utils.ExpenseCategoryNonBOP) { - id := uint64(expenseNonstock.KandangID) - kandangId = &id - } else if req.Category == string(utils.ExpenseCategoryBOP) { - if projectFlockKandangId != nil { - kandangId = &expenseNonstock.KandangID - } - } - - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expense.Id, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, @@ -254,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } @@ -361,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } + if req.LocationID != nil { + locationID := uint(*req.LocationID) + updateBody["location_id"] = locationID + } + if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { responseDTO, err := s.GetOne(c, id) @@ -475,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) for _, expenseNonstock := range *req.ExpenseNonstocks { var projectFlockKandangId *uint64 + var kandangId *uint64 - if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { - projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) - projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + // Check if attaching to kandang + if expenseNonstock.KandangID != nil { + kandangId = expenseNonstock.KandangID + + if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { + // BOP with kandang: Get active project flock kandang + projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) + projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") + id := uint64(projectFlockKandang.Id) + projectFlockKandangId = &id } - id := uint64(projectFlockKandang.Id) - projectFlockKandangId = &id + // NON-BOP: projectFlockKandangId stays nil } for _, costItem := range expenseNonstock.CostItems { @@ -498,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return err } - var kandangId *uint64 - if updatedExpense.Category == string(utils.ExpenseCategoryNonBOP) { - id := uint64(expenseNonstock.KandangID) - kandangId = &id - } else if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { - if projectFlockKandangId != nil { - kandangId = &expenseNonstock.KandangID - } - } - expenseId := uint64(id) - expenseNonstock := &entity.ExpenseNonstock{ + newExpenseNonstock := &entity.ExpenseNonstock{ ExpenseId: &expenseId, ProjectFlockKandangId: projectFlockKandangId, KandangId: kandangId, @@ -519,7 +557,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) Notes: costItem.Notes, } - if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { + if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") } } diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 9dc2b07b..4501b87d 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -9,12 +9,13 @@ type Create struct { TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` + LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` } type ExpenseNonstock struct { - KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` + KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` } @@ -22,13 +23,14 @@ type CostItem struct { NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` Price float64 `form:"price" json:"price" validate:"required,gt=0"` - Notes string `form:"notes" json:"notes" validate:"required,max=500"` + Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"` } type Update struct { TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` + LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` } diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 6b5a0ae2..c4c892b5 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -12,6 +12,7 @@ type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -33,3 +34,7 @@ func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, ex func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) } + +func (r *SupplierRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Supplier](ctx, r.db, id) +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 15afaf59..4af5cbcd 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -19,6 +19,7 @@ type ProjectflockRepository interface { GetNextPeriodsForKandangs(ctx context.Context, kandangIDs []uint) (map[uint]int, error) GetCurrentProjectPeriod(ctx context.Context, projectFlockID uint) (int, error) GetKandangPeriodSummaryRows(ctx context.Context, locationID uint) ([]KandangPeriodRow, error) + GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) AreaExists(ctx context.Context, id uint) (bool, error) FcrExists(ctx context.Context, id uint) (bool, error) LocationExists(ctx context.Context, id uint) (bool, error) @@ -295,3 +296,17 @@ func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, floc } return count > 0, nil } + +func (r *ProjectflockRepositoryImpl) GetActiveByLocationID(ctx context.Context, locationID uint64) ([]entity.ProjectFlock, error) { + var projectFlocks []entity.ProjectFlock + err := r.DB().WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.project_flock_id = project_flocks.id"). + Where("project_flocks.location_id = ?", locationID). + Where("project_flock_kandangs.closed_at IS NULL"). + Group("project_flocks.id"). + Find(&projectFlocks).Error + if err != nil { + return nil, err + } + return projectFlocks, nil +} diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index d8356e6a..fd28ada6 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -571,7 +571,7 @@ func (b *expenseBridge) createExpenseViaService( Category: "BOP", SupplierID: uint64(supplierID), ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ - KandangID: uint64(*kandangID), + KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(), CostItems: costItems, }}, } From 56811f7c5b0728111eaecfdaaed8dd496a955858 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 28 Dec 2025 08:57:35 +0700 Subject: [PATCH 18/50] feat[BE]: integrate kandang repository into expense bridge for enhanced expense management --- internal/modules/purchases/module.go | 3 +++ .../modules/purchases/services/expense_bridge.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 6daf2a39..fa10559d 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -12,6 +12,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" @@ -35,6 +36,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) @@ -67,6 +69,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate db, purchaseRepo, projectFlockKandangRepository, + kandangRepo, expenseServiceInstance, ) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index fd28ada6..146f04f2 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -17,6 +17,7 @@ import ( expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -53,6 +54,7 @@ type expenseBridge struct { db *gorm.DB purchaseRepo rPurchase.PurchaseRepository projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + kandangRepo kandangRepo.KandangRepository expenseSvc expenseSvc.ExpenseService } @@ -60,12 +62,14 @@ func NewExpenseBridge( db *gorm.DB, purchaseRepo rPurchase.PurchaseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + kandangRepo kandangRepo.KandangRepository, expenseSvc expenseSvc.ExpenseService, ) PurchaseExpenseBridge { return &expenseBridge{ db: db, purchaseRepo: purchaseRepo, projectFlockKandangRepo: projectFlockKandangRepo, + kandangRepo: kandangRepo, expenseSvc: expenseSvc, } } @@ -550,6 +554,16 @@ func (b *expenseBridge) createExpenseViaService( return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") } + kandang, err := b.kandangRepo.GetByID(ctx, *kandangID, func(db *gorm.DB) *gorm.DB { + return db.Select("id, location_id") + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + if kandang == nil { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Kandang not found: %d", *kandangID)) + } + costItems := make([]expenseValidation.CostItem, 0, len(items)) for _, gi := range items { note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) @@ -570,6 +584,7 @@ func (b *expenseBridge) createExpenseViaService( TransactionDate: utils.FormatDate(expenseDate), Category: "BOP", SupplierID: uint64(supplierID), + LocationID: uint64(kandang.LocationId), ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ KandangID: func() *uint64 { id := uint64(*kandangID); return &id }(), CostItems: costItems, From a0d2c1c7dd523b540e6aa243c955f6b2735f10d3 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 28 Dec 2025 10:40:20 +0700 Subject: [PATCH 19/50] feat[BE]: enhance marketing module by adding ProductWarehouseId to marketing delivery product creation --- internal/modules/marketing/module.go | 9 +-------- .../marketing/services/salesorder.service.go | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index d8c8fc6a..b93c6129 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -33,18 +33,15 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - // Initialize FIFO service stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - // Register marketing_delivery_products as FIFO Usable - // Note: ProductWarehouseID comes from marketing_products table via preload if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyMarketingDelivery, Table: "marketing_delivery_products", Columns: fifo.UsableColumns{ ID: "id", - ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload + ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", CreatedAt: "created_at", @@ -55,11 +52,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate } } - // Initialize approval service approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) - // Register workflow steps for marketing approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) } @@ -67,11 +62,9 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - // Initialize services salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) - // Register routes RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) } diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index bef2a477..dc6e62de 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -603,15 +603,16 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont } marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ - MarketingProductId: marketingProduct.Id, - UnitPrice: 0, - TotalWeight: 0, - AvgWeight: 0, - TotalPrice: 0, - DeliveryDate: nil, - VehicleNumber: rp.VehicleNumber, - UsageQty: 0, - PendingQty: 0, + MarketingProductId: marketingProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err From 812db3f79ec82eef442670b424a10bf158c5336d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 28 Dec 2025 19:15:41 +0700 Subject: [PATCH 20/50] feat(BE): integrate FIFO service for stock adjustments and transfers - Added FIFO service integration in the adjustments module to manage stockable and usable items for adjustments. - Created a new repository for adjustment stocks to handle database operations. - Enhanced the adjustment service to track stock adjustments using FIFO logic for both increase and decrease operations. - Updated product warehouse DTOs and repositories to include project flock information. - Implemented FIFO logic in the transfer module to manage stock transfers between warehouses. - Added integration tests for FIFO operations in stock transfers, ensuring correct stock consumption and replenishment. --- ...4527_recreate_project_chikins_table.up.sql | 2 +- ..._fields_to_stock_transfer_details.down.sql | 42 +++ ...fo_fields_to_stock_transfer_details.up.sql | 83 +++++ ...12_create_adjustment_stocks_table.down.sql | 16 + ...2012_create_adjustment_stocks_table.up.sql | 40 +++ internal/entities/adjustment_stock.go | 29 ++ internal/entities/stock_transfer_detail.go | 32 +- .../modules/inventory/adjustments/module.go | 53 ++- .../adjustment_stock.repository.go | 50 +++ .../services/adjustment.service.go | 100 +++++- .../dto/product_warehouse.dto.go | 50 ++- .../product_warehouse.repository.go | 24 +- .../services/product_warehouse.service.go | 3 +- .../inventory/transfers/dto/transfer.dto.go | 4 +- .../modules/inventory/transfers/module.go | 42 ++- .../transfers/services/transfer.service.go | 180 +++++++---- .../transfer_fifo_integration_test.go | 304 ++++++++++++++++++ 17 files changed, 950 insertions(+), 104 deletions(-) create mode 100644 internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql create mode 100644 internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql create mode 100644 internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql create mode 100644 internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql create mode 100644 internal/entities/adjustment_stock.go create mode 100644 internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go create mode 100644 test/integration/inventory/transfers/transfer_fifo_integration_test.go diff --git a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql index e029646b..4ece8942 100644 --- a/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql +++ b/internal/database/migrations/20251030134527_recreate_project_chikins_table.up.sql @@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id -- Relasi ke product_warehouses ALTER TABLE project_chickins -ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; +ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE; -- Relasi ke users ALTER TABLE project_chickins diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql new file mode 100644 index 00000000..9b5b8164 --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.down.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS +-- =============================================================== + +-- Drop indexes +DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw; +DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw; + +-- Drop foreign keys +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_source_pw'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE 'ALTER TABLE stock_transfer_details + DROP CONSTRAINT fk_stock_transfer_details_dest_pw'; + END IF; +END $$; + +-- Drop FIFO columns +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS total_used, +DROP COLUMN IF EXISTS total_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS dest_product_warehouse_id, +DROP COLUMN IF EXISTS source_product_warehouse_id; + +-- Restore original columns (in case rollback) +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0, +ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3), +ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3); diff --git a/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql new file mode 100644 index 00000000..7f6ad5cb --- /dev/null +++ b/internal/database/migrations/20251228015437_add_fifo_fields_to_stock_transfer_details.up.sql @@ -0,0 +1,83 @@ +-- =============================================================== +-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS +-- Enable transfer module to work with FIFO stock system +-- +-- Notes: +-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty) +-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy) +-- - New FIFO fields track actual allocation instead of requested quantity +-- =============================================================== + +-- Add FIFO tracking fields +ALTER TABLE stock_transfer_details +ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT, +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0; + +-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used) +ALTER TABLE stock_transfer_details +DROP COLUMN IF EXISTS quantity, +DROP COLUMN IF EXISTS before_quantity, +DROP COLUMN IF EXISTS after_quantity; + +-- Add foreign keys for product warehouse references +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + -- Source warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_source_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_source_pw + FOREIGN KEY (source_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + + -- Destination warehouse foreign key + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_stock_transfer_details_dest_pw' + ) THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_dest_pw + FOREIGN KEY (dest_product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +-- Add indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw +ON stock_transfer_details (source_product_warehouse_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw +ON stock_transfer_details (dest_product_warehouse_id); + +-- Add comments for documentation +COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS +'Source product warehouse ID - referensi warehouse asal (FIFO usable)'; + +COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS +'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)'; + +COMMENT ON COLUMN stock_transfer_details.usage_qty IS +'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field'; + +COMMENT ON COLUMN stock_transfer_details.pending_qty IS +'Quantity waiting for stock availability (FIFO usable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_qty IS +'Total lot quantity available at destination warehouse (FIFO stockable tracking)'; + +COMMENT ON COLUMN stock_transfer_details.total_used IS +'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)'; + diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql new file mode 100644 index 00000000..9941a992 --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.down.sql @@ -0,0 +1,16 @@ +-- Rollback: Drop adjustment_stocks table + +BEGIN; + +DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse; +DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse; + +ALTER TABLE adjustment_stocks +DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log; + +DROP TABLE IF EXISTS adjustment_stocks; + +COMMIT; diff --git a/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql new file mode 100644 index 00000000..1c79439b --- /dev/null +++ b/internal/database/migrations/20251228112012_create_adjustment_stocks_table.up.sql @@ -0,0 +1,40 @@ +-- Migration: Create adjustment_stocks table for FIFO tracking +-- This table tracks FIFO allocation for stock adjustments (both increase and decrease) + +BEGIN; + +CREATE TABLE IF NOT EXISTS adjustment_stocks ( + id BIGSERIAL PRIMARY KEY, + stock_log_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + + -- FIFO fields for Adjustment INCREASE (Stockable) + -- Tracks stock added to warehouse via adjustment + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + + -- FIFO fields for Adjustment DECREASE (Usable) + -- Tracks stock consumed from warehouse via adjustment + usage_qty NUMERIC(15, 3) DEFAULT 0, + pending_qty NUMERIC(15, 3) DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Foreign keys +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_stock_log +FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE adjustment_stocks +ADD CONSTRAINT fk_adjustment_stocks_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id) +ON DELETE CASCADE ON UPDATE CASCADE; + +-- Indexes +CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id); +CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id); + +COMMIT; diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go new file mode 100644 index 00000000..bbc93167 --- /dev/null +++ b/internal/entities/adjustment_stock.go @@ -0,0 +1,29 @@ +package entities + +import "time" + +// AdjustmentStock tracks FIFO allocation for stock adjustments +// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse +// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse +type AdjustmentStock struct { + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + + // === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) === + // Tracks stock added to warehouse via adjustment INCREASE + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available + TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot + + // === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) === + // Tracks stock consumed from warehouse via adjustment DECREASE + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock) + + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` + + // Relations + StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 253a3bf8..9ab27824 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -7,12 +7,28 @@ type StockTransferDetail struct { 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"` + + // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) === + // Tracking stock yang DIAMBIL dari source warehouse + SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil + PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock) + + // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) === + // Tracking stock yang DITAMBAHKAN ke destination warehouse + DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"` + TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia + TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini + + // === METADATA === + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + + // === RELATIONS === + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"` + DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 610dc11e..08e556ea 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,6 +5,9 @@ import ( "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" + rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" 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" @@ -13,19 +16,67 @@ import ( 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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type AdjustmentModule struct{} func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Repositories stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) + adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("ADJUSTMENT_IN"), + Table: "adjustment_stocks", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) + } + + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("ADJUSTMENT_OUT"), + Table: "adjustment_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) + } + + adjustmentService := sAdjustment.NewAdjustmentService( + productRepo, + stockLogsRepo, + warehouseRepo, + productWarehouseRepo, + adjustmentStockRepo, + fifoService, + validate, + projectFlockKandangRepo, + ) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go new file mode 100644 index 00000000..8d62b05c --- /dev/null +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -0,0 +1,50 @@ +package repositories + +import ( + "context" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type AdjustmentStockRepository interface { + CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error + GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) + WithTx(tx *gorm.DB) AdjustmentStockRepository + DB() *gorm.DB +} + +type adjustmentStockRepositoryImpl struct { + db *gorm.DB +} + +func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: db} +} + +func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error { + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + return q.Create(data).Error +} + +func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { + var record entity.AdjustmentStock + err := r.db.WithContext(ctx). + Where("stock_log_id = ?", stockLogID). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository { + return &adjustmentStockRepositoryImpl{db: tx} +} + +func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { + return r.db +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 5a634382..d7b1641b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -12,6 +12,7 @@ import ( common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" 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" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" @@ -29,24 +30,37 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository - ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository + FifoSvc common.FifoService } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { +func NewAdjustmentService( + productRepo productRepo.ProductRepository, + stockLogsRepo stockLogsRepo.StockLogRepository, + warehouseRepo warehouseRepo.WarehouseRepository, + productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, + adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, + fifoSvc common.FifoService, + validate *validator.Validate, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, +) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, - ProjectFlockKandangRepo: projectFlockKandangRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + AdjustmentStockRepository: adjustmentStockRepo, + FifoSvc: fifoSvc, } } @@ -152,15 +166,16 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + // Create StockLog for history tracking afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, // TODO: should Get from auth middleware + CreatedBy: actorID, } + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity @@ -177,6 +192,57 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return err } + // Create AdjustmentStock record for FIFO tracking + adjustmentStock := &entity.AdjustmentStock{ + StockLogId: newLog.Id, + ProductWarehouseId: productWarehouse.Id, + } + + if transactionType == string(utils.StockLogTransactionTypeIncrease) { + // Adjustment INCREASE → Replenish stock (Stockable) + note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) + replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + StockableKey: "ADJUSTMENT_IN", + StockableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) + } + + // Update stockable tracking fields + adjustmentStock.TotalQty = replenishResult.AddedQuantity + adjustmentStock.TotalUsed = 0 + + } else { + // Adjustment DECREASE → Consume stock (Usable) + consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + UsableKey: "ADJUSTMENT_OUT", + UsableID: newLog.Id, + ProductWarehouseID: uint(productWarehouse.Id), + Quantity: req.Quantity, + AllowPending: false, // Don't allow pending for adjustment + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) + } + + // Update usable tracking fields + adjustmentStock.UsageQty = consumeResult.UsageQuantity + adjustmentStock.PendingQty = consumeResult.PendingQuantity + } + + // Save AdjustmentStock record + if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { + s.Log.Errorf("Failed to create adjustment stock: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") + } + + // Update ProductWarehouse quantity (for backward compatibility/reporting) 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) 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 81fbec1f..57a13021 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct { type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` - CreatedUser *UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` + CreatedUser *UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserRelationDTO struct { @@ -71,6 +72,19 @@ type AreaRelationDTO struct { Name string `json:"name"` } +type ProjectFlockKandangRelationDTO struct { + Id uint `json:"id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Period int `json:"period"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` +} + +type ProjectFlockRelationDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` +} + // === Mapper Functions === func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { @@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT // Map Product relation jika ada if e.Product.Id != 0 { product := productDTO.ToProductRelationDTO(e.Product) + + // Tambahkan flock name ke product name jika ada project flock + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + } + dto.Product = &product } @@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.Warehouse = &warehouse } + // Map ProjectFlockKandang relation jika ada + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 { + pfkDTO := &ProjectFlockKandangRelationDTO{ + Id: e.ProjectFlockKandang.Id, + ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId, + KandangId: e.ProjectFlockKandang.KandangId, + Period: e.ProjectFlockKandang.Period, + } + + // Map ProjectFlock jika ada + if e.ProjectFlockKandang.ProjectFlock.Id != 0 { + pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ + Id: e.ProjectFlockKandang.ProjectFlock.Id, + FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName, + } + } + + dto.ProjectFlockKandang = pfkDTO + } + // Map CreatedUser relation jika ada // if e.CreatedUser.Id != 0 { // user := UserRelationDTO{ diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 4f213f2c..e759138e 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -81,9 +81,29 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse - if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil { + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId). + Order("id DESC"). + Preload("ProjectFlockKandang"). + First(&productWarehouse).Error + + if err == nil { + + if productWarehouse.ProjectFlockKandang.ClosedAt == nil { + return &productWarehouse, nil + } + + } + + err = r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId). + First(&productWarehouse).Error + + if err != nil { return nil, err } + return &productWarehouse, nil } @@ -244,6 +264,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u Preload("Warehouse"). Preload("Warehouse.Area"). Preload("Warehouse.Location"). + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock"). First(&productWarehouse, id).Error if err != nil { return nil, err 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 f690b2a2..152bfa24 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Warehouse.Location"). Preload("Warehouse.Area"). Preload("Warehouse.Kandang"). - Preload("ProjectFlockKandang") + Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.ProjectFlock") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index f1286595..8f075715 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -159,7 +159,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } @@ -229,7 +229,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { Id: d.Product.Id, Name: d.Product.Name, }, - Quantity: d.Quantity, + Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated }) } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 9389f9f4..60d1764a 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -18,6 +18,8 @@ import ( 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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type TransferModule struct{} @@ -34,13 +36,51 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) if err != nil { panic(err) } - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) + // Initialize FIFO Service + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Transfer as Stockable (adds stock to destination warehouse) + err = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("STOCK_TRANSFER_IN"), + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + // Register Transfer as Usable (consumes stock from source warehouse) + err = fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKey("STOCK_TRANSFER_OUT"), + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService) 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 a8a8996e..8ae019a4 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -44,9 +44,10 @@ type transferService struct { WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService + FifoSvc commonSvc.FifoService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -60,6 +61,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, + FifoSvc: fifoSvc, } } @@ -126,6 +128,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { + // === VALIDASI SOURCE WAREHOUSE === pwIDs := make([]uint, 0, len(req.Products)) for _, product := range req.Products { @@ -152,6 +155,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } + destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID)) + if err != nil { + return nil, err + } + + if s.ProjectFlockKandangRepo != nil { + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + } + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -206,14 +224,62 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - var details []*entity.StockTransferDetail + // Prepare details and fetch product warehouses + details := make([]*entity.StockTransferDetail, 0, len(req.Products)) + detailMap := make(map[uint64]*entity.StockTransferDetail) + for _, product := range req.Products { - details = append(details, &entity.StockTransferDetail{ + // Get source product warehouse + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") + } + + // Get or create destination product warehouse + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") + } + if errors.Is(err, gorm.ErrRecordNotFound) { + ctx := c.Context() + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) + if err != nil { + return err + } + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") + } + } + + detail := &entity.StockTransferDetail{ StockTransferId: entityTransfer.Id, ProductId: uint64(product.ProductID), - Quantity: product.ProductQty, - }) + + SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(), + UsageQty: 0, + PendingQty: 0, + + DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(), + TotalQty: 0, + TotalUsed: 0, + } + details = append(details, detail) + detailMap[uint64(product.ProductID)] = detail } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { return err } @@ -233,23 +299,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - detailMap := make(map[uint64]uint64) - for _, d := range details { - detailMap[d.ProductId] = d.Id - } - var deliveryItems []*entity.StockTransferDeliveryItem for i, delivery := range deliveries { item := req.Deliveries[i] for _, prod := range item.Products { - detailID, ok := detailMap[uint64(prod.ProductID)] + detail, 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, + StockTransferDetailId: detail.Id, Quantity: prod.ProductQty, }) } @@ -280,69 +341,54 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + // Execute FIFO operations for each product for _, product := range req.Products { - sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + detail := detailMap[uint64(product.ProductID)] + + // Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + Quantity: product.ProductQty, + AllowPending: false, // Don't allow pending, must have actual stock + Tx: tx, + }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") - } - if sourcePW.Quantity < product.ProductQty { - 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 { - return err + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) } - decreaseLog := &entity.StockLog{ - Decrease: product.ProductQty, - Notes: "", - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(entityTransfer.Id), - ProductWarehouseId: sourcePW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - return err + // Update usage tracking fields for source warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update usage tracking: %w", err) } - destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), - ) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") - } - if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - ctx := c.Context() - projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) - if err != nil { - return err - } - destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, - } - if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") - } + // Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN) + note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) + replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: uint(detail.Id), + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + Quantity: product.ProductQty, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) } - destPW.Quantity += product.ProductQty - if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { - return err - } - - increaseLog := &entity.StockLog{ - Increase: product.ProductQty, - LoggableType: string(utils.StockLogTypeTransfer), - LoggableId: uint(entityTransfer.Id), - Notes: "", - ProductWarehouseId: destPW.Id, - CreatedBy: actorID, - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - return err + // Update total tracking fields for destination warehouse + if err := tx.Model(&entity.StockTransferDetail{}). + Where("id = ?", detail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + return fmt.Errorf("gagal update total tracking: %w", err) } } diff --git a/test/integration/inventory/transfers/transfer_fifo_integration_test.go b/test/integration/inventory/transfers/transfer_fifo_integration_test.go new file mode 100644 index 00000000..d9f127a1 --- /dev/null +++ b/test/integration/inventory/transfers/transfer_fifo_integration_test.go @@ -0,0 +1,304 @@ +package test + +import ( + "context" + "math" + "strings" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" +) + +// Test Transfer FIFO with Purchase as initial stockable +func TestTransferFIFO_PurchaseToTransfer(t *testing.T) { + db, fifoSvc := setupTransferFIFOTest(t) + ctx := context.Background() + + // Setup warehouses + sourcePW := createProductWarehouseRow(t, db, 100) // 100 qty from purchase + destPW := createProductWarehouseRow(t, db, 0) // 0 qty initially + + // Step 1: Simulate Purchase - Replenish stock to source warehouse + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if _, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: 1, // PurchaseItem ID + ProductWarehouseID: sourcePW.Id, + Quantity: 100, + }); err != nil { + t.Fatalf("Failed to replenish from purchase: %v", err) + } + + // Verify source warehouse has stock + assertWarehouseQuantity(t, db, sourcePW.Id, 100) + assertAllocationCount(t, db, 1) // 1 allocation from purchase + + // Step 2: Create Transfer - will consume from source (usable) and replenish to dest (stockable) + + // Register Transfer as Usable (source warehouse - STOCK_TRANSFER_OUT) + transferUsableKey := fifo.UsableKey("STOCK_TRANSFER_OUT") + if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: transferUsableKey, + Table: "stock_transfer_details", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_OUT as Usable: %v", err) + } + + // Register Transfer as Stockable (destination warehouse - STOCK_TRANSFER_IN) + transferStockableKey := fifo.StockableKey("STOCK_TRANSFER_IN") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: transferStockableKey, + Table: "stock_transfer_details", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "dest_product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("Failed to register STOCK_TRANSFER_IN as Stockable: %v", err) + } + + // Create transfer detail record + transferDetail := entity.StockTransferDetail{ + Id: 1, + StockTransferId: 1, + ProductId: 1, + SourceProductWarehouseID: uint64Ptr(uint64(sourcePW.Id)), + DestProductWarehouseID: uint64Ptr(uint64(destPW.Id)), + UsageQty: 0, + PendingQty: 0, + TotalQty: 0, + TotalUsed: 0, + } + transferDetailID := uint(transferDetail.Id) + if err := db.Create(&transferDetail).Error; err != nil { + t.Fatalf("Failed to create transfer detail: %v", err) + } + + transferQty := 50.0 + + // Consume from source warehouse (STOCK_TRANSFER_OUT) + consumeResult, err := fifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: "STOCK_TRANSFER_OUT", + UsableID: transferDetailID, + ProductWarehouseID: sourcePW.Id, + Quantity: transferQty, + AllowPending: false, // Don't allow pending + }) + if err != nil { + t.Fatalf("Failed to consume from source warehouse: %v", err) + } + + // Verify consumption + if mathAbs(consumeResult.UsageQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected usage quantity %.2f, got %.2f", transferQty, consumeResult.UsageQuantity) + } + if mathAbs(consumeResult.PendingQuantity) > 1e-6 { + t.Fatalf("Expected pending quantity 0, got %.2f", consumeResult.PendingQuantity) + } + + // Update transfer detail usable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "usage_qty": consumeResult.UsageQuantity, + "pending_qty": consumeResult.PendingQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail usable fields: %v", err) + } + + // Verify source warehouse decreased + assertWarehouseQuantity(t, db, sourcePW.Id, 50) // 100 - 50 = 50 + + // Verify allocation updated - should have 50 allocated to transfer + allocations := fetchAllocationsByUsable(t, db, "STOCK_TRANSFER_OUT", transferDetailID) + if len(allocations) != 1 { + t.Fatalf("Expected 1 allocation, got %d", len(allocations)) + } + if mathAbs(allocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected allocation qty %.2f, got %.2f", transferQty, allocations[0].Qty) + } + + // Replenish to destination warehouse (STOCK_TRANSFER_IN) + note := "Transfer #1" + replenishResult, err := fifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: "STOCK_TRANSFER_IN", + StockableID: transferDetailID, + ProductWarehouseID: destPW.Id, + Quantity: transferQty, + Note: ¬e, + }) + if err != nil { + t.Fatalf("Failed to replenish to destination warehouse: %v", err) + } + + // Verify replenishment + if mathAbs(replenishResult.AddedQuantity-transferQty) > 1e-6 { + t.Fatalf("Expected added quantity %.2f, got %.2f", transferQty, replenishResult.AddedQuantity) + } + + // Update transfer detail stockable fields + if err := db.Model(&entity.StockTransferDetail{}). + Where("id = ?", transferDetail.Id). + Updates(map[string]interface{}{ + "total_qty": replenishResult.AddedQuantity, + }).Error; err != nil { + t.Fatalf("Failed to update transfer detail stockable fields: %v", err) + } + + // Verify destination warehouse increased + assertWarehouseQuantity(t, db, destPW.Id, transferQty) + + // Verify new stockable allocation created + stockableAllocations := fetchAllocationsByStockable(t, db, "STOCK_TRANSFER_IN", transferDetailID) + if len(stockableAllocations) != 1 { + t.Fatalf("Expected 1 stockable allocation, got %d", len(stockableAllocations)) + } + if mathAbs(stockableAllocations[0].Qty-transferQty) > 1e-6 { + t.Fatalf("Expected stockable allocation qty %.2f, got %.2f", transferQty, stockableAllocations[0].Qty) + } + + t.Logf("✅ Transfer FIFO test passed:") + t.Logf(" - Source warehouse: 100 → 50 (consumed %d)", int(transferQty)) + t.Logf(" - Destination warehouse: 0 → %d (replenished)", int(transferQty)) + t.Logf(" - Usable allocation: %.2f allocated to transfer", allocations[0].Qty) + t.Logf(" - Stockable allocation: %.2f available at destination", stockableAllocations[0].Qty) +} + +// Setup function for transfer FIFO test +func setupTransferFIFOTest(t *testing.T) (*gorm.DB, commonSvc.FifoService) { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open db: %v", err) + } + + if err := db.AutoMigrate( + &entity.ProductWarehouse{}, + &entity.StockAllocation{}, + &entity.StockTransferDetail{}, + ); err != nil { + t.Fatalf("auto migrate entities: %v", err) + } + + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + + // Register Purchase as Stockable + purchaseStockableKey := fifo.StockableKey("PURCHASE_ITEMS") + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: purchaseStockableKey, + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + t.Fatalf("register purchase stockable: %v", err) + } + + return db, fifoSvc +} + +// Helper functions + +func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.ProductWarehouse { + t.Helper() + pw := entity.ProductWarehouse{ + ProductId: 1, + WarehouseId: 1, + Quantity: qty, + } + if err := db.Create(&pw).Error; err != nil { + t.Fatalf("create product warehouse: %v", err) + } + return pw +} + +func assertWarehouseQuantity(t *testing.T, db *gorm.DB, pwID uint, expected float64) { + t.Helper() + var pw entity.ProductWarehouse + if err := db.First(&pw, pwID).Error; err != nil { + t.Fatalf("fetch product warehouse %d: %v", pwID, err) + } + if mathAbs(pw.Quantity-expected) > 1e-6 { + t.Fatalf("expected warehouse quantity %.2f, got %.2f", expected, pw.Quantity) + } +} + +func assertAllocationCount(t *testing.T, db *gorm.DB, expected int) { + t.Helper() + var count int64 + if err := db.Model(&entity.StockAllocation{}).Count(&count).Error; err != nil { + t.Fatalf("count allocations: %v", err) + } + if int(count) != expected { + t.Fatalf("expected %d allocations, got %d", expected, count) + } +} + +func fetchAllocationsByUsable(t *testing.T, db *gorm.DB, usableType string, usableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("usable_type = ? AND usable_id = ?", usableType, usableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by usable: %v", err) + } + return allocations +} + +func fetchAllocationsByStockable(t *testing.T, db *gorm.DB, stockableType string, stockableID uint) []entity.StockAllocation { + t.Helper() + var allocations []entity.StockAllocation + if err := db.Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Find(&allocations).Error; err != nil { + t.Fatalf("fetch allocations by stockable: %v", err) + } + return allocations +} + +func floatPtr(f float64) *float64 { + return &f +} + +func uint64Ptr(u uint64) *uint64 { + return &u +} + +func mathAbs(f float64) float64 { + return math.Abs(f) +} + +func sanitizeKey(name string) string { + return strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + return r + } + return '_' + }, name) +} From 644896edfa8bab01eef86ab81f3ed9183c622ec0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 00:21:26 +0700 Subject: [PATCH 21/50] feat(BE-281): unfinished uniformity and create project flock triger productwarehouse and add new filtering lookup --- .DS_Store | Bin 6148 -> 6148 bytes go.mod | 7 + go.sum | 22 +- ..._project_flock_kandang_uniformity.down.sql | 6 + ...te_project_flock_kandang_uniformity.up.sql | 58 ++ .../project_flock_kandang_uniformity.go | 33 + internal/entities/uniformity.go | 18 + .../product_warehouse.repository.go | 26 + .../controllers/projectflock.controller.go | 8 + .../dto/projectflock_kandang.dto.go | 1 + .../production/project_flocks/module.go | 5 +- .../project_flock_population_repository.go | 18 + .../projectflock_kandang.repository.go | 15 + .../services/projectflock.service.go | 129 +++ .../repositories/recording.repository.go | 22 + internal/modules/production/route.go | 6 +- .../controllers/uniformity.controller.go | 246 ++++++ .../uniformities/dto/uniformity.dto.go | 208 +++++ .../modules/production/uniformities/module.go | 43 + .../repositories/uniformity.repository.go | 21 + .../modules/production/uniformities/route.go | 30 + .../services/uniformity.body_weight_excel.go | 195 +++++ .../services/uniformity.service.go | 738 ++++++++++++++++++ .../validations/uniformity.validation.go | 173 ++++ internal/utils/constant.go | 23 +- 25 files changed, 2043 insertions(+), 8 deletions(-) create mode 100644 internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.down.sql create mode 100644 internal/database/migrations/20251227234328_create_project_flock_kandang_uniformity.up.sql create mode 100644 internal/entities/project_flock_kandang_uniformity.go create mode 100644 internal/entities/uniformity.go create mode 100644 internal/modules/production/uniformities/controllers/uniformity.controller.go create mode 100644 internal/modules/production/uniformities/dto/uniformity.dto.go create mode 100644 internal/modules/production/uniformities/module.go create mode 100644 internal/modules/production/uniformities/repositories/uniformity.repository.go create mode 100644 internal/modules/production/uniformities/route.go create mode 100644 internal/modules/production/uniformities/services/uniformity.body_weight_excel.go create mode 100644 internal/modules/production/uniformities/services/uniformity.service.go create mode 100644 internal/modules/production/uniformities/validations/uniformity.validation.go diff --git a/.DS_Store b/.DS_Store index 4c14efd89e4d913a63e6242a245ab626c5fffe6d..e39247fdff6549a6304ce8065c332c38da11c1a4 100644 GIT binary patch delta 31 ncmZoMXfc@J&nU4mU^g?P#AF_p{LPzLLYOBuSZrqJ_{$Ffpo$73 delta 70 zcmZoMXfc@J&nUSuU^g?P 0 { + return *latest.TotalChickQty, nil + } + } + + total, err := s.PopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang population %d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang population") + } + + return total, nil +} + func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { idStr = strings.TrimSpace(idStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) @@ -793,6 +830,9 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * } return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } + if err := s.ensureProjectFlockKandangProductWarehouses(ctx, dbTransaction, records); err != nil { + return err + } return nil } @@ -818,6 +858,23 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", "))) } + pfkIDs, err := s.pivotRepoWithTx(dbTransaction).ListIDsByProjectAndKandang(ctx, projectFlockID, kandangIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandang ids") + } + + if len(pfkIDs) > 0 { + pwRepo := s.ProductWarehouseRepo + if dbTransaction != nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) + } else if pwRepo == nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB()) + } + if err := pwRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove product warehouses for project flock kandang") + } + } + if resetStatus { if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") @@ -854,6 +911,78 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka return kandangRepository.NewKandangRepository(s.Repository.DB()) } +func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx context.Context, dbTransaction *gorm.DB, records []*entity.ProjectFlockKandang) error { + if len(records) == 0 { + return nil + } + + pwRepo := s.ProductWarehouseRepo + if dbTransaction != nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction) + } else if pwRepo == nil { + pwRepo = productWarehouseRepository.NewProductWarehouseRepository(s.Repository.DB()) + } + + warehouseRepo := s.WarehouseRepo + if dbTransaction != nil { + warehouseRepo = warehouseRepository.NewWarehouseRepository(dbTransaction) + } else if warehouseRepo == nil { + warehouseRepo = warehouseRepository.NewWarehouseRepository(s.Repository.DB()) + } + + flags := []utils.FlagType{ + utils.FlagAyamAfkir, + utils.FlagAyamCulling, + utils.FlagAyamMati, + utils.FlagTelurPecah, + utils.FlagTelurUtuh, + } + + productIDs := make(map[utils.FlagType]uint, len(flags)) + for _, flag := range flags { + product, err := pwRepo.GetFirstProductByFlag(ctx, string(flag)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product untuk flag %s tidak ditemukan", flag)) + } + return err + } + productIDs[flag] = product.Id + } + + for _, record := range records { + if record == nil || record.Id == 0 { + continue + } + + warehouse, err := warehouseRepo.GetByKandangID(ctx, record.KandangId) + if err != nil { + return err + } + + for _, flag := range flags { + productID := productIDs[flag] + if _, err := pwRepo.GetByProductWarehouseAndProjectFlockKandang(ctx, productID, warehouse.Id, record.Id); err == nil { + continue + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + newPW := entity.ProductWarehouse{ + ProductId: productID, + WarehouseId: warehouse.Id, + ProjectFlockKandangId: &record.Id, + Quantity: 0, + } + if err := pwRepo.CreateOne(ctx, &newPW, nil); err != nil { + return err + } + } + } + + return nil +} + func (s projectflockService) Resubmit(c *fiber.Ctx, req *validation.Resubmit, id uint) (*entity.ProjectFlock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6e362ba7..a615692f 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -17,6 +17,7 @@ type RecordingRepository interface { repository.BaseRepository[entity.Recording] WithRelations(db *gorm.DB) *gorm.DB + GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) CreateBodyWeights(tx *gorm.DB, bodyWeights []entity.RecordingBW) error @@ -81,6 +82,27 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs.ProductWarehouse.Warehouse") } +func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { + if projectFlockKandangId == 0 { + return nil, errors.New("project_flock_kandang_id is required") + } + + var record entity.Recording + err := r.DB().WithContext(ctx). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Order("record_datetime DESC"). + Order("created_at DESC"). + Limit(1). + Find(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) || record.Id == 0 { + return nil, nil + } + if err != nil { + return nil, err + } + return &record, nil +} + func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { var days []int if err := tx.Model(&entity.Recording{}). diff --git a/internal/modules/production/route.go b/internal/modules/production/route.go index d1425b7c..4066121a 100644 --- a/internal/modules/production/route.go +++ b/internal/modules/production/route.go @@ -8,10 +8,11 @@ import ( "gorm.io/gorm" chickins "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins" + projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" projectflocks "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks" recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" transferLayings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings" - projectFlockKandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs" + uniformitys "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities" // MODULE IMPORTS ) @@ -24,8 +25,9 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida chickins.ChickinModule{}, transferLayings.TransferLayingModule{}, projectFlockKandangs.ProjectFlockKandangModule{}, + uniformitys.UniformityModule{}, // MODULE REGISTRY -} + } for _, m := range allModules { m.RegisterRoutes(group, db, validate) diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go new file mode 100644 index 00000000..b6874ba4 --- /dev/null +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -0,0 +1,246 @@ +package controller + +import ( + "math" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type UniformityController struct { + UniformityService service.UniformityService +} + +func NewUniformityController(uniformityService service.UniformityService) *UniformityController { + return &UniformityController{ + UniformityService: uniformityService, + } +} + +func (u *UniformityController) GetAll(c *fiber.Ctx) error { + query, err := validation.ParseQuery(c) + if err != nil { + return err + } + + result, totalResults, err := u.UniformityService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all production uniformities successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + Filters: fiber.Map{ + "location_id": "", + "project_flock_id": "", + "status": "Pengajuan", + }, + }, + Data: dto.ToUniformityListDTOs(result), + }) +} + +func (u *UniformityController) GetOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + result, err := u.UniformityService.GetOne(c, id) + if err != nil { + return err + } + + withDetails := c.QueryBool("with_details", false) + calculation := service.UniformityCalculation{} + var document *entity.Document + var meanWeight float64 + if result.MeanUp > 0 { + meanWeight = math.Round(result.MeanUp / 1.10) + } + if withDetails { + var err error + calculation, document, err = u.UniformityService.CalculateUniformityFromDocument(c, id) + if err != nil { + return err + } + } else { + calculation = service.UniformityCalculation{ + ChickQtyOfWeight: result.ChickQtyOfWeight, + MeanWeight: meanWeight, + MeanDown: result.MeanDown, + MeanUp: result.MeanUp, + UniformQty: result.UniformQty, + OutsideQty: result.NotUniformQty, + Uniformity: result.Uniformity, + Cv: result.Cv, + } + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get production uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document), + }) +} + +func (u *UniformityController) CreateOne(c *fiber.Ctx) error { + req, file, err := validation.ParseCreate(c) + if err != nil { + return err + } + + rows, err := u.UniformityService.ParseBodyWeightExcel(c, file) + if err != nil { + return err + } + + calculation, err := u.UniformityService.ComputeUniformity(rows) + if err != nil { + return err + } + + result, err := u.UniformityService.CreateOne(c, req, file, rows) + if err != nil { + return err + } + + document := dto.NewDocumentForResponse(file.Filename) + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document), + }) +} + +func (u *UniformityController) UploadBodyWeightExcel(c *fiber.Ctx) error { + files, err := validation.ParseUploadFiles(c) + if err != nil { + return err + } + + rows, err := u.UniformityService.ParseBodyWeightExcel(c, files[0]) + if err != nil { + return err + } + + calculation, err := u.UniformityService.ComputeUniformity(rows) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Uniformity verified successfully", + Data: dto.ToUniformityVerificationDTO(calculation), + }) +} + +func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + req, file, err := validation.ParseUpdate(c) + if err != nil { + return err + } + + var rows []service.BodyWeightExcelRow + if file != nil { + parsed, err := u.UniformityService.ParseBodyWeightExcel(c, file) + if err != nil { + return err + } + rows = parsed + } + + result, err := u.UniformityService.UpdateOne(c, req, id, file, rows) + if err != nil { + return err + } + + calculation, document, err := u.UniformityService.CalculateUniformityFromDocument(c, id) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update uniformity successfully", + Data: dto.ToUniformityDetailDTO(*result, calculation, document), + }) +} + +func (u *UniformityController) DeleteOne(c *fiber.Ctx) error { + id, err := validation.ParseIDParam(c, "id") + if err != nil { + return err + } + + if err := u.UniformityService.DeleteOne(c, id); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete uniformity successfully", + }) +} + +func (u *UniformityController) Approve(c *fiber.Ctx) error { + req, err := validation.ParseApprove(c) + if err != nil { + return err + } + + results, err := u.UniformityService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit uniformity approvals successfully" + ) + + if len(results) == 1 { + message = "Submit uniformity approval successfully" + data = dto.ToUniformityListDTOs(results)[0] + } else { + data = dto.ToUniformityListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go new file mode 100644 index 00000000..1c9f4c4d --- /dev/null +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -0,0 +1,208 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" +) + +type UniformitySamplingDTO struct { + ChickQtyOfWeight float64 `json:"chick_qty_of_weight"` + MeanWeight float64 `json:"mean_weight"` + MeanDown float64 `json:"mean_down"` + MeanUp float64 `json:"mean_up"` +} + +type UniformityResultDTO struct { + UniformQty float64 `json:"uniform_qty"` + OutsideQty float64 `json:"outside_qty"` + Uniformity float64 `json:"uniformity"` + Cv float64 `json:"cv"` +} + +type UniformityDetailItemDTO struct { + Id int `json:"id"` + Weight float64 `json:"weight"` + Range string `json:"range"` +} + +type UniformityVerificationDTO struct { + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` +} + +type UniformityInfoDTO struct { + Tanggal string `json:"tanggal"` + LokasiFarm string `json:"lokasi_farm"` + ProjectFlock string `json:"project_flock"` + Kandang string `json:"kandang"` + FileName string `json:"file_name"` +} + +type UniformityDetailDTO struct { + Id uint `json:"id"` + InfoUmum UniformityInfoDTO `json:"info_umum"` + Sampling UniformitySamplingDTO `json:"sampling"` + Result UniformityResultDTO `json:"result"` + UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` +} + +type UniformityListDTO struct { + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + LocationName string `json:"location_name"` + FlockName string `json:"flock_name"` + KandangName string `json:"kandang_name"` + AppliedAt *time.Time `json:"applied_at"` + Week int `json:"week"` + Status string `json:"status"` + Uniformity float64 `json:"uniformity"` + Cv float64 `json:"cv"` + ChickQtyOfWeight float64 `json:"chick_qty_of_weight"` + UniformQty float64 `json:"uniform_qty"` + MeanUp float64 `json:"mean_up"` + MeanDown float64 `json:"mean_down"` + CreatedAt time.Time `json:"created_at"` + CreatedBy uint `json:"created_by"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` +} + +func NewDocumentForResponse(name string) *entity.Document { + if name == "" { + return nil + } + return &entity.Document{Name: name} +} + +func ToUniformityVerificationDTO(calc service.UniformityCalculation) UniformityVerificationDTO { + return UniformityVerificationDTO{ + Sampling: toUniformitySamplingDTO(calc), + Result: toUniformityResultDTO(calc), + UniformityDetails: toUniformityDetailItemsDTO(calc), + } +} + +func ToUniformityDetailDTO( + entityData entity.ProjectFlockKandangUniformity, + calc service.UniformityCalculation, + document *entity.Document, +) UniformityDetailDTO { + info := UniformityInfoDTO{ + Tanggal: formatUniformityDate(entityData.UniformDate), + LokasiFarm: resolveLocationName(entityData.ProjectFlockKandang), + ProjectFlock: resolveProjectFlockName(entityData.ProjectFlockKandang), + Kandang: resolveKandangName(entityData.ProjectFlockKandang), + FileName: "", + } + if document != nil { + info.FileName = document.Name + } + + return UniformityDetailDTO{ + Id: entityData.Id, + InfoUmum: info, + Sampling: toUniformitySamplingDTO(calc), + Result: toUniformityResultDTO(calc), + UniformityDetails: toUniformityDetailItemsDTO(calc), + } +} + +func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []UniformityListDTO { + result := make([]UniformityListDTO, len(items)) + for i, item := range items { + var latestApproval *approvalDTO.ApprovalRelationDTO + status := "Pengajuan" + if item.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*item.LatestApproval) + latestApproval = &mapped + if mapped.StepName != "" { + status = mapped.StepName + } + } + + result[i] = UniformityListDTO{ + Id: item.Id, + ProjectFlockKandangId: item.ProjectFlockKandangId, + LocationName: resolveLocationName(item.ProjectFlockKandang), + FlockName: resolveProjectFlockName(item.ProjectFlockKandang), + KandangName: resolveKandangName(item.ProjectFlockKandang), + AppliedAt: item.UniformDate, + Week: item.Week, + Status: status, + Uniformity: item.Uniformity, + Cv: item.Cv, + ChickQtyOfWeight: item.ChickQtyOfWeight, + UniformQty: item.UniformQty, + MeanUp: item.MeanUp, + MeanDown: item.MeanDown, + CreatedAt: item.CreatedAt, + CreatedBy: item.CreatedBy, + LatestApproval: latestApproval, + } + } + return result +} + +func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { + return UniformitySamplingDTO{ + ChickQtyOfWeight: calc.ChickQtyOfWeight, + MeanWeight: calc.MeanWeight, + MeanDown: calc.MeanDown, + MeanUp: calc.MeanUp, + } +} + +func toUniformityResultDTO(calc service.UniformityCalculation) UniformityResultDTO { + return UniformityResultDTO{ + UniformQty: calc.UniformQty, + OutsideQty: calc.OutsideQty, + Uniformity: calc.Uniformity, + Cv: calc.Cv, + } +} + +func toUniformityDetailItemsDTO(calc service.UniformityCalculation) []UniformityDetailItemDTO { + result := make([]UniformityDetailItemDTO, len(calc.Details)) + for i, item := range calc.Details { + result[i] = UniformityDetailItemDTO{ + Id: item.Id, + Weight: item.Weight, + Range: item.Range, + } + } + return result +} + +func resolveLocationName(pfk entity.ProjectFlockKandang) string { + if pfk.Kandang.Id != 0 && pfk.Kandang.Location.Id != 0 { + return pfk.Kandang.Location.Name + } + if pfk.ProjectFlock.Id != 0 && pfk.ProjectFlock.Location.Id != 0 { + return pfk.ProjectFlock.Location.Name + } + return "" +} + +func resolveProjectFlockName(pfk entity.ProjectFlockKandang) string { + if pfk.ProjectFlock.Id != 0 { + return pfk.ProjectFlock.FlockName + } + return "" +} + +func resolveKandangName(pfk entity.ProjectFlockKandang) string { + if pfk.Kandang.Id != 0 { + return pfk.Kandang.Name + } + return "" +} + +func formatUniformityDate(date *time.Time) string { + if date == nil || date.IsZero() { + return "" + } + return date.Format("2006-01-02") +} diff --git a/internal/modules/production/uniformities/module.go b/internal/modules/production/uniformities/module.go new file mode 100644 index 00000000..1032cdcf --- /dev/null +++ b/internal/modules/production/uniformities/module.go @@ -0,0 +1,43 @@ +package uniformitys + +import ( + "context" + "fmt" + + "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" + rUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + sUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type UniformityModule struct{} + +func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + uniformityRepo := rUniformity.NewUniformityRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + userRepo := rUser.NewUserRepository(db) + + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } + + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowUniformity, utils.UniformityApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register uniformity approval workflow: %v", err)) + } + + uniformityService := sUniformity.NewUniformityService(uniformityRepo, documentSvc, approvalRepo, approvalSvc, validate) + userService := sUser.NewUserService(userRepo, validate) + + UniformityRoutes(router, userService, uniformityService) +} diff --git a/internal/modules/production/uniformities/repositories/uniformity.repository.go b/internal/modules/production/uniformities/repositories/uniformity.repository.go new file mode 100644 index 00000000..3bc66f4f --- /dev/null +++ b/internal/modules/production/uniformities/repositories/uniformity.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 UniformityRepository interface { + repository.BaseRepository[entity.ProjectFlockKandangUniformity] +} + +type UniformityRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProjectFlockKandangUniformity] +} + +func NewUniformityRepository(db *gorm.DB) UniformityRepository { + return &UniformityRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db), + } +} diff --git a/internal/modules/production/uniformities/route.go b/internal/modules/production/uniformities/route.go new file mode 100644 index 00000000..d22e8761 --- /dev/null +++ b/internal/modules/production/uniformities/route.go @@ -0,0 +1,30 @@ +package uniformitys + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/controllers" + uniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func UniformityRoutes(v1 fiber.Router, u user.UserService, s uniformity.UniformityService) { + ctrl := controller.NewUniformityController(s) + + route := v1.Group("/uniformities") + + // 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.Post("/verify", ctrl.UploadBodyWeightExcel) + route.Post("/approvals", ctrl.Approve) + route.Get("/:id", ctrl.GetOne) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go new file mode 100644 index 00000000..97155a3b --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go @@ -0,0 +1,195 @@ +package service + +import ( + "io" + "mime/multipart" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +type BodyWeightExcelRow struct { + No int `json:"no"` + Weight float64 `json:"weight"` + Range string `json:"range,omitempty"` +} + +func (s uniformityService) ParseBodyWeightExcel(_ *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) { + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "file is required") + } + + reader, err := file.Open() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to open file") + } + defer reader.Close() + + rows, err := parseBodyWeightExcelReader(reader) + if err != nil { + return nil, err + } + + return rows, nil +} + +func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) { + xlsx, err := excelize.OpenReader(reader) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read excel file") + } + defer func() { + _ = xlsx.Close() + }() + + sheets := xlsx.GetSheetList() + if len(sheets) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") + } + + rows, err := xlsx.GetRows(sheets[0], excelize.Options{RawCellValue: true}) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") + } + + return parseBodyWeightRows(rows) +} + +func parseBodyWeightRows(rows [][]string) ([]BodyWeightExcelRow, error) { + headerRowIdx, noCol, bwCol, rangeCol := findBodyWeightHeader(rows) + if headerRowIdx < 0 || bwCol < 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "header BW not found") + } + + result := make([]BodyWeightExcelRow, 0) + lastNo := 0 + + for i := headerRowIdx + 1; i < len(rows); i++ { + row := rows[i] + weightStr := cellAt(row, bwCol) + weightVal, ok := parseNumber(weightStr) + if !ok { + continue + } + + noVal := 0 + if noCol >= 0 { + if parsed, ok := parseNumber(cellAt(row, noCol)); ok { + noVal = int(parsed) + } + } + if noVal <= 0 { + noVal = lastNo + 1 + } + if noVal > lastNo { + lastNo = noVal + } + + rangeVal := "" + if rangeCol >= 0 { + rangeVal = strings.TrimSpace(cellAt(row, rangeCol)) + } + + rowPayload := BodyWeightExcelRow{ + No: noVal, + Weight: weightVal, + Range: rangeVal, + } + if rowPayload.No <= 0 || rowPayload.Weight <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid body weight row data") + } + + result = append(result, rowPayload) + } + + if len(result) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + return result, nil +} + +func findBodyWeightHeader(rows [][]string) (rowIdx int, noCol int, bwCol int, rangeCol int) { + rowIdx = -1 + noCol = -1 + bwCol = -1 + rangeCol = -1 + + for i, row := range rows { + tempNo := -1 + tempBW := -1 + tempRange := -1 + for j, cell := range row { + label := normalizeHeader(cell) + switch label { + case "no": + tempNo = j + case "bw": + tempBW = j + case "outsiderange": + tempRange = j + default: + if strings.HasPrefix(label, "bw") { + tempBW = j + } else if strings.HasPrefix(label, "no") { + tempNo = j + } else if strings.Contains(label, "range") { + tempRange = j + } + } + } + if tempBW >= 0 { + rowIdx = i + bwCol = tempBW + noCol = tempNo + rangeCol = tempRange + break + } + } + + return rowIdx, noCol, bwCol, rangeCol +} + +func cellAt(row []string, idx int) string { + if idx < 0 || idx >= len(row) { + return "" + } + return strings.TrimSpace(row[idx]) +} + +func normalizeHeader(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + return "" + } + var b strings.Builder + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +func parseNumber(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + + if strings.Contains(trimmed, ",") { + if strings.Contains(trimmed, ".") { + trimmed = strings.ReplaceAll(trimmed, ",", "") + } else { + trimmed = strings.ReplaceAll(trimmed, ",", ".") + } + } + + parsed, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return parsed, true +} diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go new file mode 100644 index 00000000..786d3662 --- /dev/null +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -0,0 +1,738 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "mime/multipart" + "net/http" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" + "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" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type UniformityService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) + ParseBodyWeightExcel(ctx *fiber.Ctx, file *multipart.FileHeader) ([]BodyWeightExcelRow, error) + ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) + CalculateUniformityFromDocument(ctx *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) +} + +type uniformityService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.UniformityRepository + DocumentSvc commonSvc.DocumentService + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService +} + +func NewUniformityService( + repo repository.UniformityRepository, + documentSvc commonSvc.DocumentService, + approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, + validate *validator.Validate, +) UniformityService { + return &uniformityService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + DocumentSvc: documentSvc, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + } +} + +func (s uniformityService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("ProjectFlockKandang.ProjectFlock.Location"). + Preload("ProjectFlockKandang.Kandang.Location") +} + +func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + uniformitys, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.ProjectFlockKandangId != 0 { + db = db.Where("project_flock_kandang_id = ?", params.ProjectFlockKandangId) + } + if params.Week != 0 { + db = db.Where("week = ?", params.Week) + } + return db.Order("uniform_date DESC").Order("created_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get uniformitys: %+v", err) + return nil, 0, err + } + if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil { + return nil, 0, err + } + return uniformitys, total, nil +} + +func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + uniformity, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + if err != nil { + s.Log.Errorf("Failed get uniformity by id: %+v", err) + return nil, err + } + if err := s.attachLatestApproval(c.Context(), uniformity); err != nil { + return nil, err + } + return uniformity, nil +} + +func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) { + return s.GetOne(c, id) +} + +func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + uniformDate, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") + } + + if len(rows) == 0 { + parsedRows, err := s.ParseBodyWeightExcel(c, file) + if err != nil { + return nil, err + } + rows = parsedRows + } + + calculation, err := s.ComputeUniformity(rows) + if err != nil { + return nil, err + } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + createBody := &entity.ProjectFlockKandangUniformity{ + Uniformity: calculation.Uniformity, + Week: req.Week, + Cv: calculation.Cv, + ChickQtyOfWeight: calculation.ChickQtyOfWeight, + MeanUp: calculation.MeanUp, + MeanDown: calculation.MeanDown, + ProjectFlockKandangId: req.ProjectFlockKandangId, + UniformQty: calculation.UniformQty, + NotUniformQty: calculation.OutsideQty, + UniformDate: &uniformDate, + CreatedBy: actorID, + } + + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + if err := s.createUniformityApproval( + c.Context(), + tx, + createBody.Id, + utils.UniformityStepPengajuan, + entity.ApprovalActionCreated, + actorID, + nil, + ); err != nil { + return err + } + return nil + }); err != nil { + s.Log.Errorf("Failed to create uniformity: %+v", err) + return nil, err + } + + if s.DocumentSvc != nil { + actorIDCopy := actorID + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "UNIFORMITY", + DocumentableID: uint64(createBody.Id), + CreatedBy: &actorIDCopy, + Files: []commonSvc.DocumentFile{ + { + File: file, + Type: "UNIFORMITY", + }, + }, + }) + if err != nil { + s.rollbackUniformityCreate(c.Context(), createBody.Id) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") + } + } + + return s.GetOne(c, createBody.Id) +} + +func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Date != nil { + parsed, err := time.Parse("2006-01-02", *req.Date) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") + } + updateBody["uniform_date"] = parsed + } + if req.ProjectFlockKandangId != nil { + updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId + } + if req.Week != nil { + updateBody["week"] = *req.Week + } + + if file != nil { + if s.DocumentSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + if len(rows) == 0 { + parsedRows, err := s.ParseBodyWeightExcel(c, file) + if err != nil { + return nil, err + } + rows = parsedRows + } + + calculation, err := s.ComputeUniformity(rows) + if err != nil { + return nil, err + } + + updateBody["uniformity"] = calculation.Uniformity + updateBody["cv"] = calculation.Cv + updateBody["chick_qty_of_weight"] = calculation.ChickQtyOfWeight + updateBody["mean_up"] = calculation.MeanUp + updateBody["mean_down"] = calculation.MeanDown + updateBody["uniform_qty"] = calculation.UniformQty + updateBody["not_uniform_qty"] = calculation.OutsideQty + } + + if len(updateBody) == 0 { + return s.GetOne(c, id) + } + + if file == nil { + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to update uniformity: %+v", err) + return nil, err + } + + return s.GetOne(c, id) + } + + existingDocs, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(id)) + if err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + actorIDCopy := actorID + uploadResults, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "UNIFORMITY", + DocumentableID: uint64(id), + CreatedBy: &actorIDCopy, + Files: []commonSvc.DocumentFile{ + { + File: file, + Type: "UNIFORMITY", + }, + }, + }) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to upload uniformity document") + } + + if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { + if len(uploadResults) > 0 { + ids := make([]uint, 0, len(uploadResults)) + for _, result := range uploadResults { + if result.Document.Id != 0 { + ids = append(ids, result.Document.Id) + } + } + if len(ids) > 0 { + _ = s.DocumentSvc.DeleteDocuments(c.Context(), ids, true) + } + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + s.Log.Errorf("Failed to update uniformity: %+v", err) + return nil, err + } + + if len(existingDocs) > 0 { + oldIDs := make([]uint, 0, len(existingDocs)) + for _, doc := range existingDocs { + if doc.Id != 0 { + oldIDs = append(oldIDs, doc.Id) + } + } + if len(oldIDs) > 0 { + _ = s.DocumentSvc.DeleteDocuments(c.Context(), oldIDs, true) + } + } + + return s.GetOne(c, id) +} + +func (s uniformityService) 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, "Uniformity not found") + } + s.Log.Errorf("Failed to delete uniformity: %+v", err) + return err + } + return nil +} + +func (s uniformityService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlockKandangUniformity, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) + var action entity.ApprovalAction + switch actionValue { + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + ids := uniqueUintSlice(req.ApprovableIds) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.UniformityStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.UniformityStepDisetujui + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + ctx := c.Context() + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + + for _, id := range ids { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Uniformity %d not found", id)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowUniformity, + id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + } + + return nil + }) + if transactionErr != nil { + if fiberErr, ok := transactionErr.(*fiber.Error); ok { + return nil, fiberErr + } + s.Log.Errorf("Failed to record approvals for uniformities %+v: %+v", ids, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to submit uniformity approval") + } + + results := make([]entity.ProjectFlockKandangUniformity, 0, len(ids)) + for _, id := range ids { + loaded, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + results = append(results, *loaded) + } + + return results, nil +} + +type UniformityDetailItem struct { + Id int + Weight float64 + Range string +} + +type UniformityCalculation struct { + ChickQtyOfWeight float64 + MeanWeight float64 + MeanDown float64 + MeanUp float64 + UniformQty float64 + OutsideQty float64 + Uniformity float64 + Cv float64 + Details []UniformityDetailItem +} + +func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { + return computeUniformity(rows) +} + +func (s uniformityService) CalculateUniformityFromDocument(c *fiber.Ctx, uniformityID uint) (UniformityCalculation, *entity.Document, error) { + if s.DocumentSvc == nil { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } + + documents, err := s.DocumentSvc.ListByTarget(c.Context(), "UNIFORMITY", uint64(uniformityID)) + if err != nil { + return UniformityCalculation{}, nil, err + } + if len(documents) == 0 { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusNotFound, "Uniformity document not found") + } + + document := documents[0] + url := s.DocumentSvc.PublicURL(document) + if url == "" { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Uniformity document URL not available") + } + + req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, url, nil) + if err != nil { + return UniformityCalculation{}, nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return UniformityCalculation{}, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return UniformityCalculation{}, nil, fiber.NewError(fiber.StatusBadRequest, "Failed to download uniformity document") + } + + rows, err := parseBodyWeightExcelReader(resp.Body) + if err != nil { + return UniformityCalculation{}, nil, err + } + + calculation, err := computeUniformity(rows) + if err != nil { + return UniformityCalculation{}, nil, err + } + + return calculation, &document, nil +} + +func (s *uniformityService) createUniformityApproval( + ctx context.Context, + db *gorm.DB, + uniformityID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, +) error { + if uniformityID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Uniformity tidak valid untuk approval") + } + if actorID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") + } + + var svc commonSvc.ApprovalService + if db != nil { + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } else if s.ApprovalSvc != nil { + svc = s.ApprovalSvc + } else { + svc = commonSvc.NewApprovalService(s.ApprovalRepo) + } + + _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowUniformity, uniformityID, step, &action, actorID, notes) + return err +} + +func (s *uniformityService) attachLatestApprovals(ctx context.Context, items []entity.ProjectFlockKandangUniformity) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil + } + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowUniformity, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for uniformities: %+v", err) + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func (s *uniformityService) attachLatestApproval(ctx context.Context, item *entity.ProjectFlockKandangUniformity) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowUniformity, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for uniformity %d: %+v", item.Id, err) + return nil + } + + if len(approvals) == 0 { + item.LatestApproval = nil + return nil + } + + latest := approvals[len(approvals)-1] + item.LatestApproval = &latest + return nil +} + +func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { + if uniformityID == 0 { + return + } + + if s.ApprovalRepo != nil { + if err := s.ApprovalRepo.DeleteByTarget(ctx, utils.ApprovalWorkflowUniformity.String(), uniformityID); err != nil { + s.Log.WithError(err).Warnf("Failed to rollback uniformity approvals for %d", uniformityID) + } + } + + if err := s.Repository.DeleteOne(ctx, uniformityID); err != nil { + s.Log.WithError(err).Warnf("Failed to rollback uniformity %d", uniformityID) + } +} + +func uniqueUintSlice(values []uint) []uint { + if len(values) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if v == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { + weights := make([]float64, 0, len(rows)) + details := make([]UniformityDetailItem, 0, len(rows)) + hasRangeLabels := false + for idx, row := range rows { + if row.Weight <= 0 { + continue + } + id := row.No + if id <= 0 { + id = idx + 1 + } + weights = append(weights, row.Weight) + rangeLabel := strings.TrimSpace(row.Range) + if rangeLabel != "" { + upper := strings.ToUpper(rangeLabel) + if upper == "HIGH" || upper == "LOW" { + hasRangeLabels = true + } + rangeLabel = upper + } + details = append(details, UniformityDetailItem{ + Id: id, + Weight: row.Weight, + Range: rangeLabel, + }) + } + + total := float64(len(weights)) + if total == 0 { + return UniformityCalculation{}, fiber.NewError(fiber.StatusBadRequest, "no body weight data found") + } + + var sum float64 + for _, w := range weights { + sum += w + } + mean := sum / total + meanUpThreshold := roundToPrecision(mean*1.10, 3) + meanDownThreshold := roundToPrecision(mean*0.90, 3) + + var uniformCount float64 + for i := range details { + if hasRangeLabels { + if details[i].Range == "HIGH" || details[i].Range == "LOW" { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + continue + } + + w := details[i].Weight + if w > meanUpThreshold || w < meanDownThreshold { + details[i].Range = "Outside" + continue + } + details[i].Range = "Ideal" + uniformCount++ + } + outsideCount := total - uniformCount + + var cv float64 + if mean > 0 && total > 1 { + stddevWeights := weights + if len(stddevWeights) > 100 { + stddevWeights = stddevWeights[:100] + } + stddevCount := float64(len(stddevWeights)) + if stddevCount > 1 { + var stddevSum float64 + for _, w := range stddevWeights { + stddevSum += w + } + stddevMean := stddevSum / stddevCount + var sumSquares float64 + for _, w := range stddevWeights { + diff := w - stddevMean + sumSquares += diff * diff + } + stddev := math.Sqrt(sumSquares / (stddevCount - 1)) + cv = (stddev / mean) * 100 + } + } + + uniformity := (uniformCount / total) * 100 + + return UniformityCalculation{ + ChickQtyOfWeight: total, + MeanWeight: roundToPrecision(mean, 0), + MeanDown: roundToPrecision(mean*0.90, 0), + MeanUp: roundToPrecision(mean*1.10, 0), + UniformQty: uniformCount, + OutsideQty: outsideCount, + Uniformity: roundToPrecision(uniformity, 0), + Cv: roundToPrecision(cv, 1), + Details: details, + }, nil +} + +func roundToPrecision(value float64, precision int) float64 { + if precision < 0 { + return value + } + scale := math.Pow10(precision) + scaled := value * scale + fraction := scaled - math.Floor(scaled) + if fraction >= 0.5 { + return math.Ceil(scaled) / scale + } + return math.Floor(scaled) / scale +} diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go new file mode 100644 index 00000000..d27ed287 --- /dev/null +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -0,0 +1,173 @@ +package validation + +import ( + "mime/multipart" + "strconv" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type Create struct { + Date string `form:"date" validate:"required"` + ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"` + Week int `form:"week" validate:"required,min=1"` +} + +type Update struct { + Date *string `json:"date,omitempty" form:"date" validate:"omitempty"` + ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty" form:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week *int `json:"week,omitempty" form:"week" validate:"omitempty,min=1"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Week int `query:"week" validate:"omitempty,min=1"` +} + +type UploadExcelRequest struct { + Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` +} + +type Approve struct { + Action string `json:"action" validate:"required_strict"` + ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} + +func ParseIDParam(c *fiber.Ctx, name string) (uint, error) { + raw := strings.TrimSpace(c.Params(name)) + if raw == "" { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + id, err := strconv.Atoi(raw) + if err != nil || id <= 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + return uint(id), nil +} + +func ParseQuery(c *fiber.Ctx) (*Query, error) { + query := &Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProjectFlockKandangId: uint(c.QueryInt("project_flock_kandang_id", 0)), + Week: c.QueryInt("week", 0), + } + + if query.Page < 1 || query.Limit < 1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + return query, nil +} + +func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) { + date := strings.TrimSpace(c.FormValue("date")) + if date == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "date is required") + } + + projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id")) + if projectFlockKandangIDStr == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr) + if err != nil || projectFlockKandangID <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + weekStr := strings.TrimSpace(c.FormValue("week")) + if weekStr == "" { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + } + + week, err := strconv.Atoi(weekStr) + if err != nil || week <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required") + } + + file, err := c.FormFile("document") + if err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "document is required") + } + + return &Create{ + Date: date, + ProjectFlockKandangId: uint(projectFlockKandangID), + Week: week, + }, file, nil +} + +func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { + contentType := strings.ToLower(c.Get("Content-Type")) + if strings.Contains(contentType, "multipart/form-data") { + req := &Update{} + + date := strings.TrimSpace(c.FormValue("date")) + if date != "" { + req.Date = &date + } + + projectFlockKandangIDStr := strings.TrimSpace(c.FormValue("project_flock_kandang_id")) + if projectFlockKandangIDStr != "" { + projectFlockKandangID, err := strconv.Atoi(projectFlockKandangIDStr) + if err != nil || projectFlockKandangID <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is invalid") + } + idCopy := uint(projectFlockKandangID) + req.ProjectFlockKandangId = &idCopy + } + + weekStr := strings.TrimSpace(c.FormValue("week")) + if weekStr != "" { + week, err := strconv.Atoi(weekStr) + if err != nil || week <= 0 { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid") + } + req.Week = &week + } + + file, err := c.FormFile("document") + if err != nil { + file = nil + } + + return req, file, nil + } + + req := new(Update) + if err := c.BodyParser(req); err != nil { + return nil, nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + return req, nil, nil +} + +func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { + form, err := c.MultipartForm() + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + + files := form.File["documents"] + if len(files) == 0 { + if file, err := c.FormFile("document"); err == nil && file != nil { + files = []*multipart.FileHeader{file} + } else { + return nil, fiber.NewError(fiber.StatusBadRequest, "documents is required") + } + } + + return files, nil +} + +func ParseApprove(c *fiber.Ctx) (*Approve, error) { + req := new(Approve) + if err := c.BodyParser(req); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + return req, nil +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 354c9042..d003d996 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -250,6 +250,21 @@ var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ RecordingStepDisetujui: "Disetujui", } +// ------------------------------------------------------------------- +// Uniformity Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowUniformity approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("UNIFORMITIES") + UniformityStepPengajuan approvalutils.ApprovalStep = 1 + UniformityStepDisetujui approvalutils.ApprovalStep = 2 +) + +var UniformityApprovalSteps = map[approvalutils.ApprovalStep]string{ + UniformityStepPengajuan: "Pengajuan", + UniformityStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Purchase Approval // ------------------------------------------------------------------- @@ -324,12 +339,12 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" - DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" - DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) From db4e8232b9b09ac6ac4f6598be2fb97b65bf3ce4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 29 Dec 2025 08:03:00 +0700 Subject: [PATCH 22/50] feat(BE): enhance closing service and repository with actual usage cost calculations and egg weight tracking --- .../closings/dto/closingKeuangan.dto.go | 41 +++-- .../repositories/closing.repository.go | 149 ++++++++++++++++++ .../closings/services/closing.service.go | 69 +++++++- 3 files changed, 243 insertions(+), 16 deletions(-) diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 90dda2a9..08bfb5fc 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -35,6 +35,7 @@ const ( type CalculationContext struct { TotalPopulation float64 TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 TotalWeightSold float64 ActualPopulation float64 @@ -48,6 +49,7 @@ type ClosingKeuanganInput struct { DeliveryProducts []entities.MarketingDeliveryProduct Chickins []entities.ProjectChickin TotalWeightProduced float64 + TotalEggWeightKg float64 TotalDepletion float64 } @@ -77,8 +79,10 @@ type HppGroup struct { } type SummaryHpp struct { - Label string `json:"label"` - Comparison + Label string `json:"label"` + Comparison `json:"-"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` } type HppPurchasesSection struct { @@ -231,7 +235,7 @@ func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entiti // === HPP SUMMARY === -func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) SummaryHpp { +func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { purchaseTotal := sumPurchaseTotal(purchaseItems) budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) totalBudget := purchaseTotal + budgetTotal @@ -241,16 +245,34 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [ budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - return SummaryHpp{ + summary := SummaryHpp{ Label: label, Comparison: ToComparison( ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), ), } + + if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { + budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg) + realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg) + + summary.EggBudgeting = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: budgetEggRpPerKg, + Amount: totalBudget, + } + summary.EggRealization = &FinancialMetrics{ + RpPerBird: 0, + RpPerKg: realizationEggRpPerKg, + Amount: totalRealization, + } + } + + return summary } -func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppPurchasesSection { +func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { hppGroups := []HppGroup{ { GroupName: HPPGroupPengeluaran, @@ -259,7 +281,7 @@ func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []enti ToHppBahanBakuGroup(budgets, realizations, ctx), } - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, ctx) + summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) return HppPurchasesSection{ Hpp: hppGroups, @@ -322,11 +344,9 @@ func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.M func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { purchaseAmount := sumPurchaseTotal(purchases) - bopAmount := getOperationalExpenses(realizations) - totalCost := purchaseAmount + bopAmount return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, totalCost, ctx), + createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), } } @@ -414,12 +434,13 @@ func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { ctx := CalculationContext{ TotalPopulation: totalPopulation, TotalWeightProduced: input.TotalWeightProduced, + TotalEggWeightKg: input.TotalEggWeightKg, TotalDepletion: input.TotalDepletion, TotalWeightSold: totalWeightSold, ActualPopulation: totalPopulation - input.TotalDepletion, } - hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, ctx) + hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx) penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) overheadItems := ToOverheadItems(input.Realizations, ctx) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index cf49826a..e3f09dda 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -31,6 +31,8 @@ type ClosingRepository interface { FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) + GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } type ClosingRepositoryImpl struct { @@ -804,3 +806,150 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand }) return in, out, nil } + +type ActualUsageCostRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagName string `gorm:"column:flag_name"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + AveragePrice float64 `gorm:"column:average_price"` +} + +func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { + if projectFlockID == 0 { + return []ActualUsageCostRow{}, nil + } + + db := r.DB().WithContext(ctx) + + // Get all project flock kandang IDs for this project flock + var pfkIDs []uint + err := db.Table("project_flock_kandangs"). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &pfkIDs).Error + if err != nil { + return nil, err + } + + if len(pfkIDs) == 0 { + return []ActualUsageCostRow{}, nil + } + + var rows []ActualUsageCostRow + + // Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll) + purchaseStockableKey := "PURCHASE_ITEMS" + transferStockableKey := "STOCK_TRANSFER_DETAILS" + + recordingQuery := db. + Table("recordings AS r"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + COALESCE(f.name, tf.name) AS flag_name, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0) AS total_qty, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) AS total_price, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0) AS qty_divisor, + COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + ELSE 0 + END + ), 0) / NULLIF(COALESCE(SUM( + CASE + WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) + WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) + ELSE 0 + END + ), 0), 0) AS average_price`, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey, + purchaseStockableKey, transferStockableKey). + Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", + "recording_stocks", entity.StockAllocationStatusActive). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). + Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey). + Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct). + Where("r.project_flock_kandangs_id IN ?", pfkIDs). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name, COALESCE(f.name, tf.name)") + + if err := recordingQuery.Scan(&rows).Error; err != nil { + return nil, err + } + + // Part 2: Get usage from project_chickins (DOC, Pullet) + chickinQuery := db. + Table("project_chickins AS pc"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag_name, + COALESCE(SUM(pc.usage_qty), 0) AS total_qty, + COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price, + COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price + `). + Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products AS p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("pc.project_flock_kandang_id IN ?", pfkIDs). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name, f.name") + + var chickinRows []ActualUsageCostRow + if err := chickinQuery.Scan(&chickinRows).Error; err != nil { + return nil, err + } + + // Merge results + rows = append(rows, chickinRows...) + + return rows, nil +} + +func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { + if len(productIDs) == 0 { + return []entity.Product{}, nil + } + + var products []entity.Product + err := r.DB().WithContext(ctx). + Preload("Flags"). + Where("id IN ?", productIDs). + Find(&products).Error + + if err != nil { + return nil, err + } + + return products, nil +} diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index ab8e6f7b..9f643a78 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -426,11 +426,15 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") } - purchaseItems, err := s.PurchaseRepo.GetItemsByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch purchase items") + // Get actual usage cost instead of purchase items + actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") } + // Convert actual usage rows to pseudo purchase items + purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows) + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") @@ -455,6 +459,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) } + totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err) + } + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) if err != nil { s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) @@ -468,6 +477,7 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* DeliveryProducts: deliveryProducts, Chickins: chickins, TotalWeightProduced: totalWeightProduced, + TotalEggWeightKg: totalEggWeightKg, TotalDepletion: totalDepletion, } @@ -476,8 +486,6 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (* return &report, nil } -// GetExpeditionHPP menghitung HPP ekspedisi per vendor untuk sebuah project flock. -// Jika projectFlockKandangID tidak nil, maka hanya data untuk kandang tersebut yang dihitung. func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") @@ -778,5 +786,54 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl } return closest.Mortality, closest.FcrNumber - +} + +func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem { + if len(actualUsageRows) == 0 { + return []entity.PurchaseItem{} + } + + // Collect all product IDs + productIDs := make([]uint, len(actualUsageRows)) + for i, row := range actualUsageRows { + productIDs[i] = row.ProductID + } + + // Fetch products with flags from repository + products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs) + if err != nil { + s.Log.Warnf("Failed to fetch products for actual usage: %v", err) + products = []entity.Product{} + } + + // Create product map + productMap := make(map[uint]*entity.Product) + for i := range products { + productMap[products[i].Id] = &products[i] + } + + // Convert to pseudo purchase items + purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows)) + for _, row := range actualUsageRows { + product := productMap[row.ProductID] + + // Skip if product not found + if product == nil { + s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID) + continue + } + + purchaseItem := entity.PurchaseItem{ + Id: 0, // Pseudo item, no ID + ProductId: row.ProductID, + TotalQty: row.TotalQty, + TotalPrice: row.TotalPrice, + Price: row.AveragePrice, + Product: product, + } + + purchaseItems = append(purchaseItems, purchaseItem) + } + + return purchaseItems } From 8dfb2246142d6fa745063e179fb4b9e22f2301a0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 10:13:29 +0700 Subject: [PATCH 23/50] feat(BE-281): changes std deviasi first 100 data to all --- .../production/uniformities/services/uniformity.service.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 786d3662..871f4816 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -689,9 +689,6 @@ func computeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) var cv float64 if mean > 0 && total > 1 { stddevWeights := weights - if len(stddevWeights) > 100 { - stddevWeights = stddevWeights[:100] - } stddevCount := float64(len(stddevWeights)) if stddevCount > 1 { var stddevSum float64 From 9ee3b7582c889d0a70376913986ffa47e39f7a65 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 22 Dec 2025 13:51:27 +0700 Subject: [PATCH 24/50] Feat[BE]: on chickin laying covert Pullet to Layer --- .../chickins/services/chickin.service.go | 20 +++---------------- .../services/project_flock_kandang.service.go | 12 +++-------- .../repports/services/repport.service.go | 1 - 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index b8eefa49..0c513e88 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -188,7 +188,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) if err := s.Repository.WithTx(dbTransaction).CreateMany(c.Context(), newChikins, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") @@ -199,19 +198,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } - if category == string(utils.ProjectFlockCategoryLaying) { - for _, chickin := range newChikins { - updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} - - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update product warehouse quantity") - } - } - } - var approvalAction entity.ApprovalAction if isFirstTime { approvalAction = entity.ApprovalActionCreated @@ -472,9 +458,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } pfkID := approvableID - targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "PULLET", dbTransaction, actorID, &pfkID) + targetPW, err := s.getOrCreateProductWarehouse(c, warehouse.Id, "LAYER", dbTransaction, actorID, &pfkID) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create PULLET product warehouse") + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get/create LAYER product warehouse") } if err := s.convertChickinsToTarget(c, chickins, targetPW, dbTransaction, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert chickins to target") @@ -549,7 +535,7 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId products, err := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(ctx.Context(), categoryCode, warehouseId) if err == nil && len(products) > 0 { existingPW := &products[0] - // Update project_flock_kandang_id if not already set + if existingPW.ProjectFlockKandangId == nil && projectFlockKandangId != nil { existingPW.ProjectFlockKandangId = projectFlockKandangId if err := s.ProductWarehouseRepo.WithTx(dbTransaction).UpdateOne(ctx.Context(), existingPW.Id, existingPW, nil); err != nil { diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index cf2d87ee..66fee8ce 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -555,6 +555,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty := productWarehouse.Quantity if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { @@ -568,15 +569,8 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous availableQty = 0 } } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - populations, err := s.PopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(c.Context(), projectFlockKandang.Id, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } + var totalPendingQty float64 for _, chickin := range projectFlockKandang.Chickins { if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { @@ -584,7 +578,7 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous } } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty + availableQty = productWarehouse.Quantity - totalPendingQty if availableQty < 0 { availableQty = 0 } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index fbca69b7..f9642bd2 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -138,7 +138,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { totalCost := s.getTotalProjectCost(ctx, projectFlockID) if totalCost == 0 { - s.Log.Warnf("HPP calculation: No cost found for project flock ID %d. Check if purchase items are linked to project_flock_kandang_id", projectFlockID) return 0 } From 306cf11feec27e5a0657a13fa562843caaa93db5 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 12:26:35 +0700 Subject: [PATCH 25/50] Feat[BE]: integrate FIFO service for chickin stock management --- .../modules/production/chickins/module.go | 32 ++- .../chickins/services/chickin.service.go | 249 ++++++++++-------- internal/utils/fifo/constants.go | 1 + 3 files changed, 169 insertions(+), 113 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f4e91056..df0ebd26 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,6 +2,7 @@ package chickins import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -9,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" 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" @@ -36,16 +38,44 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) userRepo := rUser.NewUserRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsablekeyProjectChickin, + Table: "project_chickins", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_usage_qty", + CreatedAt: "id", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) + } + } + approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) + chickinService := sChickin.NewChickinService( + chickinRepo, + kandangRepo, + warehouseRepo, + productWarehouseRepo, + projectFlockRepo, + projectflockkandangrepo, + projectflockpopulationrepo, + chickinDetailRepo, + validate, + fifoService) 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 0c513e88..fe78080b 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "strings" @@ -16,6 +17,7 @@ import ( 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" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -23,6 +25,8 @@ import ( "gorm.io/gorm" ) +var chickinUsableKey = fifo.UsablekeyProjectChickin + type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -43,9 +47,10 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository + FifoSvc commonSvc.FifoService } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -57,6 +62,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, + FifoSvc: fifoSvc, } } @@ -124,8 +130,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse for Kandang not found") } - category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) - actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -152,20 +156,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) } - availableQty, err := s.calculateAvailableQuantity(c, req.ProjectFlockKandangId, productWarehouse, category) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to calculate available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) - } - + availableQty := productWarehouse.Quantity if availableQty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available quantity for product warehouse %d", chickinReq.ProductWarehouseId)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No available stock in product warehouse %d for chickin", chickinReq.ProductWarehouseId)) } newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: 0, - PendingUsageQty: availableQty, + UsageQty: availableQty, + PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, CreatedBy: actorID, @@ -193,6 +193,25 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } + for _, chickin := range newChikins { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + return err + } + + if chickin.PendingUsageQty > 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) + } + } + + warehouseDeltas := make(map[uint]float64) + for _, chickin := range newChikins { + warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty + } + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return err + } + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -287,6 +306,27 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return err + } + + if chickin.UsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + return err + } + + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for deleted chickin %d: %+v", chickin.Id, 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") @@ -297,54 +337,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s chickinService) calculateAvailableQuantity(ctx *fiber.Ctx, projectFlockKandangID uint, productWarehouse *entity.ProductWarehouse, category string) (float64, error) { - availableQty := productWarehouse.Quantity - - if category == string(utils.ProjectFlockCategoryGrowing) { - var totalPendingQty float64 - - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - - availableQty = productWarehouse.Quantity - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } else if category == string(utils.ProjectFlockCategoryLaying) { - var totalPopulation float64 - var totalPendingQty float64 - - populations, err := s.ProjectflockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx.Context(), projectFlockKandangID, productWarehouse.Id) - if err == nil { - for _, pop := range populations { - totalPopulation += pop.TotalQty - } - } - chickins, err := s.Repository.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) - if err == nil { - for _, chickin := range chickins { - - if chickin.ProductWarehouseId == productWarehouse.Id && chickin.DeletedAt.Time.IsZero() && chickin.PendingUsageQty > 0 { - totalPendingQty += chickin.PendingUsageQty - } - } - } - availableQty = productWarehouse.Quantity - totalPopulation - totalPendingQty - if availableQty < 0 { - availableQty = 0 - } - } - - return availableQty, nil -} - func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -373,11 +365,10 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, id := range approvableIDs { - idCopy := id - if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &idCopy, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { + + if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { @@ -400,7 +391,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -477,27 +467,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit continue } - kandangForRejection, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("ProjectFlockKandang %d not found", approvableID)) - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get ProjectFlockKandang") - } - - categoryForRejection := strings.ToUpper(strings.TrimSpace(kandangForRejection.ProjectFlock.Category)) - for _, chickin := range chickins { - if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) + } - if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found during rejection", chickin.ProductWarehouseId)) - } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to restore product warehouse quantity for chickin %d", chickin.Id)) - } + warehouseDeltas := make(map[uint]float64) + warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty + if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for rejected chickin %d: %+v", chickin.Id, err) + return err } if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { @@ -558,7 +538,6 @@ func (s *chickinService) getOrCreateProductWarehouse(ctx *fiber.Ctx, warehouseId WarehouseId: warehouseId, ProjectFlockKandangId: projectFlockKandangId, Quantity: 0, - // CreatedBy: actorID, } if err := s.ProductWarehouseRepo.WithTx(dbTransaction).CreateOne(ctx.Context(), newPW, nil); err != nil { @@ -574,10 +553,10 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return fmt.Errorf("invalid target product warehouse") } - chickinRepoTx := repository.NewChickinRepository(dbTransaction) - productWarehouseTx := s.ProductWarehouseRepo.WithTx(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + var totalQuantityAdded float64 + for _, chickin := range chickins { populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(ctx.Context(), chickin.Id) @@ -590,34 +569,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti continue } - quantityToConvert := chickin.PendingUsageQty - - if err := chickinRepoTx.PatchOne(ctx.Context(), chickin.Id, map[string]any{ - "usage_qty": quantityToConvert, - "pending_usage_qty": 0, - }, nil); err != nil { - return fmt.Errorf("failed to update chickin %d qty: %w", chickin.Id, err) - } - - if chickin.ProductWarehouseId != targetPW.Id { - if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "qty": gorm.Expr("qty - ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) - } - return fmt.Errorf("failed to deduct source warehouse quantity for chickin %d: %w", chickin.Id, err) - } - } - - if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "qty": gorm.Expr("qty + ?", quantityToConvert), - }, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) - } - return fmt.Errorf("failed to update target warehouse quantity: %w", err) - } + quantityToConvert := chickin.UsageQty population := &entity.ProjectFlockPopulation{ ProjectChickinId: chickin.Id, @@ -630,7 +582,80 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if err := ProjectFlockPopulationRepotx.CreateOne(ctx.Context(), population, nil); err != nil { return err } + + totalQuantityAdded += quantityToConvert + } + + if totalQuantityAdded > 0 { + if err := s.ProductWarehouseRepo.AdjustQuantities(ctx.Context(), map[uint]float64{ + targetPW.Id: totalQuantityAdded, + }, func(db *gorm.DB) *gorm.DB { + return dbTransaction + }); err != nil { + return fmt.Errorf("failed to update target product warehouse quantity: %w", err) + } } return nil } + +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + var desired float64 = chickin.UsageQty + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + ProductWarehouseID: chickin.ProductWarehouseId, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": result.UsageQuantity, + "pending_usage_qty": result.PendingQuantity, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { + if chickin == nil || s.FifoSvc == nil { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: chickinUsableKey, + UsableID: chickin.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for chickin %d: %+v", chickin.Id, err) + return err + } + + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_usage_qty": 0, + }).Error; err != nil { + return err + } + + return nil +} + +func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { + return nil + } + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) +} diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c47d3cd7..c1a79444 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,4 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From 7dc5c9e9a5b373ef2b16da5ba425c58aaf4cc13c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 14:10:08 +0700 Subject: [PATCH 26/50] Feat[BE]: add document handling to stock transfer process --- internal/entities/stock-transfer.go | 1 + internal/entities/stock_transfer_delivery.go | 34 ++++----- .../controllers/transfer.controller.go | 7 +- .../inventory/transfers/dto/transfer.dto.go | 25 ++++++- .../modules/inventory/transfers/module.go | 12 ++- .../transfers/services/transfer.service.go | 73 +++++++++++-------- 6 files changed, 95 insertions(+), 57 deletions(-) diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go index e003d601..7da7a9f5 100644 --- a/internal/entities/stock-transfer.go +++ b/internal/entities/stock-transfer.go @@ -20,4 +20,5 @@ type StockTransfer struct { Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` CreatedUser *User `gorm:"foreignKey:CreatedBy"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 3a7562ea..69324b65 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -4,20 +4,20 @@ 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 - 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 + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + 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"` +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index b53d6e9a..c21e5286 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -80,15 +80,14 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { 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) + files := form.File["documents"] + + result, err := u.TransferService.CreateOne(c, &req, files) 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 fe97ce0f..d38fb78d 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -43,6 +43,14 @@ type SupplierSimpleDTO struct { Name string `json:"name"` } +type DocumentDTO struct { + Id uint `json:"id"` + Path string `json:"path"` + Name string `json:"name"` + Ext string `json:"ext"` + Size float64 `json:"size"` +} + type WarehouseDetailDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -57,6 +65,7 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` + Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -79,7 +88,6 @@ type TransferDeliveryDTO struct { 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"` Items []TransferDeliveryItemDTO `json:"items"` @@ -174,6 +182,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { Quantity: item.Quantity, }) } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -183,12 +192,22 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, }) } + var documents []DocumentDTO + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + return TransferListDTO{ TransferRelationDTO: ToTransferRelationDTO(e), CreatedUser: createdUser, @@ -196,6 +215,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, + Documents: documents, } } @@ -232,7 +252,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { VehiclePlate: del.VehiclePlate, DriverName: del.DriverName, DocumentNumber: del.DocumentNumber, - DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, }) diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 19a0ded6..9389f9f4 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -1,10 +1,14 @@ package transfers import ( + "context" + "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" 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" @@ -29,8 +33,14 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(err) + } + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc) 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 f94295f6..33ca77ff 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "mime/multipart" "strings" "github.com/go-playground/validator/v10" @@ -27,7 +28,7 @@ import ( 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) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) } type transferService struct { @@ -42,9 +43,10 @@ type transferService struct { SupplierRepo rSupplier.SupplierRepository WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -57,6 +59,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr SupplierRepo: supplierRepo, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +75,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details"). Preload("Details.Product"). Preload("Deliveries.Items"). - Preload("Deliveries.Supplier") + Preload("Deliveries.Supplier"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", "STOCK_TRANSFER") + }) } func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { @@ -94,31 +100,31 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit return nil, 0, err } - s.Log.Infof("Retrieved %d transfers", len(transfers)) - return transfers, total, nil } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - var transfer entity.StockTransfer + s.Log.Infof("Attempting to get StockTransfer with ID: %d", id) transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) if err != nil { + s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err) 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") } - s.Log.Infof("Retrieved transfer: %+v", transfer) + if transferPtr != nil { + s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents)) + } return transferPtr, nil } -func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) { pwIDs := make([]uint, 0, len(req.Products)) @@ -180,7 +186,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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) @@ -198,10 +203,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { - s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } - s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) var details []*entity.StockTransferDetail for _, product := range req.Products { @@ -212,10 +215,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) } 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) var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { @@ -224,13 +225,11 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", 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 } @@ -256,27 +255,46 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } 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) + + actorIDCopy := actorID + if s.DocumentSvc != nil && len(files) > 0 { + s.Log.Infof("Starting document upload for %d files", len(files)) + documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + for idx, file := range files { + docIndex := idx + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: "STOCK_TRANSFER_DOCUMENT", + Index: &docIndex, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: "STOCK_TRANSFER", + DocumentableID: entityTransfer.Id, + CreatedBy: &actorIDCopy, + Files: documentFiles, + }) + if err != nil { + s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") + } + s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) + } for _, product := range req.Products { 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) decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, @@ -287,7 +305,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log decrease: %+v", err) return err } @@ -295,7 +312,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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) { @@ -311,18 +327,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } 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) increaseLog := &entity.StockLog{ Increase: product.ProductQty, @@ -333,7 +345,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: actorID, } if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log increase: %+v", err) return err } } @@ -343,7 +354,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -359,7 +370,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) } - s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } @@ -372,7 +382,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) } - s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") } From ebf0f8c5ab686de1a83548884abb047615d4e400 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 23 Dec 2025 17:51:42 +0700 Subject: [PATCH 27/50] Feat[BE]: refactor document handling in transfer service and introduce document type constants --- internal/entities/stock_transfer_delivery.go | 1 + .../controllers/transfer.controller.go | 9 ++- .../inventory/transfers/dto/transfer.dto.go | 62 ++++++++++--------- .../transfers/services/transfer.service.go | 39 ++++++------ internal/utils/constant.go | 13 ++++ 5 files changed, 72 insertions(+), 52 deletions(-) diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index 69324b65..0eeccc04 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -20,4 +20,5 @@ type StockTransferDelivery struct { StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` Supplier *Supplier `gorm:"foreignKey:SupplierId"` Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` + Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index c21e5286..4f060dc2 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } @@ -87,6 +87,11 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { files := form.File["documents"] + if len(files) != len(req.Deliveries) { + return fiber.NewError(fiber.StatusBadRequest, + fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message) + } + result, err := u.TransferService.CreateOne(c, &req, files) if err != nil { return err @@ -97,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error { Code: fiber.StatusCreated, Status: "success", Message: "Create transfer successfully", - Data: dto.ToTransferListDTO(*result), + Data: dto.ToTransferDetailDTO(*result), }) } diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index d38fb78d..14ca04d2 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -7,8 +7,6 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// === DTO Structs === - type TransferRelationDTO struct { Id uint64 `json:"id"` TransferReason string `json:"transfer_reason"` @@ -17,7 +15,6 @@ type TransferRelationDTO struct { DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } -// Only id and name for warehouse simple view type WarehouseSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -65,7 +62,6 @@ type TransferListDTO struct { UpdatedAt time.Time `json:"updated_at"` Details []TransferDetailItemDTO `json:"details"` Deliveries []TransferDeliveryDTO `json:"deliveries"` - Documents []DocumentDTO `json:"documents"` } type TransferDetailDTO struct { @@ -74,14 +70,12 @@ type TransferDetailDTO struct { Deliveries []TransferDeliveryDTO `json:"deliveries"` } -// Detail produk type TransferDetailItemDTO struct { Id uint64 `json:"id"` - Proudct ProductSimpleDTO `json:"product"` + Product ProductSimpleDTO `json:"product"` Quantity float64 `json:"quantity"` } -// Delivery ekspedisi type TransferDeliveryDTO struct { Id uint64 `json:"id"` Supplier SupplierSimpleDTO `json:"supplier"` @@ -91,6 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` + Documents []DocumentDTO `json:"documents"` } type TransferDeliveryItemDTO struct { @@ -99,10 +94,7 @@ type TransferDeliveryItemDTO struct { Quantity float64 `json:"quantity"` } -// === Mapper Functions === - func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) @@ -148,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { Id: w.Id, Name: w.Name, Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + Area: toAreaDTO(&w.Area), } } @@ -158,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) createdUser = &mapped } - // Map details + var details []TransferDetailItemDTO for _, d := range e.Details { details = append(details, TransferDetailItemDTO{ Id: d.Id, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, 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{ @@ -183,6 +174,17 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -195,16 +197,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - }) - } - var documents []DocumentDTO - for _, doc := range e.Documents { - documents = append(documents, DocumentDTO{ - Id: doc.Id, - Path: doc.Path, - Name: doc.Name, - Ext: doc.Ext, - Size: doc.Size, + Documents: documents, }) } @@ -215,7 +208,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { UpdatedAt: e.UpdatedAt, Details: details, Deliveries: deliveries, - Documents: documents, } } @@ -228,21 +220,31 @@ 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, - Proudct: ProductSimpleDTO{ + Product: ProductSimpleDTO{ Id: d.Product.Id, Name: d.Product.Name, }, Quantity: d.Quantity, }) } - // Map deliveries + var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { + var documents []DocumentDTO + for _, doc := range del.Documents { + documents = append(documents, DocumentDTO{ + Id: doc.Id, + Path: doc.Path, + Name: doc.Name, + Ext: doc.Ext, + Size: doc.Size, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ Id: del.Id, Supplier: SupplierSimpleDTO{ @@ -254,8 +256,10 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, + Documents: documents, }) } + return TransferDetailDTO{ TransferListDTO: ToTransferListDTO(e), Details: details, diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 33ca77ff..89e7b271 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -76,8 +76,8 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { Preload("Details.Product"). Preload("Deliveries.Items"). Preload("Deliveries.Supplier"). - Preload("Documents", func(db *gorm.DB) *gorm.DB { - return db.Where("documentable_type = ?", "STOCK_TRANSFER") + Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer)) }) } @@ -258,29 +258,26 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - actorIDCopy := actorID if s.DocumentSvc != nil && len(files) > 0 { - s.Log.Infof("Starting document upload for %d files", len(files)) - documentFiles := make([]commonSvc.DocumentFile, 0, len(files)) + // Upload documents for each delivery for idx, file := range files { - docIndex := idx - documentFiles = append(documentFiles, commonSvc.DocumentFile{ - File: file, - Type: "STOCK_TRANSFER_DOCUMENT", - Index: &docIndex, + documentFiles := []commonSvc.DocumentFile{ + { + File: file, + Type: string(utils.DocumentTypeTransfer), + Index: &idx, + }, + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeTransfer), + DocumentableID: deliveries[idx].Id, + CreatedBy: &actorID, + Files: documentFiles, }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1)) + } } - _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ - DocumentableType: "STOCK_TRANSFER", - DocumentableID: entityTransfer.Id, - CreatedBy: &actorIDCopy, - Files: documentFiles, - }) - if err != nil { - s.Log.Errorf("Failed to upload documents for transfer %d: %+v", entityTransfer.Id, err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload transfer documents") - } - s.Log.Infof("Successfully uploaded documents for transfer ID %d", entityTransfer.Id) } for _, product := range req.Products { diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 7caa637e..20e0ab6a 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -398,6 +398,19 @@ var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ InjectionStepDisetujui: "Disetujui", } +// ------------------------------------------------------------------- +// Document +// ------------------------------------------------------------------- + +type DocumentType string +type DocumentableType string + +const ( + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- From 67ddd8e667cf51ee773a07fea9561fe542fc60db Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 09:24:32 +0700 Subject: [PATCH 28/50] Feat[BE]: enhance chickin stock management with FIFO service integration and fix key naming inconsistencies --- .../modules/production/chickins/module.go | 8 ++-- .../chickins/services/chickin.service.go | 38 +++++++++---------- internal/route/route.go | 8 ++-- internal/utils/fifo/constants.go | 2 +- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index df0ebd26..2cd0ad7e 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -39,19 +39,19 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsablekeyProjectChickin, + Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", Columns: fifo.UsableColumns{ ID: "id", ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_usage_qty", - CreatedAt: "id", + CreatedAt: "created_at", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index fe78080b..965e39ba 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -25,7 +25,7 @@ import ( "gorm.io/gorm" ) -var chickinUsableKey = fifo.UsablekeyProjectChickin +var chickinUsableKey = fifo.UsableKeyProjectChickin type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) @@ -135,8 +135,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) + chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index - for _, chickinReq := range req.ChickinRequests { + for idx, chickinReq := range req.ChickinRequests { productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), chickinReq.ProductWarehouseId, nil) if err != nil { @@ -164,7 +165,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChickin := &entity.ProjectChickin{ ProjectFlockKandangId: req.ProjectFlockKandangId, ChickInDate: chickinDate, - UsageQty: availableQty, + UsageQty: 0, PendingUsageQty: 0, ProductWarehouseId: chickinReq.ProductWarehouseId, Notes: chickinReq.Note, @@ -172,6 +173,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti } newChikins = append(newChikins, newChickin) + chickinQtyMap[uint(idx)] = availableQty } if len(newChikins) == 0 { @@ -193,24 +195,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - for _, chickin := range newChikins { - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin); err != nil { + for idx, chickin := range newChikins { + desiredQty := chickinQtyMap[uint(idx)] + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { return err } - - if chickin.PendingUsageQty > 0 { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot create chickin %d - insufficient stock. Required: %.0f, Available: %.0f, Pending: %.0f", chickin.Id, chickin.UsageQty+chickin.PendingUsageQty, chickin.UsageQty, chickin.PendingUsageQty)) - } } - warehouseDeltas := make(map[uint]float64) - for _, chickin := range newChikins { - warehouseDeltas[chickin.ProductWarehouseId] -= chickin.UsageQty - } - if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) - return err - } + // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { @@ -599,19 +591,20 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { if chickin == nil || s.FifoSvc == nil { return nil } - var desired float64 = chickin.UsageQty + s.Log.Infof("ConsumeChickinStocks: chickin_id=%d, product_warehouse_id=%d, desired_qty=%.3f", + chickin.Id, chickin.ProductWarehouseId, desiredQty) result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, ProductWarehouseID: chickin.ProductWarehouseId, - Quantity: desired, - AllowPending: false, + Quantity: desiredQty, + AllowPending: true, Tx: tx, }) if err != nil { @@ -619,6 +612,9 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + s.Log.Infof("ConsumeChickinStocks result: usage_qty=%.3f, pending_qty=%.3f, allocated_allocations=%d", + result.UsageQuantity, result.PendingQuantity, len(result.AddedAllocations)) + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Updates(map[string]interface{}{ "usage_qty": result.UsageQuantity, "pending_usage_qty": result.PendingQuantity, diff --git a/internal/route/route.go b/internal/route/route.go index aa538b0c..877ec875 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -12,15 +12,15 @@ import ( closings "gitlab.com/mbugroup/lti-api.git/internal/modules/closings" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" expenses "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses" + finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" - finance "gitlab.com/mbugroup/lti-api.git/internal/modules/finance" // MODULE IMPORTS ) @@ -44,8 +44,8 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, - repports.RepportModule{}, - finance.FinanceModule{}, + repports.RepportModule{}, + finance.FinanceModule{}, // MODULE REGISTRY } diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index c1a79444..fd0bca06 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,5 +2,5 @@ package fifo const ( UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsablekeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" ) From 20f8a45823e1531e2edbda394041bd0c59548a76 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 24 Dec 2025 10:42:27 +0700 Subject: [PATCH 29/50] Feat[BE]: update update dto for transfer document --- .../inventory/transfers/dto/transfer.dto.go | 24 ++++++++++--------- 1 file 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 14ca04d2..f1286595 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -85,7 +85,7 @@ type TransferDeliveryDTO struct { ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostTotal float64 `json:"shipping_cost_total"` Items []TransferDeliveryItemDTO `json:"items"` - Documents []DocumentDTO `json:"documents"` + Document *DocumentDTO `json:"document,omitempty"` } type TransferDeliveryItemDTO struct { @@ -174,15 +174,16 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { }) } - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -197,7 +198,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, Items: items, - Documents: documents, + Document: document, }) } @@ -234,15 +235,16 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { var deliveries []TransferDeliveryDTO for _, del := range e.Deliveries { - var documents []DocumentDTO - for _, doc := range del.Documents { - documents = append(documents, DocumentDTO{ + var document *DocumentDTO + if len(del.Documents) > 0 { + doc := del.Documents[0] // Take first document + document = &DocumentDTO{ Id: doc.Id, Path: doc.Path, Name: doc.Name, Ext: doc.Ext, Size: doc.Size, - }) + } } deliveries = append(deliveries, TransferDeliveryDTO{ @@ -256,7 +258,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentNumber: del.DocumentNumber, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Documents: documents, + Document: document, }) } From c7ae836cf01996e37f66c5ee16610a465de0dbb8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 09:19:39 +0700 Subject: [PATCH 30/50] Feat[BE]: refactor stock log handling and introduce new log types for adjustments and transfers --- .../common/service/common.fifo.service.go | 25 ++++++-- internal/entities/stock_log.go | 10 ---- .../repositories/closing.repository.go | 4 +- .../services/adjustment.service.go | 12 ++-- .../transfers/services/transfer.service.go | 6 +- .../modules/production/chickins/module.go | 1 - .../chickins/services/chickin.service.go | 59 ++++++++++++++++--- internal/utils/constant.go | 2 + 8 files changed, 83 insertions(+), 36 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index e3b80268..bf97f831 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa var lots []stockLot for key, cfg := range configs { - selectStmt := fmt.Sprintf( - "%s AS id, %s AS available_qty, %s AS created_at", - cfg.Columns.ID, - fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), - cfg.Columns.CreatedAt, - ) + + usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID + + var selectStmt string + if usesNumericTime { + + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + ) + } else { + selectStmt = fmt.Sprintf( + "%s AS id, %s AS available_qty, %s AS created_at", + cfg.Columns.ID, + fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), + cfg.Columns.CreatedAt, + ) + } var rows []struct { ID uint diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 310d8cf8..d6acafb8 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -2,16 +2,6 @@ package entities import "time" -const ( - LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" -) - -const ( - TransactionTypeIncrease = "INCREASE" - TransactionTypeDecrease = "DECREASE" -) - type StockLog struct { Id uint `gorm:"primaryKey;column:id"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 6a59c5f9..cf49826a 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -783,7 +783,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) } func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false) if err != nil { return nil, nil, err } @@ -792,7 +792,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka } func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) + rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) if err != nil { return nil, nil, err } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7bcbca7e..5a634382 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -70,7 +70,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != entity.LogTypeAdjustment { + if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") } @@ -97,7 +97,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } transactionType := strings.ToUpper(req.TransactionType) - if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { + if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } @@ -154,14 +154,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e afterQuantity := productWarehouse.Quantity newLog := &entity.StockLog{ - // TransactionType: transactionType, - LoggableType: entity.LogTypeAdjustment, + + LoggableType: string(utils.StockLogTypeAdjustment), LoggableId: 0, Notes: req.Note, ProductWarehouseId: productWarehouse.Id, CreatedBy: actorID, // TODO: should Get from auth middleware } - if transactionType == entity.TransactionTypeIncrease { + if transactionType == string(utils.StockLogTransactionTypeIncrease) { afterQuantity += req.Quantity newLog.Increase = afterQuantity } else { @@ -248,7 +248,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu db = s.withRelations(db) - db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) + db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 89e7b271..a8a8996e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -259,7 +259,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if s.DocumentSvc != nil && len(files) > 0 { - // Upload documents for each delivery + for idx, file := range files { documentFiles := []commonSvc.DocumentFile{ { @@ -296,7 +296,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques decreaseLog := &entity.StockLog{ Decrease: product.ProductQty, Notes: "", - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), ProductWarehouseId: sourcePW.Id, CreatedBy: actorID, @@ -335,7 +335,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques increaseLog := &entity.StockLog{ Increase: product.ProductQty, - LoggableType: entity.LogTypeTransfer, + LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(entityTransfer.Id), Notes: "", ProductWarehouseId: destPW.Id, diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 2cd0ad7e..6c9b8984 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -42,7 +42,6 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) userRepo := rUser.NewUserRepository(db) - // Register PROJECT_CHICKIN as usable if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyProjectChickin, Table: "project_chickins", diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 965e39ba..871c8fce 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -16,6 +16,7 @@ import ( 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" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -48,6 +49,7 @@ type chickinService struct { ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { @@ -63,6 +65,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, FifoSvc: fifoSvc, + StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -135,7 +138,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, err } newChikins := make([]*entity.ProjectChickin, 0) - chickinQtyMap := make(map[uint]float64) // Store desired qty for each chickin index + chickinQtyMap := make(map[uint]float64) for idx, chickinReq := range req.ChickinRequests { @@ -197,13 +200,11 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty); err != nil { + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } - // Note: FIFO Consume already adjusts product warehouse quantities, no need to adjust again - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") @@ -306,8 +307,13 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return err + } + if chickin.UsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } @@ -461,7 +467,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { - if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin); err != nil { + if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } @@ -591,7 +597,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti return nil } -func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64) error { +func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } @@ -622,14 +628,35 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return err } + if result.UsageQuantity > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, decreaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + + } + } + return nil } -func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin) error { +func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { if chickin == nil || s.FifoSvc == nil { return nil } + var currentUsage float64 + if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { + s.Log.Warnf("Failed to get current usage for chickin %d: %+v", chickin.Id, err) + currentUsage = 0 + } + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ UsableKey: chickinUsableKey, UsableID: chickin.Id, @@ -646,6 +673,22 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } + // Create stock log for the restoration + if currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeChikin), + LoggableId: chickin.Id, + ProductWarehouseId: chickin.ProductWarehouseId, + CreatedBy: actorID, + Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), + } + if err := s.StockLogRepo.CreateOne(ctx, increaseLog, nil); err != nil { + s.Log.Errorf("Failed to create stock log for chickin %d: %+v", chickin.Id, err) + // Don't return error here, stock already released + } + } + return nil } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 20e0ab6a..db5598e5 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -111,6 +111,8 @@ type StockLogType string const ( StockLogTypeAdjustment StockLogType = "ADJUSTMENT" StockLogTypeTransfer StockLogType = "TRANSFER" + StockLogTypeMarketing StockLogType = "MARKETING" + StockLogTypeChikin StockLogType = "CHICKIN" ) // ------------------------------------------------------------------- From c3302397ccd3a736946b609c803032318844c7b1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:20:57 +0700 Subject: [PATCH 31/50] Feat[BE]: integrate document service into expense module and update related DTOs for document handling --- internal/entities/expense.go | 13 +- internal/modules/expenses/dto/expense.dto.go | 21 +- internal/modules/expenses/module.go | 8 +- .../expenses/services/expense.service.go | 252 ++++++++---------- internal/modules/purchases/module.go | 7 + internal/utils/constant.go | 8 +- 6 files changed, 150 insertions(+), 159 deletions(-) diff --git a/internal/entities/expense.go b/internal/entities/expense.go index e6ab1d77..83a6031b 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -1,7 +1,6 @@ package entities import ( - "database/sql" "time" "gorm.io/gorm" @@ -13,8 +12,6 @@ type Expense struct { SupplierId uint64 `gorm:""` Category string `gorm:"type:varchar(50);not null"` PoNumber string `gorm:"type:varchar(50)"` - DocumentPath sql.NullString `gorm:"type:json"` - RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` @@ -23,8 +20,10 @@ type Expense struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` - LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` + Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` + Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"` + RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"` + LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` } diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index c55dba2c..4bb9ebe1 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -1,7 +1,6 @@ package dto import ( - "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -41,8 +40,8 @@ type ExpenseListDTO struct { type ExpenseDetailDTO struct { ExpenseBaseDTO - Documents []DocumentDTO `json:"documents,omitempty"` - RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` + Documents []DocumentDTO `json:"documents"` + RealizationDocs []DocumentDTO `json:"realization_docs"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` TotalPengajuan float64 `json:"total_pengajuan"` TotalRealisasi float64 `json:"total_realisasi"` @@ -179,12 +178,20 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { var pengajuans []ExpenseNonstockDTO var realisasi []ExpenseRealizationDTO - if e.DocumentPath.Valid && e.DocumentPath.String != "" { - json.Unmarshal([]byte(e.DocumentPath.String), &documents) + // Map documents from Document service + for _, doc := range e.Documents { + documents = append(documents, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } - if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { - json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) + // Map realization documents from Document service + for _, doc := range e.RealizationDocuments { + realizationDocs = append(realizationDocs, DocumentDTO{ + ID: uint64(doc.Id), + Path: doc.Path, + }) } if len(e.Nonstocks) > 0 { diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 6d276b5d..b495b5b9 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -1,6 +1,7 @@ package expenses import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + documentRepo := commonRepo.NewDocumentRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } // Register workflow steps for EXPENSES approval if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) } - expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) + expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate) userService := sUser.NewUserService(userRepo, validate) ExpenseRoutes(router, userService, expenseService) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 24ba4f2e..728c689f 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -2,11 +2,8 @@ package service import ( "context" - "database/sql" - "encoding/json" "errors" "fmt" - "mime/multipart" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -49,9 +46,10 @@ type expenseService struct { ApprovalSvc commonSvc.ApprovalService RealizationRepository repository.ExpenseRealizationRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + DocumentSvc commonSvc.DocumentService } -func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { +func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService { return &expenseService{ Log: utils.Log, Validate: validate, @@ -61,6 +59,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR ApprovalSvc: approvalSvc, RealizationRepository: realizationRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + DocumentSvc: documentSvc, } } @@ -72,7 +71,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Nonstocks.Realization"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.Kandang"). - Preload("Nonstocks.Kandang.Location") + Preload("Nonstocks.Kandang.Location"). + Preload("Documents", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense)) + }). + Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB { + return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization)) + }) } func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { @@ -269,9 +274,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: expense.Id, + CreatedBy: &createdByUint, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -527,9 +546,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpense), + Index: &idx, + }) + } + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpense), + DocumentableID: uint64(id), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents") } } @@ -658,9 +691,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -833,9 +881,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va } } - if len(req.Documents) > 0 { - if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { - return err + if s.DocumentSvc != nil && len(req.Documents) > 0 { + documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents)) + for idx, file := range req.Documents { + documentFiles = append(documentFiles, commonSvc.DocumentFile{ + File: file, + Type: string(utils.DocumentTypeExpenseRealization), + Index: &idx, + }) + } + actorID := uint(1) // TODO: replace with authenticated user id + _, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypeExpenseRealization), + DocumentableID: uint64(expenseID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents") } } @@ -870,79 +933,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va return responseDTO, nil } -func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error { - - if len(documents) == 0 { - return nil - } - - var existingDocuments []expenseDto.DocumentDTO - var fieldName string - - if isRealization { - fieldName = "realization_document_path" - } else { - fieldName = "document_path" - } - - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing") - } - } else { - - var documentField sql.NullString - if isRealization { - documentField = expense.RealizationDocumentPath - } else { - documentField = expense.DocumentPath - } - - if documentField.Valid && documentField.String != "" { - if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil { - existingDocuments = []expenseDto.DocumentDTO{} - } - } - } - - var startID uint64 = 1 - if len(existingDocuments) > 0 { - - maxID := uint64(0) - for _, doc := range existingDocuments { - if doc.ID > maxID { - maxID = doc.ID - } - } - startID = maxID + 1 - } - - for i, doc := range documents { - documentPath := doc.Filename - - document := expenseDto.DocumentDTO{ - ID: startID + uint64(i), - Path: documentPath, - } - existingDocuments = append(existingDocuments, document) - } - - documentJSON, err := json.Marshal(existingDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil -} - func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { if err := commonSvc.EnsureRelations(ctx.Context(), @@ -951,62 +941,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document return err } - if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { - expenseRepoTx := repository.NewExpenseRepository(tx) + if s.DocumentSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Document service not available") + } - expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") + // Verify document exists and belongs to the expense + var documentableType string + if isRealization { + documentableType = string(utils.DocumentableTypeExpenseRealization) + } else { + documentableType = string(utils.DocumentableTypeExpense) + } + + documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents") + } + + documentFound := false + var documentIDsToDelete []uint + for _, doc := range documents { + if uint64(doc.Id) == documentID { + documentFound = true + documentIDsToDelete = append(documentIDsToDelete, doc.Id) + break } + } - var existingDocuments []expenseDto.DocumentDTO - var fieldName string + if !documentFound { + return fiber.NewError(fiber.StatusNotFound, "Document not found") + } - if isRealization { - fieldName = "realization_document_path" - if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents") - } - } - } else { - fieldName = "document_path" - if expense.DocumentPath.Valid && expense.DocumentPath.String != "" { - if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents") - } - } - } - - var updatedDocuments []expenseDto.DocumentDTO - documentFound := false - - for _, doc := range existingDocuments { - if doc.ID == documentID { - documentFound = true - continue - } - updatedDocuments = append(updatedDocuments, doc) - } - - if !documentFound { - return fiber.NewError(fiber.StatusNotFound, "Document not found") - } - - documentJSON, err := json.Marshal(updatedDocuments) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{ - fieldName: string(documentJSON), - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents") - } - - return nil - }); err != nil { - return err + // Delete document from database and storage + if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document") } return nil diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index ec1b24f7..6daf2a39 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -1,6 +1,7 @@ package purchases import ( + "context" "fmt" "github.com/go-playground/validator/v10" @@ -41,6 +42,11 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) + documentRepo := commonRepo.NewDocumentRepository(db) + documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) + if err != nil { + panic(fmt.Sprintf("failed to create document service: %v", err)) + } if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } @@ -54,6 +60,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseRealizationRepo, projectFlockKandangRepository, + documentSvc, validate, ) expenseBridge := service.NewExpenseBridge( diff --git a/internal/utils/constant.go b/internal/utils/constant.go index db5598e5..85b0cc91 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -408,9 +408,13 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) // ------------------------------------------------------------------- From 96c29178348361de82e2f20db70c070cc586fa19 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 11:21:23 +0700 Subject: [PATCH 32/50] Feat[BE]: add migration scripts to manage document columns in expenses table for Document service integration --- ...20251226031727_alter_table_expense_delete_document.down.sql | 3 +++ .../20251226031727_alter_table_expense_delete_document.up.sql | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql create mode 100644 internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql new file mode 100644 index 00000000..59e54379 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.down.sql @@ -0,0 +1,3 @@ +-- Rollback: restore document columns to expenses table +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON; +ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON; diff --git a/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql new file mode 100644 index 00000000..c75bc307 --- /dev/null +++ b/internal/database/migrations/20251226031727_alter_table_expense_delete_document.up.sql @@ -0,0 +1,3 @@ +-- Delete document columns from expenses table since we now use Document service with polymorphic relations +ALTER TABLE expenses DROP COLUMN IF EXISTS document_path; +ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path; From ac8536a4a1e9466dbe2ccc47cc18d91362a4e101 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 19:02:50 +0700 Subject: [PATCH 33/50] Feat[BE]: implement FIFO stock management for marketing delivery products, including migration scripts and updates to related services and DTOs --- ...ds_to_marketing_delivery_products.down.sql | 28 +++++ ...elds_to_marketing_delivery_products.up.sql | 58 +++++++++ .../migrations/20251226114218_add.down.sql | 7 ++ .../migrations/20251226114218_add.up.sql | 19 +++ .../entities/marketing_delivery_product.go | 23 ++-- internal/middleware/permissions.go | 1 + .../closings/dto/closingMarketing.dto.go | 2 +- .../closings/services/closing.service.go | 6 +- .../marketing/dto/deliveryorder.dto.go | 2 +- internal/modules/marketing/module.go | 33 ++++- .../salesorder_delivery_product.repository.go | 33 +++++ internal/modules/marketing/route.go | 15 ++- .../services/deliveryorder.service.go | 113 ++++++++++++------ .../marketing/services/salesorder.service.go | 9 +- .../repports/dto/repportMarketing.dto.go | 8 +- internal/utils/fifo/constants.go | 5 +- 16 files changed, 290 insertions(+), 72 deletions(-) create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql create mode 100644 internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql create mode 100644 internal/database/migrations/20251226114218_add.down.sql create mode 100644 internal/database/migrations/20251226114218_add.up.sql diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql new file mode 100644 index 00000000..b9637077 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.down.sql @@ -0,0 +1,28 @@ +-- ============================================ +-- Rollback: Remove FIFO fields and restore qty column +-- ============================================ + +-- STEP 1: Drop indexes +DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup; +DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty; +DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at; + +-- STEP 2: Drop constraints +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg; + +-- STEP 3: Restore qty column from usage_qty data +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +-- Migrate data back from usage_qty to qty +UPDATE marketing_delivery_products +SET qty = usage_qty +WHERE qty = 0; + +-- STEP 4: Drop FIFO columns +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS usage_qty, +DROP COLUMN IF EXISTS pending_qty, +DROP COLUMN IF EXISTS created_at; diff --git a/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql new file mode 100644 index 00000000..160c1f05 --- /dev/null +++ b/internal/database/migrations/20251226054852_add_fifo_fields_to_marketing_delivery_products.up.sql @@ -0,0 +1,58 @@ +-- ============================================ +-- Add FIFO fields to marketing_delivery_products +-- This migration adds fields needed for FIFO stock management +-- and removes the old qty field in favor of FIFO-based allocation +-- ============================================ + +-- STEP 0: Drop orphan indexes from previous migration +DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at; + +-- STEP 1: Add created_at column (required for FIFO ordering) +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(); + +-- STEP 2: Add FIFO tracking fields +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0, +ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0; + +-- STEP 3: Migrate data from old qty to usage_qty for existing records +-- This preserves existing quantity data as allocated quantity +UPDATE marketing_delivery_products +SET + usage_qty = COALESCE(qty, 0), + pending_qty = 0 +WHERE usage_qty = 0; + +-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty) +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS qty; + +-- STEP 5: Make FIFO fields NOT NULL +ALTER TABLE marketing_delivery_products +ALTER COLUMN usage_qty SET NOT NULL, +ALTER COLUMN pending_qty SET NOT NULL, +ALTER COLUMN created_at SET NOT NULL; + +-- STEP 6: Add constraints to ensure non-negative values +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK ( + usage_qty >= 0 AND + pending_qty >= 0 +); + +-- STEP 7: Create indexes for FIFO operations +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at +ON marketing_delivery_products(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty +ON marketing_delivery_products(usage_qty) +WHERE usage_qty > 0; + +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty +ON marketing_delivery_products(pending_qty) +WHERE pending_qty > 0; + +-- Composite index for FIFO lookups +CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup +ON marketing_delivery_products(marketing_product_id, created_at DESC); diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add.down.sql new file mode 100644 index 00000000..15f3368a --- /dev/null +++ b/internal/database/migrations/20251226114218_add.down.sql @@ -0,0 +1,7 @@ +-- Remove foreign key constraint +ALTER TABLE marketing_delivery_products +DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse; + +-- Drop product_warehouse_id column +ALTER TABLE marketing_delivery_products +DROP COLUMN IF EXISTS product_warehouse_id; diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add.up.sql new file mode 100644 index 00000000..97e5b4ee --- /dev/null +++ b/internal/database/migrations/20251226114218_add.up.sql @@ -0,0 +1,19 @@ +-- Add product_warehouse_id column to marketing_delivery_products +ALTER TABLE marketing_delivery_products +ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0; + +-- Fill product_warehouse_id from marketing_products +UPDATE marketing_delivery_products mdp +SET product_warehouse_id = mp.product_warehouse_id +FROM marketing_products mp +WHERE mdp.marketing_product_id = mp.id + AND mdp.product_warehouse_id = 0; + +-- Set NOT NULL constraint +ALTER TABLE marketing_delivery_products +ALTER COLUMN product_warehouse_id SET NOT NULL; + +-- Add foreign key constraint +ALTER TABLE marketing_delivery_products +ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse +FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id); diff --git a/internal/entities/marketing_delivery_product.go b/internal/entities/marketing_delivery_product.go index 47f6e89d..7ac3d967 100644 --- a/internal/entities/marketing_delivery_product.go +++ b/internal/entities/marketing_delivery_product.go @@ -5,15 +5,20 @@ import ( ) type MarketingDeliveryProduct struct { - Id uint `gorm:"primaryKey;autoIncrement"` - MarketingProductId uint `gorm:"uniqueIndex;not null"` - Qty float64 `gorm:"type:numeric(15,3)"` - UnitPrice float64 `gorm:"type:numeric(15,3)"` - TotalWeight float64 `gorm:"type:numeric(15,3)"` - AvgWeight float64 `gorm:"type:numeric(15,3)"` - TotalPrice float64 `gorm:"type:numeric(15,3)"` - DeliveryDate *time.Time `gorm:"type:timestamptz"` - VehicleNumber string `gorm:"type:varchar(50)"` + Id uint `gorm:"primaryKey;autoIncrement"` + MarketingProductId uint `gorm:"uniqueIndex;not null"` + ProductWarehouseId uint `gorm:"not null"` + UnitPrice float64 `gorm:"type:numeric(15,3)"` + TotalWeight float64 `gorm:"type:numeric(15,3)"` + AvgWeight float64 `gorm:"type:numeric(15,3)"` + TotalPrice float64 `gorm:"type:numeric(15,3)"` + DeliveryDate *time.Time `gorm:"type:timestamptz"` + VehicleNumber string `gorm:"type:varchar(50)"` + + // FIFO Fields + UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"` + CreatedAt *time.Time `gorm:"type:timestamptz;not null"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` } diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 02145930..7a21262c 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -77,6 +77,7 @@ const ( P_DeliveryGetAll = "lti.marketing.delivery_order.list" P_DeliveryGetOne = "lti.marketing.delivery_order.detail" P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" + P_DeliveryCreateOne = "lti.marketing.delivery_order.Create" P_SalesOrderDelete = "lti.marketing.sales_order.delete" P_SalesOrderApproval = "lti.marketing.sales_order.approve" P_SalesOrderCreateOne = "lti.marketing.sales_order.create" diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 8c904561..4c7b4d35 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -64,7 +64,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { DoNumber: doNumber, Product: product, Customer: customer, - Qty: e.Qty, + Qty: e.UsageQty, // Show allocated quantity from FIFO Weight: e.TotalWeight, AvgWeight: e.AvgWeight, Price: e.UnitPrice, diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 48728195..ab8e6f7b 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -712,13 +712,13 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo ) for _, product := range deliveryProducts { - if product.Qty == 0 { + if product.UsageQty == 0 { continue } projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) - totalAgeWeeks += float64(ageWeeks) * product.Qty - totalQty += product.Qty + totalAgeWeeks += float64(ageWeeks) * product.UsageQty + totalQty += product.UsageQty } if totalQty == 0 { diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index b2bb70d7..a6eea180 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -121,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD return MarketingDeliveryProductDTO{ Id: e.Id, MarketingProductId: e.MarketingProductId, - Qty: e.Qty, + Qty: e.UsageQty, UnitPrice: e.UnitPrice, TotalWeight: e.TotalWeight, AvgWeight: e.AvgWeight, diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 586e7961..d8c8fc6a 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,6 +2,7 @@ package marketing import ( "fmt" + "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -13,11 +14,12 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "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/utils/fifo" ) type MarketingModule struct{} @@ -31,6 +33,28 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + // Initialize FIFO service + stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + + // Register marketing_delivery_products as FIFO Usable + // Note: ProductWarehouseID comes from marketing_products table via preload + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyMarketingDelivery, + Table: "marketing_delivery_products", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", // Resolved from marketing_products via preload + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) + } + } + // Initialize approval service approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -42,9 +66,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + // Initialize services - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) // Register routes diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index ba2c1133..04051009 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -17,6 +17,9 @@ type MarketingDeliveryProductRepository interface { GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) + UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error + GetUsageQty(ctx context.Context, id uint) (float64, error) + ResetFifoFields(ctx context.Context, id uint) error } type MarketingDeliveryProductRepositoryImpl struct { @@ -241,3 +244,33 @@ func containsJoin(db *gorm.DB, tableName string) bool { joinSQL := statement.SQL.String() return strings.Contains(joinSQL, "JOIN "+tableName) } + +func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + +func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) { + var usageQty float64 + err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Select("usage_qty"). + Scan(&usageQty).Error + return usageQty, err +} + +func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error { + return r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "usage_qty": 0, + "pending_qty": 0, + }).Error +} diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 139d1ee9..81402c7c 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -16,12 +16,15 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde route := router.Group("/marketing") route.Use(m.Auth(userService)) - route.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) - route.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) - route.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) + route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) + route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) - route.Post("/sales-orders",m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) - route.Patch("/sales-orders/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) - route.Post("/sales-orders/approvals",m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) + + route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 793ed716..e864a778 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -15,10 +15,10 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -30,12 +30,12 @@ type DeliveryOrdersService interface { } type deliveryOrdersService struct { - Log *logrus.Logger Validate *validator.Validate MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + FifoSvc commonSvc.FifoService } func NewDeliveryOrdersService( @@ -43,15 +43,16 @@ func NewDeliveryOrdersService( marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, + fifoSvc commonSvc.FifoService, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ - Log: utils.Log, Validate: validate, MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + FifoSvc: fifoSvc, } } @@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO }) if err != nil { - s.Log.Errorf("Failed to get marketings: %+v", err) return nil, 0, err } for i := range marketings { @@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Preload("ActionUser") }) if err != nil { - s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) + continue } marketings[i].LatestApproval = latestApproval } @@ -247,7 +247,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - deliveryProduct.Qty = requestedProduct.Qty + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -256,7 +256,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil { + + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { return err } } @@ -354,8 +355,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO itemDeliveryDate = deliveryProduct.DeliveryDate } - oldQty := deliveryProduct.Qty - deliveryProduct.Qty = requestedProduct.Qty + oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight @@ -363,14 +365,18 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber - qtyChange := requestedProduct.Qty - oldQty - if qtyChange > 0 { - if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { - return err + if requestedProduct.Qty != oldRequestedQty { + + if oldRequestedQty > 0 { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + return err + } } - } else if qtyChange < 0 { - if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { - return err + + if requestedProduct.Qty > 0 { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + return err + } } } @@ -393,50 +399,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") + } + + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + ProductWarehouseID: marketingProduct.ProductWarehouseId, + Quantity: requestedQty, + AllowPending: false, + Tx: tx, + }) + + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) + pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + if err2 != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + + if pw == nil || pw.Quantity < requestedQty { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) + } + + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + return nil } - if pw.Quantity < qtyDeliver { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) + if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - pw.Quantity = pw.Quantity - qtyDeliver - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") - } return nil } -func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return nil + } + if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } - pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) - pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") - } - - return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock") + currentUsage = 0 } - pw.Quantity = pw.Quantity + qtyRestore - if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") + if currentUsage == 0 { + return nil + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: fifo.UsableKeyMarketingDelivery, + UsableID: deliveryProduct.Id, + Tx: tx, + }); err != nil { + return err + } + + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { + return err } return nil diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 02cd2e42..bef2a477 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -307,15 +307,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { + mdp := &entity.MarketingDeliveryProduct{ MarketingProductId: old.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") @@ -340,7 +342,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } if err == nil { - if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { + if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) } @@ -602,13 +604,14 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ MarketingProductId: marketingProduct.Id, - Qty: 0, UnitPrice: 0, TotalWeight: 0, AvgWeight: 0, TotalPrice: 0, DeliveryDate: nil, VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 9c026590..90c2fe50 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -61,7 +61,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - totalWeightKg := mdp.Qty * mdp.AvgWeight + totalWeightKg := mdp.UsageQty * mdp.AvgWeight salesAmount := totalWeightKg * mdp.UnitPrice var hpp float64 @@ -78,7 +78,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK AgingDays: agingDays, DoNumber: doNumber, MarketingType: getMarketingType(mdp), - Qty: mdp.Qty, + Qty: mdp.UsageQty, AverageWeightKg: mdp.AvgWeight, TotalWeightKg: totalWeightKg, SalesPricePerKg: mdp.UnitPrice, @@ -194,8 +194,8 @@ func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, ca totalHppAmount := int64(0) for _, mdp := range mdps { - calculatedTotalWeight := mdp.Qty * mdp.AvgWeight - totalQty += int(mdp.Qty) + calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight + totalQty += int(mdp.UsageQty) totalWeightKg += calculatedTotalWeight totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index fd0bca06..ea6f96c0 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -1,6 +1,7 @@ package fifo const ( - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" ) From dbb13da7c4e0667c73cf6c2cc2b419fc8764e47c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 26 Dec 2025 23:36:53 +0700 Subject: [PATCH 34/50] Feat[BE]: add migration scripts for product warehouse ID management and create production standards tables with constraints and indexes --- ...d_to_marketing_delivery_products.down.sql} | 0 ..._id_to_marketing_delivery_products.up.sql} | 0 ...reate_production_standards_tables.down.sql | 10 ++++ ..._create_production_standards_tables.up.sql | 54 +++++++++++++++++++ 4 files changed, 64 insertions(+) rename internal/database/migrations/{20251226114218_add.down.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql} (100%) rename internal/database/migrations/{20251226114218_add.up.sql => 20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql} (100%) create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.down.sql create mode 100644 internal/database/migrations/20251226161036_create_production_standards_tables.up.sql diff --git a/internal/database/migrations/20251226114218_add.down.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.down.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.down.sql diff --git a/internal/database/migrations/20251226114218_add.up.sql b/internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql similarity index 100% rename from internal/database/migrations/20251226114218_add.up.sql rename to internal/database/migrations/20251226114218_add_product_warehouse_id_to_marketing_delivery_products.up.sql diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql new file mode 100644 index 00000000..f5cc2237 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.down.sql @@ -0,0 +1,10 @@ +-- Drop indexes +DROP INDEX IF EXISTS idx_standard_growth_details_standard_week; +DROP INDEX IF EXISTS idx_production_standard_details_standard_week; +DROP INDEX IF EXISTS idx_production_standards_project_category; +DROP INDEX IF EXISTS idx_production_standards_deleted_at; + +-- Drop tables (in reverse order due to foreign keys) +DROP TABLE IF EXISTS standard_growth_details; +DROP TABLE IF EXISTS production_standard_details; +DROP TABLE IF EXISTS production_standards; diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql new file mode 100644 index 00000000..61aa3071 --- /dev/null +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -0,0 +1,54 @@ +-- Create production_standards table +CREATE TABLE IF NOT EXISTS production_standards ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +-- Create index for deleted_at (soft delete) +CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); + +-- Create production_standard_details table +CREATE TABLE IF NOT EXISTS production_standard_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + week INT NOT NULL, + target_hen_day_production NUMERIC(15, 3), + target_hen_house_production NUMERIC(15, 3), + target_egg_weight NUMERIC(15, 3), + target_egg_mass NUMERIC(15, 3), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_production_standard_details_standard_week + ON production_standard_details(production_standard_id, week); + +-- Create standard_growth_details table +CREATE TABLE IF NOT EXISTS standard_growth_details ( + id BIGSERIAL PRIMARY KEY, + production_standard_id BIGINT NOT NULL, + target_mean_bw INT, + max_depletion NUMERIC(15, 3), + min_uniformity NUMERIC(15, 3) NOT NULL, + week INT NOT NULL, + feed_intake INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by BIGINT NOT NULL, + CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) + REFERENCES production_standards(id) ON DELETE CASCADE +); + +-- Create unique constraint for standard_id + week +CREATE UNIQUE INDEX idx_standard_growth_details_standard_week + ON standard_growth_details(production_standard_id, week); + +-- Create index for project_category +CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); From bb76d27f2514a559c7a9a262e746593992ada53a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 27 Dec 2025 09:02:16 +0700 Subject: [PATCH 35/50] feat[BE#US386]: add production standards module with CRUD operations - Created database migration for production standards and related tables. - Implemented entities for ProductionStandard, ProductionStandardDetail, and StandardGrowthDetail. - Developed controller for handling production standard requests. - Added DTOs for data transfer between layers. - Implemented service layer for business logic related to production standards. - Created repository interfaces and implementations for data access. - Added validation for production standard requests. - Registered routes for production standards in the main application. --- ..._create_production_standards_tables.up.sql | 60 +++- internal/entities/production_standard.go | 19 ++ .../entities/production_standard_detail.go | 19 ++ internal/entities/standard_growth_detail.go | 19 ++ .../production-standard.controller.go | 145 +++++++++ .../dto/production-standard.dto.go | 155 +++++++++ .../master/production-standards/module.go | 33 ++ .../production_standard.repository.go | 103 ++++++ .../production_standard_detail.repository.go | 63 ++++ .../standard_growth_detail.repository.go | 63 ++++ .../master/production-standards/route.go | 23 ++ .../services/production-standard.service.go | 302 ++++++++++++++++++ .../production-standard.validation.go | 41 +++ internal/modules/master/route.go | 2 + 14 files changed, 1038 insertions(+), 9 deletions(-) create mode 100644 internal/entities/production_standard.go create mode 100644 internal/entities/production_standard_detail.go create mode 100644 internal/entities/standard_growth_detail.go create mode 100644 internal/modules/master/production-standards/controllers/production-standard.controller.go create mode 100644 internal/modules/master/production-standards/dto/production-standard.dto.go create mode 100644 internal/modules/master/production-standards/module.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard.repository.go create mode 100644 internal/modules/master/production-standards/repositories/production_standard_detail.repository.go create mode 100644 internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go create mode 100644 internal/modules/master/production-standards/route.go create mode 100644 internal/modules/master/production-standards/services/production-standard.service.go create mode 100644 internal/modules/master/production-standards/validations/production-standard.validation.go diff --git a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql index 61aa3071..2af43d20 100644 --- a/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql +++ b/internal/database/migrations/20251226161036_create_production_standards_tables.up.sql @@ -6,12 +6,25 @@ CREATE TABLE IF NOT EXISTS production_standards ( created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, - created_by BIGINT NOT NULL + created_by BIGINT ); -- Create index for deleted_at (soft delete) CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at); +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE production_standards + ADD CONSTRAINT fk_production_standards_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- Index +CREATE INDEX idx_production_standards_created_by ON production_standards(created_by); + -- Create production_standard_details table CREATE TABLE IF NOT EXISTS production_standard_details ( id BIGSERIAL PRIMARY KEY, @@ -22,11 +35,19 @@ CREATE TABLE IF NOT EXISTS production_standard_details ( target_egg_weight NUMERIC(15, 3), target_egg_mass NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - CONSTRAINT fk_production_standard_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + updated_at TIMESTAMPTZ DEFAULT NOW() ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE production_standard_details + ADD CONSTRAINT fk_production_standard_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_production_standard_details_standard_week ON production_standard_details(production_standard_id, week); @@ -35,20 +56,41 @@ CREATE UNIQUE INDEX idx_production_standard_details_standard_week CREATE TABLE IF NOT EXISTS standard_growth_details ( id BIGSERIAL PRIMARY KEY, production_standard_id BIGINT NOT NULL, - target_mean_bw INT, + target_mean_bw NUMERIC(15, 3), max_depletion NUMERIC(15, 3), min_uniformity NUMERIC(15, 3) NOT NULL, week INT NOT NULL, - feed_intake INT, + feed_intake NUMERIC(15, 3), created_at TIMESTAMPTZ DEFAULT NOW(), - created_by BIGINT NOT NULL, - CONSTRAINT fk_standard_growth_details_standard FOREIGN KEY (production_standard_id) - REFERENCES production_standards(id) ON DELETE CASCADE + created_by BIGINT ); +-- Tambahkan Foreign Key ke production_standards +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_standard + FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE; + END IF; +END $$; + +-- Tambahkan Foreign Key ke users +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE standard_growth_details + ADD CONSTRAINT fk_standard_growth_details_created_by + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + -- Create unique constraint for standard_id + week CREATE UNIQUE INDEX idx_standard_growth_details_standard_week ON standard_growth_details(production_standard_id, week); +-- Index +CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by); + -- Create index for project_category CREATE INDEX idx_production_standards_project_category ON production_standards(project_category); diff --git a/internal/entities/production_standard.go b/internal/entities/production_standard.go new file mode 100644 index 00000000..1214f6cf --- /dev/null +++ b/internal/entities/production_standard.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandard struct { + Id uint `gorm:"primaryKey;autoIncrement"` + Name string `gorm:"type:varchar(100);uniqueIndex;not null"` + ProjectCategory string `gorm:"type:varchar(20);not null"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + DeletedAt *time.Time `gorm:"type:timestamptz"` + CreatedBy uint `gorm:"not null"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` + StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go new file mode 100644 index 00000000..cd50a572 --- /dev/null +++ b/internal/entities/production_standard_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type ProductionStandardDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + Week int `gorm:"not null"` + TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"` + TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` + TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` + TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `gorm:"type:timestamptz;not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/entities/standard_growth_detail.go b/internal/entities/standard_growth_detail.go new file mode 100644 index 00000000..a3d19fb8 --- /dev/null +++ b/internal/entities/standard_growth_detail.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" +) + +type StandardGrowthDetail struct { + Id uint `gorm:"primaryKey;autoIncrement"` + ProductionStandardId uint `gorm:"not null"` + TargetMeanBw *float64 `gorm:"type:numeric(15,3)"` + MaxDepletion *float64 `gorm:"type:numeric(15,3)"` + MinUniformity float64 `gorm:"type:numeric(15,3);not null"` + Week int `gorm:"not null"` + FeedIntake *float64 `gorm:"type:numeric(15,3)"` + CreatedAt time.Time `gorm:"type:timestamptz;not null"` + CreatedBy uint `gorm:"not null"` + + ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` +} diff --git a/internal/modules/master/production-standards/controllers/production-standard.controller.go b/internal/modules/master/production-standards/controllers/production-standard.controller.go new file mode 100644 index 00000000..1635385d --- /dev/null +++ b/internal/modules/master/production-standards/controllers/production-standard.controller.go @@ -0,0 +1,145 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductionStandardController struct { + ProductionStandardService service.ProductionStandardService +} + +func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController { + return &ProductionStandardController{ + ProductionStandardService: productionStandardService, + } +} + +func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectCategory: c.Query("project_category", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := u.ProductionStandardService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all productionStandards successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductionStandardListDTOs(result), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.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 productionStandard successfully", + Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails), + }) +} + +func (u *ProductionStandardController) 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.ProductionStandardService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete productionStandard successfully", + }) +} diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go new file mode 100644 index 00000000..9544732a --- /dev/null +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type ProductionStandardListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + ProjectCategory string `json:"project_category"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` +} + +type ProductionStandardDetailDTO struct { + ProductionStandardListDTO + Details []WeeklyProductionStandardDTO `json:"details"` +} + +type GrowthStandardDetailDTO struct { + Id uint `json:"id"` + TargetMeanBW *float64 `json:"target_mean_bw"` + MaxDepletion *float64 `json:"max_depletion"` + MinUniformity float64 `json:"min_uniformity"` + FeedIntake *float64 `json:"feed_intake"` +} + +type EggProductionStandardDetailDTO struct { + Id uint `json:"id"` + TargetHenDayProduction *float64 `json:"target_hen_day_production"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production"` + TargetEggWeight *float64 `json:"target_egg_weight"` + TargetEggMass *float64 `json:"target_egg_mass"` +} + +type WeeklyProductionStandardDTO struct { + Week int `json:"week"` + GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"` + EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"` +} + +// === Mapper Functions === + +func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO { + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductionStandardListDTO{ + Id: e.Id, + Name: e.Name, + ProjectCategory: e.ProjectCategory, + CreatedUser: createdUser, + } +} + +func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO { + result := make([]ProductionStandardListDTO, len(e)) + for i, r := range e { + result[i] = ToProductionStandardListDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO { + return WeeklyProductionStandardDTO{ + Week: e.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: e.Id, + TargetMeanBW: e.TargetMeanBw, + MaxDepletion: e.MaxDepletion, + MinUniformity: e.MinUniformity, + FeedIntake: e.FeedIntake, + }, + EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details + } +} + +func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO { + eggDetail := &EggProductionStandardDetailDTO{ + Id: detail.Id, + TargetHenDayProduction: detail.TargetHenDayProduction, + TargetHenHouseProduction: detail.TargetHenHouseProduction, + TargetEggWeight: detail.TargetEggWeight, + TargetEggMass: detail.TargetEggMass, + } + + return WeeklyProductionStandardDTO{ + Week: growth.Week, + GrowthStandardDetail: GrowthStandardDetailDTO{ + Id: growth.Id, + TargetMeanBW: growth.TargetMeanBw, + MaxDepletion: growth.MaxDepletion, + MinUniformity: growth.MinUniformity, + FeedIntake: growth.FeedIntake, + }, + EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details + } +} + +func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(e)) + for i, r := range e { + result[i] = ToWeeklyProductionStandardDTO(r) + } + return result +} + +func ToWeeklyProductionStandardDTOsWithDetails( + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) []WeeklyProductionStandardDTO { + result := make([]WeeklyProductionStandardDTO, len(growthDetails)) + + // Create map for production standard details by week + prodDetailMap := make(map[int]entity.ProductionStandardDetail) + for _, detail := range productionStandardDetails { + prodDetailMap[detail.Week] = detail + } + + // Map growth details and combine with production standard details + for i, growth := range growthDetails { + if prodDetail, exists := prodDetailMap[growth.Week]; exists { + result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail) + } else { + result[i] = ToWeeklyProductionStandardDTO(growth) + } + } + + return result +} + +func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO { + return EggProductionStandardDetailDTO{ + TargetHenDayProduction: e.TargetHenDayProduction, + TargetHenHouseProduction: e.TargetHenHouseProduction, + TargetEggWeight: e.TargetEggWeight, + TargetEggMass: e.TargetEggMass, + } +} + +func ToProductionStandardDetailDTO( + standard entity.ProductionStandard, + growthDetails []entity.StandardGrowthDetail, + productionStandardDetails []entity.ProductionStandardDetail, +) ProductionStandardDetailDTO { + return ProductionStandardDetailDTO{ + ProductionStandardListDTO: ToProductionStandardListDTO(standard), + Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails), + } +} diff --git a/internal/modules/master/production-standards/module.go b/internal/modules/master/production-standards/module.go new file mode 100644 index 00000000..bd08ee88 --- /dev/null +++ b/internal/modules/master/production-standards/module.go @@ -0,0 +1,33 @@ +package productionstandards + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductionStandardModule struct{} + +func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + userRepo := rUser.NewUserRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) + userService := sUser.NewUserService(userRepo, validate) + + ProductionStandardRoutes(router, userService, productionStandardService) +} + diff --git a/internal/modules/master/production-standards/repositories/production_standard.repository.go b/internal/modules/master/production-standards/repositories/production_standard.repository.go new file mode 100644 index 00000000..26330d63 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard.repository.go @@ -0,0 +1,103 @@ +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 ProductionStandardRepository interface { + repository.BaseRepository[entity.ProductionStandard] + GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) + GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) + GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) +} + +type ProductionStandardRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandard] + db *gorm.DB +} + +func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository { + return &ProductionStandardRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db), + db: db, + } +} + +func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) { + var standards []entity.ProductionStandard + var total int64 + + // Build base query + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier for filters + if modifier != nil { + q = modifier(q) + } + + // Count total + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Re-apply modifier and add preloads for Find + q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + if modifier != nil { + q = modifier(q) + } + q = q.Preload("CreatedUser") + + // Find with offset and limit + if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil { + return nil, 0, err + } + + return standards, total, nil +} + +func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) { + var standard entity.ProductionStandard + + q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{}) + + // Apply modifier + if modifier != nil { + q = modifier(q) + } + + // Ensure CreatedUser is preloaded + q = q.Preload("CreatedUser") + + if err := q.First(&standard, id).Error; err != nil { + return nil, err + } + + return &standard, nil +} + +func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID) +} + +func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandard](ctx, r.db, id) +} + +func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) { + var standards []entity.ProductionStandard + err := r.db.WithContext(ctx). + Preload("CreatedUser"). + Where("project_category = ?", projectCategory). + Where("deleted_at IS NULL"). + Find(&standards).Error + if err != nil { + return nil, err + } + return standards, nil +} diff --git a/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go new file mode 100644 index 00000000..ad680c01 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/production_standard_detail.repository.go @@ -0,0 +1,63 @@ +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 ProductionStandardDetailRepository interface { + repository.BaseRepository[entity.ProductionStandardDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type ProductionStandardDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductionStandardDetail] + db *gorm.DB +} + +func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository { + return &ProductionStandardDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db), + db: db, + } +} + +func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id) +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) { + var details []entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) { + var detail entity.ProductionStandardDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.ProductionStandardDetail{}).Error +} diff --git a/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go new file mode 100644 index 00000000..afe93d81 --- /dev/null +++ b/internal/modules/master/production-standards/repositories/standard_growth_detail.repository.go @@ -0,0 +1,63 @@ +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 StandardGrowthDetailRepository interface { + repository.BaseRepository[entity.StandardGrowthDetail] + IdExists(ctx context.Context, id uint) (bool, error) + GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) + GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) + DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error +} + +type StandardGrowthDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StandardGrowthDetail] + db *gorm.DB +} + +func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository { + return &StandardGrowthDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db), + db: db, + } +} + +func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id) +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) { + var details []entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Order("week ASC"). + Find(&details).Error + if err != nil { + return nil, err + } + return details, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) { + var detail entity.StandardGrowthDetail + err := r.db.WithContext(ctx). + Where("production_standard_id = ?", standardId). + Where("week = ?", week). + First(&detail).Error + if err != nil { + return nil, err + } + return &detail, nil +} + +func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error { + return r.db.WithContext(ctx). + Where("production_standard_id = ?", productionStandardId). + Delete(&entity.StandardGrowthDetail{}).Error +} diff --git a/internal/modules/master/production-standards/route.go b/internal/modules/master/production-standards/route.go new file mode 100644 index 00000000..d2035bea --- /dev/null +++ b/internal/modules/master/production-standards/route.go @@ -0,0 +1,23 @@ +package productionstandards + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/controllers" + productionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductionStandardRoutes(v1 fiber.Router, u user.UserService, s productionStandard.ProductionStandardService) { + ctrl := controller.NewProductionStandardController(s) + + route := v1.Group("/production-standards") + route.Use(m.Auth(u)) + + 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/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go new file mode 100644 index 00000000..b81faf8b --- /dev/null +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -0,0 +1,302 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/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 ProductionStandardService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductionStandard, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productionStandardService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductionStandardRepository + ProductionStandardDetailRepo repository.ProductionStandardDetailRepository + StandardGrowthDetailRepo repository.StandardGrowthDetailRepository +} + +func NewProductionStandardService( + repo repository.ProductionStandardRepository, + productionStandardDetailRepo repository.ProductionStandardDetailRepository, + standardGrowthDetailRepo repository.StandardGrowthDetailRepository, + validate *validator.Validate, +) ProductionStandardService { + return &productionStandardService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProductionStandardDetailRepo: productionStandardDetailRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + } +} + +func (s productionStandardService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("ProductionStandardDetails"). + Preload("StandardGrowthDetails") +} + +func (s productionStandardService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductionStandard, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productionStandards, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + if params.ProjectCategory != "" { + return db.Where("project_category = ?", params.ProjectCategory) + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get productionStandards: %+v", err) + return nil, 0, err + } + return productionStandards, total, nil +} + +func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductionStandard, error) { + productionStandard, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + if err != nil { + s.Log.Errorf("Failed get productionStandard by id: %+v", err) + return nil, err + } + return productionStandard, nil +} + +func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + nameExists, err := s.Repository.NameExists(c.Context(), req.Name, nil) + if err != nil { + return nil, err + } + if nameExists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", req.Name)) + } + + var createdStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + newStandard := &entity.ProductionStandard{ + Name: req.Name, + ProjectCategory: req.ProjectCategory, + CreatedBy: actorID, + } + + if err := standardRepoTx.CreateOne(c.Context(), newStandard, nil); err != nil { + return fmt.Errorf("failed to create production standard: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if req.ProjectCategory == string(utils.ProjectFlockCategoryLaying) { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: newStandard.Id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + + createdStandard = newStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, createdStandard.Id) +} + +func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var updatedStandard *entity.ProductionStandard + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) + productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) + standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) + + existingStandard, err := standardRepoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") + } + return fmt.Errorf("failed to get production standard: %w", err) + } + + updateBody := make(map[string]any) + if req.Name != nil { + + nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) + if err != nil { + s.Log.Errorf("Failed to check existing production standard: %+v", err) + return err + } + if nameExists { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Production standard with name '%s' already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.ProjectCategory != nil { + updateBody["project_category"] = *req.ProjectCategory + } + + if len(updateBody) > 0 { + if err := standardRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return fmt.Errorf("failed to update production standard: %w", err) + } + } + + if req.Details != nil && len(req.Details) > 0 { + + projectCategory := existingStandard.ProjectCategory + if req.ProjectCategory != nil { + projectCategory = *req.ProjectCategory + } + + if err := productionStandardDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old production standard details: %w", err) + } + if err := standardGrowthDetailRepoTx.DeleteByProductionStandardID(c.Context(), id); err != nil { + return fmt.Errorf("failed to delete old standard growth details: %w", err) + } + + for _, detailReq := range req.Details { + if detailReq.ProductionStandardUniformityDetails == nil { + return fmt.Errorf("production_standard_uniformity_details is required in week %d", detailReq.Week) + } + + if projectCategory == "LAYING" { + if detailReq.ProductionStandardDetails == nil { + return fmt.Errorf("production_standard_details is required for LAYING category in week %d", detailReq.Week) + } + + productionStandardDetail := &entity.ProductionStandardDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetHenDayProduction: detailReq.ProductionStandardDetails.TargetHenDayProduction, + TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, + TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, + TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + } + + if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { + return fmt.Errorf("failed to create production standard detail for week %d: %w", detailReq.Week, err) + } + } + + standardGrowthDetail := &entity.StandardGrowthDetail{ + ProductionStandardId: id, + Week: detailReq.Week, + TargetMeanBw: detailReq.ProductionStandardUniformityDetails.TargetMeanBw, + MaxDepletion: detailReq.ProductionStandardUniformityDetails.MaxDepletion, + MinUniformity: detailReq.ProductionStandardUniformityDetails.MinUniformity, + FeedIntake: detailReq.ProductionStandardUniformityDetails.FeedIntake, + CreatedBy: actorID, + } + + if err := standardGrowthDetailRepoTx.CreateOne(c.Context(), standardGrowthDetail, nil); err != nil { + return fmt.Errorf("failed to create standard growth detail for week %d: %w", detailReq.Week, err) + } + } + } + + updatedStandard = existingStandard + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to update production standard: %+v", err) + return nil, err + } + + return s.GetOne(c, updatedStandard.Id) +} + +func (s productionStandardService) 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, "ProductionStandard not found") + } + s.Log.Errorf("Failed to delete productionStandard: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go new file mode 100644 index 00000000..51aeecc7 --- /dev/null +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -0,0 +1,41 @@ +package validation + +type ProductionStandardDetailItem struct { + TargetHenDayProduction *float64 `json:"target_hen_day_production" validate:"omitempty,gte=0"` + TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` + TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` + TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` +} + +type StandardGrowthDetailItem struct { + TargetMeanBw *float64 `json:"target_mean_bw" validate:"omitempty,gte=0"` + MaxDepletion *float64 `json:"max_depletion" validate:"omitempty,gte=0,lte=100"` + MinUniformity float64 `json:"min_uniformity" validate:"required,gte=0,lte=100"` + FeedIntake *float64 `json:"feed_intake" validate:"omitempty,gte=0"` +} + +type DetailItem struct { + Week int `json:"week" validate:"required,gte=1"` + ProductionStandardDetails *ProductionStandardDetailItem `json:"production_standard_details,omitempty"` + ProductionStandardUniformityDetails *StandardGrowthDetailItem `json:"production_standard_uniformity_details" validate:"required"` +} + + +type Create struct { + Name string `json:"name" validate:"required,min=3"` + ProjectCategory string `json:"project_category" validate:"required,oneof=GROWING LAYING"` + Details []DetailItem `json:"details" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + ProjectCategory *string `json:"project_category,omitempty" validate:"omitempty,oneof=GROWING LAYING"` + Details []DetailItem `json:"details,omitempty"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + ProjectCategory string `query:"project_category" validate:"omitempty,oneof=GROWING LAYING"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 44702e1a..26ae28ee 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -20,6 +20,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" + productionStandards "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards" // MODULE IMPORTS ) @@ -40,6 +41,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida products.ProductModule{}, banks.BankModule{}, flocks.FlockModule{}, + productionStandards.ProductionStandardModule{}, // MODULE REGISTRY } From e30ef5ef10b3de0db068b18d680b13922d8ce136 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Sat, 27 Dec 2025 14:30:03 +0700 Subject: [PATCH 36/50] feat(BE): finance (payment, initial_balance, injection). fix(BE): kandang capacity --- internal/utils/constant.go | 53 +++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b0cc91..1fb156d2 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -408,15 +408,60 @@ type DocumentType string type DocumentableType string const ( - DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" - DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" + DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" + DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" - DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" - DocumentableTypeExpense DocumentableType = "EXPENSE" + DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" + DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" ) +// ------------------------------------------------------------------- +// Payment Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPayment approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PAYMENTS") + PaymentStepPengajuan approvalutils.ApprovalStep = 1 + PaymentStepDisetujui approvalutils.ApprovalStep = 2 +) + +var PaymentApprovalSteps = map[approvalutils.ApprovalStep]string{ + PaymentStepPengajuan: "Pengajuan", + PaymentStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Inisial Balance Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInitial approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INITIAL_BALANCES") + InitialStepPengajuan approvalutils.ApprovalStep = 1 + InitialStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InitialApprovalSteps = map[approvalutils.ApprovalStep]string{ + InitialStepPengajuan: "Pengajuan", + InitialStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Injection Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowInjection approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("INJECTIONS") + InjectionStepPengajuan approvalutils.ApprovalStep = 1 + InjectionStepDisetujui approvalutils.ApprovalStep = 2 +) + +var InjectionApprovalSteps = map[approvalutils.ApprovalStep]string{ + InjectionStepPengajuan: "Pengajuan", + InjectionStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- From a2066979c1af83b9ed1c244e4ab1f569c2fe24cd Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 19:04:10 +0700 Subject: [PATCH 37/50] feat(BE-281): adjustment uniformity for make unique for week,projectflockandang, and date --- .../modules/production/uniformities/module.go | 4 +- .../services/uniformity.service.go | 101 +++++++++++++++--- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/internal/modules/production/uniformities/module.go b/internal/modules/production/uniformities/module.go index 1032cdcf..27a73fbc 100644 --- a/internal/modules/production/uniformities/module.go +++ b/internal/modules/production/uniformities/module.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" sUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" @@ -24,6 +25,7 @@ func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat uniformityRepo := rUniformity.NewUniformityRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) @@ -36,7 +38,7 @@ func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat panic(fmt.Sprintf("failed to register uniformity approval workflow: %v", err)) } - uniformityService := sUniformity.NewUniformityService(uniformityRepo, documentSvc, approvalRepo, approvalSvc, validate) + uniformityService := sUniformity.NewUniformityService(uniformityRepo, documentSvc, approvalRepo, approvalSvc, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) UniformityRoutes(router, userService, uniformityService) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 871f4816..6f8ba6ac 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -14,6 +14,7 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -39,12 +40,13 @@ type UniformityService interface { } type uniformityService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.UniformityRepository - DocumentSvc commonSvc.DocumentService - ApprovalRepo commonRepo.ApprovalRepository - ApprovalSvc commonSvc.ApprovalService + Log *logrus.Logger + Validate *validator.Validate + Repository repository.UniformityRepository + DocumentSvc commonSvc.DocumentService + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService + ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository } func NewUniformityService( @@ -52,15 +54,17 @@ func NewUniformityService( documentSvc commonSvc.DocumentService, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, + projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository, validate *validator.Validate, ) UniformityService { return &uniformityService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - DocumentSvc: documentSvc, - ApprovalRepo: approvalRepo, - ApprovalSvc: approvalSvc, + Log: utils.Log, + Validate: validate, + Repository: repo, + DocumentSvc: documentSvc, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } @@ -121,6 +125,9 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file if err := s.Validate.Struct(req); err != nil { return nil, err } + if s.ProjectFlockKandangRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") + } if file == nil { return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") @@ -131,6 +138,16 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") } + if err := commonSvc.EnsureRelations( + c.Context(), + commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: &req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } + if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil { + return nil, err + } + if len(rows) == 0 { parsedRows, err := s.ParseBodyWeightExcel(c, file) if err != nil { @@ -212,6 +229,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui } updateBody := make(map[string]any) + var uniformDate *time.Time if req.Date != nil { parsed, err := time.Parse("2006-01-02", *req.Date) @@ -219,14 +237,51 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui return nil, fiber.NewError(fiber.StatusBadRequest, "date must be in YYYY-MM-DD format") } updateBody["uniform_date"] = parsed + uniformDate = &parsed } if req.ProjectFlockKandangId != nil { + if s.ProjectFlockKandangRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock kandang repository not available") + } + if err := commonSvc.EnsureRelations( + c.Context(), + commonSvc.RelationCheck{Name: "Project Flock Kandang", ID: req.ProjectFlockKandangId, Exists: s.ProjectFlockKandangRepo.IdExists}, + ); err != nil { + return nil, err + } updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId } if req.Week != nil { updateBody["week"] = *req.Week } + if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil { + current, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Uniformity not found") + } + return nil, err + } + targetDate := uniformDate + if targetDate == nil { + targetDate = current.UniformDate + } + targetWeek := current.Week + if req.Week != nil { + targetWeek = *req.Week + } + targetPFKID := current.ProjectFlockKandangId + if req.ProjectFlockKandangId != nil { + targetPFKID = *req.ProjectFlockKandangId + } + if targetDate != nil { + if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil { + return nil, err + } + } + } + if file != nil { if s.DocumentSvc == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Document service not available") @@ -331,6 +386,28 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui return s.GetOne(c, id) } +func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error { + if projectFlockKandangID == 0 || week == 0 || uniformDate == nil || uniformDate.IsZero() { + return nil + } + + query := s.Repository.DB().WithContext(ctx). + Model(&entity.ProjectFlockKandangUniformity{}). + Where("project_flock_kandang_id = ? AND week = ? AND uniform_date = ?", projectFlockKandangID, week, *uniformDate) + if id != 0 { + query = query.Where("id <> ?", id) + } + + var count int64 + if err := query.Count(&count).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity uniqueness") + } + if count > 0 { + return fiber.NewError(fiber.StatusConflict, "Uniformity already exists for the same project flock kandang, week, and date") + } + return nil +} + func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { From 6523290aaf44f62ed1875dbb219120e3377ce066 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 19:44:10 +0700 Subject: [PATCH 38/50] feat(BE-281): change template excel --- Tamplate-Uniformity.xlsx | Bin 0 -> 123855 bytes .../services/uniformity.body_weight_excel.go | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 Tamplate-Uniformity.xlsx diff --git a/Tamplate-Uniformity.xlsx b/Tamplate-Uniformity.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bb24c303ef9e441d65d7ae1ee72a9286ecf9dbf7 GIT binary patch literal 123855 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

beG<&g&;kXK%L9Vc0Dk;fl@6ib6O3_j{FYs1F# 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 literal 0 HcmV?d00001 diff --git a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go index 97155a3b..4e87f0cc 100644 --- a/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go +++ b/internal/modules/production/uniformities/services/uniformity.body_weight_excel.go @@ -49,7 +49,12 @@ func parseBodyWeightExcelReader(reader io.Reader) ([]BodyWeightExcelRow, error) return nil, fiber.NewError(fiber.StatusBadRequest, "no sheets found in file") } - rows, err := xlsx.GetRows(sheets[0], excelize.Options{RawCellValue: true}) + sheetName := sheets[0] + if len(sheets) > 1 { + sheetName = sheets[1] + } + + rows, err := xlsx.GetRows(sheetName, excelize.Options{RawCellValue: true}) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "failed to read sheet rows") } From 635049163e396ac599014c2e1f6c40fadc9addd9 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 29 Dec 2025 23:15:34 +0700 Subject: [PATCH 39/50] feat(BE-74): add production standart to project_flock and implement rbac finance and standart production --- .DS_Store | Bin 6148 -> 6148 bytes ..._standart_production_projectflock.down.sql | 7 +++++ ...nt_standart_production_projectflock.up.sql | 15 +++++++++++ internal/database/seed/seeder.go | 4 +-- internal/entities/projectflock.go | 2 ++ internal/middleware/auth.go | 11 ++++---- internal/middleware/permissions.go | 24 ++++++++++++++++++ internal/modules/finance/initials/route.go | 10 ++++---- internal/modules/finance/injections/route.go | 10 ++++---- internal/modules/finance/payments/route.go | 10 ++++---- .../modules/finance/transactions/route.go | 10 ++++---- .../production-standard.controller.go | 2 +- .../dto/production-standard.dto.go | 22 +++++++++++----- .../master/production-standards/route.go | 10 ++++---- .../dto/project_flock_kandang.dto.go | 3 +++ .../project_flocks/dto/projectflock.dto.go | 9 +++++++ .../dto/projectflock_kandang.dto.go | 6 +++++ .../repositories/projectflock.repository.go | 9 +++++++ .../services/projectflock.service.go | 2 ++ .../validations/projectflock.validation.go | 1 + 20 files changed, 126 insertions(+), 41 deletions(-) create mode 100644 internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.down.sql create mode 100644 internal/database/migrations/20251229125951_adjustment_standart_production_projectflock.up.sql diff --git a/.DS_Store b/.DS_Store index 4c14efd89e4d913a63e6242a245ab626c5fffe6d..e39247fdff6549a6304ce8065c332c38da11c1a4 100644 GIT binary patch delta 31 ncmZoMXfc@J&nU4mU^g?P#AF_p{LPzLLYOBuSZrqJ_{$Ffpo$73 delta 70 zcmZoMXfc@J&nUSuU^g?P Date: Tue, 30 Dec 2025 09:56:48 +0700 Subject: [PATCH 40/50] feat(BE-281):fix document payload --- .../validations/uniformity.validation.go | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/internal/modules/production/uniformities/validations/uniformity.validation.go b/internal/modules/production/uniformities/validations/uniformity.validation.go index d27ed287..b2aeaf26 100644 --- a/internal/modules/production/uniformities/validations/uniformity.validation.go +++ b/internal/modules/production/uniformities/validations/uniformity.validation.go @@ -147,21 +147,12 @@ func ParseUpdate(c *fiber.Ctx) (*Update, *multipart.FileHeader, error) { } func ParseUploadFiles(c *fiber.Ctx) ([]*multipart.FileHeader, error) { - form, err := c.MultipartForm() - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + file, err := c.FormFile("document") + if err != nil || file == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "document is required") } - files := form.File["documents"] - if len(files) == 0 { - if file, err := c.FormFile("document"); err == nil && file != nil { - files = []*multipart.FileHeader{file} - } else { - return nil, fiber.NewError(fiber.StatusBadRequest, "documents is required") - } - } - - return files, nil + return []*multipart.FileHeader{file}, nil } func ParseApprove(c *fiber.Ctx) (*Approve, error) { From e4acd9a21edc559e02be691145e3b146df90d492 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 30 Dec 2025 10:27:12 +0700 Subject: [PATCH 41/50] feat(BE): add standard_fcr column to production_standard_details and update related services and validations --- ...oduction_standards_add_fcr_column.down.sql | 3 ++ ...production_standards_add_fcr_column.up.sql | 3 ++ internal/database/seed/seeder.go | 32 +++++++++++++------ .../entities/production_standard_detail.go | 1 + .../dto/production-standard.dto.go | 3 ++ .../services/production-standard.service.go | 2 ++ .../production-standard.validation.go | 1 + 7 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql create mode 100644 internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql diff --git a/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql new file mode 100644 index 00000000..b686a59a --- /dev/null +++ b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.down.sql @@ -0,0 +1,3 @@ +-- Remove standard_fcr column from production_standard_details table +ALTER TABLE production_standard_details +DROP COLUMN IF EXISTS standard_fcr; diff --git a/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql new file mode 100644 index 00000000..560a24ac --- /dev/null +++ b/internal/database/migrations/20251230014159_alter_table_production_standards_add_fcr_column.up.sql @@ -0,0 +1,3 @@ +-- Add standard_fcr column to production_standard_details table +ALTER TABLE production_standard_details +ADD COLUMN standard_fcr NUMERIC(15, 3); diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index bb4090bb..26c3f6e8 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -891,14 +891,14 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { WarehouseName string Quantity float64 }{ - {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 100}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 200}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 300}, - {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 5000}, - {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 600}, - {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 80}, - {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 450}, - {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 60}, + {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 0}, + {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 0}, + {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 0}, + {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, + {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, + {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, + {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, + {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, } for _, seed := range seeds { @@ -962,12 +962,24 @@ func seedTransferStock(tx *gorm.DB) error { { StockTransferId: transfer.Id, ProductId: 1, - Quantity: 10, + + SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), + DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), + UsageQty: 10, + PendingQty: 0, + TotalQty: 10, + TotalUsed: 0, }, { StockTransferId: transfer.Id, ProductId: 2, - Quantity: 5, + + SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), + DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), + UsageQty: 5, + PendingQty: 0, + TotalQty: 5, + TotalUsed: 0, }, } for i := range details { diff --git a/internal/entities/production_standard_detail.go b/internal/entities/production_standard_detail.go index cd50a572..1a18c8b8 100644 --- a/internal/entities/production_standard_detail.go +++ b/internal/entities/production_standard_detail.go @@ -12,6 +12,7 @@ type ProductionStandardDetail struct { TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"` TargetEggWeight *float64 `gorm:"type:numeric(15,3)"` TargetEggMass *float64 `gorm:"type:numeric(15,3)"` + StandardFCR *float64 `gorm:"type:numeric(15,3)"` CreatedAt time.Time `gorm:"type:timestamptz;not null"` UpdatedAt time.Time `gorm:"type:timestamptz;not null"` diff --git a/internal/modules/master/production-standards/dto/production-standard.dto.go b/internal/modules/master/production-standards/dto/production-standard.dto.go index 9544732a..d683257f 100644 --- a/internal/modules/master/production-standards/dto/production-standard.dto.go +++ b/internal/modules/master/production-standards/dto/production-standard.dto.go @@ -33,6 +33,7 @@ type EggProductionStandardDetailDTO struct { TargetHenHouseProduction *float64 `json:"target_hen_house_production"` TargetEggWeight *float64 `json:"target_egg_weight"` TargetEggMass *float64 `json:"target_egg_mass"` + StandardFCR *float64 `json:"standard_fcr"` } type WeeklyProductionStandardDTO struct { @@ -87,6 +88,7 @@ func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail TargetHenHouseProduction: detail.TargetHenHouseProduction, TargetEggWeight: detail.TargetEggWeight, TargetEggMass: detail.TargetEggMass, + StandardFCR: detail.StandardFCR, } return WeeklyProductionStandardDTO{ @@ -140,6 +142,7 @@ func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProd TargetHenHouseProduction: e.TargetHenHouseProduction, TargetEggWeight: e.TargetEggWeight, TargetEggMass: e.TargetEggMass, + StandardFCR: e.StandardFCR, } } diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index b81faf8b..77c56299 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -142,6 +142,7 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, } if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { @@ -255,6 +256,7 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat TargetHenHouseProduction: detailReq.ProductionStandardDetails.TargetHenHouseProduction, TargetEggWeight: detailReq.ProductionStandardDetails.TargetEggWeight, TargetEggMass: detailReq.ProductionStandardDetails.TargetEggMass, + StandardFCR: detailReq.ProductionStandardDetails.StandardFCR, } if err := productionStandardDetailRepoTx.CreateOne(c.Context(), productionStandardDetail, nil); err != nil { diff --git a/internal/modules/master/production-standards/validations/production-standard.validation.go b/internal/modules/master/production-standards/validations/production-standard.validation.go index 51aeecc7..cdc321f8 100644 --- a/internal/modules/master/production-standards/validations/production-standard.validation.go +++ b/internal/modules/master/production-standards/validations/production-standard.validation.go @@ -5,6 +5,7 @@ type ProductionStandardDetailItem struct { TargetHenHouseProduction *float64 `json:"target_hen_house_production" validate:"omitempty,gte=0"` TargetEggWeight *float64 `json:"target_egg_weight" validate:"omitempty,gte=0"` TargetEggMass *float64 `json:"target_egg_mass" validate:"omitempty,gte=0"` + StandardFCR *float64 `json:"standard_fcr" validate:"omitempty,gte=0"` } type StandardGrowthDetailItem struct { From 90125ffe1a2717f95ea368be019b88fe2da06b89 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 30 Dec 2025 12:07:28 +0700 Subject: [PATCH 42/50] feat(BE-281):add dto standart mean bw and uniformity --- .../controllers/uniformity.controller.go | 56 +++++- .../uniformities/dto/uniformity.dto.go | 28 +++ .../modules/production/uniformities/module.go | 14 +- .../services/uniformity.service.go | 175 ++++++++++++++++-- 4 files changed, 253 insertions(+), 20 deletions(-) diff --git a/internal/modules/production/uniformities/controllers/uniformity.controller.go b/internal/modules/production/uniformities/controllers/uniformity.controller.go index b6874ba4..12cc3739 100644 --- a/internal/modules/production/uniformities/controllers/uniformity.controller.go +++ b/internal/modules/production/uniformities/controllers/uniformity.controller.go @@ -32,6 +32,10 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { if err != nil { return err } + standards, err := u.UniformityService.MapStandards(c, result) + if err != nil { + return err + } return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.UniformityListDTO]{ @@ -49,7 +53,7 @@ func (u *UniformityController) GetAll(c *fiber.Ctx) error { "status": "Pengajuan", }, }, - Data: dto.ToUniformityListDTOs(result), + Data: dto.ToUniformityListDTOsWithStandard(result, standards), }) } @@ -90,12 +94,24 @@ func (u *UniformityController) GetOne(c *fiber.Ctx) error { } } + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", Message: "Get production uniformity successfully", - Data: dto.ToUniformityDetailDTO(*result, calculation, document), + Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), }) } @@ -121,13 +137,24 @@ func (u *UniformityController) CreateOne(c *fiber.Ctx) error { } document := dto.NewDocumentForResponse(file.Filename) + standard, err := u.UniformityService.GetStandard(c, result) + if err != nil { + return err + } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } return c.Status(fiber.StatusCreated). JSON(response.Success{ Code: fiber.StatusCreated, Status: "success", Message: "Create uniformity successfully", - Data: dto.ToUniformityDetailDTO(*result, calculation, document), + Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), }) } @@ -181,17 +208,36 @@ func (u *UniformityController) UpdateOne(c *fiber.Ctx) error { return err } - calculation, document, err := u.UniformityService.CalculateUniformityFromDocument(c, id) + standard, err := u.UniformityService.GetStandard(c, result) if err != nil { return err } + var standardDTO *dto.UniformityStandardDTO + if standard != nil { + standardDTO = &dto.UniformityStandardDTO{ + MeanWeight: standard.MeanWeight, + Uniformity: standard.Uniformity, + } + } + + calculation := service.UniformityCalculation{ + ChickQtyOfWeight: result.ChickQtyOfWeight, + MeanWeight: math.Round(result.MeanUp / 1.10), + MeanDown: result.MeanDown, + MeanUp: result.MeanUp, + UniformQty: result.UniformQty, + OutsideQty: result.NotUniformQty, + Uniformity: result.Uniformity, + Cv: result.Cv, + } + var document *entity.Document return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", Message: "Update uniformity successfully", - Data: dto.ToUniformityDetailDTO(*result, calculation, document), + Data: dto.ToUniformityDetailDTO(*result, calculation, document, standardDTO), }) } diff --git a/internal/modules/production/uniformities/dto/uniformity.dto.go b/internal/modules/production/uniformities/dto/uniformity.dto.go index 1c9f4c4d..1324d805 100644 --- a/internal/modules/production/uniformities/dto/uniformity.dto.go +++ b/internal/modules/production/uniformities/dto/uniformity.dto.go @@ -22,6 +22,11 @@ type UniformityResultDTO struct { Cv float64 `json:"cv"` } +type UniformityStandardDTO struct { + MeanWeight *float64 `json:"mean_weight"` + Uniformity *float64 `json:"uniformity"` +} + type UniformityDetailItemDTO struct { Id int `json:"id"` Weight float64 `json:"weight"` @@ -47,6 +52,7 @@ type UniformityDetailDTO struct { InfoUmum UniformityInfoDTO `json:"info_umum"` Sampling UniformitySamplingDTO `json:"sampling"` Result UniformityResultDTO `json:"result"` + Standard *UniformityStandardDTO `json:"standard"` UniformityDetails []UniformityDetailItemDTO `json:"uniformity_details"` } @@ -65,6 +71,8 @@ type UniformityListDTO struct { UniformQty float64 `json:"uniform_qty"` MeanUp float64 `json:"mean_up"` MeanDown float64 `json:"mean_down"` + StandardMeanWeight *float64 `json:"standard_mean_weight"` + StandardUniformity *float64 `json:"standard_uniformity"` CreatedAt time.Time `json:"created_at"` CreatedBy uint `json:"created_by"` LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` @@ -89,6 +97,7 @@ func ToUniformityDetailDTO( entityData entity.ProjectFlockKandangUniformity, calc service.UniformityCalculation, document *entity.Document, + standard *UniformityStandardDTO, ) UniformityDetailDTO { info := UniformityInfoDTO{ Tanggal: formatUniformityDate(entityData.UniformDate), @@ -106,6 +115,7 @@ func ToUniformityDetailDTO( InfoUmum: info, Sampling: toUniformitySamplingDTO(calc), Result: toUniformityResultDTO(calc), + Standard: standard, UniformityDetails: toUniformityDetailItemsDTO(calc), } } @@ -146,6 +156,24 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor return result } +func ToUniformityListDTOsWithStandard( + items []entity.ProjectFlockKandangUniformity, + standards map[uint]service.UniformityStandard, +) []UniformityListDTO { + result := ToUniformityListDTOs(items) + if len(result) == 0 || len(standards) == 0 { + return result + } + + for i := range result { + if std, ok := standards[result[i].Id]; ok { + result[i].StandardMeanWeight = std.MeanWeight + result[i].StandardUniformity = std.Uniformity + } + } + return result +} + func toUniformitySamplingDTO(calc service.UniformityCalculation) UniformitySamplingDTO { return UniformitySamplingDTO{ ChickQtyOfWeight: calc.ChickQtyOfWeight, diff --git a/internal/modules/production/uniformities/module.go b/internal/modules/production/uniformities/module.go index 27a73fbc..b3162940 100644 --- a/internal/modules/production/uniformities/module.go +++ b/internal/modules/production/uniformities/module.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" sUniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" @@ -26,6 +27,8 @@ func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat documentRepo := commonRepo.NewDocumentRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) userRepo := rUser.NewUserRepository(db) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo) @@ -38,7 +41,16 @@ func (UniformityModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat panic(fmt.Sprintf("failed to register uniformity approval workflow: %v", err)) } - uniformityService := sUniformity.NewUniformityService(uniformityRepo, documentSvc, approvalRepo, approvalSvc, projectFlockKandangRepo, validate) + uniformityService := sUniformity.NewUniformityService( + uniformityRepo, + documentSvc, + approvalRepo, + approvalSvc, + projectFlockKandangRepo, + productionStandardRepo, + standardGrowthDetailRepo, + validate, + ) userService := sUser.NewUserService(userRepo, validate) UniformityRoutes(router, userService, uniformityService) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 6f8ba6ac..2e76e48f 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -14,6 +14,7 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/validations" @@ -30,6 +31,8 @@ type UniformityService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandangUniformity, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) GetSummary(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandangUniformity, error) + GetStandard(ctx *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) + MapStandards(ctx *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) CreateOne(ctx *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -40,13 +43,15 @@ type UniformityService interface { } type uniformityService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.UniformityRepository - DocumentSvc commonSvc.DocumentService - ApprovalRepo commonRepo.ApprovalRepository - ApprovalSvc commonSvc.ApprovalService - ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.UniformityRepository + DocumentSvc commonSvc.DocumentService + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService + ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository + ProductionStandardRepo rProductionStandard.ProductionStandardRepository + StandardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository } func NewUniformityService( @@ -55,16 +60,20 @@ func NewUniformityService( approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository, + productionStandardRepo rProductionStandard.ProductionStandardRepository, + standardGrowthDetailRepo rProductionStandard.StandardGrowthDetailRepository, validate *validator.Validate, ) UniformityService { return &uniformityService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - DocumentSvc: documentSvc, - ApprovalRepo: approvalRepo, - ApprovalSvc: approvalSvc, - ProjectFlockKandangRepo: projectFlockKandangRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + DocumentSvc: documentSvc, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProductionStandardRepo: productionStandardRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, } } @@ -121,6 +130,64 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo return s.GetOne(c, id) } +func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { + if uniformity == nil { + return nil, nil + } + return s.resolveUniformityStandard(c.Context(), *uniformity) +} + +func (s uniformityService) MapStandards(c *fiber.Ctx, items []entity.ProjectFlockKandangUniformity) (map[uint]UniformityStandard, error) { + if len(items) == 0 { + return nil, nil + } + if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { + return nil, nil + } + + categoryStandard := make(map[string]*entity.ProductionStandard) + detailCache := make(map[uint]map[int]entity.StandardGrowthDetail) + result := make(map[uint]UniformityStandard, len(items)) + + for _, item := range items { + if item.Id == 0 { + continue + } + standard, err := s.resolveCategoryStandard(c.Context(), item.ProjectFlockKandang.ProjectFlock.Category, categoryStandard) + if err != nil { + return nil, err + } + if standard == nil { + continue + } + + weekMap, ok := detailCache[standard.Id] + if !ok { + details, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(c.Context(), standard.Id) + if err != nil { + return nil, err + } + weekMap = make(map[int]entity.StandardGrowthDetail, len(details)) + for _, detail := range details { + weekMap[detail.Week] = detail + } + detailCache[standard.Id] = weekMap + } + + detail, ok := weekMap[item.Week] + if !ok { + continue + } + standardDTO := UniformityStandard{ + MeanWeight: cloneFloat64(detail.TargetMeanBw), + Uniformity: float64Ptr(detail.MinUniformity), + } + result[item.Id] = standardDTO + } + + return result, nil +} + func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file *multipart.FileHeader, rows []BodyWeightExcelRow) (*entity.ProjectFlockKandangUniformity, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -516,6 +583,11 @@ type UniformityCalculation struct { Details []UniformityDetailItem } +type UniformityStandard struct { + MeanWeight *float64 + Uniformity *float64 +} + func (s uniformityService) ComputeUniformity(rows []BodyWeightExcelRow) (UniformityCalculation, error) { return computeUniformity(rows) } @@ -664,6 +736,81 @@ func (s *uniformityService) attachLatestApproval(ctx context.Context, item *enti return nil } +func (s *uniformityService) resolveUniformityStandard(ctx context.Context, item entity.ProjectFlockKandangUniformity) (*UniformityStandard, error) { + if s.ProductionStandardRepo == nil || s.StandardGrowthDetailRepo == nil { + return nil, nil + } + + standard, err := s.resolveCategoryStandard(ctx, item.ProjectFlockKandang.ProjectFlock.Category, nil) + if err != nil || standard == nil { + return nil, err + } + + detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standard.Id, item.Week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &UniformityStandard{ + MeanWeight: cloneFloat64(detail.TargetMeanBw), + Uniformity: float64Ptr(detail.MinUniformity), + }, nil +} + +func (s *uniformityService) resolveCategoryStandard( + ctx context.Context, + category string, + cache map[string]*entity.ProductionStandard, +) (*entity.ProductionStandard, error) { + category = strings.TrimSpace(category) + if category == "" { + return nil, nil + } + if cache != nil { + if cached, ok := cache[category]; ok { + return cached, nil + } + } + + var standard entity.ProductionStandard + err := s.ProductionStandardRepo.DB().WithContext(ctx). + Where("project_category = ?", category). + Where("deleted_at IS NULL"). + Order("created_at DESC"). + First(&standard).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if cache != nil { + cache[category] = nil + } + return nil, nil + } + return nil, err + } + + standardCopy := standard + if cache != nil { + cache[category] = &standardCopy + } + return &standardCopy, nil +} + +func cloneFloat64(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +func float64Ptr(value float64) *float64 { + copy := value + return © +} + func (s *uniformityService) rollbackUniformityCreate(ctx context.Context, uniformityID uint) { if uniformityID == 0 { return From 0c776e83328f1e7dc630282af3fd15ee7af4c71b Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 30 Dec 2025 12:08:49 +0700 Subject: [PATCH 43/50] feat(BE-281): uncoment auth --- Tamplate-Uniformity.xlsx | Bin 123855 -> 0 bytes internal/middleware/auth.go | 11 +++++------ 2 files changed, 5 insertions(+), 6 deletions(-) delete mode 100644 Tamplate-Uniformity.xlsx diff --git a/Tamplate-Uniformity.xlsx b/Tamplate-Uniformity.xlsx deleted file mode 100644 index bb24c303ef9e441d65d7ae1ee72a9286ecf9dbf7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 123855 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

beG<&g&;kXK%L9Vc0Dk;fl@6ib6O3_j{FYs1F# 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 diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index a08d431b..a831c25b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -104,12 +104,11 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { } func ActorIDFromContext(c *fiber.Ctx) (uint, error) { - // user, ok := AuthenticatedUser(c) - // if !ok || user == nil || user.Id == 0 { - // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } - // return user.Id, nil - return 1, nil + user, ok := AuthenticatedUser(c) + if !ok || user == nil || user.Id == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + return user.Id, nil } // AuthDetails returns the full authentication context (token, claims, user). From 0285852c42dfb3a6f5c4548df41aa32a0c78c81a Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 30 Dec 2025 14:42:53 +0700 Subject: [PATCH 44/50] fix api get all closing; fix get closing sapronak; fix get all maste data product --- internal/modules/closings/dto/closing.dto.go | 50 ++++---- .../repositories/closing.repository.go | 114 +++++++++++++++--- .../closings/services/closing.service.go | 15 ++- .../products/services/product.service.go | 1 + 4 files changed, 131 insertions(+), 49 deletions(-) diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index c05bd741..ac172c83 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -28,18 +28,19 @@ type ClosingDetailDTO struct { } type ClosingListItemDTO struct { - Id uint `json:"id"` - LocationID uint `json:"location_id"` - LocationName string `json:"location_name"` - ProjectCategory string `json:"project_category"` - Period int `json:"period"` - ClosingDate string `json:"closing_date"` - ShedLabel string `json:"shed_label"` - ShedCount int `json:"shed_count"` - SalesPaidAmount int64 `json:"sales_paid_amount"` - SalesRemainingAmount int64 `json:"sales_remaining_amount"` - SalesPaymentStatus string `json:"sales_payment_status"` - ProjectStatus string `json:"project_status"` + Id uint `json:"id"` + ProjectName string `json:"project_name"` + LocationID uint `json:"location_id"` + LocationName string `json:"location_name"` + ProjectCategory string `json:"project_category"` + Period int `json:"period"` + ClosingDate string `json:"closing_date"` + ShedLabel string `json:"shed_label"` + ShedCount int `json:"shed_count"` + // SalesPaidAmount int64 `json:"sales_paid_amount"` + // SalesRemainingAmount int64 `json:"sales_remaining_amount"` + // SalesPaymentStatus string `json:"sales_payment_status"` + ProjectStatus string `json:"project_status"` } type ClosingSummaryDTO struct { @@ -133,18 +134,19 @@ func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) Clo shedCount := len(project.KandangHistory) return ClosingListItemDTO{ - Id: project.Id, - LocationID: project.LocationId, - LocationName: project.Location.Name, - ProjectCategory: project.Category, - Period: maxPeriod(project.KandangHistory), - ClosingDate: "17-Nov-2025", - ShedLabel: fmt.Sprintf("%d Kandang", shedCount), - ShedCount: shedCount, - SalesPaidAmount: 21993726, - SalesRemainingAmount: 11075919, - SalesPaymentStatus: "Lunas", - ProjectStatus: projectStatus, + Id: project.Id, + ProjectName: project.FlockName, + LocationID: project.LocationId, + LocationName: project.Location.Name, + ProjectCategory: project.Category, + Period: maxPeriod(project.KandangHistory), + ClosingDate: "17-Nov-2025", + ShedLabel: fmt.Sprintf("%d Kandang", shedCount), + ShedCount: shedCount, + // SalesPaidAmount: 21993726, + // SalesRemainingAmount: 11075919, + // SalesPaymentStatus: "Lunas", + ProjectStatus: projectStatus, } } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index e3f09dda..912f2f25 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -330,13 +330,33 @@ SELECT COALESCE(p.po_number, '') AS reference_number, 'Purchase' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, - 'External Supplier' AS source_warehouse, + '-' AS source_warehouse, w.name AS destination_warehouse, '' AS destination, pi.total_qty AS quantity, @@ -345,7 +365,6 @@ SELECT FROM purchase_items pi JOIN purchases p ON p.id = pi.purchase_id JOIN products prod ON prod.id = pi.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id JOIN warehouses w ON w.id = pi.warehouse_id WHERE pi.warehouse_id IN ? @@ -359,9 +378,29 @@ SELECT st.movement_number AS reference_number, 'Internal Transfer In' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, @@ -376,7 +415,6 @@ JOIN stock_transfers st ON st.id = std.stock_transfer_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id JOIN products prod ON prod.id = std.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id WHERE st.to_warehouse_id IN ? ` @@ -389,9 +427,29 @@ SELECT st.movement_number AS reference_number, 'Internal Transfer Out' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, @@ -406,7 +464,6 @@ JOIN stock_transfers st ON st.id = std.stock_transfer_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id JOIN products prod ON prod.id = std.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id WHERE st.from_warehouse_id IN ? ` @@ -419,9 +476,29 @@ SELECT m.so_number AS reference_number, 'Trading Sales' AS transaction_type, prod.name AS product_name, - pc.name AS product_category, COALESCE(( - SELECT string_agg(f.name, ' ') + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_category, + COALESCE(( + SELECT string_agg( + f.name, + ' ' ORDER BY + CASE + WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0 + ELSE 1 + END, + f.name + ) FROM flags f WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, @@ -435,7 +512,6 @@ FROM marketing_products mp JOIN marketings m ON m.id = mp.marketing_id JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN products prod ON prod.id = pw.product_id -JOIN product_categories pc ON pc.id = prod.product_category_id JOIN uoms u ON u.id = prod.uom_id JOIN warehouses w ON w.id = pw.warehouse_id WHERE pw.project_flock_kandang_id IN ? @@ -808,12 +884,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand } type ActualUsageCostRow struct { - ProductID uint `gorm:"column:product_id"` - ProductName string `gorm:"column:product_name"` - FlagName string `gorm:"column:flag_name"` - TotalQty float64 `gorm:"column:total_qty"` - TotalPrice float64 `gorm:"column:total_price"` - AveragePrice float64 `gorm:"column:average_price"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagName string `gorm:"column:flag_name"` + TotalQty float64 `gorm:"column:total_qty"` + TotalPrice float64 `gorm:"column:total_price"` + AveragePrice float64 `gorm:"column:average_price"` } func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 9f643a78..47e30a7f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -6,6 +6,7 @@ import ( "math" "strconv" "strings" + "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -332,18 +333,20 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID } var ( - minStep uint16 - statusProject string - completed int + minStep uint16 + statusProject string + completed int + latestActionAt time.Time ) for _, rec := range records { if minStep == 0 || rec.StepNumber < minStep { minStep = rec.StepNumber - statusProject = rec.StepName } - if rec.StepNumber == uint16(utils.ProjectFlockStepAktif) { - completed++ + + if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) { + latestActionAt = rec.ActionAt + statusProject = rec.StepName } } diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 35e24927..f40d92be 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -70,6 +70,7 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) + db = db.Where("is_visible = ?", true) if params.Search != "" { return db.Where("name LIKE ?", "%"+params.Search+"%") } From 4e5caa8cbafa5bc32c48b0bd17a3e3061008f526 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 30 Dec 2025 15:23:34 +0700 Subject: [PATCH 45/50] feat(BE-281): add rbac for uniformity --- internal/middleware/permissions.go | 10 ++++++++ .../modules/production/uniformities/route.go | 23 ++++++++----------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index d384fee7..32ee5ecb 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -193,6 +193,16 @@ const ( P_PurchaseApprovalManager = "lti.Purchase.approve.manager" ) +const ( + P_Uniformities_GetAll = "lti.production.uniformity.list" + P_Uniformities_GetOne = "lti.production.uniformity.detail" + P_Uniformities_Verify = "lti.production.uniformity.verify" + P_Uniformities_CreateOne = "lti.production.uniformity.create" + P_Uniformities_UpdateOne = "lti.production.uniformity.update" + P_Uniformities_DeleteOne = "lti.production.uniformity.delete" + P_Uniformities_Approval = "lti.production.uniformity.approve" +) + const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" diff --git a/internal/modules/production/uniformities/route.go b/internal/modules/production/uniformities/route.go index d22e8761..ff2b1805 100644 --- a/internal/modules/production/uniformities/route.go +++ b/internal/modules/production/uniformities/route.go @@ -1,7 +1,7 @@ package uniformitys import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/controllers" uniformity "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,18 +13,13 @@ func UniformityRoutes(v1 fiber.Router, u user.UserService, s uniformity.Uniformi ctrl := controller.NewUniformityController(s) route := v1.Group("/uniformities") + route.Use(m.Auth(u)) - // 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.Post("/verify", ctrl.UploadBodyWeightExcel) - route.Post("/approvals", ctrl.Approve) - route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + route.Get("/", m.RequirePermissions(m.P_Uniformities_GetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_Uniformities_CreateOne), ctrl.CreateOne) + route.Post("/verify", m.RequirePermissions(m.P_Uniformities_Verify), ctrl.UploadBodyWeightExcel) + route.Post("/approvals", m.RequirePermissions(m.P_Uniformities_Approval), ctrl.Approve) + route.Get("/:id", m.RequirePermissions(m.P_Uniformities_GetOne), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_Uniformities_UpdateOne), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_Uniformities_DeleteOne), ctrl.DeleteOne) } From 471fd1dbbf9a0f04e6ebeee183c79ee6fa24b2a6 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 30 Dec 2025 16:30:44 +0700 Subject: [PATCH 46/50] feat(BE): enhance product warehouse handling and automatic calculations for delivery and sales orders --- .../services/adjustment.service.go | 40 +++++++++---------- .../product_warehouse.repository.go | 15 +++++++ .../transfers/services/transfer.service.go | 11 ++--- .../services/deliveryorder.service.go | 16 ++++++-- .../marketing/services/salesorder.service.go | 37 ++++++++++------- .../validations/deliveryorder.validation.go | 2 - .../validations/salesorder.validation.go | 2 - .../services/production-standard.service.go | 5 +-- .../validations/projectflock.validation.go | 16 ++++---- 9 files changed, 80 insertions(+), 64 deletions(-) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index d7b1641b..edf5f72b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -117,39 +117,37 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e var createdLogId uint - isProductWarehouseExist, err := s.ProductWarehouseRepo.ProductWarehouseExistByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) - if err != nil { - s.Log.Errorf("Failed to check product warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + var projectFlockKandangID *uint + pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(req.WarehouseID)) + if err == nil && pfk != nil { + idCopy := uint(pfk.Id) + projectFlockKandangID = &idCopy } - if !isProductWarehouseExist { - projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) - if err != nil { - return nil, err + + pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk( + ctx, + uint(req.ProductID), + uint(req.WarehouseID), + projectFlockKandangID, + ) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to find product warehouse: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + newPW := &entity.ProductWarehouse{ ProductId: uint(req.ProductID), WarehouseId: uint(req.WarehouseID), Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, - // CreatedBy: 1, // TODO: should Get from auth middleware + ProjectFlockKandangId: projectFlockKandangID, } 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") } - s.Log.Infof("Product warehouse created: %+v", newPW.Id) - } - - pw, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( - ctx, - uint(req.ProductID), - uint(req.WarehouseID), - ) - if err != nil { - s.Log.Errorf("Failed to get product warehouse for project flock check: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + pw = newPW } if err := common.EnsureProjectFlockNotClosedForProductWarehouses( diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index e759138e..92330f26 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -18,6 +18,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) GetByFlagAndWarehouseID(ctx context.Context, flagName string, warehouseId uint) ([]entity.ProductWarehouse, error) @@ -107,6 +108,20 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous return &productWarehouse, nil } +func (r *ProductWarehouseRepositoryImpl) FindByProductWarehouseAndPfk(ctx context.Context, productID uint, warehouseID uint, projectFlockKandangID *uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + + err := r.DB().WithContext(ctx). + Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT DISTINCT FROM ?", productID, warehouseID, projectFlockKandangID). + First(&productWarehouse).Error + + if err != nil { + return nil, err + } + + return &productWarehouse, nil +} + func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) { var productWarehouses []entity.ProductWarehouse q := r.DB().WithContext(ctx).Model(&entity.ProductWarehouse{}). diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 8ae019a4..1ca35a71 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -106,23 +106,17 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { - s.Log.Infof("Attempting to get StockTransfer with ID: %d", id) transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) if err != nil { - s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err) 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") } - if transferPtr != nil { - s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents)) - } - return transferPtr, nil } @@ -336,7 +330,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Files: documentFiles, }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1)) + s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", + idx+1, deliveries[idx].Id, file.Filename) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", idx+1, err)) } } } @@ -396,7 +392,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) if err != nil { - s.Log.Errorf("Transaction failed in CreateOne: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) } diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index e864a778..a1f4e1dd 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -247,11 +247,15 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } + // Hitung total_weight dan total_price otomatis + totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight + totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight - deliveryProduct.TotalWeight = requestedProduct.TotalWeight - deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.TotalWeight = totalWeight + deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber @@ -357,11 +361,15 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + // Hitung total_weight dan total_price otomatis + totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight + totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.AvgWeight = requestedProduct.AvgWeight - deliveryProduct.TotalWeight = requestedProduct.TotalWeight - deliveryProduct.TotalPrice = requestedProduct.TotalPrice + deliveryProduct.TotalWeight = totalWeight + deliveryProduct.TotalPrice = totalPrice deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index dc6e62de..d57b323e 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -75,7 +75,6 @@ func (s salesOrdersService) getOne(c *fiber.Ctx, id uint) (*entity.Marketing, er return nil, fiber.NewError(fiber.StatusNotFound, "SalesOrders not found") } if err != nil { - s.Log.Errorf("Failed get marketing by id: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") } @@ -293,13 +292,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u for _, rp := range req.MarketingProducts { if old, ok := oldByPW[rp.ProductWarehouseId]; ok { + // Hitung total_weight dan total_price otomatis + totalWeight := rp.Qty * rp.AvgWeight + totalPrice := rp.UnitPrice * rp.Qty + updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, "qty": rp.Qty, "unit_price": rp.UnitPrice, "avg_weight": rp.AvgWeight, - "total_weight": rp.TotalWeight, - "total_price": rp.TotalPrice, + "total_weight": totalWeight, + "total_price": totalPrice, } if err := marketingProductRepoTx.PatchOne(c.Context(), old.Id, updateBody, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing product") @@ -589,30 +592,34 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { + // Hitung total_weight dan total_price otomatis + totalWeight := rp.Qty * rp.AvgWeight + totalPrice := rp.UnitPrice * rp.Qty + marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, ProductWarehouseId: rp.ProductWarehouseId, Qty: rp.Qty, UnitPrice: rp.UnitPrice, AvgWeight: rp.AvgWeight, - TotalWeight: rp.TotalWeight, - TotalPrice: rp.TotalPrice, + TotalWeight: totalWeight, + TotalPrice: totalPrice, } if err := marketingProductRepo.CreateOne(ctx, marketingProduct, nil); err != nil { return err } marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ - MarketingProductId: marketingProduct.Id, - ProductWarehouseId: marketingProduct.ProductWarehouseId, - UnitPrice: 0, - TotalWeight: 0, - AvgWeight: 0, - TotalPrice: 0, - DeliveryDate: nil, - VehicleNumber: rp.VehicleNumber, - UsageQty: 0, - PendingQty: 0, + MarketingProductId: marketingProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + UnitPrice: 0, + TotalWeight: 0, + AvgWeight: 0, + TotalPrice: 0, + DeliveryDate: nil, + VehicleNumber: rp.VehicleNumber, + UsageQty: 0, + PendingQty: 0, } if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { return err diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 7db2cdd1..a879db6f 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -5,8 +5,6 @@ type DeliveryProduct struct { Qty float64 `json:"qty" validate:"omitempty,gte=0"` UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"` AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"` - TotalWeight float64 `json:"total_weight" validate:"omitempty,gte=0"` - TotalPrice float64 `json:"total_price" validate:"omitempty,gte=0"` DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"` VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` } diff --git a/internal/modules/marketing/validations/salesorder.validation.go b/internal/modules/marketing/validations/salesorder.validation.go index 47d2e616..b69da394 100644 --- a/internal/modules/marketing/validations/salesorder.validation.go +++ b/internal/modules/marketing/validations/salesorder.validation.go @@ -12,10 +12,8 @@ type CreateMarketingProduct struct { VehicleNumber string `json:"vehicle_number" validate:"required,min=1,max=50"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,gt=0"` UnitPrice float64 `json:"unit_price" validate:"required,gt=0"` - TotalWeight float64 `json:"total_weight" validate:"required,gt=0"` Qty float64 `json:"qty" validate:"required,gt=0"` AvgWeight float64 `json:"avg_weight" validate:"required,gt=0"` - TotalPrice float64 `json:"total_price" validate:"required,gt=0"` } type Update struct { diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index 77c56299..4005b014 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -84,7 +84,6 @@ func (s productionStandardService) GetOne(c *fiber.Ctx, id uint) (*entity.Produc return nil, fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") } if err != nil { - s.Log.Errorf("Failed get productionStandard by id: %+v", err) return nil, err } return productionStandard, nil @@ -111,6 +110,7 @@ func (s *productionStandardService) CreateOne(c *fiber.Ctx, req *validation.Crea var createdStandard *entity.ProductionStandard err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + standardRepoTx := repository.NewProductionStandardRepository(tx) productionStandardDetailRepoTx := repository.NewProductionStandardDetailRepository(tx) standardGrowthDetailRepoTx := repository.NewStandardGrowthDetailRepository(tx) @@ -207,7 +207,6 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat nameExists, err := s.Repository.NameExists(c.Context(), *req.Name, &id) if err != nil { - s.Log.Errorf("Failed to check existing production standard: %+v", err) return err } if nameExists { @@ -285,7 +284,6 @@ func (s productionStandardService) UpdateOne(c *fiber.Ctx, req *validation.Updat }) if err != nil { - s.Log.Errorf("Failed to update production standard: %+v", err) return nil, err } @@ -297,7 +295,6 @@ func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "ProductionStandard not found") } - s.Log.Errorf("Failed to delete productionStandard: %+v", 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 2e938041..5b2a9407 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,14 +1,14 @@ package validation type Create struct { - FlockName string `json:"flock_name" validate:"required_strict"` - AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` - Category string `json:"category" validate:"required_strict"` - FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` - ProductionStandardId uint `json:"production_standard_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"` - ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` + FlockName string `json:"flock_name" validate:"required_strict"` + AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` + Category string `json:"category" validate:"required_strict"` + FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` + ProductionStandardId uint `json:"production_standard_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"` + ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } type Query struct { From 78359db88052fed61c658dedf290143a196923ce Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 30 Dec 2025 23:52:37 +0700 Subject: [PATCH 47/50] fix: setup seeder for development --- internal/database/seed/seeder.backup | 1047 ++++++++++++++++++++++++++ internal/database/seed/seeder.go | 873 ++++----------------- 2 files changed, 1186 insertions(+), 734 deletions(-) create mode 100644 internal/database/seed/seeder.backup diff --git a/internal/database/seed/seeder.backup b/internal/database/seed/seeder.backup new file mode 100644 index 00000000..c0e3628c --- /dev/null +++ b/internal/database/seed/seeder.backup @@ -0,0 +1,1047 @@ +// package seed + +// import ( +// "errors" +// "fmt" +// "strings" +// "time" + +// entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" + +// "gorm.io/gorm" +// ) + +// func Run(db *gorm.DB) error { +// return db.Transaction(func(tx *gorm.DB) error { +// users, err := seedUsers(tx) +// if err != nil { +// return err +// } +// adminID := users["admin"] + +// uoms, err := seedUoms(tx, adminID) +// if err != nil { +// return err +// } + +// areas, err := seedAreas(tx, adminID) +// if err != nil { +// return err +// } + +// locations, err := seedLocations(tx, adminID, areas) +// if err != nil { +// return err +// } + +// productCategories, err := seedProductCategories(tx, adminID) +// if err != nil { +// return err +// } + +// if _, err := seedFlocks(tx, adminID); err != nil { +// return err +// } + +// if _, err := seedFcr(tx, adminID); err != nil { +// return err +// } + +// kandangs, err := seedKandangs(tx, adminID, locations, users) +// if err != nil { +// return err +// } + +// if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { +// return err +// } + +// suppliers, err := seedSuppliers(tx, adminID) +// if err != nil { +// return err +// } + +// if err := seedCustomers(tx, adminID, users); err != nil { +// return err +// } + +// if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { +// return err +// } + +// if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { +// return err +// } + +// if err := seedBanks(tx, adminID); err != nil { +// return err +// } + +// if err := seedProductWarehouse(tx, adminID); err != nil { +// return err +// } + +// if err := seedTransferStock(tx); err != nil { +// return err +// } +// fmt.Println("✅ Master data seeding completed") +// return nil +// }) +// } + +// func seedUsers(tx *gorm.DB) (map[string]uint, error) { +// seeds := []struct { +// Key string +// Data entity.User +// }{ +// { +// Key: "admin", +// Data: entity.User{Email: "admin@mbugroup.id", IdUser: 1, Name: "Super Admin"}, +// }, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var user entity.User +// err := tx.Where("email = ?", seed.Data.Email).First(&user).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// user = seed.Data +// if err := tx.Create(&user).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Key] = user.Id +// } + +// return result, nil +// } + +// func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var uom entity.Uom +// err := tx.Where("name = ?", name).First(&uom).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// uom = entity.Uom{Name: name, CreatedBy: createdBy} +// if err := tx.Create(&uom).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[name] = uom.Id +// } + +// return result, nil +// } + +// func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// names := []string{"Priangan", "Banten"} +// result := make(map[string]uint, len(names)) + +// for _, name := range names { +// var area entity.Area +// err := tx.Where("name = ?", name).First(&area).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// area = entity.Area{Name: name, CreatedBy: createdBy} +// if err := tx.Create(&area).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[name] = area.Id +// } + +// return result, nil +// } + +// func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Address string +// Area string +// }{ +// {"Singaparna", "Tasik", "Priangan"}, +// {"Cikaum", "Cikaum", "Banten"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// areaID, ok := areas[seed.Area] +// if !ok { +// return nil, fmt.Errorf("area %s not seeded", seed.Area) +// } + +// var loc entity.Location +// err := tx.Where("name = ?", seed.Name).First(&loc).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// loc = entity.Location{ +// Name: seed.Name, +// Address: seed.Address, +// AreaId: areaID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&loc).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = loc.Id +// } + +// return result, nil +// } + +// 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 seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Status utils.KandangStatus +// Capacity float64 +// Location string +// PicKey string +// }{ +// {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, +// {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, +// {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, +// {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// locID, ok := locations[seed.Location] +// if !ok { +// return nil, fmt.Errorf("location %s not seeded", seed.Location) +// } +// picID, ok := users[seed.PicKey] +// if !ok { +// return nil, fmt.Errorf("user %s not seeded", seed.PicKey) +// } + +// 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, +// Status: string(seed.Status), +// LocationId: locID, +// PicId: picID, +// 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 err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { +// return nil, err +// } +// } +// result[seed.Name] = kandang.Id +// } + +// return result, nil +// } + +// func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { +// seeds := []struct { +// Name string +// Type string +// Area string +// Location *string +// Kandang *string +// }{ +// {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, +// {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, +// {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, +// {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, +// {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, +// {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, +// {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, +// {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, +// } + +// for _, seed := range seeds { +// areaID, ok := areas[seed.Area] +// if !ok { +// return fmt.Errorf("area %s not seeded", seed.Area) +// } + +// var warehouse entity.Warehouse +// err := tx.Where("name = ?", seed.Name).First(&warehouse).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// warehouse = entity.Warehouse{ +// Name: seed.Name, +// Type: seed.Type, +// AreaId: areaID, +// CreatedBy: createdBy, +// } +// } else if err != nil { +// return err +// } + +// if seed.Location != nil { +// locID, ok := locations[*seed.Location] +// if !ok { +// return fmt.Errorf("location %s not seeded", *seed.Location) +// } +// warehouse.LocationId = uintPtr(locID) +// } +// if seed.Kandang != nil { +// kandangID, ok := kandangs[*seed.Kandang] +// if !ok { +// return fmt.Errorf("kandang %s not seeded", *seed.Kandang) +// } +// warehouse.KandangId = uintPtr(kandangID) +// } + +// if warehouse.Id == 0 { +// if err := tx.Create(&warehouse).Error; err != nil { +// return err +// } +// } else { +// if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ +// "type": warehouse.Type, +// "area_id": warehouse.AreaId, +// "location_id": warehouse.LocationId, +// "kandang_id": warehouse.KandangId, +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Code string +// }{ +// {"Pullet", "PLT"}, +// {"Bahan Baku", "RAW"}, +// {"Day Old Chick", "DOC"}, +// {"Telur", "EGG"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for _, seed := range seeds { +// var category entity.ProductCategory +// err := tx.Where("name = ?", seed.Name).First(&category).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// category = entity.ProductCategory{Name: seed.Name, Code: seed.Code, CreatedBy: createdBy} +// if err := tx.Create(&category).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } else { +// if err := tx.Model(&entity.ProductCategory{}).Where("id = ?", category.Id).Updates(map[string]any{ +// "code": seed.Code, +// }).Error; err != nil { +// return nil, err +// } +// } +// result[seed.Name] = category.Id +// } + +// return result, nil +// } + +// func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Alias string +// Category string +// Email string +// Phone string +// Address string +// }{ +// {"PT CHAROEN POKPHAND INDONESIA Tbk", "CPI", string(utils.SupplierCategorySapronak), "cpi@gmail.com", "081200000001", "Jl. Pakan 1, Bekasi"}, +// {"BOP Vendor", "BOP", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, +// {"Ekspedisi", "EKS", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, +// } + +// result := make(map[string]uint, len(seeds)) + +// for idx, seed := range seeds { +// var supplier entity.Supplier +// err := tx.Where("name = ?", seed.Name).First(&supplier).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// supplier = entity.Supplier{ +// Name: seed.Name, +// Alias: seed.Alias, +// Pic: "John Doe", +// Type: string(utils.CustomerSupplierTypeBisnis), +// Category: seed.Category, +// Phone: seed.Phone, +// Email: seed.Email, +// Address: seed.Address, +// DueDate: 30, +// CreatedBy: createdBy, +// AccountNumber: strPtr(fmt.Sprintf("%03d", idx+1)), +// } +// if err := tx.Create(&supplier).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = supplier.Id +// } + +// return result, nil +// } + +// func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { +// seeds := []struct { +// Name string +// PicKey string +// Address string +// Phone string +// Email string +// }{ +// {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, +// } + +// for idx, seed := range seeds { +// picID, ok := users[seed.PicKey] +// if !ok { +// return fmt.Errorf("user %s not seeded", seed.PicKey) +// } + +// var customer entity.Customer +// err := tx.Where("name = ?", seed.Name).First(&customer).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// customer = entity.Customer{ +// Name: seed.Name, +// PicId: picID, +// Type: string(utils.CustomerSupplierTypeBisnis), +// Address: seed.Address, +// Phone: seed.Phone, +// Email: seed.Email, +// AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), +// CreatedBy: createdBy, +// } +// if err := tx.Create(&customer).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } +// } + +// return nil +// } + +// func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { +// seeds := []struct { +// Name string +// Standards []struct { +// Weight float64 +// FcrNumber float64 +// Mortality float64 +// } +// }{ +// { +// Name: "FCR Layer", +// Standards: []struct { +// Weight float64 +// FcrNumber float64 +// Mortality float64 +// }{ +// {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, +// {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, +// }, +// }, +// } + +// 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 nil, err +// } +// } else if err != nil { +// return nil, err +// } +// result[seed.Name] = fcr.Id + +// for _, std := range seed.Standards { +// var standard entity.FcrStandard +// err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// standard = entity.FcrStandard{ +// FcrID: fcr.Id, +// Weight: std.Weight, +// FcrNumber: std.FcrNumber, +// Mortality: std.Mortality, +// } +// if err := tx.Create(&standard).Error; err != nil { +// return nil, err +// } +// } else if err != nil { +// 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 nil, err +// } +// } +// } +// } + +// return result, nil +// } + +// func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Brand string +// Sku string +// Uom string +// Category string +// Price float64 +// Selling *float64 +// Tax *float64 +// Expiry *int +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "DOC Broiler", +// Brand: "MBU Broiler", +// Sku: "BRO0001", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 7500, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagDOC}, +// }, +// { +// Name: "Ayam Pullet", +// Brand: "MBU Pullet", +// Sku: "PLT0001", +// Uom: "Ekor", +// Category: "Pullet", +// Price: 15000, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagPullet}, +// }, +// { +// Name: "Ayam Afkir", +// Brand: "-", +// Sku: "1", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamAfkir}, +// }, +// { +// Name: "Ayam Mati", +// Brand: "-", +// Sku: "2", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamMati}, +// }, +// { +// Name: "Ayam Culling", +// Brand: "-", +// Sku: "3", +// Uom: "Ekor", +// Category: "Day Old Chick", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagAyamCulling}, +// }, +// { +// Name: "Telur Konsumsi Baik", +// Brand: "-", +// Sku: "4", +// Uom: "Unit", +// Category: "Telur", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagTelurUtuh}, +// }, +// { +// Name: "Telur Pecah", +// Brand: "-", +// Sku: "5", +// Uom: "Unit", +// Category: "Telur", +// Price: 1, +// Flags: []utils.FlagType{utils.FlagTelurPecah}, +// }, +// { +// Name: "281 SPECIAL STARTER", +// Brand: "281 STARTER", +// Sku: "281", +// Uom: "Kilogram", +// Category: "Bahan Baku", +// Price: 7850, +// Expiry: intPtr(60), +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, +// }, +// { +// Name: "Ayam Layer", +// Brand: "-", +// Sku: "LYR0001", +// Uom: "Ekor", +// Category: "Pullet", +// Price: 20000, +// Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, +// Flags: []utils.FlagType{utils.FlagLayer}, +// }, +// } + +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } +// categoryID, ok := categories[seed.Category] +// if !ok { +// return fmt.Errorf("product category %s not seeded", seed.Category) +// } + +// var product entity.Product +// err := tx.Where("name = ?", seed.Name).First(&product).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// selling := seed.Selling +// tax := seed.Tax +// product = entity.Product{ +// Name: seed.Name, +// Brand: seed.Brand, +// Sku: &seed.Sku, +// UomId: uomID, +// ProductCategoryId: categoryID, +// ProductPrice: seed.Price, +// SellingPrice: selling, +// Tax: tax, +// ExpiryPeriod: seed.Expiry, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&product).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// updates := map[string]any{ +// "brand": seed.Brand, +// "uom_id": uomID, +// "product_category_id": categoryID, +// "product_price": seed.Price, +// "selling_price": seed.Selling, +// "tax": seed.Tax, +// "expiry_period": seed.Expiry, +// } +// if seed.Sku != "" { +// updates["sku"] = seed.Sku +// } +// if err := tx.Model(&entity.Product{}).Where("id = ?", product.Id).Updates(updates).Error; err != nil { +// return err +// } +// } + +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.ProductSupplier +// err := tx.Where("product_id = ? AND supplier_id = ?", product.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.ProductSupplier{ProductId: product.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } + +// if err := seedFlags(tx, product.Id, entity.FlagableTypeProduct, seed.Flags); err != nil { +// return err +// } +// } + +// return nil +// } + +// func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Uom string +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "Expedisi DOC", +// Uom: "Ekor", +// Suppliers: []string{"Ekspedisi"}, +// Flags: []utils.FlagType{utils.FlagEkspedisi}, +// }, +// { +// Name: "Solar", +// Uom: "Liter", +// Suppliers: []string{"BOP Vendor"}, +// Flags: []utils.FlagType{}, +// }, +// } + +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } + +// var nonstock entity.Nonstock +// err := tx.Where("name = ?", seed.Name).First(&nonstock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// nonstock = entity.Nonstock{ +// Name: seed.Name, +// UomId: uomID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&nonstock).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ +// "uom_id": uomID, +// }).Error; err != nil { +// return err +// } +// } + +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.NonstockSupplier +// err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } + +// if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { +// return err +// } +// } + +// return nil +// } + +// // nanti saya isi + +// func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils.FlagType) error { +// if len(flags) == 0 { +// return nil +// } +// for _, flag := range flags { +// name := strings.ToUpper(string(flag)) +// var existing entity.Flag +// err := tx.Where("name = ? AND flagable_id = ? AND flagable_type = ?", name, flagableID, flagableType).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// record := entity.Flag{ +// Name: name, +// FlagableID: flagableID, +// FlagableType: flagableType, +// } +// if err := tx.Create(&record).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } +// } +// return nil +// } + +// func seedBanks(tx *gorm.DB, createdBy uint) error { +// seeds := []struct { +// Name string +// Alias string +// Owner *string +// AccountNumber string +// }{ +// { +// Name: "Bank Central Asia", +// Alias: "BCA", +// AccountNumber: "1234567890", +// Owner: ptr("PT MBU Group"), +// }, +// { +// Name: "Bank Rakyat Indonesia", +// Alias: "BRI", +// AccountNumber: "9876543210", +// Owner: ptr("PT MBU Group"), +// }, +// { +// Name: "Bank Mandiri", +// Alias: "MAND", +// AccountNumber: "1122334455", +// Owner: ptr("PT MBU Group"), +// }, +// } + +// for _, seed := range seeds { +// var bank entity.Bank +// err := tx.Where("name = ?", seed.Name).First(&bank).Error + +// if errors.Is(err, gorm.ErrRecordNotFound) { +// bank = entity.Bank{ +// Name: seed.Name, +// Alias: seed.Alias, +// Owner: seed.Owner, +// AccountNumber: seed.AccountNumber, +// CreatedBy: createdBy, +// CreatedAt: time.Now(), +// UpdatedAt: time.Now(), +// } +// if err := tx.Create(&bank).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// // update data jika sudah ada +// if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ +// "alias": seed.Alias, +// "owner": seed.Owner, +// "account_number": seed.AccountNumber, +// "updated_at": time.Now(), +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { +// seeds := []struct { +// ProductName string +// WarehouseName string +// Quantity float64 +// }{ +// {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 0}, +// {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 0}, +// {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 0}, +// {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, +// {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, +// {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, +// } + +// for _, seed := range seeds { +// var product entity.Product +// if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil { +// if errors.Is(err, gorm.ErrRecordNotFound) { +// return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName) +// } +// return err +// } + +// var warehouse entity.Warehouse +// if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil { +// if errors.Is(err, gorm.ErrRecordNotFound) { +// return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName) +// } +// return err +// } + +// var productWarehouse entity.ProductWarehouse +// err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// productWarehouse = entity.ProductWarehouse{ +// ProductId: product.Id, +// WarehouseId: warehouse.Id, +// Quantity: seed.Quantity, +// // CreatedBy: createdBy, +// } +// if err := tx.Create(&productWarehouse).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&productWarehouse).Updates(map[string]any{ +// "quantity": seed.Quantity, +// }).Error; err != nil { +// return err +// } +// } +// } + +// return nil +// } + +// func seedTransferStock(tx *gorm.DB) error { + +// 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 +// } + +// details := []entity.StockTransferDetail{ +// { +// StockTransferId: transfer.Id, +// ProductId: 1, + +// SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), +// DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), +// UsageQty: 10, +// PendingQty: 0, +// TotalQty: 10, +// TotalUsed: 0, +// }, +// { +// StockTransferId: transfer.Id, +// ProductId: 2, + +// SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), +// DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), +// UsageQty: 5, +// PendingQty: 0, +// TotalQty: 5, +// TotalUsed: 0, +// }, +// } +// for i := range details { +// if err := tx.Create(&details[i]).Error; err != nil { +// return err +// } +// } + +// 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 +// } + +// func strPtr(s string) *string { +// return &s +// } + +// func intPtr(v int) *int { +// return &v +// } + +// func uintPtr(v uint) *uint { +// return &v +// } diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 26c3f6e8..b4f6886e 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "strings" - "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -25,66 +24,20 @@ func Run(db *gorm.DB) error { return err } - areas, err := seedAreas(tx, adminID) - if err != nil { - return err - } - - locations, err := seedLocations(tx, adminID, areas) - if err != nil { - return err - } - productCategories, err := seedProductCategories(tx, adminID) if err != nil { return err } - if _, err := seedFlocks(tx, adminID); err != nil { - return err - } - - if _, err := seedFcr(tx, adminID); err != nil { - return err - } - - kandangs, err := seedKandangs(tx, adminID, locations, users) - if err != nil { - return err - } - - if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { - return err - } - suppliers, err := seedSuppliers(tx, adminID) if err != nil { return err } - if err := seedCustomers(tx, adminID, users); err != nil { - return err - } - if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { return err } - if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { - return err - } - - if err := seedBanks(tx, adminID); err != nil { - return err - } - - if err := seedProductWarehouse(tx, adminID); err != nil { - return err - } - - if err := seedTransferStock(tx); err != nil { - return err - } fmt.Println("✅ Master data seeding completed") return nil }) @@ -141,224 +94,6 @@ func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - names := []string{"Priangan", "Banten"} - result := make(map[string]uint, len(names)) - - for _, name := range names { - var area entity.Area - err := tx.Where("name = ?", name).First(&area).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - area = entity.Area{Name: name, CreatedBy: createdBy} - if err := tx.Create(&area).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[name] = area.Id - } - - return result, nil -} - -func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Address string - Area string - }{ - {"Singaparna", "Tasik", "Priangan"}, - {"Cikaum", "Cikaum", "Banten"}, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - areaID, ok := areas[seed.Area] - if !ok { - return nil, fmt.Errorf("area %s not seeded", seed.Area) - } - - var loc entity.Location - err := tx.Where("name = ?", seed.Name).First(&loc).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - loc = entity.Location{ - Name: seed.Name, - Address: seed.Address, - AreaId: areaID, - CreatedBy: createdBy, - } - if err := tx.Create(&loc).Error; err != nil { - return nil, err - } - } else if err != nil { - return nil, err - } - result[seed.Name] = loc.Id - } - - return result, nil -} - -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 seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { - seeds := []struct { - Name string - Status utils.KandangStatus - Capacity float64 - Location string - PicKey string - }{ - {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, - {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, - {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, - {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, - } - - result := make(map[string]uint, len(seeds)) - - for _, seed := range seeds { - locID, ok := locations[seed.Location] - if !ok { - return nil, fmt.Errorf("location %s not seeded", seed.Location) - } - picID, ok := users[seed.PicKey] - if !ok { - return nil, fmt.Errorf("user %s not seeded", seed.PicKey) - } - - 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, - Status: string(seed.Status), - LocationId: locID, - PicId: picID, - 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 err := tx.Model(&entity.Kandang{}).Where("id = ?", kandang.Id).Updates(updates).Error; err != nil { - return nil, err - } - } - result[seed.Name] = kandang.Id - } - - return result, nil -} - -func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { - seeds := []struct { - Name string - Type string - Area string - Location *string - Kandang *string - }{ - {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, - {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, - {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, - {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, - {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, - {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, - {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, - {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, - } - - for _, seed := range seeds { - areaID, ok := areas[seed.Area] - if !ok { - return fmt.Errorf("area %s not seeded", seed.Area) - } - - var warehouse entity.Warehouse - err := tx.Where("name = ?", seed.Name).First(&warehouse).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - warehouse = entity.Warehouse{ - Name: seed.Name, - Type: seed.Type, - AreaId: areaID, - CreatedBy: createdBy, - } - } else if err != nil { - return err - } - - if seed.Location != nil { - locID, ok := locations[*seed.Location] - if !ok { - return fmt.Errorf("location %s not seeded", *seed.Location) - } - warehouse.LocationId = uintPtr(locID) - } - if seed.Kandang != nil { - kandangID, ok := kandangs[*seed.Kandang] - if !ok { - return fmt.Errorf("kandang %s not seeded", *seed.Kandang) - } - warehouse.KandangId = uintPtr(kandangID) - } - - if warehouse.Id == 0 { - if err := tx.Create(&warehouse).Error; err != nil { - return err - } - } else { - if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ - "type": warehouse.Type, - "area_id": warehouse.AreaId, - "location_id": warehouse.LocationId, - "kandang_id": warehouse.KandangId, - }).Error; err != nil { - return err - } - } - } - - return nil -} - func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { seeds := []struct { Name string @@ -440,113 +175,6 @@ func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { - seeds := []struct { - Name string - PicKey string - Address string - Phone string - Email string - }{ - {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, - } - - for idx, seed := range seeds { - picID, ok := users[seed.PicKey] - if !ok { - return fmt.Errorf("user %s not seeded", seed.PicKey) - } - - var customer entity.Customer - err := tx.Where("name = ?", seed.Name).First(&customer).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - customer = entity.Customer{ - Name: seed.Name, - PicId: picID, - Type: string(utils.CustomerSupplierTypeBisnis), - Address: seed.Address, - Phone: seed.Phone, - Email: seed.Email, - AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), - CreatedBy: createdBy, - } - if err := tx.Create(&customer).Error; err != nil { - return err - } - } else if err != nil { - return err - } - } - - return nil -} - -func seedFcr(tx *gorm.DB, createdBy uint) (map[string]uint, error) { - seeds := []struct { - Name string - Standards []struct { - Weight float64 - FcrNumber float64 - Mortality float64 - } - }{ - { - Name: "FCR Layer", - Standards: []struct { - Weight float64 - FcrNumber float64 - Mortality float64 - }{ - {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, - {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, - }, - }, - } - - 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 nil, err - } - } else if err != nil { - return nil, err - } - result[seed.Name] = fcr.Id - - for _, std := range seed.Standards { - var standard entity.FcrStandard - err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - standard = entity.FcrStandard{ - FcrID: fcr.Id, - Weight: std.Weight, - FcrNumber: std.FcrNumber, - Mortality: std.Mortality, - } - if err := tx.Create(&standard).Error; err != nil { - return nil, err - } - } else if err != nil { - 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 nil, err - } - } - } - } - - return result, nil -} - func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { seeds := []struct { Name string @@ -560,92 +188,88 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Expiry *int Suppliers []string Flags []utils.FlagType + IsVisible bool }{ { - Name: "DOC Broiler", - Brand: "MBU Broiler", - Sku: "BRO0001", + Name: "ISA Brown", + Brand: "ISA Brown", + Sku: "ISA0001", Uom: "Ekor", Category: "Day Old Chick", Price: 7500, Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagDOC}, + Flags: []utils.FlagType{utils.FlagDOC, utils.FlagPullet, utils.FlagLayer}, + IsVisible: true, }, { - Name: "Ayam Pullet", - Brand: "MBU Pullet", - Sku: "PLT0001", - Uom: "Ekor", - Category: "Pullet", - Price: 15000, - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagPullet}, - }, - { - Name: "Ayam Afkir", - Brand: "-", - Sku: "1", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - Flags: []utils.FlagType{utils.FlagAyamAfkir}, - }, - { - Name: "Ayam Mati", - Brand: "-", - Sku: "2", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - Flags: []utils.FlagType{utils.FlagAyamMati}, - }, - { - Name: "Ayam Culling", - Brand: "-", - Sku: "3", - Uom: "Ekor", - Category: "Day Old Chick", - Price: 1, - Flags: []utils.FlagType{utils.FlagAyamCulling}, - }, - { - Name: "Telur Konsumsi Baik", - Brand: "-", - Sku: "4", - Uom: "Unit", - Category: "Telur", - Price: 1, - Flags: []utils.FlagType{utils.FlagTelurUtuh}, - }, - { - Name: "Telur Pecah", - Brand: "-", - Sku: "5", - Uom: "Unit", - Category: "Telur", - Price: 1, - Flags: []utils.FlagType{utils.FlagTelurPecah}, - }, - { - Name: "281 SPECIAL STARTER", - Brand: "281 STARTER", - Sku: "281", - Uom: "Kilogram", - Category: "Bahan Baku", - Price: 7850, - Expiry: intPtr(60), - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, - }, - { - Name: "Ayam Layer", + Name: "Ayam Afkir", Brand: "-", - Sku: "LYR0001", + Sku: "1", Uom: "Ekor", - Category: "Pullet", - Price: 20000, - Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, - Flags: []utils.FlagType{utils.FlagLayer}, + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamAfkir}, + IsVisible: false, + }, + { + Name: "Ayam Mati", + Brand: "-", + Sku: "2", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamMati}, + IsVisible: false, + }, + { + Name: "Ayam Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + Flags: []utils.FlagType{utils.FlagAyamCulling}, + IsVisible: false, + }, + { + Name: "Telur Utuh", + Brand: "-", + Sku: "4", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurUtuh}, + IsVisible: false, + }, + { + Name: "Telur Pecah", + Brand: "-", + Sku: "5", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurPecah}, + IsVisible: false, + }, + { + Name: "Telur Putih", + Brand: "-", + Sku: "6", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurPutih}, + IsVisible: false, + }, + { + Name: "Telur Retak", + Brand: "-", + Sku: "7", + Uom: "Gram", + Category: "Telur", + Price: 1, + Flags: []utils.FlagType{utils.FlagTelurRetak}, + IsVisible: false, }, } @@ -724,78 +348,78 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories return nil } -func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { - seeds := []struct { - Name string - Uom string - Suppliers []string - Flags []utils.FlagType - }{ - { - Name: "Expedisi DOC", - Uom: "Ekor", - Suppliers: []string{"Ekspedisi"}, - Flags: []utils.FlagType{utils.FlagEkspedisi}, - }, - { - Name: "Solar", - Uom: "Liter", - Suppliers: []string{"BOP Vendor"}, - Flags: []utils.FlagType{}, - }, - } +// func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { +// seeds := []struct { +// Name string +// Uom string +// Suppliers []string +// Flags []utils.FlagType +// }{ +// { +// Name: "LAJ", +// Uom: "Unit", +// Suppliers: []string{"Ekspedisi"}, +// Flags: []utils.FlagType{utils.FlagEkspedisi}, +// }, +// { +// Name: "Solar", +// Uom: "Liter", +// Suppliers: []string{"BOP Vendor"}, +// Flags: []utils.FlagType{}, +// }, +// } - for _, seed := range seeds { - uomID, ok := uoms[seed.Uom] - if !ok { - return fmt.Errorf("uom %s not seeded", seed.Uom) - } +// for _, seed := range seeds { +// uomID, ok := uoms[seed.Uom] +// if !ok { +// return fmt.Errorf("uom %s not seeded", seed.Uom) +// } - var nonstock entity.Nonstock - err := tx.Where("name = ?", seed.Name).First(&nonstock).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - nonstock = entity.Nonstock{ - Name: seed.Name, - UomId: uomID, - CreatedBy: createdBy, - } - if err := tx.Create(&nonstock).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ - "uom_id": uomID, - }).Error; err != nil { - return err - } - } +// var nonstock entity.Nonstock +// err := tx.Where("name = ?", seed.Name).First(&nonstock).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// nonstock = entity.Nonstock{ +// Name: seed.Name, +// UomId: uomID, +// CreatedBy: createdBy, +// } +// if err := tx.Create(&nonstock).Error; err != nil { +// return err +// } +// } else if err != nil { +// return err +// } else { +// if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ +// "uom_id": uomID, +// }).Error; err != nil { +// return err +// } +// } - for _, supplierName := range seed.Suppliers { - supplierID, ok := suppliers[supplierName] - if !ok { - return fmt.Errorf("supplier %s not seeded", supplierName) - } - var existing entity.NonstockSupplier - err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} - if err := tx.Create(&link).Error; err != nil { - return err - } - } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } +// for _, supplierName := range seed.Suppliers { +// supplierID, ok := suppliers[supplierName] +// if !ok { +// return fmt.Errorf("supplier %s not seeded", supplierName) +// } +// var existing entity.NonstockSupplier +// err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error +// if errors.Is(err, gorm.ErrRecordNotFound) { +// link := entity.NonstockSupplier{NonstockId: nonstock.Id, SupplierId: supplierID} +// if err := tx.Create(&link).Error; err != nil { +// return err +// } +// } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { +// return err +// } +// } - if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { - return err - } - } +// if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { +// return err +// } +// } - return nil -} +// return nil +// } // nanti saya isi @@ -823,225 +447,6 @@ func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils. return nil } -func seedBanks(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - Name string - Alias string - Owner *string - AccountNumber string - }{ - { - Name: "Bank Central Asia", - Alias: "BCA", - AccountNumber: "1234567890", - Owner: ptr("PT MBU Group"), - }, - { - Name: "Bank Rakyat Indonesia", - Alias: "BRI", - AccountNumber: "9876543210", - Owner: ptr("PT MBU Group"), - }, - { - Name: "Bank Mandiri", - Alias: "MAND", - AccountNumber: "1122334455", - Owner: ptr("PT MBU Group"), - }, - } - - for _, seed := range seeds { - var bank entity.Bank - err := tx.Where("name = ?", seed.Name).First(&bank).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - bank = entity.Bank{ - Name: seed.Name, - Alias: seed.Alias, - Owner: seed.Owner, - AccountNumber: seed.AccountNumber, - CreatedBy: createdBy, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - if err := tx.Create(&bank).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - // update data jika sudah ada - if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ - "alias": seed.Alias, - "owner": seed.Owner, - "account_number": seed.AccountNumber, - "updated_at": time.Now(), - }).Error; err != nil { - return err - } - } - } - - return nil -} - -func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProductName string - WarehouseName string - Quantity float64 - }{ - {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 0}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 0}, - {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 0}, - {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, - {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, - {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 0}, - {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, - {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 0}, - } - - for _, seed := range seeds { - var product entity.Product - if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName) - } - return err - } - - var warehouse entity.Warehouse - if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName) - } - return err - } - - var productWarehouse entity.ProductWarehouse - err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - productWarehouse = entity.ProductWarehouse{ - ProductId: product.Id, - WarehouseId: warehouse.Id, - Quantity: seed.Quantity, - // CreatedBy: createdBy, - } - if err := tx.Create(&productWarehouse).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&productWarehouse).Updates(map[string]any{ - "quantity": seed.Quantity, - }).Error; err != nil { - return err - } - } - } - - return nil -} - -func seedTransferStock(tx *gorm.DB) error { - - 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 - } - - details := []entity.StockTransferDetail{ - { - StockTransferId: transfer.Id, - ProductId: 1, - - SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), - DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), - UsageQty: 10, - PendingQty: 0, - TotalQty: 10, - TotalUsed: 0, - }, - { - StockTransferId: transfer.Id, - ProductId: 2, - - SourceProductWarehouseID: func() *uint64 { id := uint64(1); return &id }(), - DestProductWarehouseID: func() *uint64 { id := uint64(2); return &id }(), - UsageQty: 5, - PendingQty: 0, - TotalQty: 5, - TotalUsed: 0, - }, - } - for i := range details { - if err := tx.Create(&details[i]).Error; err != nil { - return err - } - } - - 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 -} - func strPtr(s string) *string { return &s } - -func intPtr(v int) *int { - return &v -} - -func uintPtr(v uint) *uint { - return &v -} From 6c42119f4dda73b1bfacbc625a3f53876ffcd28d Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 31 Dec 2025 06:43:34 +0700 Subject: [PATCH 48/50] fix(be): remove omitempty in dto and validation nonstock --- internal/modules/master/nonstocks/dto/nonstock.dto.go | 5 +++-- .../master/nonstocks/validations/nonstock.validation.go | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index b2af526c..71e1bb20 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -1,11 +1,12 @@ package dto import ( + "time" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" - "time" ) // === DTO Structs === @@ -22,7 +23,7 @@ type NonstockListDTO struct { Name string `json:"name"` Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers,omitempty"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index c421b7ec..f3a298ef 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -3,8 +3,8 @@ package validation type Create struct { Name string `json:"name" validate:"required_strict,min=3,max=50"` UomID uint `json:"uom_id" validate:"required,gt=0"` - SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` - Flags []string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` + SupplierIDs []uint `json:"supplier_ids" validate:"dive,gt=0"` + Flags []string `json:"flags" validate:"dive,max=50"` } type Update struct { From 5302713811af99a07327838994749575182cf7a5 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Wed, 31 Dec 2025 06:52:38 +0700 Subject: [PATCH 49/50] fix(be): nonstock response supplier null to empty array --- internal/modules/master/nonstocks/dto/nonstock.dto.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index 71e1bb20..9954ee76 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -101,7 +101,7 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { if len(relations) == 0 { - return nil + return make([]supplierDTO.SupplierRelationDTO, 0) } result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) @@ -113,7 +113,7 @@ func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.S } if len(result) == 0 { - return nil + return make([]supplierDTO.SupplierRelationDTO, 0) } return result From 709e304f7ff47d0b6f264e62a4a309cedd934891 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 07:39:20 +0700 Subject: [PATCH 50/50] feat(BE-281): adjustment bug erorr 500 if 404 record projectflock --- .../production/project_flocks/services/projectflock.service.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 75c89c8e..1e859e47 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -959,6 +959,9 @@ func (s projectflockService) ensureProjectFlockKandangProductWarehouses(ctx cont warehouse, err := warehouseRepo.GetByKandangID(ctx, record.KandangId) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse untuk kandang %d belum tersedia", record.KandangId)) + } return err }