From f0b4fe916c918494689c08b8fc0a9f71b71ad5dc Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 12 Jan 2026 20:00:49 +0700 Subject: [PATCH 001/117] FEAT[BE] ;: inisiate customer payment report route and related DTOs --- internal/middleware/permissions.go | 6 +- .../chickins/services/chickin.service.go | 1 - .../controllers/repport.controller.go | 27 ++++++++ .../dto/repportCustomerPayment.dto.go | 63 +++++++++++++++++++ internal/modules/repports/route.go | 2 +- 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 internal/modules/repports/dto/repportCustomerPayment.dto.go diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 10741bff..9450d228 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,8 +1,9 @@ package middleware -const( +const ( P_DashboardGetAll = "lti.dashboard.list" ) + // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" @@ -50,6 +51,7 @@ const ( P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" P_ReportProductionResultGetAll = "lti.repport.production_result.list" + P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list" ) const ( @@ -150,7 +152,7 @@ const ( P_ProductsCreateOne = "lti.master.products.create" P_ProductsUpdateOne = "lti.master.products.update" P_ProductsDeleteOne = "lti.master.products.delete" - + P_SuppliersGetAll = "lti.master.suppliers.list" P_SuppliersGetOne = "lti.master.suppliers.detail" P_SuppliersCreateOne = "lti.master.suppliers.create" diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index eabe596c..84e98f2d 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -584,7 +584,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return updated, nil } -// autoAddFlagToProduct adds target flag to product if not already present (idempotent) func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error { if s.ProductRepo == nil { return nil diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 22ff4acf..d89effa3 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -282,6 +282,33 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } +func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { + page := ctx.QueryInt("page", 1) + limit := ctx.QueryInt("limit", 10) + + if page < 1 || limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + // TODO: Implement service call + data := []dto.CustomerPaymentReportItem{} + totalResults := int64(0) + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer payment report successfully", + Meta: response.Meta{ + Page: page, + Limit: limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))), + TotalResults: totalResults, + }, + Data: data, + }) +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go new file mode 100644 index 00000000..e0938b51 --- /dev/null +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -0,0 +1,63 @@ +package dto + +import ( + "time" +) + +// CustomerPaymentReportCustomer represents customer information in the report +type CustomerPaymentReportCustomer struct { + ID uint `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AccountNumber string `json:"account_number"` + Balance float64 `json:"balance"` + Address string `json:"address"` +} + +// CustomerPaymentReportRow represents each transaction row +type CustomerPaymentReportRow struct { + ID uint `json:"id"` + DoDate time.Time `json:"do_date"` + RealizationDate time.Time `json:"realization_date"` + AgingDay int `json:"aging_day"` + Reference string `json:"reference"` + VehiclePlate []string `json:"vehicle_plate"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + Price float64 `json:"price"` + CreditNote float64 `json:"credit_note"` + FinalPrice float64 `json:"final_price"` + PPN float64 `json:"ppn"` + Total float64 `json:"total"` + Payment float64 `json:"payment"` + AccountsReceivable float64 `json:"accounts_receivable"` + Notes string `json:"notes"` + PickupInfo string `json:"pickup_info"` + SalesMarketing string `json:"sales_marketing"` +} + +// CustomerPaymentReportSummary represents summary calculations per customer +type CustomerPaymentReportSummary struct { + TotalQty float64 `json:"total_qty"` + TotalWeight float64 `json:"total_weight"` + TotalInitialAmount float64 `json:"total_initial_amount"` + TotalCreditNote float64 `json:"total_credit_note"` + TotalFinalAmount float64 `json:"total_final_amount"` + TotalPPN float64 `json:"total_ppn"` + TotalGrandAmount float64 `json:"total_grand_amount"` + TotalPayment float64 `json:"total_payment"` + TotalAccountsReceivable float64 `json:"total_accounts_receivable"` +} + +// CustomerPaymentReportItem represents data grouped by customer +type CustomerPaymentReportItem struct { + Customer CustomerPaymentReportCustomer `json:"customer"` + Rows []CustomerPaymentReportRow `json:"rows"` + Summary CustomerPaymentReportSummary `json:"summary"` +} + +// CustomerPaymentReportResponse represents the complete response +type CustomerPaymentReportResponse struct { + Data []CustomerPaymentReportItem `json:"data"` +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 0a0cf8a3..2f5eceec 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -21,5 +21,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) - + route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } From bba2dec8c645a4ed420f621b9ee28e57a80636c7 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 09:52:25 +0700 Subject: [PATCH 002/117] FEAT[BE] :update route --- internal/modules/repports/controllers/repport.controller.go | 5 ++++- internal/modules/repports/route.go | 2 +- internal/modules/repports/services/repport.service.go | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index d89effa3..613c4e00 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -289,8 +289,11 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { if page < 1 || limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + + + + - // TODO: Implement service call data := []dto.CustomerPaymentReportItem{} totalResults := int64(0) diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 2f5eceec..3f803677 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -21,5 +21,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) - route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) + route.Get("/customer-payment", ctrl.GetCustomerPayment) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c7576e5f..e54d8674 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -40,6 +40,7 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) + GetCustomerPayment(ctx *fiber.Ctx) (int, error) } type repportService struct { @@ -1232,6 +1233,11 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } +func (s *repportService) GetCustomerPayment(c *fiber.Ctx) (int, error) { + + return 0, nil +} + func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { From 64fe8451281357265a1e71188a3125fc4c4861fe Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Tue, 13 Jan 2026 10:46:55 +0700 Subject: [PATCH 003/117] Update CICD --- .gitlab-ci.yml | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b0e3883e..e5f0f4ac 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,31 +6,31 @@ stages: default: tags: - - self-hosted-stg + - self-hosted-prod workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' when: always - when: never variables: DOCKER_BUILDKIT: "1" - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" - DEPLOY_DIR: "/opt/deploy/stg-lti-api" + DEPLOY_DIR: "/opt/deploy/lti" COMPOSE_FILE: "docker-compose.yaml" # ========================= # BUILD (AUTO) # ========================= -build_staging: +build_production: stage: build rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' script: | set -e docker info @@ -51,16 +51,16 @@ build_staging: # ========================= # MIGRATE (AUTO) # ========================= -migrate_staging: +migrate_production: stage: migrate rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: build_staging + - job: build_production artifacts: false script: | set -e - echo "✅ Running migrations (staging) ..." + echo "✅ Running migrations (production) ..." cd "$DEPLOY_DIR" test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) @@ -128,14 +128,14 @@ migrate_staging: # ========================= # DEPLOY (AUTO) # ========================= -deploy_staging: +deploy_production: stage: deploy rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: migrate_staging + - job: migrate_production artifacts: false - - job: build_staging + - job: build_production artifacts: false script: | set -e @@ -154,12 +154,12 @@ deploy_staging: # ========================= # SEED (MANUAL) # ========================= -seed_staging: +seed_production: stage: seed rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: deploy_staging + - job: deploy_production artifacts: false when: manual allow_failure: false @@ -170,4 +170,6 @@ seed_staging: test -f .env || (echo "❌ .env not found" && exit 1) docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file + docker compose -f "$COMPOSE_FILE" run --rm seed + + From f8415ea15d5e44c5b2434db3941daf52fc5f01c2 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Tue, 13 Jan 2026 10:59:51 +0700 Subject: [PATCH 004/117] Update gitlab --- .gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e5f0f4ac..0985896e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -83,7 +83,7 @@ migrate_production: # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) echo "✅ Ensuring postgres & redis running ..." - docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true + docker compose -f "$COMPOSE_FILE" up -d postgres-lti redis-lti || true # ✅ Ambil network key dari compose COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" @@ -172,4 +172,3 @@ seed_production: docker compose -f "$COMPOSE_FILE" pull seed || true docker compose -f "$COMPOSE_FILE" run --rm seed - From d26c2dba3f1f6192b8f728f428dcfc1cc7843e36 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:15:08 +0000 Subject: [PATCH 005/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 71 ++++++++++++-------------------------------------- 1 file changed, 16 insertions(+), 55 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0985896e..52a49bd6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,80 +49,41 @@ build_production: # ========================= -# MIGRATE (AUTO) +# MIGRATE (PRODUCTION - MANUAL) # ========================= migrate_production: stage: migrate rules: - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false needs: - job: build_production artifacts: false script: | set -e - echo "✅ Running migrations (production) ..." + cd /opt/deploy/lti-prod + test -f .env || (echo "❌ .env not found" && exit 1) - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - # ✅ load env dari server set -a . ./.env set +a - # ✅ validasi - test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) - test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) - test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) - test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) - test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL=$DATABASE_URL" + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) - echo "✅ Ensuring postgres & redis running ..." - docker compose -f "$COMPOSE_FILE" up -d postgres-lti redis-lti || true - - # ✅ Ambil network key dari compose - COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" - - # ✅ Cari network name yang dipakai docker - NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) - - echo "✅ Docker network detected: $NETWORK_NAME" - - # ✅ Migrations dari repo (CI workspace) - echo "✅ Checking migrations from repo..." - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - - echo "✅ Running migrations via migrate/migrate container" - set +e - out=$(docker run --rm \ - --network "$NETWORK_NAME" \ + echo "✅ Running migrations (production)..." + docker run --rm \ -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up 2>&1) - code=$? - set -e - - echo "$out" - - # ✅ Handle no change dengan benar (tidak false-success) - if echo "$out" | grep -qi "no change"; then - echo "✅ No change (already up to date)" - exit 0 - fi - - if [ $code -ne 0 ]; then - echo "❌ Migration failed with exit code $code" - exit $code - fi - - echo "✅ Migration applied successfully" + -path=/migrations -database "$DATABASE_URL" up # ========================= From 7fd90f32681df609351091a7d255a6e02713ff6c Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:19:00 +0000 Subject: [PATCH 006/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 52a49bd6..7fc8bac8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,7 +62,7 @@ migrate_production: artifacts: false script: | set -e - cd /opt/deploy/lti-prod + cd /opt/deploy/lti test -f .env || (echo "❌ .env not found" && exit 1) set -a From 4c434899aaeccd0fc2107859ceb695a66d629d3f Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:36:34 +0000 Subject: [PATCH 007/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7fc8bac8..abe16f61 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -118,18 +118,15 @@ deploy_production: seed_production: stage: seed rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - needs: - - job: deploy_production - artifacts: false - when: manual - allow_failure: false + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual script: | set -e - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) + cd /opt/deploy/lti-prod test -f .env || (echo "❌ .env not found" && exit 1) - docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed From cfbe431222f1c78a8be8e4aeeafd31fdbae5a112 Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 13 Jan 2026 04:43:44 +0000 Subject: [PATCH 008/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index abe16f61..c99f940f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,7 +122,7 @@ seed_production: when: manual script: | set -e - cd /opt/deploy/lti-prod + cd /opt/deploy/lti test -f .env || (echo "❌ .env not found" && exit 1) echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" From 7f1d796b650a1a5fbd2fecd9c49b10130012a43d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 22:50:58 +0700 Subject: [PATCH 009/117] fix[BE]: correct total price calculation in delivery and sales order services --- internal/modules/marketing/services/deliveryorder.service.go | 4 ++-- internal/modules/marketing/services/salesorder.service.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index a1f4e1dd..a521e5bc 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -249,7 +249,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery // Hitung total_weight dan total_price otomatis totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + totalPrice := requestedProduct.UnitPrice * totalWeight deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -363,7 +363,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO // Hitung total_weight dan total_price otomatis totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty + totalPrice := requestedProduct.UnitPrice * totalWeight deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index d57b323e..e73184dd 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -294,7 +294,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u // Hitung total_weight dan total_price otomatis totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * rp.Qty + totalPrice := rp.UnitPrice * totalWeight updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, @@ -594,7 +594,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont // Hitung total_weight dan total_price otomatis totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * rp.Qty + totalPrice := rp.UnitPrice * totalWeight marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, From f6e872c0aa2c026a1fee649635f71d72a9f41d54 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 11:46:39 +0700 Subject: [PATCH 010/117] feat[BE]: implement customer payment report retrieval with pagination and filtering --- .../controllers/repport.controller.go | 84 ++++--- .../dto/repportCustomerPayment.dto.go | 62 +++--- internal/modules/repports/module.go | 3 +- .../customer_payment.repository.go | 205 ++++++++++++++++++ .../repports/services/repport.service.go | 171 ++++++++++++++- .../validations/repport.validation.go | 8 + 6 files changed, 460 insertions(+), 73 deletions(-) create mode 100644 internal/modules/repports/repositories/customer_payment.repository.go diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index d9701305..577c1b1b 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -242,6 +242,60 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusOK).JSON(resp) } +func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { + var customerID *uint + if customerIDStr := ctx.Query("customer_id"); customerIDStr != "" { + if id, err := strconv.ParseUint(customerIDStr, 10, 32); err == nil { + cid := uint(id) + customerID = &cid + } + } + + query := &validation.CustomerPaymentQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + CustomerID: customerID, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), + } + + // Validate pagination + if customerID == nil && (query.Page < 1 || query.Limit < 1) { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetCustomerPayment(ctx, query) + if err != nil { + return err + } + + // If single customer mode, return without pagination + if customerID != nil { + return ctx.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer payment report successfully", + Data: result, + }) + } + + // Multiple customers mode with pagination + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get customer payment report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { idParam := ctx.Params("idProjectFlockKandang") if idParam == "" { @@ -283,36 +337,6 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { }) } -func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { - page := ctx.QueryInt("page", 1) - limit := ctx.QueryInt("limit", 10) - - if page < 1 || limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") - } - - - - - - data := []dto.CustomerPaymentReportItem{} - totalResults := int64(0) - - return ctx.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{ - Code: fiber.StatusOK, - Status: "success", - Message: "Get customer payment report successfully", - Meta: response.Meta{ - Page: page, - Limit: limit, - TotalPages: int64(math.Ceil(float64(totalResults) / float64(limit))), - TotalResults: totalResults, - }, - Data: data, - }) -} - func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index e0938b51..2f200379 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -2,42 +2,33 @@ package dto import ( "time" + + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" ) -// CustomerPaymentReportCustomer represents customer information in the report -type CustomerPaymentReportCustomer struct { - ID uint `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - AccountNumber string `json:"account_number"` - Balance float64 `json:"balance"` - Address string `json:"address"` -} - -// CustomerPaymentReportRow represents each transaction row type CustomerPaymentReportRow struct { - ID uint `json:"id"` - DoDate time.Time `json:"do_date"` - RealizationDate time.Time `json:"realization_date"` - AgingDay int `json:"aging_day"` - Reference string `json:"reference"` - VehiclePlate []string `json:"vehicle_plate"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AverageWeight float64 `json:"average_weight"` - Price float64 `json:"price"` - CreditNote float64 `json:"credit_note"` - FinalPrice float64 `json:"final_price"` - PPN float64 `json:"ppn"` - Total float64 `json:"total"` - Payment float64 `json:"payment"` - AccountsReceivable float64 `json:"accounts_receivable"` - Notes string `json:"notes"` - PickupInfo string `json:"pickup_info"` - SalesMarketing string `json:"sales_marketing"` + TransactionType string `json:"transaction_type"` + TransactionID int64 `json:"transaction_id"` + TransDate time.Time `json:"trans_date"` + DeliveryDate *time.Time `json:"delivery_date"` + Reference string `json:"reference"` + VehicleNumbers string `json:"vehicle_numbers"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + Price float64 `json:"price"` + CreditNote float64 `json:"credit_note"` + FinalPrice float64 `json:"final_price"` + PPN float64 `json:"ppn"` + TotalPrice float64 `json:"total_price"` + PaymentAmount float64 `json:"payment_amount"` + AccountsReceivable float64 `json:"accounts_receivable"` + AgingDay int `json:"aging_day"` + Status string `json:"status"` + PickupInfo string `json:"pickup_info"` + SalesPerson string `json:"sales_person"` } -// CustomerPaymentReportSummary represents summary calculations per customer type CustomerPaymentReportSummary struct { TotalQty float64 `json:"total_qty"` TotalWeight float64 `json:"total_weight"` @@ -50,14 +41,13 @@ type CustomerPaymentReportSummary struct { TotalAccountsReceivable float64 `json:"total_accounts_receivable"` } -// CustomerPaymentReportItem represents data grouped by customer type CustomerPaymentReportItem struct { - Customer CustomerPaymentReportCustomer `json:"customer"` - Rows []CustomerPaymentReportRow `json:"rows"` - Summary CustomerPaymentReportSummary `json:"summary"` + Customer customerDTO.CustomerRelationDTO `json:"customer"` + InitialBalance float64 `json:"initial_balance"` + Rows []CustomerPaymentReportRow `json:"rows"` + Summary CustomerPaymentReportSummary `json:"summary"` } -// CustomerPaymentReportResponse represents the complete response type CustomerPaymentReportResponse struct { Data []CustomerPaymentReportItem `json:"data"` } diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 61f37d4d..b0432316 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -34,10 +34,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) + customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository) + repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go new file mode 100644 index 00000000..1d4ffd28 --- /dev/null +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -0,0 +1,205 @@ +package repositories + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +type CustomerPaymentTransaction struct { + TransactionType string `gorm:"column:transaction_type"` + TransactionID int64 `gorm:"column:transaction_id"` + CustomerID int64 `gorm:"column:customer_id"` + TransDate time.Time `gorm:"column:trans_date"` + DeliveryDate *time.Time `gorm:"column:delivery_date"` + Reference string `gorm:"column:reference"` + VehicleNumbers string `gorm:"column:vehicle_numbers"` + Qty float64 `gorm:"column:qty"` + Weight float64 `gorm:"column:weight"` + AverageWeight float64 `gorm:"column:average_weight"` + Price float64 `gorm:"column:price"` + CreditNote float64 `gorm:"column:credit_note"` + FinalPrice float64 `gorm:"column:final_price"` + PPN float64 `gorm:"column:ppn"` + TotalPrice float64 `gorm:"column:total_price"` + PaymentAmount float64 `gorm:"column:payment_amount"` + PickupInfo string `gorm:"column:pickup_info"` + SalesPerson string `gorm:"column:sales_person"` +} + +type CustomerPaymentRepository interface { + GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) + GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) + GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) +} + +type customerPaymentRepositoryImpl struct { + db *gorm.DB +} + +func NewCustomerPaymentRepository(db *gorm.DB) CustomerPaymentRepository { + return &customerPaymentRepositoryImpl{db: db} +} + +func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) { + // Build SALES subquery + salesQuery := r.db.WithContext(ctx). + Table("marketings m"). + Select(` + 'SALES' AS transaction_type, + m.id::BIGINT AS transaction_id, + c.id::BIGINT AS customer_id, + m.so_date::DATE AS trans_date, + MAX(mdp.delivery_date)::DATE AS delivery_date, + m.so_number AS reference, + COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL AND mdp.vehicle_number != ''), '') AS vehicle_numbers, + COALESCE(SUM(COALESCE(mp.qty, 0)), 0)::NUMERIC(15,3) AS qty, + COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0)::NUMERIC(15,3) AS weight, + CASE WHEN COALESCE(SUM(COALESCE(mp.qty, 0)), 0) > 0 + THEN (COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0) / COALESCE(SUM(COALESCE(mp.qty, 0)), 0))::NUMERIC(15,3) + ELSE 0::NUMERIC(15,3) + END AS average_weight, + COALESCE(AVG(COALESCE(mdp.unit_price, mp.unit_price, 0)), 0)::NUMERIC(15,3) AS price, + 0::NUMERIC(15,3) AS credit_note, + COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS final_price, + 0::NUMERIC(15,3) AS ppn, + COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS total_price, + 0::NUMERIC(15,3) AS payment_amount, + COALESCE(STRING_AGG(DISTINCT w.name, ', ') FILTER (WHERE w.name IS NOT NULL), '') AS pickup_info, + MAX(u.name) AS sales_person + `). + Joins("INNER JOIN customers c ON c.id = m.customer_id"). + Joins("LEFT JOIN marketing_products mp ON mp.marketing_id = m.id"). + Joins("LEFT JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). + Joins("LEFT JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("LEFT JOIN users u ON u.id = m.sales_person_id"). + Where("m.deleted_at IS NULL"). + Where("c.deleted_at IS NULL") + + if customerID != nil { + salesQuery = salesQuery.Where("c.id = ?", *customerID) + } + + salesQuery = salesQuery.Group("m.id, c.id, m.so_date, m.so_number") + + // Build PAYMENT subquery + paymentQuery := r.db.WithContext(ctx). + Table("payments p"). + Select(` + 'PAYMENT' AS transaction_type, + p.id::BIGINT AS transaction_id, + c.id::BIGINT AS customer_id, + p.payment_date::DATE AS trans_date, + NULL AS delivery_date, + COALESCE(p.reference_number, p.payment_code) AS reference, + '-' AS vehicle_numbers, + 0::NUMERIC(15,3) AS qty, + 0::NUMERIC(15,3) AS weight, + 0::NUMERIC(15,3) AS average_weight, + 0::NUMERIC(15,3) AS price, + 0::NUMERIC(15,3) AS credit_note, + 0::NUMERIC(15,3) AS final_price, + 0::NUMERIC(15,3) AS ppn, + 0::NUMERIC(15,3) AS total_price, + p.nominal::NUMERIC(15,3) AS payment_amount, + '-' AS pickup_info, + '-' AS sales_person + `). + Joins("INNER JOIN customers c ON c.id = p.party_id"). + Where("p.party_type = ?", "CUSTOMER"). + Where("p.direction = ?", "IN"). + Where("p.transaction_type = ?", "PENJUALAN"). + Where("p.deleted_at IS NULL"). + Where("c.deleted_at IS NULL") + + if customerID != nil { + paymentQuery = paymentQuery.Where("c.id = ?", *customerID) + } + + // Combine with UNION ALL and execute + var results []CustomerPaymentTransaction + err := r.db.WithContext(ctx). + Raw("? UNION ALL ? ORDER BY customer_id, trans_date, transaction_type DESC, transaction_id", + salesQuery, + paymentQuery, + ). + Scan(&results). + Error + + if err != nil { + return nil, err + } + + return results, nil +} + +func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) { + var result struct { + Nominal float64 + } + + err := r.db.WithContext(ctx). + Table("payments"). + Select("COALESCE(SUM(nominal), 0) as nominal"). + Where("party_type = ?", "CUSTOMER"). + Where("party_id = ?", customerID). + Where("transaction_type = ?", "SALDO_AWAL"). + Where("deleted_at IS NULL"). + Scan(&result). + Error + + if err != nil { + return 0, err + } + + return result.Nominal, nil +} + +func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) { + // Subquery to get all distinct customer IDs with transactions + subQuery := r.db.WithContext(ctx). + Table("(" + + "SELECT DISTINCT c.id as customer_id FROM marketings m " + + "INNER JOIN customers c ON c.id = m.customer_id " + + "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL " + + "UNION " + + "SELECT DISTINCT c.id as customer_id FROM payments p " + + "INNER JOIN customers c ON c.id = p.party_id " + + "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " + + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + + ") as customer_ids") + + // Count total customers + var total int64 + if err := subQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated customer IDs + var customerIDs []uint + err := r.db.WithContext(ctx). + Table("("+ + "SELECT DISTINCT c.id as customer_id FROM marketings m "+ + "INNER JOIN customers c ON c.id = m.customer_id "+ + "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL "+ + "UNION "+ + "SELECT DISTINCT c.id as customer_id FROM payments p "+ + "INNER JOIN customers c ON c.id = p.party_id "+ + "WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' "+ + "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL"+ + ") as customer_ids"). + Select("customer_id"). + Order("customer_id ASC"). + Limit(limit). + Offset(offset). + Pluck("customer_id", &customerIDs). + Error + + if err != nil { + return nil, 0, err + } + + return customerIDs, total, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a36d4d21..89acca09 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -19,6 +19,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" @@ -40,12 +41,13 @@ type RepportService interface { GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) - GetCustomerPayment(ctx *fiber.Ctx) (int, error) + GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) } type repportService struct { Log *logrus.Logger Validate *validator.Validate + DB *gorm.DB ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository PurchaseRepo purchaseRepo.PurchaseRepository @@ -56,6 +58,7 @@ type repportService struct { DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository + CustomerPaymentRepo repportRepo.CustomerPaymentRepository } type HppCostAggregate struct { @@ -68,6 +71,7 @@ type HppCostAggregate struct { } func NewRepportService( + db *gorm.DB, validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, @@ -79,10 +83,12 @@ func NewRepportService( debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, + customerPaymentRepo repportRepo.CustomerPaymentRepository, ) RepportService { return &repportService{ Log: utils.Log, Validate: validate, + DB: db, ExpenseRealizationRepo: expenseRealizationRepo, MarketingDeliveryRepo: marketingDeliveryRepo, PurchaseRepo: purchaseRepo, @@ -93,6 +99,7 @@ func NewRepportService( DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, + CustomerPaymentRepo: customerPaymentRepo, } } @@ -308,6 +315,163 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. return weeklyResults, totalWeeks, nil } +func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + // Determine customer IDs to process + var customerIDs []uint + var totalCustomers int64 + + if params.CustomerID != nil { + // Single customer mode + customerIDs = []uint{*params.CustomerID} + totalCustomers = 1 + } else { + // Multiple customers mode with pagination + page := params.Page + limit := params.Limit + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + + offset := (page - 1) * limit + + var err error + customerIDs, totalCustomers, err = s.CustomerPaymentRepo.GetCustomerIDsWithTransactions(ctx.Context(), limit, offset) + if err != nil { + return nil, 0, err + } + + if len(customerIDs) == 0 { + return []dto.CustomerPaymentReportItem{}, 0, nil + } + } + + // Process each customer + var result []dto.CustomerPaymentReportItem + for _, customerID := range customerIDs { + item, err := s.processCustomerPayment(ctx.Context(), customerID) + if err != nil { + return nil, 0, err + } + result = append(result, item) + } + + return result, totalCustomers, nil +} + +func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint) (dto.CustomerPaymentReportItem, error) { + // Get customer info + customer := entity.Customer{} + if err := s.DB.WithContext(ctx). + Where("id = ?", customerID). + First(&customer).Error; err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + // Get initial balance + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(ctx, customerID) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + // Get transactions for this customer + cid := customerID + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(ctx, &cid) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + + // Process transactions and calculate running balance + rows := make([]dto.CustomerPaymentReportRow, 0, len(transactions)) + runningBalance := initialBalance + + for _, tx := range transactions { + row := dto.CustomerPaymentReportRow{ + TransactionType: tx.TransactionType, + TransactionID: tx.TransactionID, + TransDate: tx.TransDate, + DeliveryDate: tx.DeliveryDate, + Reference: tx.Reference, + VehicleNumbers: tx.VehicleNumbers, + Qty: tx.Qty, + Weight: tx.Weight, + AverageWeight: tx.AverageWeight, + Price: tx.Price, + CreditNote: tx.CreditNote, + FinalPrice: tx.FinalPrice, + PPN: tx.PPN, + TotalPrice: tx.TotalPrice, + PaymentAmount: tx.PaymentAmount, + PickupInfo: tx.PickupInfo, + SalesPerson: tx.SalesPerson, + } + + // Calculate running balance + if tx.TransactionType == "SALES" { + runningBalance -= tx.TotalPrice + // Status will be calculated later (requires looking ahead) + row.Status = "" + row.AgingDay = 0 // Will be calculated later + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + row.Status = "" + row.AgingDay = 0 + } + + row.AccountsReceivable = runningBalance + rows = append(rows, row) + } + + // Calculate summary + summary := s.calculateSummary(rows, initialBalance) + + // Build customer DTO + customerDTO := customerDTO.CustomerRelationDTO{ + Id: customer.Id, + Name: customer.Name, + Type: customer.Type, + AccountNumber: customer.AccountNumber, + Balance: customer.Balance, + } + + return dto.CustomerPaymentReportItem{ + Customer: customerDTO, + InitialBalance: initialBalance, + Rows: rows, + Summary: summary, + }, nil +} + +func (s *repportService) calculateSummary(rows []dto.CustomerPaymentReportRow, initialBalance float64) dto.CustomerPaymentReportSummary { + summary := dto.CustomerPaymentReportSummary{} + + for _, row := range rows { + summary.TotalQty += row.Qty + summary.TotalWeight += row.Weight + summary.TotalCreditNote += row.CreditNote + summary.TotalPPN += row.PPN + + if row.TransactionType == "SALES" { + summary.TotalInitialAmount += row.TotalPrice + summary.TotalFinalAmount += row.FinalPrice + summary.TotalGrandAmount += row.TotalPrice + } else if row.TransactionType == "PAYMENT" { + summary.TotalPayment += row.PaymentAmount + } + } + + // Final AR = initial balance + total sales - total payment + summary.TotalAccountsReceivable = initialBalance + summary.TotalGrandAmount - summary.TotalPayment + + return summary +} + func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionResultDTO { result := dto.ProductionResultDTO{ CreatedAt: record.CreatedAt, @@ -1374,11 +1538,6 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp return params, filters, nil } -func (s *repportService) GetCustomerPayment(c *fiber.Ctx) (int, error) { - - return 0, nil -} - func parseCommaSeparatedInt64s(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 5dde8f51..c79dd90d 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -71,3 +71,11 @@ type ProductionResultQuery struct { Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` } + +type CustomerPaymentQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + CustomerID *uint `query:"customer_id" validate:"omitempty,gt=0"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` +} From 894fa0b22a48ea1f5adaebdf2ca0d3c5a2b4f07c Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 06:55:28 +0000 Subject: [PATCH 011/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 245 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 164 insertions(+), 81 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,173 @@ stages: + - build + - migrate - deploy + - seed -deploy-dev: +default: + tags: + - self-hosted-stg + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + + DEPLOY_DIR: "/opt/deploy/stg-lti-api" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_staging: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (AUTO) +# ========================= +migrate_staging: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: build_staging + artifacts: false + script: | + set -e + echo "✅ Running migrations (staging) ..." + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + # ✅ load env dari server + set -a + . ./.env + set +a + + # ✅ validasi + test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) + test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) + test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) + test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) + test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) + + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL=$DATABASE_URL" + + # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) + echo "✅ Ensuring postgres & redis running ..." + docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true + + # ✅ Ambil network key dari compose + COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" + echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" + + # ✅ Cari network name yang dipakai docker + NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" + test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) + + echo "✅ Docker network detected: $NETWORK_NAME" + + # ✅ Migrations dari repo (CI workspace) + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + + echo "✅ Running migrations via migrate/migrate container" + set +e + out=$(docker run --rm \ + --network "$NETWORK_NAME" \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up 2>&1) + code=$? + set -e + + echo "$out" + + # ✅ Handle no change dengan benar (tidak false-success) + if echo "$out" | grep -qi "no change"; then + echo "✅ No change (already up to date)" + exit 0 + fi + + if [ $code -ne 0 ]; then + echo "❌ Migration failed with exit code $code" + exit $code + fi + + echo "✅ Migration applied successfully" + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_staging: stage: deploy - image: alpine:3.20 - variables: - DEPLOY_APP: "LTI-MBUGROUP" - # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga - GIT_SUBMODULE_STRATEGY: recursive - GIT_DEPTH: "1" + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: migrate_staging + artifacts: false + - job: build_staging + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - # Setup SSH di runner - - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa - - eval "$(ssh-agent -s)" - - ssh-add ~/.ssh/id_rsa + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f - # Trust host keys (server + gitlab) biar SSH gak nanya interaktif - - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts - - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - script: - - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" +# ========================= +# SEED (MANUAL) +# ========================= +seed_staging: + stage: seed + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: deploy_staging + artifacts: false + when: manual + allow_failure: false + script: | + set -e + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) + test -f .env || (echo "❌ .env not found" && exit 1) - - > - if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " - set -e - - cd /home/devops/docker/deployment/development/lti-api - - # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) - git remote set-url origin git@gitlab.com:mbugroup/lti-api.git - - # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - # Fetch/reset pakai SSH - GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development - git reset --hard origin/development - - docker compose restart dev-api-lti || docker compose up -d dev-api-lti - "; then - STATUS='success'; - else - STATUS='failed'; - fi; - - RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; - - if [ "$STATUS" = "success" ]; then - COLOR=3066993; - TITLE="✅ Deployment API Succeeded"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; - else - COLOR=15158332; - TITLE="❌ Deployment API Failed Gaes"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; - fi; - - echo "{ - \"username\": \"CI Bot\", - \"embeds\": [{ - \"title\": \"$TITLE\", - \"description\": \"$DESC\", - \"color\": $COLOR, - \"fields\": [ - {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, - {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, - {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, - {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} - ] - }] - }" > payload.json; - - echo "📡 Sending notification to Discord..."; - curl -sS -H "Content-Type: application/json" \ - -d @payload.json "$DISCORD_WEBHOOK_URL"; - - only: - - development - - environment: - name: development \ No newline at end of file + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file From 7daa509cd053d334de49766713e5d7652802b35a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 14:06:34 +0700 Subject: [PATCH 012/117] feat[BE]: update customer payment report to support multiple customer IDs and nullable aging days --- .../controllers/repport.controller.go | 33 +++--- .../dto/repportCustomerPayment.dto.go | 2 +- .../customer_payment.repository.go | 1 + .../repports/services/repport.service.go | 101 ++++++++++++++---- .../validations/repport.validation.go | 10 +- 5 files changed, 109 insertions(+), 38 deletions(-) diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 577c1b1b..f83f0902 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -243,25 +243,30 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error { } func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { - var customerID *uint - if customerIDStr := ctx.Query("customer_id"); customerIDStr != "" { - if id, err := strconv.ParseUint(customerIDStr, 10, 32); err == nil { - cid := uint(id) - customerID = &cid + var customerIDs []uint + if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" { + ids := strings.Split(customerIDsStr, ",") + for _, idStr := range ids { + idStr = strings.TrimSpace(idStr) + if idStr != "" { + if id, err := strconv.ParseUint(idStr, 10, 32); err == nil { + customerIDs = append(customerIDs, uint(id)) + } + } } } query := &validation.CustomerPaymentQuery{ - Page: ctx.QueryInt("page", 1), - Limit: ctx.QueryInt("limit", 10), - CustomerID: customerID, - StartDate: ctx.Query("start_date", ""), - EndDate: ctx.Query("end_date", ""), + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + CustomerIDs: customerIDs, + StartDate: ctx.Query("start_date", ""), + EndDate: ctx.Query("end_date", ""), } // Validate pagination - if customerID == nil && (query.Page < 1 || query.Limit < 1) { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + if len(customerIDs) == 0 && (query.Page < 1 || query.Limit < 1) { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0 when customer_ids is not provided") } result, totalResults, err := c.RepportService.GetCustomerPayment(ctx, query) @@ -269,8 +274,8 @@ func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error { return err } - // If single customer mode, return without pagination - if customerID != nil { + // If single customer mode (only 1 customer ID), return without pagination + if len(customerIDs) == 1 { return ctx.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 2f200379..439eed42 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -23,7 +23,7 @@ type CustomerPaymentReportRow struct { TotalPrice float64 `json:"total_price"` PaymentAmount float64 `json:"payment_amount"` AccountsReceivable float64 `json:"accounts_receivable"` - AgingDay int `json:"aging_day"` + AgingDay *int `json:"aging_day"` Status string `json:"status"` PickupInfo string `json:"pickup_info"` SalesPerson string `json:"sales_person"` diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 1d4ffd28..49e9424c 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "time" "gorm.io/gorm" diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 89acca09..c6f18002 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -324,10 +324,14 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C var customerIDs []uint var totalCustomers int64 - if params.CustomerID != nil { - // Single customer mode - customerIDs = []uint{*params.CustomerID} - totalCustomers = 1 + if len(params.CustomerIDs) > 0 { + // Specific customer IDs mode (no pagination) + customerIDs = params.CustomerIDs + totalCustomers = int64(len(customerIDs)) + + if len(customerIDs) == 0 { + return []dto.CustomerPaymentReportItem{}, 0, nil + } } else { // Multiple customers mode with pagination page := params.Page @@ -366,7 +370,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C } func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint) (dto.CustomerPaymentReportItem, error) { - // Get customer info customer := entity.Customer{} if err := s.DB.WithContext(ctx). Where("id = ?", customerID). @@ -374,24 +377,21 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID return dto.CustomerPaymentReportItem{}, err } - // Get initial balance initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(ctx, customerID) if err != nil { return dto.CustomerPaymentReportItem{}, err } - // Get transactions for this customer cid := customerID transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(ctx, &cid) if err != nil { return dto.CustomerPaymentReportItem{}, err } - // Process transactions and calculate running balance rows := make([]dto.CustomerPaymentReportRow, 0, len(transactions)) runningBalance := initialBalance - for _, tx := range transactions { + for i, tx := range transactions { row := dto.CustomerPaymentReportRow{ TransactionType: tx.TransactionType, TransactionID: tx.TransactionID, @@ -412,26 +412,48 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID SalesPerson: tx.SalesPerson, } - // Calculate running balance + previousBalance := runningBalance + if tx.TransactionType == "SALES" { runningBalance -= tx.TotalPrice - // Status will be calculated later (requires looking ahead) - row.Status = "" - row.AgingDay = 0 // Will be calculated later + status, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, runningBalance) + row.Status = status + + if status == "LUNAS" { + if previousBalance >= tx.TotalPrice { + days := 0 + row.AgingDay = &days + } else if paymentDate != nil { + // Aging = payment_date - trans_date (SO date) + days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + if days < 0 { + days = 0 + } + row.AgingDay = &days + } else { + days := 0 + row.AgingDay = &days + } + } else { + // Aging = current_date - trans_date (SO date) + days := int(time.Since(tx.TransDate).Hours() / 24) + if days < 0 { + days = 0 + } + row.AgingDay = &days + } } else if tx.TransactionType == "PAYMENT" { runningBalance += tx.PaymentAmount row.Status = "" - row.AgingDay = 0 + row.AgingDay = nil } row.AccountsReceivable = runningBalance rows = append(rows, row) } - // Calculate summary summary := s.calculateSummary(rows, initialBalance) - // Build customer DTO customerDTO := customerDTO.CustomerRelationDTO{ Id: customer.Id, Name: customer.Name, @@ -466,12 +488,55 @@ func (s *repportService) calculateSummary(rows []dto.CustomerPaymentReportRow, i } } - // Final AR = initial balance + total sales - total payment - summary.TotalAccountsReceivable = initialBalance + summary.TotalGrandAmount - summary.TotalPayment + // Formula: Total AR = Initial Balance - Total Sales + Total Payment + // - Initial balance: positive (customer deposit) + // - Sales: reduces balance (customer debt) + // - Payment: increases balance (customer pays) + summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment return summary } +func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { + currentSales := transactions[currentIndex] + + // Status Logic: + // 1. LUNAS: previousBalance >= salesAmount (paid from deposit) + // 2. LUNAS: future payments make AR >= 0 (eventually paid) + // 3. DIBAYAR SEBAGIAN: has payment but not enough + // 4. BELUM LUNAS: no payment at all + + if previousBalance >= currentSales.TotalPrice { + return "LUNAS", nil + } + + hasPartialPaymentFromBalance := previousBalance > 0 && previousBalance < currentSales.TotalPrice + + futureBalance := currentBalance + hasPayment := false + var paymentDateThatMadeItLunas *time.Time + + for i := currentIndex + 1; i < len(transactions); i++ { + if transactions[i].TransactionType == "PAYMENT" { + futureBalance += transactions[i].PaymentAmount + hasPayment = true + + if futureBalance >= 0 { + paymentDateThatMadeItLunas = &transactions[i].TransDate + return "LUNAS", paymentDateThatMadeItLunas + } + } else if transactions[i].TransactionType == "SALES" { + futureBalance -= transactions[i].TotalPrice + } + } + + if hasPayment || hasPartialPaymentFromBalance { + return "DIBAYAR SEBAGIAN", nil + } + + return "BELUM LUNAS", nil +} + func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionResultDTO { result := dto.ProductionResultDTO{ CreatedAt: record.CreatedAt, diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index c79dd90d..68bfee90 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -73,9 +73,9 @@ type ProductionResultQuery struct { } type CustomerPaymentQuery struct { - Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` - CustomerID *uint `query:"customer_id" validate:"omitempty,gt=0"` - StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` - EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"` + StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` + EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` } From dbf72c72484fff68acca67d0c476641182ed7534 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 07:06:37 +0000 Subject: [PATCH 013/117] Delete .air.toml --- .air.toml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .air.toml diff --git a/.air.toml b/.air.toml deleted file mode 100644 index c463b5b2..00000000 --- a/.air.toml +++ /dev/null @@ -1,13 +0,0 @@ -# .air.toml -root = "." -tmp_dir = "tmp" - -[build] -cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" -bin = "tmp/main" -full_bin = "APP_ENV=dev ./tmp/main" -include_ext = ["go", "tpl", "tmpl", "html"] -exclude_dir = ["vendor", "tmp"] - -[log] -time = true From 804ff45dbd1bc6e2ccf4c324853834efc702c31c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 15:15:29 +0700 Subject: [PATCH 014/117] feat[BE]: enhance customer payment report with vehicle numbers and pickup info, add date filtering --- .../master/customers/dto/customer.dto.go | 1 + .../dto/repportCustomerPayment.dto.go | 110 ++++++++++++++---- .../customer_payment.repository.go | 6 +- .../repports/services/repport.service.go | 102 ++++++++-------- 4 files changed, 141 insertions(+), 78 deletions(-) diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index 444c6768..592f14cd 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -52,6 +52,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO { Name: e.Name, Type: e.Type, AccountNumber: e.AccountNumber, + Balance: e.Balance, Pic: pic, } } diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 439eed42..3f6b7a2d 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -1,32 +1,35 @@ package dto import ( + "strings" "time" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" ) type CustomerPaymentReportRow struct { - TransactionType string `json:"transaction_type"` - TransactionID int64 `json:"transaction_id"` - TransDate time.Time `json:"trans_date"` - DeliveryDate *time.Time `json:"delivery_date"` - Reference string `json:"reference"` - VehicleNumbers string `json:"vehicle_numbers"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AverageWeight float64 `json:"average_weight"` - Price float64 `json:"price"` - CreditNote float64 `json:"credit_note"` - FinalPrice float64 `json:"final_price"` - PPN float64 `json:"ppn"` - TotalPrice float64 `json:"total_price"` - PaymentAmount float64 `json:"payment_amount"` - AccountsReceivable float64 `json:"accounts_receivable"` - AgingDay *int `json:"aging_day"` - Status string `json:"status"` - PickupInfo string `json:"pickup_info"` - SalesPerson string `json:"sales_person"` + TransactionType string `json:"transaction_type"` + TransactionID int64 `json:"transaction_id"` + TransDate time.Time `json:"trans_date"` + DeliveryDate *time.Time `json:"delivery_date"` + Reference string `json:"reference"` + + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AverageWeight float64 `json:"average_weight"` + Price float64 `json:"price"` + CreditNote float64 `json:"credit_note"` + FinalPrice float64 `json:"final_price"` + PPN float64 `json:"ppn"` + TotalPrice float64 `json:"total_price"` + PaymentAmount float64 `json:"payment_amount"` + AccountsReceivable float64 `json:"accounts_receivable"` + AgingDay *int `json:"aging_day"` + Status string `json:"status"` + VehicleNumbers []string `json:"vehicle_numbers"` + PickupInfo []string `json:"pickup_info"` + SalesPerson string `json:"sales_person"` } type CustomerPaymentReportSummary struct { @@ -51,3 +54,70 @@ type CustomerPaymentReportItem struct { type CustomerPaymentReportResponse struct { Data []CustomerPaymentReportItem `json:"data"` } + +func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) CustomerPaymentReportRow { + return CustomerPaymentReportRow{ + TransactionType: tx.TransactionType, + TransactionID: tx.TransactionID, + TransDate: tx.TransDate, + DeliveryDate: tx.DeliveryDate, + Reference: tx.Reference, + Qty: tx.Qty, + Weight: tx.Weight, + AverageWeight: tx.AverageWeight, + Price: tx.Price, + CreditNote: tx.CreditNote, + FinalPrice: tx.FinalPrice, + PPN: tx.PPN, + TotalPrice: tx.TotalPrice, + PaymentAmount: tx.PaymentAmount, + VehicleNumbers: parseStringSlice(tx.VehicleNumbers), + PickupInfo: parseStringSlice(tx.PickupInfo), + SalesPerson: tx.SalesPerson, + } +} + +func parseStringSlice(str string) []string { + str = strings.TrimSpace(str) + if str == "" || str == "-" { + return []string{} + } + + parts := strings.Split(str, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + + return result +} + +func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { + summary := CustomerPaymentReportSummary{} + + for _, row := range rows { + summary.TotalQty += row.Qty + summary.TotalWeight += row.Weight + summary.TotalCreditNote += row.CreditNote + summary.TotalPPN += row.PPN + + if row.TransactionType == "SALES" { + summary.TotalInitialAmount += row.TotalPrice + summary.TotalFinalAmount += row.FinalPrice + summary.TotalGrandAmount += row.TotalPrice + } else if row.TransactionType == "PAYMENT" { + summary.TotalPayment += row.PaymentAmount + } + } + + // Formula: Total AR = Initial Balance - Total Sales + Total Payment + // - Initial balance: positive (customer deposit) + // - Sales: reduces balance (customer debt) + // - Payment: increases balance (customer pays) + summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment + + return summary +} diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 49e9424c..5a39b127 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -54,7 +54,8 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte m.so_date::DATE AS trans_date, MAX(mdp.delivery_date)::DATE AS delivery_date, m.so_number AS reference, - COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL AND mdp.vehicle_number != ''), '') AS vehicle_numbers, + COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL), '') AS vehicle_numbers, + COALESCE(SUM(COALESCE(mp.qty, 0)), 0)::NUMERIC(15,3) AS qty, COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0)::NUMERIC(15,3) AS weight, CASE WHEN COALESCE(SUM(COALESCE(mp.qty, 0)), 0) > 0 @@ -77,7 +78,8 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("LEFT JOIN users u ON u.id = m.sales_person_id"). Where("m.deleted_at IS NULL"). - Where("c.deleted_at IS NULL") + Where("c.deleted_at IS NULL"). + Where("mdp.delivery_date IS NOT NULL") if customerID != nil { salesQuery = salesQuery.Where("c.id = ?", *customerID) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c6f18002..1dba2114 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -359,7 +359,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C // Process each customer var result []dto.CustomerPaymentReportItem for _, customerID := range customerIDs { - item, err := s.processCustomerPayment(ctx.Context(), customerID) + item, err := s.processCustomerPayment(ctx.Context(), customerID, params) if err != nil { return nil, 0, err } @@ -369,7 +369,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return result, totalCustomers, nil } -func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint) (dto.CustomerPaymentReportItem, error) { +func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { customer := entity.Customer{} if err := s.DB.WithContext(ctx). Where("id = ?", customerID). @@ -392,28 +392,11 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID runningBalance := initialBalance for i, tx := range transactions { - row := dto.CustomerPaymentReportRow{ - TransactionType: tx.TransactionType, - TransactionID: tx.TransactionID, - TransDate: tx.TransDate, - DeliveryDate: tx.DeliveryDate, - Reference: tx.Reference, - VehicleNumbers: tx.VehicleNumbers, - Qty: tx.Qty, - Weight: tx.Weight, - AverageWeight: tx.AverageWeight, - Price: tx.Price, - CreditNote: tx.CreditNote, - FinalPrice: tx.FinalPrice, - PPN: tx.PPN, - TotalPrice: tx.TotalPrice, - PaymentAmount: tx.PaymentAmount, - PickupInfo: tx.PickupInfo, - SalesPerson: tx.SalesPerson, - } previousBalance := runningBalance + row := dto.ToCustomerPaymentReportRow(tx) + if tx.TransactionType == "SALES" { runningBalance -= tx.TotalPrice status, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, runningBalance) @@ -452,51 +435,58 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID rows = append(rows, row) } - summary := s.calculateSummary(rows, initialBalance) + if params.StartDate != "" || params.EndDate != "" { + filteredRows := make([]dto.CustomerPaymentReportRow, 0, len(rows)) + location, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } - customerDTO := customerDTO.CustomerRelationDTO{ - Id: customer.Id, - Name: customer.Name, - Type: customer.Type, - AccountNumber: customer.AccountNumber, - Balance: customer.Balance, + var startDate, endDate *time.Time + if params.StartDate != "" { + parsed, err := time.ParseInLocation("2006-01-02", params.StartDate, location) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + startDate = &parsed + } + if params.EndDate != "" { + parsed, err := time.ParseInLocation("2006-01-02", params.EndDate, location) + if err != nil { + return dto.CustomerPaymentReportItem{}, err + } + // End date should be inclusive, so set to end of day + endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 999999999, location) + endDate = &endOfDay + } + + for _, row := range rows { + transDate := row.TransDate.In(location) + + // Check if transaction date is within range + if startDate != nil && transDate.Before(*startDate) { + continue + } + if endDate != nil && transDate.After(*endDate) { + continue + } + + filteredRows = append(filteredRows, row) + } + + rows = filteredRows } + summary := dto.CalculateCustomerPaymentSummary(rows, initialBalance) + return dto.CustomerPaymentReportItem{ - Customer: customerDTO, + Customer: customerDTO.ToCustomerRelationDTO(customer), InitialBalance: initialBalance, Rows: rows, Summary: summary, }, nil } -func (s *repportService) calculateSummary(rows []dto.CustomerPaymentReportRow, initialBalance float64) dto.CustomerPaymentReportSummary { - summary := dto.CustomerPaymentReportSummary{} - - for _, row := range rows { - summary.TotalQty += row.Qty - summary.TotalWeight += row.Weight - summary.TotalCreditNote += row.CreditNote - summary.TotalPPN += row.PPN - - if row.TransactionType == "SALES" { - summary.TotalInitialAmount += row.TotalPrice - summary.TotalFinalAmount += row.FinalPrice - summary.TotalGrandAmount += row.TotalPrice - } else if row.TransactionType == "PAYMENT" { - summary.TotalPayment += row.PaymentAmount - } - } - - // Formula: Total AR = Initial Balance - Total Sales + Total Payment - // - Initial balance: positive (customer deposit) - // - Sales: reduces balance (customer debt) - // - Payment: increases balance (customer pays) - summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment - - return summary -} - func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { currentSales := transactions[currentIndex] From e0ff6e6d7902b21990d04ef501c337eedd0402a7 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 08:26:07 +0000 Subject: [PATCH 015/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46bb3aa..80e4f608 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From e0043544208300b401b5044916cc4fb6cd6e6f1a Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 14 Jan 2026 16:20:59 +0700 Subject: [PATCH 016/117] adjust api production-result --- internal/modules/repports/module.go | 19 ++- .../production_result.repository.go | 23 ++++ .../repports/services/repport.service.go | 115 +++++++++++++++--- 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 61f37d4d..c6495a14 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -12,6 +12,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + productionStandardRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -34,10 +35,26 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) + standardGrowthDetailRepository := productionStandardRepo.NewStandardGrowthDetailRepository(db) + productionStandardDetailRepository := productionStandardRepo.NewProductionStandardDetailRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository) + repportService := sRepport.NewRepportService( + validate, + expenseRealizationRepository, + marketingDeliveryProductRepository, + purchaseRepository, + chickinRepository, + recordingRepository, + approvalSvc, + purchaseSupplierRepository, + debtSupplierRepository, + hppPerKandangRepository, + productionResultRepository, + standardGrowthDetailRepository, + productionStandardDetailRepository, + ) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go index 19007d0f..a8eccb91 100644 --- a/internal/modules/repports/repositories/production_result.repository.go +++ b/internal/modules/repports/repositories/production_result.repository.go @@ -11,6 +11,7 @@ import ( type ProductionResultRepository interface { GetRecordingsByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint, offset, limit int) ([]entity.Recording, int64, error) + GetProductionStandardIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) } type productionResultRepositoryImpl struct { @@ -76,3 +77,25 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( return recordings, total, nil } + +func (r *productionResultRepositoryImpl) GetProductionStandardIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, nil + } + + var row struct { + ProductionStandardID uint `gorm:"column:production_standard_id"` + } + + err := r.db.WithContext(ctx). + Table("project_flock_kandangs pfk"). + Select("pf.production_standard_id"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where("pfk.id = ?", projectFlockKandangID). + Take(&row).Error + if err != nil { + return 0, err + } + + return row.ProductionStandardID, nil +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..2577787a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "math" "sort" @@ -21,6 +22,7 @@ import ( areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" + productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -55,6 +57,8 @@ type repportService struct { DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository + StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository + ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository } type HppCostAggregate struct { @@ -78,6 +82,8 @@ func NewRepportService( debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, + standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, + productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, ) RepportService { return &repportService{ Log: utils.Log, @@ -92,6 +98,8 @@ func NewRepportService( DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + ProductionStandardDetailRepo: productionStandardDetailRepo, } } @@ -285,6 +293,21 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. weeklyResults := summarizeProductionResults(dailyResults, recordsPerWeek) + var productionStandardID uint + if s.ProductionResultRepo != nil { + standardID, err := s.ProductionResultRepo.GetProductionStandardIDByProjectFlockKandangID(ctx.Context(), params.ProjectFlockKandangID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, err + } + } else { + productionStandardID = standardID + } + } + + standardDetailCache := make(map[int]*entity.ProductionStandardDetail) + growthDetailCache := make(map[int]*entity.StandardGrowthDetail) + var cumulativeButir int64 var cumulativeKg float64 for i := range weeklyResults { @@ -300,6 +323,66 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. cumulativeKg += weeklyResults[i].KgJumlah weeklyResults[i].TotalKg = cumulativeKg + + if productionStandardID == 0 { + continue + } + + week := int(weeklyResults[i].Woa) + if s.ProductionStandardDetailRepo != nil { + detail, ok := standardDetailCache[week] + if !ok { + fetched, fetchErr := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx.Context(), productionStandardID, week) + if fetchErr != nil { + if !errors.Is(fetchErr, gorm.ErrRecordNotFound) { + return nil, 0, fetchErr + } + } else { + detail = fetched + } + standardDetailCache[week] = detail + } + + if detail != nil { + if detail.TargetHenDayProduction != nil { + weeklyResults[i].HdStd = *detail.TargetHenDayProduction + } + if detail.TargetHenHouseProduction != nil { + weeklyResults[i].HhStd = *detail.TargetHenHouseProduction + } + if detail.TargetEggWeight != nil { + weeklyResults[i].EwStd = *detail.TargetEggWeight + } + if detail.TargetEggMass != nil { + weeklyResults[i].EmStd = *detail.TargetEggMass + } + if detail.StandardFCR != nil { + weeklyResults[i].FcrStd = *detail.StandardFCR + } + } + } + + if s.StandardGrowthDetailRepo != nil { + detail, ok := growthDetailCache[week] + if !ok { + fetched, fetchErr := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx.Context(), productionStandardID, week) + if fetchErr != nil { + if !errors.Is(fetchErr, gorm.ErrRecordNotFound) { + return nil, 0, fetchErr + } + } else { + detail = fetched + } + growthDetailCache[week] = detail + } + + if detail != nil && detail.FeedIntake != nil { + weeklyResults[i].FiStd = *detail.FeedIntake + } + if detail != nil && detail.TargetMeanBw != nil { + weeklyResults[i].StdBw = *detail.TargetMeanBw + } + } } totalWeeks := int64(math.Ceil(float64(totalRecordings) / float64(recordsPerWeek))) @@ -314,17 +397,17 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe StdUniformity: "90% up", DepKum: valueOrZero(record.CumDepletionRate), DepStd: valueOrZero(record.TotalDepletionQty), + Hd: valueOrZero(record.HenDay), + Fi: valueOrZero(record.FeedIntake), Fcr: valueOrZero(record.FcrValue), - Hh: valueOrZero(record.TotalChickQty), + Hh: valueOrZero(record.HenHouse), + Em: valueOrZero(record.EggMass), + Ew: valueOrZero(record.EggWeight), } if record.Day != nil { result.Woa = float64(*record.Day) } - if record.CumIntake != nil { - result.Fi = float64(*record.CumIntake) - } - // avgWeight := calculateAverageBodyWeight(record.BodyWeights) avgWeight := 1.0 if avgWeight > 0 { @@ -351,8 +434,6 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe result.PersenPutih = roundFloat((float64(result.ButiranPutih)/total)*100, 2) result.PersenRetak = roundFloat((float64(result.ButiranRetak)/total)*100, 2) result.PersenPecah = roundFloat((float64(result.ButiranPecah)/total)*100, 2) - result.Ew = (eggSummary.TotalKg * 1000) / total - result.Em = eggSummary.TotalKg } return result @@ -464,13 +545,13 @@ func summarizeProductionResults(daily []dto.ProductionResultDTO, groupSize int) if end > len(daily) { end = len(daily) } - result = append(result, aggregateProductionResultGroup(daily[i:end])) + result = append(result, aggregateProductionResultGroup(daily[i:end], groupSize)) } return result } -func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.ProductionResultDTO { +func aggregateProductionResultGroup(group []dto.ProductionResultDTO, groupSize int) dto.ProductionResultDTO { count := len(group) if count == 0 { return dto.ProductionResultDTO{} @@ -542,6 +623,10 @@ func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.Product if divider == 0 { divider = 1 } + weeklyDivider := float64(groupSize) + if weeklyDivider == 0 { + weeklyDivider = divider + } agg.Bw = sumBw / divider agg.StdBw = sumStdBw / divider @@ -570,17 +655,17 @@ func aggregateProductionResultGroup(group []dto.ProductionResultDTO) dto.Product agg.PersenPecah = roundFloat(sumPersenPecah/percentDivider, 2) } - agg.Hd = sumHd / divider + agg.Hd = roundFloat(sumHd/weeklyDivider, 2) agg.HdStd = sumHdStd / divider - agg.Fi = sumFi / divider + agg.Fi = roundFloat(sumFi/weeklyDivider, 2) agg.FiStd = sumFiStd / divider - agg.Em = sumEm / divider + agg.Em = group[count-1].Em agg.EmStd = sumEmStd / divider - agg.Ew = sumEw / divider + agg.Ew = group[count-1].Ew agg.EwStd = sumEwStd / divider - agg.Fcr = sumFcr / divider + agg.Fcr = roundFloat(sumFcr/weeklyDivider, 2) agg.FcrStd = sumFcrStd / divider - agg.Hh = sumHh / divider + agg.Hh = roundFloat(sumHh/weeklyDivider, 2) agg.HhStd = sumHhStd / divider return agg From cad15bcd786d90d0ef637b18c33ca6738ac75f17 Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 09:46:39 +0000 Subject: [PATCH 017/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 80e4f608..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From aeb5433346edd647972287c528e89c015340a3ec Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 20:00:44 +0700 Subject: [PATCH 018/117] feat[BE]: refine customer payment report structure by removing unused fields and enhancing query logic for better performance --- .../dto/repportCustomerPayment.dto.go | 15 +--- internal/modules/repports/module.go | 4 +- .../customer_payment.repository.go | 69 ++++++++----------- .../repports/services/repport.service.go | 20 +++--- 4 files changed, 40 insertions(+), 68 deletions(-) diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 3f6b7a2d..cdac5029 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -19,9 +19,7 @@ type CustomerPaymentReportRow struct { Weight float64 `json:"weight"` AverageWeight float64 `json:"average_weight"` Price float64 `json:"price"` - CreditNote float64 `json:"credit_note"` FinalPrice float64 `json:"final_price"` - PPN float64 `json:"ppn"` TotalPrice float64 `json:"total_price"` PaymentAmount float64 `json:"payment_amount"` AccountsReceivable float64 `json:"accounts_receivable"` @@ -35,10 +33,7 @@ type CustomerPaymentReportRow struct { type CustomerPaymentReportSummary struct { TotalQty float64 `json:"total_qty"` TotalWeight float64 `json:"total_weight"` - TotalInitialAmount float64 `json:"total_initial_amount"` - TotalCreditNote float64 `json:"total_credit_note"` TotalFinalAmount float64 `json:"total_final_amount"` - TotalPPN float64 `json:"total_ppn"` TotalGrandAmount float64 `json:"total_grand_amount"` TotalPayment float64 `json:"total_payment"` TotalAccountsReceivable float64 `json:"total_accounts_receivable"` @@ -66,9 +61,7 @@ func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) Custo Weight: tx.Weight, AverageWeight: tx.AverageWeight, Price: tx.Price, - CreditNote: tx.CreditNote, FinalPrice: tx.FinalPrice, - PPN: tx.PPN, TotalPrice: tx.TotalPrice, PaymentAmount: tx.PaymentAmount, VehicleNumbers: parseStringSlice(tx.VehicleNumbers), @@ -101,11 +94,8 @@ func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBal for _, row := range rows { summary.TotalQty += row.Qty summary.TotalWeight += row.Weight - summary.TotalCreditNote += row.CreditNote - summary.TotalPPN += row.PPN if row.TransactionType == "SALES" { - summary.TotalInitialAmount += row.TotalPrice summary.TotalFinalAmount += row.FinalPrice summary.TotalGrandAmount += row.TotalPrice } else if row.TransactionType == "PAYMENT" { @@ -113,10 +103,7 @@ func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBal } } - // Formula: Total AR = Initial Balance - Total Sales + Total Payment - // - Initial balance: positive (customer deposit) - // - Sales: reduces balance (customer debt) - // - Payment: increases balance (customer pays) + // Total AR = Initial Balance - Total Sales + Total Payment summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment return summary diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index b0432316..d081306c 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -12,6 +12,7 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -35,10 +36,11 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db) customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db) + customerRepository := customerRepo.NewCustomerRepository(db) userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository) + repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/customer_payment.repository.go b/internal/modules/repports/repositories/customer_payment.repository.go index 5a39b127..8a5747aa 100644 --- a/internal/modules/repports/repositories/customer_payment.repository.go +++ b/internal/modules/repports/repositories/customer_payment.repository.go @@ -20,9 +20,7 @@ type CustomerPaymentTransaction struct { Weight float64 `gorm:"column:weight"` AverageWeight float64 `gorm:"column:average_weight"` Price float64 `gorm:"column:price"` - CreditNote float64 `gorm:"column:credit_note"` FinalPrice float64 `gorm:"column:final_price"` - PPN float64 `gorm:"column:ppn"` TotalPrice float64 `gorm:"column:total_price"` PaymentAmount float64 `gorm:"column:payment_amount"` PickupInfo string `gorm:"column:pickup_info"` @@ -44,50 +42,41 @@ func NewCustomerPaymentRepository(db *gorm.DB) CustomerPaymentRepository { } func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) { - // Build SALES subquery salesQuery := r.db.WithContext(ctx). - Table("marketings m"). + Table("marketing_delivery_products mdp"). Select(` 'SALES' AS transaction_type, - m.id::BIGINT AS transaction_id, + mdp.id::BIGINT AS transaction_id, c.id::BIGINT AS customer_id, m.so_date::DATE AS trans_date, - MAX(mdp.delivery_date)::DATE AS delivery_date, - m.so_number AS reference, - COALESCE(STRING_AGG(DISTINCT mdp.vehicle_number, ', ') FILTER (WHERE mdp.vehicle_number IS NOT NULL), '') AS vehicle_numbers, + mdp.delivery_date::DATE AS delivery_date, + m.so_number || '-' || TO_CHAR(mdp.delivery_date, 'YYYYMMDD') || '-' || CAST(pw.warehouse_id AS VARCHAR) AS reference, + COALESCE(mdp.vehicle_number, '') AS vehicle_numbers, - COALESCE(SUM(COALESCE(mp.qty, 0)), 0)::NUMERIC(15,3) AS qty, - COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0)::NUMERIC(15,3) AS weight, - CASE WHEN COALESCE(SUM(COALESCE(mp.qty, 0)), 0) > 0 - THEN (COALESCE(SUM(COALESCE(mdp.total_weight, mp.total_weight, 0)), 0) / COALESCE(SUM(COALESCE(mp.qty, 0)), 0))::NUMERIC(15,3) - ELSE 0::NUMERIC(15,3) - END AS average_weight, - COALESCE(AVG(COALESCE(mdp.unit_price, mp.unit_price, 0)), 0)::NUMERIC(15,3) AS price, - 0::NUMERIC(15,3) AS credit_note, - COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS final_price, - 0::NUMERIC(15,3) AS ppn, - COALESCE(SUM(COALESCE(mdp.total_price, mp.total_price)), 0)::NUMERIC(15,3) AS total_price, + COALESCE(mdp.usage_qty, 0)::NUMERIC(15,3) AS qty, + COALESCE(mdp.total_weight, 0)::NUMERIC(15,3) AS weight, + COALESCE(mdp.avg_weight, 0)::NUMERIC(15,3) AS average_weight, + COALESCE(mdp.unit_price, 0)::NUMERIC(15,3) AS price, + COALESCE(mdp.total_price, 0)::NUMERIC(15,3) AS final_price, + COALESCE(mdp.total_price, 0)::NUMERIC(15,3) AS total_price, 0::NUMERIC(15,3) AS payment_amount, - COALESCE(STRING_AGG(DISTINCT w.name, ', ') FILTER (WHERE w.name IS NOT NULL), '') AS pickup_info, - MAX(u.name) AS sales_person + w.name AS pickup_info, + u.name AS sales_person `). + Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("INNER JOIN marketings m ON m.id = mp.marketing_id"). Joins("INNER JOIN customers c ON c.id = m.customer_id"). - Joins("LEFT JOIN marketing_products mp ON mp.marketing_id = m.id"). - Joins("LEFT JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id"). - Joins("LEFT JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). - Joins("LEFT JOIN warehouses w ON w.id = pw.warehouse_id"). - Joins("LEFT JOIN users u ON u.id = m.sales_person_id"). + Joins("INNER JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id"). + Joins("INNER JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("INNER JOIN users u ON u.id = m.sales_person_id"). + Where("mdp.delivery_date IS NOT NULL"). Where("m.deleted_at IS NULL"). - Where("c.deleted_at IS NULL"). - Where("mdp.delivery_date IS NOT NULL") + Where("c.deleted_at IS NULL") if customerID != nil { salesQuery = salesQuery.Where("c.id = ?", *customerID) } - salesQuery = salesQuery.Group("m.id, c.id, m.so_date, m.so_number") - - // Build PAYMENT subquery paymentQuery := r.db.WithContext(ctx). Table("payments p"). Select(` @@ -102,9 +91,7 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte 0::NUMERIC(15,3) AS weight, 0::NUMERIC(15,3) AS average_weight, 0::NUMERIC(15,3) AS price, - 0::NUMERIC(15,3) AS credit_note, 0::NUMERIC(15,3) AS final_price, - 0::NUMERIC(15,3) AS ppn, 0::NUMERIC(15,3) AS total_price, p.nominal::NUMERIC(15,3) AS payment_amount, '-' AS pickup_info, @@ -121,7 +108,6 @@ func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx conte paymentQuery = paymentQuery.Where("c.id = ?", *customerID) } - // Combine with UNION ALL and execute var results []CustomerPaymentTransaction err := r.db.WithContext(ctx). Raw("? UNION ALL ? ORDER BY customer_id, trans_date, transaction_type DESC, transaction_id", @@ -161,12 +147,13 @@ func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context. } func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) { - // Subquery to get all distinct customer IDs with transactions subQuery := r.db.WithContext(ctx). Table("(" + - "SELECT DISTINCT c.id as customer_id FROM marketings m " + + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " + + "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " + + "INNER JOIN marketings m ON m.id = mp.marketing_id " + "INNER JOIN customers c ON c.id = m.customer_id " + - "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL " + + "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " + "UNION " + "SELECT DISTINCT c.id as customer_id FROM payments p " + "INNER JOIN customers c ON c.id = p.party_id " + @@ -174,19 +161,19 @@ func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx conte "AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" + ") as customer_ids") - // Count total customers var total int64 if err := subQuery.Count(&total).Error; err != nil { return nil, 0, err } - // Get paginated customer IDs var customerIDs []uint err := r.db.WithContext(ctx). Table("("+ - "SELECT DISTINCT c.id as customer_id FROM marketings m "+ + "SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp "+ + "INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id "+ + "INNER JOIN marketings m ON m.id = mp.marketing_id "+ "INNER JOIN customers c ON c.id = m.customer_id "+ - "WHERE m.deleted_at IS NULL AND c.deleted_at IS NULL "+ + "WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL "+ "UNION "+ "SELECT DISTINCT c.id as customer_id FROM payments p "+ "INNER JOIN customers c ON c.id = p.party_id "+ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 1dba2114..2c102418 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -20,6 +20,7 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" @@ -59,6 +60,7 @@ type repportService struct { HppPerKandangRepo repportRepo.HppPerKandangRepository ProductionResultRepo repportRepo.ProductionResultRepository CustomerPaymentRepo repportRepo.CustomerPaymentRepository + CustomerRepo customerRepo.CustomerRepository } type HppCostAggregate struct { @@ -84,6 +86,7 @@ func NewRepportService( hppPerKandangRepo repportRepo.HppPerKandangRepository, productionResultRepo repportRepo.ProductionResultRepository, customerPaymentRepo repportRepo.CustomerPaymentRepository, + customerRepo customerRepo.CustomerRepository, ) RepportService { return &repportService{ Log: utils.Log, @@ -100,6 +103,7 @@ func NewRepportService( HppPerKandangRepo: hppPerKandangRepo, ProductionResultRepo: productionResultRepo, CustomerPaymentRepo: customerPaymentRepo, + CustomerRepo: customerRepo, } } @@ -356,7 +360,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C } } - // Process each customer var result []dto.CustomerPaymentReportItem for _, customerID := range customerIDs { item, err := s.processCustomerPayment(ctx.Context(), customerID, params) @@ -370,10 +373,9 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C } func (s *repportService) processCustomerPayment(ctx context.Context, customerID uint, params *validation.CustomerPaymentQuery) (dto.CustomerPaymentReportItem, error) { - customer := entity.Customer{} - if err := s.DB.WithContext(ctx). - Where("id = ?", customerID). - First(&customer).Error; err != nil { + + customer, err := s.CustomerRepo.GetByID(ctx, customerID, nil) + if err != nil { return dto.CustomerPaymentReportItem{}, err } @@ -407,7 +409,6 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID days := 0 row.AgingDay = &days } else if paymentDate != nil { - // Aging = payment_date - trans_date (SO date) days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) if days < 0 { days = 0 @@ -418,7 +419,6 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID row.AgingDay = &days } } else { - // Aging = current_date - trans_date (SO date) days := int(time.Since(tx.TransDate).Hours() / 24) if days < 0 { days = 0 @@ -455,22 +455,18 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID if err != nil { return dto.CustomerPaymentReportItem{}, err } - // End date should be inclusive, so set to end of day endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 999999999, location) endDate = &endOfDay } for _, row := range rows { transDate := row.TransDate.In(location) - - // Check if transaction date is within range if startDate != nil && transDate.Before(*startDate) { continue } if endDate != nil && transDate.After(*endDate) { continue } - filteredRows = append(filteredRows, row) } @@ -480,7 +476,7 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID summary := dto.CalculateCustomerPaymentSummary(rows, initialBalance) return dto.CustomerPaymentReportItem{ - Customer: customerDTO.ToCustomerRelationDTO(customer), + Customer: customerDTO.ToCustomerRelationDTO(*customer), InitialBalance: initialBalance, Rows: rows, Summary: summary, From c6dc94a4e11c5b94fe0c50344191a82fa78afe5c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 20:06:41 +0700 Subject: [PATCH 019/117] feat[BE]: add permission requirement for customer payment report route --- internal/modules/repports/route.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 3f803677..2f5eceec 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -21,5 +21,5 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier) route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang) route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult) - route.Get("/customer-payment", ctrl.GetCustomerPayment) + route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment) } From c316a6d7a93cd4619bcf0f7ff961fb05a1de5c22 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 15 Jan 2026 10:41:44 +0700 Subject: [PATCH 020/117] feat[BE]: add address field to CustomerRelationDTO and refactor payment report functions for improved clarity and structure --- .../master/customers/dto/customer.dto.go | 2 + .../dto/repportCustomerPayment.dto.go | 42 ++++++++++++------- .../repports/services/repport.service.go | 10 +---- 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index 592f14cd..eceafa39 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -14,6 +14,7 @@ type CustomerRelationDTO struct { Name string `json:"name"` Type string `json:"type"` AccountNumber string `json:"account_number"` + Address string `json:"address,omitempty"` Balance float64 `json:"balance"` Pic *userDTO.UserRelationDTO `json:"pic,omitempty"` } @@ -52,6 +53,7 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO { Name: e.Name, Type: e.Type, AccountNumber: e.AccountNumber, + Address: e.Address, Balance: e.Balance, Pic: pic, } diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index cdac5029..5a8a69ac 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "gitlab.com/mbugroup/lti-api.git/internal/entities" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories" ) @@ -70,25 +71,16 @@ func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) Custo } } -func parseStringSlice(str string) []string { - str = strings.TrimSpace(str) - if str == "" || str == "-" { - return []string{} +func ToCustomerPaymentReportItem(customer entities.Customer, initialBalance float64, rows []CustomerPaymentReportRow, summary CustomerPaymentReportSummary) CustomerPaymentReportItem { + return CustomerPaymentReportItem{ + Customer: customerDTO.ToCustomerRelationDTO(customer), + InitialBalance: initialBalance, + Rows: rows, + Summary: summary, } - - parts := strings.Split(str, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - result = append(result, part) - } - } - - return result } -func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { +func ToCustomerPaymentReportSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary { summary := CustomerPaymentReportSummary{} for _, row := range rows { @@ -108,3 +100,21 @@ func CalculateCustomerPaymentSummary(rows []CustomerPaymentReportRow, initialBal return summary } + +func parseStringSlice(str string) []string { + str = strings.TrimSpace(str) + if str == "" || str == "-" { + return []string{} + } + + parts := strings.Split(str, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + + return result +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 4e2104e8..1b721d64 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -19,7 +19,6 @@ import ( expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" - customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" @@ -473,14 +472,9 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID rows = filteredRows } - summary := dto.CalculateCustomerPaymentSummary(rows, initialBalance) + summary := dto.ToCustomerPaymentReportSummary(rows, initialBalance) - return dto.CustomerPaymentReportItem{ - Customer: customerDTO.ToCustomerRelationDTO(*customer), - InitialBalance: initialBalance, - Rows: rows, - Summary: summary, - }, nil + return dto.ToCustomerPaymentReportItem(*customer, initialBalance, rows, summary), nil } func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { From 37c26d58774409c5feac80f757ba799fe2f86508 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 10:45:13 +0700 Subject: [PATCH 021/117] add daily checklist permission --- internal/middleware/permissions.go | 17 +++++++++-- internal/modules/daily-checklists/route.go | 28 +++++++++---------- .../modules/master/config-checklists/route.go | 10 +++---- internal/modules/master/employees/route.go | 10 +++---- .../modules/master/phase-activities/route.go | 10 +++---- internal/modules/master/phasess/route.go | 10 +++---- 6 files changed, 49 insertions(+), 36 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 5d5290d3..b5d3c727 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,8 +1,9 @@ package middleware -const( +const ( P_DashboardGetAll = "lti.dashboard.list" ) + // project-flock const ( P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" @@ -151,7 +152,7 @@ const ( P_ProductsCreateOne = "lti.master.products.create" P_ProductsUpdateOne = "lti.master.products.update" P_ProductsDeleteOne = "lti.master.products.delete" - + P_SuppliersGetAll = "lti.master.suppliers.list" P_SuppliersGetOne = "lti.master.suppliers.detail" P_SuppliersCreateOne = "lti.master.suppliers.create" @@ -238,3 +239,15 @@ const ( P_UserGetAll = "lti.users.list" P_UserGetOne = "lti.users.detail" ) + +// daily-checklist +const ( + P_DailyChecklistDashboardList = "lti.daily_checklist.dashboard.list" + P_DailyChecklistCreateOne = "lti.daily_checklist.create" + P_DailyChecklistGetAll = "lti.daily_checklist.list" + P_DailyChecklistGetOne = "lti.daily_checklist.detail" + P_DailyChecklistReports = "lti.daily_checklist.reports" + P_DailyChecklistEmployee = "lti.daily_checklist.master_data.employee" + P_DailyChecklistActivity = "lti.daily_checklist.master_data.activity" + P_DailyChecklistActivityConfig = "lti.daily_checklist.master_data.configuration" +) diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index 9e576a05..0927486a 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -15,49 +15,49 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. route := v1.Group("/daily-checklists") route.Use(m.Auth(u)) - route.Get("/", ctrl.GetAll) - route.Get("/report", ctrl.GetReport) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistGetAll), ctrl.GetAll) + route.Get("/report", m.RequirePermissions(m.P_DailyChecklistReports), ctrl.GetReport) - route.Get("/summary", ctrl.GetSummary) + route.Get("/summary", m.RequirePermissions(m.P_DailyChecklistDashboardList), ctrl.GetSummary) - route.Get("/report", ctrl.GetReport) + // route.Get("/report", ctrl.GetReport) // upsert daily checklist - route.Post("/", ctrl.CreateOne) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateOne) // get detail data daily checklist by id - route.Get("/relation/:idDailyChecklist", ctrl.GetOne) + route.Get("/relation/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistGetOne), ctrl.GetOne) // get phases by daily checklist id - route.Get("/phase/:idDailyChecklist", ctrl.GetPhaseByIdChecklist) + route.Get("/phase/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.GetPhaseByIdChecklist) // create task /* ketika add phase */ - route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) + route.Post("/phase/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateDailyChecklistPhase) // create assigment /* ketika add ABK */ - route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) + route.Post("/assignment/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateAssignment) // remove assignment /* ketika remove ABK */ - route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) + route.Delete("/:idDailyChecklist/assignments/:idEmployee", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.RemoveAssignment) //get all tasks - route.Get("/tasks", ctrl.GetAllTasks) + route.Get("/tasks", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.GetAllTasks) // update assignment /* ketika check dan uncheck tugas oleh ABK */ - route.Post("/assignment", ctrl.UpdateAssignment) + route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment) - route.Patch("/:idDailyChecklist", ctrl.UpdateOne) - route.Delete("/:idDailyChecklist", ctrl.DeleteOne) + route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne) + route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne) } diff --git a/internal/modules/master/config-checklists/route.go b/internal/modules/master/config-checklists/route.go index 1b590067..a7e09500 100644 --- a/internal/modules/master/config-checklists/route.go +++ b/internal/modules/master/config-checklists/route.go @@ -15,9 +15,9 @@ func ConfigChecklistRoutes(v1 fiber.Router, u user.UserService, s configChecklis route := v1.Group("/config-checklists") 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) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.DeleteOne) } diff --git a/internal/modules/master/employees/route.go b/internal/modules/master/employees/route.go index 53974814..08fb4870 100644 --- a/internal/modules/master/employees/route.go +++ b/internal/modules/master/employees/route.go @@ -15,9 +15,9 @@ func EmployeesRoutes(v1 fiber.Router, u user.UserService, s employees.EmployeesS route := v1.Group("/employees") 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) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.DeleteOne) } diff --git a/internal/modules/master/phase-activities/route.go b/internal/modules/master/phase-activities/route.go index 6fcef558..723fd7bd 100644 --- a/internal/modules/master/phase-activities/route.go +++ b/internal/modules/master/phase-activities/route.go @@ -15,9 +15,9 @@ func PhaseActivityRoutes(v1 fiber.Router, u user.UserService, s phaseActivity.Ph route := v1.Group("/phase-activities") 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) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.DeleteOne) } diff --git a/internal/modules/master/phasess/route.go b/internal/modules/master/phasess/route.go index b4ca202d..1da6aeeb 100644 --- a/internal/modules/master/phasess/route.go +++ b/internal/modules/master/phasess/route.go @@ -15,9 +15,9 @@ func PhasesRoutes(v1 fiber.Router, u user.UserService, s phases.PhasesService) { route := v1.Group("/phases") 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) + route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetOne) + route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.UpdateOne) + route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.DeleteOne) } From 8792161c02c437e32a43f28d83b961c224172408 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 15 Jan 2026 10:58:00 +0700 Subject: [PATCH 022/117] feat[BE]: rename Price field to UnitPrice in CustomerPaymentReportRow for clarity --- internal/modules/repports/dto/repportCustomerPayment.dto.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/dto/repportCustomerPayment.dto.go b/internal/modules/repports/dto/repportCustomerPayment.dto.go index 5a8a69ac..99862349 100644 --- a/internal/modules/repports/dto/repportCustomerPayment.dto.go +++ b/internal/modules/repports/dto/repportCustomerPayment.dto.go @@ -19,7 +19,7 @@ type CustomerPaymentReportRow struct { Qty float64 `json:"qty"` Weight float64 `json:"weight"` AverageWeight float64 `json:"average_weight"` - Price float64 `json:"price"` + UnitPrice float64 `json:"unit_price"` FinalPrice float64 `json:"final_price"` TotalPrice float64 `json:"total_price"` PaymentAmount float64 `json:"payment_amount"` @@ -61,7 +61,7 @@ func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) Custo Qty: tx.Qty, Weight: tx.Weight, AverageWeight: tx.AverageWeight, - Price: tx.Price, + UnitPrice: tx.Price, FinalPrice: tx.FinalPrice, TotalPrice: tx.TotalPrice, PaymentAmount: tx.PaymentAmount, From 3f4d6c630a34a9041ecb7271bd68bcc3e13ddec0 Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 15 Jan 2026 06:46:07 +0000 Subject: [PATCH 023/117] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a46bb3aa..41aa41be 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,7 +25,7 @@ variables: COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= build_staging: stage: build From 7f2401311b3cc458e8b72aaad513b248a337197a Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 13:48:00 +0700 Subject: [PATCH 024/117] [FIX/BE-US] add response warehouse and project flock kandang --- ...115062032_add_fifo_recording_eggs.down.sql | 3 + ...60115062032_add_fifo_recording_eggs.up.sql | 7 ++ internal/entities/recording_egg.go | 2 + .../controllers/projectflock.controller.go | 7 ++ .../dto/projectflock_kandang.dto.go | 30 ++++---- .../services/projectflock.service.go | 26 +++++++ .../recordings/dto/recording.dto.go | 4 +- .../modules/production/recordings/module.go | 16 ++++ .../recordings/services/recording.service.go | 74 ++++++++++++++++++- internal/utils/fifo/constants.go | 1 + 10 files changed, 150 insertions(+), 20 deletions(-) create mode 100644 internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql create mode 100644 internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql diff --git a/internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql new file mode 100644 index 00000000..8fb42a96 --- /dev/null +++ b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS total_used, + DROP COLUMN IF EXISTS total_qty; diff --git a/internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql new file mode 100644 index 00000000..dbadd9e3 --- /dev/null +++ b/internal/database/migrations/20260115062032_add_fifo_recording_eggs.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE recording_eggs + ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL, + ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL; + +UPDATE recording_eggs +SET total_qty = qty +WHERE total_qty = 0; diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 68269728..b48c49ca 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -7,6 +7,8 @@ type RecordingEgg struct { RecordingId uint `gorm:"column:recording_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` Qty int `gorm:"column:qty;not null"` + TotalQty float64 `gorm:"column:total_qty"` + TotalUsed float64 `gorm:"column:total_used"` Weight *float64 `gorm:"column:weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 4315b948..c13baab6 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" @@ -281,6 +282,12 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult := dto.ToProjectFlockKandangDTO(*result) dtoResult.AvailableQuantity = float64(availableStock) + if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { + return werr + } else if warehouse != nil { + mapped := warehouseDTO.ToWarehouseRelationDTO(*warehouse) + dtoResult.Warehouse = &mapped + } if withPopulation { population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) if err != nil { diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 8dedaf15..b6bbf852 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -7,6 +7,7 @@ import ( kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" productionStandardDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -17,24 +18,25 @@ type KandangWithPivotDTO struct { type ProjectFlockWithPivotDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` - AvailableQuantity float64 `json:"available_quantity"` - Population *float64 `json:"population,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` + Population *float64 `json:"population,omitempty"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 3dbe3f4b..05e21894 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -38,6 +38,7 @@ type ProjectflockService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) + GetWarehouseByKandangID(ctx *fiber.Ctx, kandangID uint) (*entity.Warehouse, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) @@ -518,6 +519,31 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } +func (s projectflockService) GetWarehouseByKandangID(ctx *fiber.Ctx, kandangID uint) (*entity.Warehouse, error) { + if kandangID == 0 || s.WarehouseRepo == nil { + return nil, nil + } + + var warehouse entity.Warehouse + err := s.WarehouseRepo.DB().WithContext(ctx.Context()). + Preload("Area"). + Preload("Location"). + Preload("Kandang"). + Where("kandang_id = ?", kandangID). + Where("deleted_at IS NULL"). + Order("id DESC"). + First(&warehouse).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + s.Log.Errorf("Failed to fetch warehouse for kandang %d: %+v", kandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouse") + } + + return &warehouse, nil +} + func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 4deb9f7e..ebb093ba 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -82,11 +82,11 @@ type RecordingListDTO struct { CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` } type RecordingDetailDTO struct { RecordingListDTO - Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` ProductCategory string `json:"product_category"` Depletions []RecordingDepletionDTO `json:"depletions"` Stocks []RecordingStockDTO `json:"stocks"` @@ -133,7 +133,6 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: listDTO, - Warehouse: recordingWarehouseDTO(e), ProductCategory: recordingProductCategory(e), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), @@ -203,6 +202,7 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, + Warehouse: recordingWarehouseDTO(e), } } diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 91b024ac..11a1e152 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -43,6 +43,22 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + if err := fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyRecordingEgg, + Table: "recording_eggs", + 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"}, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) + } + } if err := fifoService.RegisterUsable(fifo.UsableConfig{ Key: fifo.UsableKeyRecordingStock, Table: "recording_stocks", diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 88ed4cf7..5dabad9f 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -290,8 +290,19 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to persist eggs: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + return err + } + } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { + var warehouseDeltas map[uint]float64 + if s.FifoSvc != nil { + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) + } else { + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err } @@ -438,6 +449,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasEggChanges { + if s.FifoSvc != nil { + if err := ensureRecordingEggsUnused(existingEggs); err != nil { + return err + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return err + } + } + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear eggs: %+v", err) return err @@ -449,9 +470,15 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { - s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) - return err + if s.FifoSvc != nil { + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + return err + } + } else { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return err + } } } @@ -626,6 +653,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to list eggs before delete: %+v", err) return err } + if s.FifoSvc != nil { + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + } oldStocks, err := s.Repository.ListStocks(tx, id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -802,6 +834,32 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } +func (s *recordingService) replenishRecordingEggs(ctx context.Context, tx *gorm.DB, eggs []entity.RecordingEgg) error { + if len(eggs) == 0 || s.FifoSvc == nil { + return nil + } + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + note := fmt.Sprintf("Recording egg #%d", egg.Id) + if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyRecordingEgg, + StockableID: egg.Id, + ProductWarehouseID: egg.ProductWarehouseId, + Quantity: float64(egg.Qty), + Note: ¬e, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) + return err + } + } + + return nil +} + type desiredStock struct { Usage float64 Pending float64 @@ -922,6 +980,14 @@ type eggTotals struct { Weight float64 } +func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { + for _, egg := range eggs { + if egg.TotalUsed > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") + } + } + return nil +} func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { hasPending := false diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 03f61f82..076d960d 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -15,4 +15,5 @@ const ( StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" + StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" ) From f1032b44d17a7dc96487f35a7f0f0607b463feb1 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 15:42:57 +0700 Subject: [PATCH 025/117] [FIX/BE-US] adjustment recording --- .../controllers/projectflock.controller.go | 12 +++++++----- .../dto/projectflock_kandang.dto.go | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index c13baab6..e82d3af5 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -279,9 +279,14 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { if err != nil { return err } + _ = availableStock dtoResult := dto.ToProjectFlockKandangDTO(*result) - dtoResult.AvailableQuantity = float64(availableStock) + if population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id); err != nil { + return err + } else { + dtoResult.AvailableQuantity = population + } if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { return werr } else if warehouse != nil { @@ -289,10 +294,7 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult.Warehouse = &mapped } if withPopulation { - population, err := u.ProjectflockService.GetProjectFlockKandangPopulation(c, result.Id) - if err != nil { - return err - } + population := dtoResult.AvailableQuantity dtoResult.Population = &population } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index b6bbf852..c18f3f65 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -18,13 +18,14 @@ type KandangWithPivotDTO struct { type ProjectFlockWithPivotDTO struct { ProjectFlockRelationDTO - Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` - Category string `json:"category"` - Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` - ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` - Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` - Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + Area *areaDTO.AreaRelationDTO `json:"area,omitempty"` + Category string `json:"category"` + Fcr *fcrDTO.FcrRelationDTO `json:"fcr,omitempty"` + ProductionStandard *productionStandardDTO.ProductionStandardRelationDTO `json:"production_standard,omitempty"` + ProductionStandardId uint `json:"production_standard_id"` + Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + Kandangs []KandangWithPivotDTO `json:"kandangs,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` } type ProjectFlockKandangDTO struct { @@ -55,7 +56,8 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD Period: e.Period, FlockName: e.ProjectFlock.FlockName, }, - Category: e.ProjectFlock.Category, + Category: e.ProjectFlock.Category, + ProductionStandardId: e.ProjectFlock.ProductionStandardId, } if e.ProjectFlock.Area.Id != 0 { From fe002c9602792ef43a8cc81dd65ad7fd1fa30d52 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 15 Jan 2026 15:49:15 +0700 Subject: [PATCH 026/117] fix(BE): remove supplier price in master nonstock --- ...ove_price_from_nonstock_suppliers.down.sql | 3 ++ ...emove_price_from_nonstock_suppliers.up.sql | 3 ++ internal/entities/nonstock_supplier.go | 1 - .../master/nonstocks/dto/nonstock.dto.go | 2 - .../repositories/nonstock.repository.go | 16 ++----- .../nonstocks/services/nonstock.service.go | 44 +++++++++---------- .../validations/nonstock.validation.go | 21 ++++----- .../suppliers/dto/supplier_nonstock.dto.go | 2 - 8 files changed, 38 insertions(+), 54 deletions(-) create mode 100644 internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql create mode 100644 internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql diff --git a/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql new file mode 100644 index 00000000..503f592d --- /dev/null +++ b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.down.sql @@ -0,0 +1,3 @@ +-- Rollback: add price back to nonstock_suppliers +ALTER TABLE nonstock_suppliers + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0; diff --git a/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql new file mode 100644 index 00000000..07fdd009 --- /dev/null +++ b/internal/database/migrations/20260115082849_remove_price_from_nonstock_suppliers.up.sql @@ -0,0 +1,3 @@ +-- Migration: remove price from nonstock_suppliers +ALTER TABLE nonstock_suppliers + DROP COLUMN IF EXISTS price; diff --git a/internal/entities/nonstock_supplier.go b/internal/entities/nonstock_supplier.go index d666e3c8..2206390c 100644 --- a/internal/entities/nonstock_supplier.go +++ b/internal/entities/nonstock_supplier.go @@ -5,7 +5,6 @@ import "time" type NonstockSupplier struct { NonstockId uint `gorm:"not null"` SupplierId uint `gorm:"not null"` - Price float64 `gorm:"type:numeric(15,3);not null;default:0"` CreatedAt time.Time `gorm:"autoCreateTime"` Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"` diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index fa102b9c..8182da21 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -37,7 +37,6 @@ type NonstockSupplierDTO struct { Name string `json:"name"` Alias string `json:"alias"` Category string `json:"category"` - Price float64 `json:"price"` } // === Mapper Functions === @@ -121,7 +120,6 @@ func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []NonstockSuppl Name: relation.Supplier.Name, Alias: relation.Supplier.Alias, Category: relation.Supplier.Category, - Price: relation.Price, }) } diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go index 16260272..56ef39b8 100644 --- a/internal/modules/master/nonstocks/repositories/nonstock.repository.go +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -61,30 +61,20 @@ func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm return err } - existingMap := make(map[uint]entity.NonstockSupplier, len(existing)) + existingMap := make(map[uint]struct{}, len(existing)) for _, rel := range existing { - existingMap[rel.SupplierId] = rel + existingMap[rel.SupplierId] = struct{}{} } incomingMap := make(map[uint]struct{}, len(suppliers)) for _, rel := range suppliers { incomingMap[rel.SupplierId] = struct{}{} - if existingRel, exists := existingMap[rel.SupplierId]; exists { - if existingRel.Price != rel.Price { - if err := db.WithContext(ctx). - Model(&entity.NonstockSupplier{}). - Where("nonstock_id = ? AND supplier_id = ?", nonstockID, rel.SupplierId). - Update("price", rel.Price). - Error; err != nil { - return err - } - } + if _, exists := existingMap[rel.SupplierId]; exists { continue } record := entity.NonstockSupplier{ NonstockId: nonstockID, SupplierId: rel.SupplierId, - Price: rel.Price, } if err := db.WithContext(ctx).Create(&record).Error; err != nil { return err diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index e1cc5495..1b6a409c 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -115,19 +115,18 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti supplierLinks []entity.NonstockSupplier supplierIDs []uint ) - if len(req.Suppliers) > 0 { - seen := make(map[uint]struct{}, len(req.Suppliers)) - supplierLinks = make([]entity.NonstockSupplier, 0, len(req.Suppliers)) - supplierIDs = make([]uint, 0, len(req.Suppliers)) - for _, supplier := range req.Suppliers { - if _, exists := seen[supplier.SupplierID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + if len(req.SupplierIDs) > 0 { + seen := make(map[uint]struct{}, len(req.SupplierIDs)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(req.SupplierIDs)) + supplierIDs = make([]uint, 0, len(req.SupplierIDs)) + for _, supplierID := range req.SupplierIDs { + if _, exists := seen[supplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplierID)) } - seen[supplier.SupplierID] = struct{}{} - supplierIDs = append(supplierIDs, supplier.SupplierID) + seen[supplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplierID) supplierLinks = append(supplierLinks, entity.NonstockSupplier{ - SupplierId: supplier.SupplierID, - Price: supplier.Price, + SupplierId: supplierID, }) } supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) @@ -212,21 +211,20 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint var supplierLinks []entity.NonstockSupplier var supplierUpdate bool - if req.Suppliers != nil { + if req.SupplierIDs != nil { supplierUpdate = true - if len(*req.Suppliers) > 0 { - seen := make(map[uint]struct{}, len(*req.Suppliers)) - supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.Suppliers)) - supplierIDs := make([]uint, 0, len(*req.Suppliers)) - for _, supplier := range *req.Suppliers { - if _, exists := seen[supplier.SupplierID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + if len(*req.SupplierIDs) > 0 { + seen := make(map[uint]struct{}, len(*req.SupplierIDs)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.SupplierIDs)) + supplierIDs := make([]uint, 0, len(*req.SupplierIDs)) + for _, supplierID := range *req.SupplierIDs { + if _, exists := seen[supplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplierID)) } - seen[supplier.SupplierID] = struct{}{} - supplierIDs = append(supplierIDs, supplier.SupplierID) + seen[supplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplierID) supplierLinks = append(supplierLinks, entity.NonstockSupplier{ - SupplierId: supplier.SupplierID, - Price: supplier.Price, + SupplierId: supplierID, }) } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index c5491991..6378ac18 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,22 +1,17 @@ package validation -type SupplierPrice struct { - SupplierID uint `json:"supplier_id" validate:"required,gt=0"` - Price float64 `json:"price" validate:"required,gte=0"` -} - type Create struct { - Name string `json:"name" validate:"required_strict,min=3,max=50"` - UomID uint `json:"uom_id" validate:"required,gt=0"` - Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags []string `json:"flags" validate:"dive,max=50"` + 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" validate:"dive,max=50"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` - UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` - Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` + UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` + SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` } type Query struct { diff --git a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go index 8c5e0082..828063eb 100644 --- a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go @@ -10,7 +10,6 @@ import ( type SupplierNonstockDTO struct { Id uint `json:"id"` Name string `json:"name"` - Price float64 `json:"price"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags []string `json:"flags"` } @@ -43,7 +42,6 @@ func toSupplierNonstockDTOs(relations []entity.NonstockSupplier) []SupplierNonst result = append(result, SupplierNonstockDTO{ Id: Nonstock.Id, Name: Nonstock.Name, - Price: relation.Price, Uom: uomRef, Flags: flags, }) From 89293a843e566d852938716790de14dd18994a27 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 16:07:45 +0700 Subject: [PATCH 027/117] adjust api hpp kandang --- .../modules/repports/dto/repportHpp.dto.go | 58 +++---- .../hpp_per_kandang.repository.go | 160 +++++++++++++----- .../repports/services/repport.service.go | 108 +++++++++--- 3 files changed, 227 insertions(+), 99 deletions(-) diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index 63c5dce9..f790244c 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -25,14 +25,12 @@ type HppPerKandangResponseData struct { } type HppPerKandangRowDTO struct { - ID int `json:"id"` - Kandang HppPerKandangRowKandangDTO `json:"kandang"` - WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` - RemainingChickenBirds int64 `json:"remaining_chicken_birds"` - RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` - AvgWeightKg float64 `json:"avg_weight_kg"` - EggProductionPieces int64 `json:"egg_production_pieces"` - EggProductionKg float64 `json:"egg_production_kg"` + ID int `json:"id"` + Kandang HppPerKandangRowKandangDTO `json:"kandang"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` // FeedCostRp float64 `json:"feed_cost_rp"` // OvkCostRp float64 `json:"ovk_cost_rp"` EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` @@ -80,34 +78,28 @@ type HppPerKandangSummaryDTO struct { } type HppPerKandangSummaryWeightRangeDTO struct { - ID int `json:"id"` - WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` - Label string `json:"label"` - RemainingChickenBirds int64 `json:"remaining_chicken_birds"` - RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` - AvgWeightKg float64 `json:"avg_weight_kg"` - EggProductionPieces int64 `json:"egg_production_pieces"` - EggProductionKg float64 `json:"egg_production_kg"` - EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` - EggValueRp int64 `json:"egg_value_rp"` - FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` - DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` - AverageDocPriceRp float64 `json:"average_doc_price_rp"` - HppRp float64 `json:"hpp_rp"` - RemainingValueRp int64 `json:"remaining_value_rp"` + ID int `json:"id"` + WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` + Label string `json:"label"` + AvgWeightKg float64 `json:"avg_weight_kg"` + EggProductionPieces int64 `json:"egg_production_pieces"` + EggProductionKg float64 `json:"egg_production_kg"` + EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` + EggValueRp int64 `json:"egg_value_rp"` + FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` + DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` + AverageDocPriceRp float64 `json:"average_doc_price_rp"` + HppRp float64 `json:"hpp_rp"` + RemainingValueRp int64 `json:"remaining_value_rp"` } type HppPerKandangSummaryTotalDTO struct { - TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"` - TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"` - AverageWeightKg float64 `json:"average_weight_kg"` - TotalRemainingValueRp int64 `json:"total_remaining_value_rp"` - TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` - TotalEggProductionKg float64 `json:"total_egg_production_kg"` - AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` - TotalEggValueRp int64 `json:"total_egg_value_rp"` - TotalHppRp float64 `json:"total_hpp_rp"` - TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` + AverageWeightKg float64 `json:"average_weight_kg"` + TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` + TotalEggProductionKg float64 `json:"total_egg_production_kg"` + AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"` + TotalEggValueRp int64 `json:"total_egg_value_rp"` + TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"` } func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO { diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 4bd9aab4..64676ca8 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -11,6 +11,7 @@ import ( ) type HppPerKandangRow struct { + ProjectFlockKandangID uint KandangID uint KandangName string KandangStatus string @@ -18,6 +19,7 @@ type HppPerKandangRow struct { LocationName string PicID uint PicName string + RecordingCount int64 RemainingChickenBirds float64 RemainingChickenWeight float64 EggProductionWeightKg float64 @@ -44,7 +46,8 @@ type HppPerKandangSupplierRow struct { type HppPerKandangRepository interface { GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) - GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) + GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) + GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) } type hppPerKandangRepository struct { @@ -58,9 +61,31 @@ func NewHppPerKandangRepository(db *gorm.DB) HppPerKandangRepository { func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) { var rows []HppPerKandangRow - query := r.db.WithContext(ctx). + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + + validRecordings := r.db.WithContext(ctx). Table("recordings AS r"). + Select("r.id, r.project_flock_kandangs_id, r.total_chick_qty"). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). + Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) + + query := r.db.WithContext(ctx). + Table("project_flocks AS pf"). Select(` + pfk.id AS project_flock_kandang_id, k.id AS kandang_id, k.name AS kandang_name, k.status AS kandang_status, @@ -68,22 +93,21 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en loc.name AS location_name, pic.id AS pic_id, pic.name AS pic_name, - COALESCE(MAX(r.total_chick_qty), 0) AS remaining_chicken_birds, - COALESCE(SUM(rbw.total_weight), 0) AS remaining_chicken_weight, - COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, - COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + COALESCE(COUNT(vr.id), 0) AS recording_count, + COALESCE(MAX(vr.total_chick_qty), 0) AS remaining_chicken_birds, + 0 AS remaining_chicken_weight, + 0 AS egg_production_weight_kg, + 0 AS egg_production_pieces`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN users AS pic ON pic.id = k.pic_id"). - Joins("LEFT JOIN recording_bws AS rbw ON rbw.recording_id = r.id"). - Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL") + Joins("LEFT JOIN (?) AS vr ON vr.project_flock_kandangs_id = pfk.id", validRecordings). + Where("pfk.closed_at IS NULL") query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) - query = query.Group("k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + query = query.Group("pfk.id, k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). Order("k.id ASC") if err := query.Scan(&rows).Error; err != nil { @@ -93,41 +117,44 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en return rows, nil } -func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { +func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { var rows []HppPerKandangCostRow - recordingPfk := r.db.WithContext(ctx). - Table("recordings AS r"). - Select("DISTINCT pfk.id"). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL") - recordingPfk = applyLocationFilters(recordingPfk, areaIDs, locationIDs, kandangIDs) - purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() transferStockableKey := fifo.StockableKeyStockTransferIn.String() + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + query := r.db.WithContext(ctx). Table("recordings AS r"). Select(` k.id AS kandang_id, COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) ELSE 0 END), 0) AS feed_cost, COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0) + WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) ELSE 0 END), 0) AS ovk_cost`, utils.FlagPakan, transferStockableKey, utils.FlagPakan, utils.FlagOVK, transferStockableKey, utils.FlagOVK). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). @@ -136,11 +163,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, 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 (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). - Where("r.deleted_at IS NULL") - - query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) query = query.Group("k.id").Order("k.id ASC") @@ -172,9 +198,8 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). - Where("pc.project_flock_kandang_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). Group("pfk.kandang_id, s.id, s.name, s.alias") - docQuery = applyLocationFilters(docQuery, areaIDs, locationIDs, kandangIDs) if err := docQuery.Scan(&docRows).Error; err != nil { return nil, nil, err @@ -254,9 +279,9 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). - Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("pfk.id IN (?)", projectFlockKandangIDs). Group("k.id") - budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) + // budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) if err := budgetQuery.Scan(&budgetRows).Error; err != nil { return nil, nil, err @@ -288,9 +313,9 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). - Where("pfk.id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). + Where("pfk.id IN (?)", projectFlockKandangIDs). Group("k.id") - expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) + // expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) if err := expenseQuery.Scan(&expenseRows).Error; err != nil { return nil, nil, err @@ -323,10 +348,10 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). - Where("r.project_flock_kandangs_id IN (?)", recordingPfk.Session(&gorm.Session{NewDB: true})). - Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). Where("r.deleted_at IS NULL") - feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) + // feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) if err := feedQuery.Scan(&feedSuppliers).Error; err != nil { return nil, nil, err @@ -347,6 +372,61 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, return rows, supplierRows, nil } +func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) { + if len(projectFlockKandangIDs) == 0 { + return map[uint]HppPerKandangRow{}, nil + } + + latestApproval := r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowRecording), + ) + + type eggRow struct { + ProjectFlockKandangID uint + EggProductionWeightKg float64 + EggProductionPieces float64 + } + + eggRows := make([]eggRow, 0) + query := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + r.project_flock_kandangs_id AS project_flock_kandang_id, + COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, + COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). + Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). + // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.deleted_at IS NULL"). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Group("r.project_flock_kandangs_id") + + if err := query.Scan(&eggRows).Error; err != nil { + return nil, err + } + + result := make(map[uint]HppPerKandangRow, len(eggRows)) + for _, row := range eggRows { + result[row.ProjectFlockKandangID] = HppPerKandangRow{ + ProjectFlockKandangID: row.ProjectFlockKandangID, + EggProductionWeightKg: row.EggProductionWeightKg, + EggProductionPieces: row.EggProductionPieces, + } + } + + return result, nil +} + func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int64) *gorm.DB { if len(areaIDs) > 0 { query = query.Where("loc.area_id IN ?", areaIDs) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index c4883b72..9be8d8c4 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -972,10 +972,37 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes if err != nil { return nil, nil, err } - costRows, supplierRows, err := s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, params.AreaIDs, params.LocationIDs, params.KandangIDs) - if err != nil { - return nil, nil, err + + validPfkIDs := make([]uint, 0, len(repoRows)) + pfkIndex := make(map[uint]int, len(repoRows)) + for idx := range repoRows { + row := repoRows[idx] + pfkIndex[row.ProjectFlockKandangID] = idx + if row.RecordingCount > 0 { + validPfkIDs = append(validPfkIDs, row.ProjectFlockKandangID) + } } + + costRows := make([]repportRepo.HppPerKandangCostRow, 0) + supplierRows := make([]repportRepo.HppPerKandangSupplierRow, 0) + if len(validPfkIDs) > 0 { + costRows, supplierRows, err = s.HppPerKandangRepo.GetFeedOvkDocCostByPeriod(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + if err != nil { + return nil, nil, err + } + + eggMap, err := s.HppPerKandangRepo.GetEggProductionByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + if err != nil { + return nil, nil, err + } + for pfkID, egg := range eggMap { + if rowIdx, ok := pfkIndex[pfkID]; ok { + repoRows[rowIdx].EggProductionWeightKg = egg.EggProductionWeightKg + repoRows[rowIdx].EggProductionPieces = egg.EggProductionPieces + } + } + } + costMap := make(map[uint]HppCostAggregate, len(costRows)) for _, row := range costRows { costMap[row.KandangID] = HppCostAggregate{ @@ -1028,9 +1055,13 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes Max float64 } type weightRangeAggregate struct { - Summary *dto.HppPerKandangSummaryWeightRangeDTO - EggHppSum float64 - EggHppCount int + Summary *dto.HppPerKandangSummaryWeightRangeDTO + RemainingBirds int64 + RemainingWeightKg float64 + EggHppSum float64 + EggHppCount int + FeedSuppliers map[int64]dto.HppPerKandangSupplierDTO + DocSuppliers map[int64]dto.HppPerKandangSupplierDTO } dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) @@ -1049,6 +1080,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var totalEggHppCount int for _, row := range repoRows { + if !params.ShowUnrecorded && row.RecordingCount == 0 { + continue + } + birdsFloat := row.RemainingChickenBirds if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { birdsFloat = 0 @@ -1067,9 +1102,16 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } avgWeight := 0.0 - if birdsFloat > 0 { - avgWeight = weightFloat / birdsFloat + if eggPiecesFloat > 0 { + avgWeight = eggWeightFloat / eggPiecesFloat } + if params.WeightMin != nil && avgWeight < *params.WeightMin { + continue + } + if params.WeightMax != nil && avgWeight > *params.WeightMax { + continue + } + weightMin := math.Floor(avgWeight*10) / 10 if weightMin < 0 { weightMin = 0 @@ -1116,9 +1158,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - RemainingChickenBirds: rowBirds, - RemainingChickenWeightKg: weightFloat, - AvgWeightKg: avgWeight, + AvgWeightKg: avgWeight, // FeedCostRp: costEntry.FeedCost, // OvkCostRp: costEntry.OvkCost, DocSuppliers: docSupplierMap[row.KandangID], @@ -1126,10 +1166,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes EggProductionPieces: rowEggPieces, EggProductionKg: eggWeightFloat, AverageDocPriceRp: avgDocPrice, - HppRp: hppRp, - EggHppRpPerKg: eggHpp, - RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + // HppRp: hppRp, + EggHppRpPerKg: eggHpp, + RemainingValueRp: rowRemainingValue, + EggValueRp: rowEggValue, }) totalBirds += rowBirds @@ -1161,13 +1201,25 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes }, Label: fmt.Sprintf("%.2f - %.2f", weightMin, weightMax), }, + FeedSuppliers: make(map[int64]dto.HppPerKandangSupplierDTO), + DocSuppliers: make(map[int64]dto.HppPerKandangSupplierDTO), } perRangeMap[rangeKey] = rangeAgg } rangeSummary := rangeAgg.Summary - rangeSummary.RemainingChickenBirds += rowBirds - rangeSummary.RemainingChickenWeightKg += row.RemainingChickenWeight + rangeAgg.RemainingBirds += rowBirds + rangeAgg.RemainingWeightKg += row.RemainingChickenWeight + for _, supplier := range feedSupplierMap[row.KandangID] { + if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { + rangeAgg.FeedSuppliers[supplier.ID] = supplier + } + } + for _, supplier := range docSupplierMap[row.KandangID] { + if _, ok := rangeAgg.DocSuppliers[supplier.ID]; !ok { + rangeAgg.DocSuppliers[supplier.ID] = supplier + } + } rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggWeightFloat rangeSummary.RemainingValueRp += rowRemainingValue @@ -1194,22 +1246,27 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes agg := perRangeMap[key] entry := agg.Summary entry.ID = idx + 1 - if entry.RemainingChickenBirds > 0 { - entry.AvgWeightKg = entry.RemainingChickenWeightKg / float64(entry.RemainingChickenBirds) + if agg.RemainingBirds > 0 { + entry.AvgWeightKg = agg.RemainingWeightKg / float64(agg.RemainingBirds) } if agg.EggHppCount > 0 { entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) } + entry.FeedSuppliers = make([]dto.HppPerKandangSupplierDTO, 0, len(agg.FeedSuppliers)) + for _, supplier := range agg.FeedSuppliers { + entry.FeedSuppliers = append(entry.FeedSuppliers, supplier) + } + entry.DocSuppliers = make([]dto.HppPerKandangSupplierDTO, 0, len(agg.DocSuppliers)) + for _, supplier := range agg.DocSuppliers { + entry.DocSuppliers = append(entry.DocSuppliers, supplier) + } perRangeSummary = append(perRangeSummary, *entry) } totalSummary := dto.HppPerKandangSummaryTotalDTO{ - TotalRemainingChickenBirds: totalBirds, - TotalRemainingChickenWeightKg: totalWeight, - TotalEggProductionPieces: totalEggPieces, - TotalEggProductionKg: totalEggKg, - TotalRemainingValueRp: totalRemainingValueRp, - TotalEggValueRp: totalEggValueRp, + TotalEggProductionPieces: totalEggPieces, + TotalEggProductionKg: totalEggKg, + TotalEggValueRp: totalEggValueRp, } if totalBirds > 0 { totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) @@ -1218,7 +1275,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) } if totalHppCount > 0 { - totalSummary.TotalHppRp = totalHppSum / float64(totalHppCount) } if totalDocPriceCount > 0 { totalSummary.TotalAverageDocPriceRp = totalDocPriceSum / float64(totalDocPriceCount) From 62ccc2e5d6f28da75bdb5c61a832022c5e22b4b0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 16:48:37 +0700 Subject: [PATCH 028/117] adjust avg weight --- .../modules/repports/services/repport.service.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9be8d8c4..738bd34d 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1058,6 +1058,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes Summary *dto.HppPerKandangSummaryWeightRangeDTO RemainingBirds int64 RemainingWeightKg float64 + AvgWeightSum float64 + AvgWeightCount int64 EggHppSum float64 EggHppCount int FeedSuppliers map[int64]dto.HppPerKandangSupplierDTO @@ -1078,6 +1080,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes var totalDocPriceCount int var totalEggHppSum float64 var totalEggHppCount int + var totalAvgWeightSum float64 + var totalAvgWeightCount int64 for _, row := range repoRows { if !params.ShowUnrecorded && row.RecordingCount == 0 { @@ -1178,6 +1182,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes totalEggKg += eggWeightFloat totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue + totalAvgWeightSum += avgWeight + totalAvgWeightCount++ if weightFloat > 0 { totalHppSum += hppRp totalHppCount++ @@ -1210,6 +1216,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rangeSummary := rangeAgg.Summary rangeAgg.RemainingBirds += rowBirds rangeAgg.RemainingWeightKg += row.RemainingChickenWeight + rangeAgg.AvgWeightSum += avgWeight + rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.KandangID] { if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { rangeAgg.FeedSuppliers[supplier.ID] = supplier @@ -1246,8 +1254,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes agg := perRangeMap[key] entry := agg.Summary entry.ID = idx + 1 - if agg.RemainingBirds > 0 { - entry.AvgWeightKg = agg.RemainingWeightKg / float64(agg.RemainingBirds) + if agg.AvgWeightCount > 0 { + entry.AvgWeightKg = agg.AvgWeightSum / float64(agg.AvgWeightCount) } if agg.EggHppCount > 0 { entry.EggHppRpPerKg = agg.EggHppSum / float64(agg.EggHppCount) @@ -1269,7 +1277,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes TotalEggValueRp: totalEggValueRp, } if totalBirds > 0 { - totalSummary.AverageWeightKg = totalWeight / float64(totalBirds) + } + if totalAvgWeightCount > 0 { + totalSummary.AverageWeightKg = totalAvgWeightSum / float64(totalAvgWeightCount) } if totalEggHppCount > 0 { totalSummary.AverageEggHppRpPerKg = totalEggHppSum / float64(totalEggHppCount) From 2ca84ecffee6c8b076491abc391338126dcbe424 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 18:16:23 +0700 Subject: [PATCH 029/117] fixing api closing sapronak and data produksi --- internal/modules/closings/dto/closing.dto.go | 8 ++--- .../repositories/closing.repository.go | 8 ++--- .../closings/services/closing.service.go | 33 +++++++++++-------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 470f506d..1c191d29 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -111,13 +111,13 @@ type ClosingPerformanceDTO struct { FeedIntake float64 `json:"feed_intake"` FeedIntakeStd float64 `json:"feed_intake_std"` HenDayAct *float64 `json:"hen_day_act,omitempty"` - HendayStd *float64 `json:"hen_day_std,omitempty"` + HendayStd float64 `json:"hen_day_std"` EggMass *float64 `json:"egg_mass,omitempty"` - EggMassStd *float64 `json:"egg_mass_std,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` EggWeight *float64 `json:"egg_weight,omitempty"` - EggWeightStd *float64 `json:"egg_weight_std,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` HenHouseAct *float64 `json:"hen_housed_act,omitempty"` - HenHouseStd *float64 `json:"hen_housed_std,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 2ce3e496..30d29b14 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -473,8 +473,8 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, COALESCE(fw.name, '') AS source_warehouse, - '' AS destination_warehouse, - COALESCE(tw.name, '') AS destination, + COALESCE(tw.name, '') AS destination_warehouse, + '' AS destination, std.usage_qty AS quantity, u.name AS unit, 'Transfer to other unit' AS notes @@ -522,8 +522,8 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, w.name AS source_warehouse, - '' AS destination_warehouse, - 'RETAIL CUSTOMER' AS destination, + 'RETAIL CUSTOMER' AS destination_warehouse, + '' AS destination, mp.qty AS quantity, u.name AS unit, m.notes AS notes diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 38529b0d..6b0b56f6 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -337,8 +337,10 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa } var projectFlockKandangIDs []uint - if params.Type == validation.SapronakTypeOutgoing { - projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, params.KandangID) + if params.KandangID != nil && *params.KandangID > 0 { + projectFlockKandangIDs = []uint{*params.KandangID} + } else if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") @@ -418,14 +420,11 @@ func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, proje return ids, nil } -func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) { +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { var ids []uint query := s.Repository.DB().WithContext(ctx). Model(&entity.ProjectFlockKandang{}). Where("project_flock_id = ?", projectFlockID) - if kandangID != nil { - query = query.Where("kandang_id = ?", *kandangID) - } err := query.Order("id ASC").Pluck("id", &ids).Error if err != nil { return nil, err @@ -690,10 +689,16 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") } - projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID, kandangID) - if err != nil { - s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + var projectFlockKandangIDs []uint + if kandangID != nil && *kandangID > 0 { + projectFlockKandangIDs = []uint{*kandangID} + } else { + var err error + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + } } if len(projectFlockKandangIDs) == 0 { @@ -954,16 +959,16 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } if !isGrowing { if productionStandardDetail.TargetHenDayProduction != nil { - performance.HendayStd = productionStandardDetail.TargetHenDayProduction + performance.HendayStd = *productionStandardDetail.TargetHenDayProduction } if productionStandardDetail.TargetHenHouseProduction != nil { - performance.HenHouseStd = productionStandardDetail.TargetHenHouseProduction + performance.HenHouseStd = *productionStandardDetail.TargetHenHouseProduction } if productionStandardDetail.TargetEggWeight != nil { - performance.EggWeightStd = productionStandardDetail.TargetEggWeight + performance.EggWeightStd = *productionStandardDetail.TargetEggWeight } if productionStandardDetail.TargetEggMass != nil { - performance.EggMassStd = productionStandardDetail.TargetEggMass + performance.EggMassStd = *productionStandardDetail.TargetEggMass } } } From 77af262662aa69723ef6b96b0ff92e58e5edae5b Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 15 Jan 2026 18:45:52 +0700 Subject: [PATCH 030/117] [FIX/BE-US] adjustment purchase,closing hpp expedition,supplier filter flags --- .../repositories/closing.repository.go | 58 +++++++-- .../closings/services/sapronak.service.go | 113 ++++++++++-------- .../controllers/supplier.controller.go | 1 + .../suppliers/services/supplier.service.go | 24 +++- .../validations/supplier.validation.go | 3 +- .../purchases/services/expense_bridge.go | 25 +++- .../purchases/services/purchase.service.go | 14 ++- 7 files changed, 169 insertions(+), 69 deletions(-) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 2ce3e496..30dc98f4 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -318,6 +318,7 @@ func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlo Joins("JOIN suppliers s ON s.id = e.supplier_id"). Where("pfk.project_flock_id = ?", projectFlockID). Where("e.category = ?", "BOP"). + Where("e.realization_date IS NOT NULL"). Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))) if projectFlockKandangID != nil && *projectFlockKandangID != 0 { @@ -889,17 +890,58 @@ 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, string(utils.StockLogTypeTransfer), true) + incomingQuery := r.withCtx(ctx). + Table("stock_transfer_details AS std"). + Select(` + std.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + st.transfer_date::timestamp AS date, + COALESCE(st.movement_number, '') AS reference, + COALESCE(std.total_qty, 0) AS qty_in, + 0 AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll) + incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } - in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { - if ref := strings.TrimSpace(row.MovementNumber); ref != "" { - return ref - } - return fmt.Sprintf("TRF-%d", row.ID) - }) - return in, out, nil + + outgoingQuery := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + std.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + st.transfer_date::timestamp AS date, + COALESCE(st.movement_number, '') AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN stock_transfer_details std ON std.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyStockTransferOut.String()). + Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") + outgoing, err := scanAndGroupDetails(outgoingQuery) + if err != nil { + return nil, nil, err + } + + return incoming, outgoing, nil } type ActualUsageCostRow struct { diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index b923db5d..fc354f46 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -3,7 +3,6 @@ package service import ( "context" "strings" - "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -112,7 +111,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val } // We no longer filter by date for closing sapronak report; pass nil pointers. - items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, nil, nil, params.Flag) + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") @@ -126,8 +125,6 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val KandangName: pfk.Kandang.Name, Period: pfk.Period, Status: status, - StartDate: nil, - EndDate: nil, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, @@ -318,7 +315,7 @@ func buildSapronakDetails( return result } -func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { +func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { // For sapronak closing report we intentionally ignore date range // and aggregate all historical transactions for the kandang/project. incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) @@ -419,6 +416,22 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj return groupMap[flag] } + resolveFlagName := func(productID uint, details []dto.SapronakDetailDTO) (string, string) { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if flag == "" && len(details) > 0 { + flag = details[0].Flag + } + if name == "" && len(details) > 0 { + name = details[0].ProductName + } + return flag, name + } + for _, row := range incoming { if !matchesFlag(row.Flag) { continue @@ -554,19 +567,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range incomingDetails { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai @@ -575,19 +587,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range adjIncoming { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai @@ -596,19 +607,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range usageDetails { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar @@ -616,19 +626,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range adjOutgoing { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar @@ -636,19 +645,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range transIncoming { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalMasuk += d.QtyMasuk group.TotalNilai += d.Nilai @@ -657,19 +665,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for productID, details := range transOutgoing { - flag := "" - name := "" - if item, ok := itemMap[productID]; ok { - flag = item.Flag - name = item.ProductName - } + flag, name := resolveFlagName(productID, details) if !matchesFlag(flag) { continue } group := ensureGroup(flag) for _, d := range details { - d.Flag = flag - d.ProductName = name + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } group.Items = append(group.Items, d) group.TotalKeluar += d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go index c427316d..a373b680 100644 --- a/internal/modules/master/suppliers/controllers/supplier.controller.go +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -28,6 +28,7 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), Search: c.Query("search", ""), Category: c.Query("category", ""), + Flag: c.Query("flag", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index c331647d..d211ed9d 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -47,6 +47,10 @@ func (s supplierService) withRelations(db *gorm.DB) *gorm.DB { Preload("NonstockSuppliers.Nonstock.Flags") } +func (s supplierService) withListRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Supplier, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -63,7 +67,7 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit offset := (params.Page - 1) * params.Limit suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + db = s.withListRelations(db) if params.Search != "" { return db.Where("name ILIKE ?", "%"+params.Search+"%") } @@ -72,7 +76,23 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit db = db.Where("category ILIKE ?", "%"+params.Category+"%") } - return db.Order("created_at DESC").Order("updated_at DESC") + if params.Flag != "" { + flag := strings.ToUpper(params.Flag) + db = db.Where(` + EXISTS ( + SELECT 1 + FROM nonstock_suppliers nsup + JOIN nonstocks n ON n.id = nsup.nonstock_id + JOIN flags f ON f.flagable_id = n.id AND f.flagable_type = ? + WHERE nsup.supplier_id = suppliers.id + AND UPPER(f.name) = ? + )`, + entity.FlagableTypeNonstock, + flag, + ) + } + + return db.Order("suppliers.created_at DESC").Order("suppliers.updated_at DESC") }) if err != nil { diff --git a/internal/modules/master/suppliers/validations/supplier.validation.go b/internal/modules/master/suppliers/validations/supplier.validation.go index ec02cd8e..720e784e 100644 --- a/internal/modules/master/suppliers/validations/supplier.validation.go +++ b/internal/modules/master/suppliers/validations/supplier.validation.go @@ -32,7 +32,8 @@ type Update struct { type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Limit int `query:"limit" validate:"omitempty,number,min=1"` + Flag string `query:"flag" validate:"omitempty"` Search string `query:"search" validate:"omitempty,max=50"` Category string `query:"category" validate:"omitempty,max=50"` } diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 7e5cbd91..56097a90 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -36,6 +36,7 @@ type ExpenseReceivingPayload struct { TransportPerItem *float64 ReceivedQty float64 ReceivedDate *time.Time + VehicleNumber *string } type groupedItem struct { @@ -182,6 +183,22 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } ctx := c.Context() + filtered := make([]ExpenseReceivingPayload, 0, len(updates)) + for _, upd := range updates { + if upd.SupplierID == 0 { + continue + } + if upd.TransportPerItem == nil || *upd.TransportPerItem <= 0 { + continue + } + if upd.VehicleNumber == nil || strings.TrimSpace(*upd.VehicleNumber) == "" { + continue + } + filtered = append(filtered, upd) + } + if len(filtered) == 0 { + return nil + } // Load current links to decide whether to update in place or recreate. type itemLink struct { @@ -205,9 +222,9 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ itemLinks := make(map[uint]itemLink) updatedExpenses := make(map[uint64]struct{}) - if len(updates) > 0 { - ids := make([]uint, 0, len(updates)) - for _, upd := range updates { + if len(filtered) > 0 { + ids := make([]uint, 0, len(filtered)) + for _, upd := range filtered { if upd.PurchaseItemID != 0 { ids = append(ids, upd.PurchaseItemID) } @@ -252,7 +269,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ groups := make(map[string][]groupedItem) - for _, payload := range updates { + for _, payload := range filtered { if payload.ReceivedDate == nil { return fiber.NewError(fiber.StatusBadRequest, "received_date is required") } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 35ca2f75..f6337c8a 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -702,6 +702,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation warehouseID uint supplierID uint transportPerItem *float64 + vehicleNumber *string overrideWarehouse bool receivedQty float64 } @@ -756,7 +757,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } visitedItems[payload.PurchaseItemID] = struct{}{} - supplierID := purchase.SupplierId + var supplierID uint if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { supplierID = *payload.ExpeditionVendorID } @@ -770,6 +771,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation transportPerItem = &val } + var vehicleNumber *string + if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" { + val := strings.TrimSpace(*payload.VehicleNumber) + vehicleNumber = &val + } else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" { + val := strings.TrimSpace(*item.VehicleNumber) + vehicleNumber = &val + } + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, @@ -777,6 +787,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation warehouseID: warehouseID, supplierID: supplierID, transportPerItem: transportPerItem, + vehicleNumber: vehicleNumber, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) @@ -964,6 +975,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation TransportPerItem: prep.transportPerItem, ReceivedQty: prep.receivedQty, ReceivedDate: &date, + VehicleNumber: prep.vehicleNumber, } receivingPayloads = append(receivingPayloads, payload) } From e770526c1ad6193c7d6d95375e040e3fcc5f849e Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Thu, 15 Jan 2026 18:51:32 +0700 Subject: [PATCH 031/117] fix(BE): warehouse provided location --- .../warehouses/services/warehouse.service.go | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 7eeaad3d..0b9dfc18 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -110,7 +110,11 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } typ := strings.ToUpper(req.Type) - if err := validateWarehouseTypeRequirements(typ, &req.AreaId, req.LocationId, req.KandangId); err != nil { + createValidationOpts := WarehouseTypeValidationOptions{ + LocationProvided: req.LocationId != nil, + KandangProvided: req.KandangId != nil, + } + if err := validateWarehouseTypeRequirements(typ, &req.AreaId, &req.LocationId, &req.KandangId, createValidationOpts); err != nil { return nil, err } @@ -208,9 +212,22 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin finalKandangId = req.KandangId } - if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, finalLocationId, finalKandangId); err != nil { + originalLocationId := finalLocationId + originalKandangId := finalKandangId + updateValidationOpts := WarehouseTypeValidationOptions{ + AutoClear: true, + LocationProvided: req.LocationId != nil, + KandangProvided: req.KandangId != nil, + } + if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, &finalLocationId, &finalKandangId, updateValidationOpts); err != nil { return nil, err } + if originalLocationId != finalLocationId { + updateBody["location_id"] = nil + } + if originalKandangId != finalKandangId { + updateBody["kandang_id"] = nil + } if len(updateBody) == 0 { return s.GetOne(c, id) @@ -238,47 +255,65 @@ func (s warehouseService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID *uint, kandangID *uint) error { +type WarehouseTypeValidationOptions struct { + AutoClear bool + LocationProvided bool + KandangProvided bool +} + +func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID **uint, kandangID **uint, opts WarehouseTypeValidationOptions) error { switch utils.WarehouseType(typ) { case utils.WarehouseTypeArea: if areaID == nil || *areaID == 0 { return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is AREA") } - if locationID != nil { - return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA") + if locationID != nil && *locationID != nil { + if opts.AutoClear && !opts.LocationProvided { + *locationID = nil + } else { + return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA") + } } - if kandangID != nil { - return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA") + if kandangID != nil && *kandangID != nil { + if opts.AutoClear && !opts.KandangProvided { + *kandangID = nil + } else { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA") + } } return nil case utils.WarehouseTypeLokasi: if areaID == nil || *areaID == 0 { return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is LOCATION") } - if locationID == nil { + if locationID == nil || *locationID == nil { return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is LOCATION") } - if *locationID == 0 { + if **locationID == 0 { return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is LOCATION") } - if kandangID != nil { - return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION") + if kandangID != nil && *kandangID != nil { + if opts.AutoClear && !opts.KandangProvided { + *kandangID = nil + } else { + return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION") + } } return nil case utils.WarehouseTypeKandang: if areaID == nil || *areaID == 0 { return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is KANDANG") } - if locationID == nil { + if locationID == nil || *locationID == nil { return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is KANDANG") } - if *locationID == 0 { + if **locationID == 0 { return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is KANDANG") } - if kandangID == nil { + if kandangID == nil || *kandangID == nil { return fiber.NewError(fiber.StatusBadRequest, "kandang_id is required when type is KANDANG") } - if *kandangID == 0 { + if **kandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "kandang_id must be greater than 0 when type is KANDANG") } return nil From 688d3fa75703f34b98b3bfc9411ecf8c5478dee3 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 15 Jan 2026 19:16:58 +0700 Subject: [PATCH 032/117] fix api production result --- .../repports/services/repport.service.go | 140 ++++++++++++++---- 1 file changed, 109 insertions(+), 31 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 83d611d6..2e07e212 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -21,9 +22,9 @@ import ( marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" - productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" @@ -47,21 +48,21 @@ type RepportService interface { } type repportService struct { - Log *logrus.Logger - Validate *validator.Validate - DB *gorm.DB - ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository - MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository - PurchaseRepo purchaseRepo.PurchaseRepository - ChickinRepo chickinRepo.ProjectChickinRepository - RecordingRepo recordingRepo.RecordingRepository - ApprovalSvc approvalService.ApprovalService - PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository - DebtSupplierRepo repportRepo.DebtSupplierRepository - HppPerKandangRepo repportRepo.HppPerKandangRepository - ProductionResultRepo repportRepo.ProductionResultRepository - CustomerPaymentRepo repportRepo.CustomerPaymentRepository - CustomerRepo customerRepo.CustomerRepository + Log *logrus.Logger + Validate *validator.Validate + DB *gorm.DB + ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository + PurchaseRepo purchaseRepo.PurchaseRepository + ChickinRepo chickinRepo.ProjectChickinRepository + RecordingRepo recordingRepo.RecordingRepository + ApprovalSvc approvalService.ApprovalService + PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository + DebtSupplierRepo repportRepo.DebtSupplierRepository + HppPerKandangRepo repportRepo.HppPerKandangRepository + ProductionResultRepo repportRepo.ProductionResultRepository + CustomerPaymentRepo repportRepo.CustomerPaymentRepository + CustomerRepo customerRepo.CustomerRepository StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository } @@ -94,21 +95,21 @@ func NewRepportService( productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, ) RepportService { return &repportService{ - Log: utils.Log, - Validate: validate, - DB: db, - ExpenseRealizationRepo: expenseRealizationRepo, - MarketingDeliveryRepo: marketingDeliveryRepo, - PurchaseRepo: purchaseRepo, - ChickinRepo: chickinRepo, - RecordingRepo: recordingRepo, - ApprovalSvc: approvalSvc, - PurchaseSupplierRepo: purchaseSupplierRepo, - DebtSupplierRepo: debtSupplierRepo, - HppPerKandangRepo: hppPerKandangRepo, - ProductionResultRepo: productionResultRepo, - CustomerPaymentRepo: customerPaymentRepo, - CustomerRepo: customerRepo, + Log: utils.Log, + Validate: validate, + DB: db, + ExpenseRealizationRepo: expenseRealizationRepo, + MarketingDeliveryRepo: marketingDeliveryRepo, + PurchaseRepo: purchaseRepo, + ChickinRepo: chickinRepo, + RecordingRepo: recordingRepo, + ApprovalSvc: approvalSvc, + PurchaseSupplierRepo: purchaseSupplierRepo, + DebtSupplierRepo: debtSupplierRepo, + HppPerKandangRepo: hppPerKandangRepo, + ProductionResultRepo: productionResultRepo, + CustomerPaymentRepo: customerPaymentRepo, + CustomerRepo: customerRepo, StandardGrowthDetailRepo: standardGrowthDetailRepo, ProductionStandardDetailRepo: productionStandardDetailRepo, } @@ -319,6 +320,15 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. standardDetailCache := make(map[int]*entity.ProductionStandardDetail) growthDetailCache := make(map[int]*entity.StandardGrowthDetail) + weeks := make([]int, len(weeklyResults)) + for i := range weeklyResults { + weeks[i] = defaultStartWoa + i + } + uniformityMap, err := s.getUniformityByWeek(ctx.Context(), params.ProjectFlockKandangID, weeks) + if err != nil { + return nil, 0, err + } + var cumulativeButir int64 var cumulativeKg float64 for i := range weeklyResults { @@ -328,6 +338,12 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. if weeklyResults[i].StdUniformity == "" { weeklyResults[i].StdUniformity = defaultUniformText } + if uniformity, ok := uniformityMap[defaultStartWoa+i]; ok { + weeklyResults[i].Uniformity = uniformity.Uniformity + if uniformity.AvgWeight != nil { + weeklyResults[i].Bw = *uniformity.AvgWeight + } + } cumulativeButir += weeklyResults[i].ButiranJumlah weeklyResults[i].TotalButir = cumulativeButir @@ -744,6 +760,68 @@ func getEggFlagType(egg entity.RecordingEgg) (utils.FlagType, bool) { return "", false } +type uniformityWeekData struct { + Uniformity float64 + AvgWeight *float64 +} + +type uniformityChartPayload struct { + Statistics *uniformityChartStats `json:"statistics"` +} + +type uniformityChartStats struct { + AverageWeight *float64 `json:"average_weight"` +} + +func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKandangID uint, weeks []int) (map[int]uniformityWeekData, error) { + result := make(map[int]uniformityWeekData, len(weeks)) + if projectFlockKandangID == 0 || len(weeks) == 0 { + return result, nil + } + + var rows []entity.ProjectFlockKandangUniformity + if err := s.DB.WithContext(ctx). + Model(&entity.ProjectFlockKandangUniformity{}). + Select("week, uniformity, uniform_date, id"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("week IN ?", weeks). + Order("uniform_date DESC"). + Order("id DESC"). + Find(&rows).Error; err != nil { + return nil, err + } + + for _, row := range rows { + if _, exists := result[row.Week]; exists { + continue + } + result[row.Week] = uniformityWeekData{ + Uniformity: row.Uniformity, + AvgWeight: extractAverageWeight(row.ChartData, s.Log), + } + } + + return result, nil +} + +func extractAverageWeight(raw json.RawMessage, log *logrus.Logger) *float64 { + if len(raw) == 0 { + return nil + } + + var payload uniformityChartPayload + if err := json.Unmarshal(raw, &payload); err != nil { + if log != nil { + log.WithError(err).Warn("uniformity chart_data decode failed") + } + return nil + } + if payload.Statistics == nil { + return nil + } + return payload.Statistics.AverageWeight +} + func summarizeProductionResults(daily []dto.ProductionResultDTO, groupSize int) []dto.ProductionResultDTO { if groupSize <= 0 || len(daily) == 0 { return daily From 8ad11af9c90894065a16d08973f02d2ebe90d372 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 12:27:18 +0700 Subject: [PATCH 033/117] feat: Refactor closing services and add ClosingKeuanganService - Updated ClosingRoutes to include ClosingKeuanganService. - Removed GetClosingKeuangan method from ClosingService interface and its implementation. - Introduced new ClosingKeuanganService with GetClosingKeuangan method to handle financial logic. - Implemented detailed logging and error handling in the new service. - Added GetTotalWeightProducedFromUniformityByProjectFlockID method in RecordingRepository to support weight calculations. - Enhanced the logic for fetching and classifying product usage data by flags. - Built comprehensive DTO responses for HPP and Profit Loss sections. --- .../controllers/closing.controller.go | 14 +- .../closings/dto/closingKeuangan.dto.go | 588 ---------------- .../closings/dto/closingKeuanganNew.dto.go | 161 +++++ internal/modules/closings/module.go | 4 +- .../repositories/closing.repository.go | 127 ---- .../closingKeuangan.repository.go | 316 +++++++++ internal/modules/closings/route.go | 4 +- .../closings/services/closing.service.go | 126 ---- .../services/closingKeuangan.service.go | 655 ++++++++++++++++++ .../repositories/recording.repository.go | 29 + 10 files changed, 1174 insertions(+), 850 deletions(-) delete mode 100644 internal/modules/closings/dto/closingKeuangan.dto.go create mode 100644 internal/modules/closings/dto/closingKeuanganNew.dto.go create mode 100644 internal/modules/closings/repositories/closingKeuangan.repository.go create mode 100644 internal/modules/closings/services/closingKeuangan.service.go diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 3e64f89b..9dfae460 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -14,14 +14,16 @@ import ( ) type ClosingController struct { - ClosingService service.ClosingService - SapronakService service.SapronakService + ClosingService service.ClosingService + SapronakService service.SapronakService + ClosingKeuanganService service.ClosingKeuanganService } -func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, closingKeuanganService service.ClosingKeuanganService) *ClosingController { return &ClosingController{ - ClosingService: closingService, - SapronakService: sapronakService, + ClosingService: closingService, + SapronakService: sapronakService, + ClosingKeuanganService: closingKeuanganService, } } @@ -338,7 +340,7 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") } - result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID)) + result, err := u.ClosingKeuanganService.GetClosingKeuangan(c, uint(projectFlockID)) if err != nil { return err } diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go deleted file mode 100644 index fa99a59d..00000000 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ /dev/null @@ -1,588 +0,0 @@ -package dto - -import ( - "slices" - "strings" - - "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -// === CONSTANTS === -const ( - HPPGroupPengeluaran = "HPP dan Pengeluaran" - HPPGroupBahanBaku = "HPP dan Bahan Baku" - HPPLabelOverhead = "Pengeluaran Overhead" - HPPLabelEkspedisi = "Beban Ekspedisi" - HPPSummaryLabel = "HPP" - - PLSalesTypeChicken = "Penjualan Ayam Besar" - PLSalesTypeEgg = "Penjualan Telur" - - PLItemTypeSapronak = "Pembelian Sapronak" - PLItemTypeOverhead = "Pengeluaran Overhead" - PLItemTypeEkspedisi = "Beban Ekspedisi" - - PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO" - PLSummaryLabelSubTotal = "SUB TOTAL" - PLSummaryLabelNetProfit = "LABA RUGI NETTO" - - PurchaseLabelPrefix = "Pembelian " -) - -// === CONTEXT STRUCTS === - -type CalculationContext struct { - TotalPopulation float64 - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 - TotalWeightSold float64 - ActualPopulation float64 -} - -type ClosingKeuanganInput struct { - ProjectFlockCategory string - PurchaseItems []entities.PurchaseItem - Budgets []entities.ProjectBudget - Realizations []entities.ExpenseRealization - DeliveryProducts []entities.MarketingDeliveryProduct - Chickins []entities.ProjectChickin - TotalWeightProduced float64 - TotalEggWeightKg float64 - TotalDepletion float64 -} - -// === BASE METRICS === - -type FinancialMetrics struct { - RpPerBird float64 `json:"rp_per_bird"` - RpPerKg float64 `json:"rp_per_kg"` - Amount float64 `json:"amount"` -} - -type Comparison struct { - Budgeting FinancialMetrics `json:"budgeting"` - Realization FinancialMetrics `json:"realization"` -} - -// === HPP PURCHASES PACKAGE === - -type HppItem struct { - Type string `json:"type"` - Comparison -} - -type HppGroup struct { - GroupName string `json:"group_name"` - Data []HppItem `json:"data"` -} - -type SummaryHpp struct { - Label string `json:"label"` - Budgeting FinancialMetrics `json:"budgeting"` - Realization FinancialMetrics `json:"realization"` - EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` - EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` -} - -type HppPurchasesSection struct { - Hpp []HppGroup `json:"hpp"` - SummaryHpp SummaryHpp `json:"summary_hpp"` -} - -// === PROFIT LOSS PACKAGE === - -type PLItem struct { - Type string `json:"type"` - FinancialMetrics -} - -type PLSummaryItem struct { - Label string `json:"label"` - FinancialMetrics -} - -type PLSummaryGroup struct { - GrossProfit PLSummaryItem `json:"gross_profit"` - SubTotal PLSummaryItem `json:"sub_total"` - NetProfit PLSummaryItem `json:"net_profit"` -} - -type ProfitLossData struct { - Penjualan []PLItem `json:"penjualan"` - Pembelian []PLItem `json:"pembelian"` - Overhead PLItem `json:"overhead"` - Ekspedisi PLItem `json:"ekspedisi"` - Summary PLSummaryGroup `json:"summary"` -} - -type ProfitLossSection struct { - Data ProfitLossData `json:"data"` -} - -// === RESPONSE DTO (ROOT) === - -type ReportResponse struct { - HppPurchases HppPurchasesSection `json:"hpp_purchases"` - ProfitLoss ProfitLossSection `json:"profit_loss"` -} - -// === MAPPER FUNCTIONS === - -func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { - return FinancialMetrics{ - RpPerBird: rpPerBird, - RpPerKg: rpPerKg, - Amount: amount, - } -} - -func ToComparison(budgeting, realization FinancialMetrics) Comparison { - return Comparison{ - Budgeting: budgeting, - Realization: realization, - } -} - -// === HPP PENGELUARAN (from Purchase Items) === - -func getFlagLabel(flagType utils.FlagType) string { - return PurchaseLabelPrefix + string(flagType) -} - -func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem { - flags := []utils.FlagType{ - utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan, - utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher, - utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia, - } - - items := []HppItem{} - seenFlags := make(map[utils.FlagType]bool) - - for _, item := range purchaseItems { - if item.Product == nil || len(item.Product.Flags) == 0 { - continue - } - - for _, flag := range item.Product.Flags { - flagType := utils.FlagType(flag.Name) - - if slices.Contains(flags, flagType) && !seenFlags[flagType] { - amount := sumPurchasesByFlag(purchaseItems, flagType) - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - items = append(items, HppItem{ - Type: getFlagLabel(flagType), - Comparison: ToComparison( - ToFinancialMetrics(rpPerBird, rpPerKg, amount), - ToFinancialMetrics(rpPerBird, rpPerKg, amount), - ), - }) - seenFlags[flagType] = true - } - } - } - - return items -} - -// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) === - -func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem { - budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - return HppItem{ - Type: HPPLabelOverhead, - Comparison: ToComparison( - ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount), - ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount), - ), - } -} - -func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem { - ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) - - return HppItem{ - Type: HPPLabelEkspedisi, - Comparison: ToComparison( - ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), - ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount), - ), - } -} - -func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup { - items := []HppItem{} - - budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) - realizationAmount := getOperationalExpenses(realizations) - - if budgetAmount > 0 || realizationAmount > 0 { - items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx)) - } - - ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) - items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx)) - - return HppGroup{ - GroupName: HPPGroupBahanBaku, - Data: items, - } -} - -// === HPP SUMMARY === - -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 - - totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true }) - - budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) - realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) - - summary := SummaryHpp{ - Label: label, - Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), - Realization: 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, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection { - hppGroups := []HppGroup{ - { - GroupName: HPPGroupPengeluaran, - Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx), - }, - ToHppBahanBakuGroup(budgets, realizations, ctx), - } - - summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx) - - return HppPurchasesSection{ - Hpp: hppGroups, - SummaryHpp: summaryHpp, - } -} - -// === PROFIT & LOSS === - -func ToPLItem(itemType string, metrics FinancialMetrics) PLItem { - return PLItem{ - Type: itemType, - FinancialMetrics: metrics, - } -} - -func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem { - return PLSummaryItem{ - Label: label, - FinancialMetrics: metrics, - } -} - -func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem { - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced) - return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) -} - -func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) { - for _, item := range items { - totalAmount += item.Amount - totalPerBird += item.RpPerBird - } - return -} - -func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem { - rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold) - return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount)) -} - -func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem { - items := []PLItem{} - - categorized := categorizeDeliveriesBySalesType(deliveryProducts) - - if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) { - ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) - telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg]) - - items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) - items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx)) - } else { - ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken]) - items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx)) - } - - return items -} - -func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - purchaseAmount := sumPurchaseTotal(purchases) - - return []PLItem{ - createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx), - } -} - -func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - realizationAmount := getOperationalExpenses(realizations) - return []PLItem{ - createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx), - } -} - -func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { - amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi)) - return []PLItem{ - createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx), - } -} - -func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup { - totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems) - totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems) - totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems) - totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems) - - grossProfit := totalPenjualan - totalPembelian - grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird - - totalOtherExpenses := totalOverhead + totalEkspedisi - totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird - - netProfit := grossProfit - totalOtherExpenses - netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird - - return PLSummaryGroup{ - GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)), - SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)), - NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)), - } -} - -func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData { - summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) - - totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead) - totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi) - - return ProfitLossData{ - Penjualan: penjualanItems, - Pembelian: pembelianItems, - Overhead: totalOverhead, - Ekspedisi: totalEkspedisi, - Summary: summary, - } -} - -func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection { - return ProfitLossSection{ - Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems), - } -} - -func aggregatePLItems(items []PLItem, label string) PLItem { - totalAmount, totalPerBird := sumPLItems(items) - return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount)) -} - -func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse { - return ReportResponse{ - HppPurchases: hppPurchases, - ProfitLoss: profitLoss, - } -} - -func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { - var totalPopulation float64 - var totalWeightSold float64 - - for _, chickin := range input.Chickins { - totalPopulation += chickin.UsageQty - } - - for _, delivery := range input.DeliveryProducts { - totalWeightSold += delivery.TotalWeight - } - - 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, input.ProjectFlockCategory, ctx) - penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) - pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) - overheadItems := ToOverheadItems(input.Realizations, ctx) - ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx) - plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems) - - return ToReportResponse(hppSection, plSection) -} - -// === HELPER FUNCTIONS === - -func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) { - if totalPopulation > 0 { - rpPerBird = amount / totalPopulation - } - if totalWeightSold > 0 { - rpPerKg = amount / totalWeightSold - } - return rpPerBird, rpPerKg -} - -func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool { - for _, flag := range flags { - if strings.ToUpper(flag.Name) == string(flagType) { - return true - } - } - return false -} - -func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool { - return func(item *entities.PurchaseItem) bool { - if item.Product == nil || len(item.Product.Flags) == 0 { - return false - } - return hasProductFlag(item.Product.Flags, flagType) - } -} - -func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { - return func(realization *entities.ExpenseRealization) bool { - if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil { - return false - } - return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType) - } -} - -func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool { - hasFlag := filterRealizationByNonstockFlag(flagType) - return func(realization *entities.ExpenseRealization) bool { - return !hasFlag(realization) - } -} - -func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 { - amount := 0.0 - for i := range items { - if filter(&items[i]) { - amount += extractor(&items[i]) - } - } - return amount -} - -func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 { - return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter) -} - -func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 { - return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType)) -} - -func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 { - return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true }) -} - -func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 { - return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter) -} - -func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 { - return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter) -} - -func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 { - return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi)) -} - -func isChickenProductFlag(flagType utils.FlagType) bool { - switch flagType { - case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, - utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati: - return true - } - return false -} - -func isEggProductFlag(flagType utils.FlagType) bool { - switch flagType { - case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah, - utils.FlagTelurPutih, utils.FlagTelurRetak: - return true - } - return false -} - -func getSalesTypeFromProductFlags(product *entities.Product) string { - if product == nil || len(product.Flags) == 0 { - return PLSalesTypeChicken - } - - for _, flag := range product.Flags { - flagType := utils.FlagType(strings.ToUpper(flag.Name)) - - if isEggProductFlag(flagType) { - return PLSalesTypeEgg - } - if isChickenProductFlag(flagType) { - return PLSalesTypeChicken - } - } - - return PLSalesTypeChicken -} - -func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct { - categorized := make(map[string][]entities.MarketingDeliveryProduct) - - for _, delivery := range deliveries { - product := delivery.MarketingProduct.ProductWarehouse.Product - salesType := getSalesTypeFromProductFlags(&product) - - categorized[salesType] = append(categorized[salesType], delivery) - } - - return categorized -} - -func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 { - amount := 0.0 - for _, delivery := range deliveries { - amount += delivery.TotalPrice - } - return amount -} diff --git a/internal/modules/closings/dto/closingKeuanganNew.dto.go b/internal/modules/closings/dto/closingKeuanganNew.dto.go new file mode 100644 index 00000000..4bef7280 --- /dev/null +++ b/internal/modules/closings/dto/closingKeuanganNew.dto.go @@ -0,0 +1,161 @@ +package dto + +// === NEW CLOSING KEUANGAN DTO === + +// FinancialMetrics represents financial metrics with per unit and total amounts +type FinancialMetrics struct { + RpPerBird float64 `json:"rp_per_bird"` + RpPerKg float64 `json:"rp_per_kg"` + Amount float64 `json:"amount"` +} + +// HPPItem represents an item in HPP section +type HPPItem struct { + ID uint `json:"id"` + Category string `json:"category"` // "purchase" or "overhead" + Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI" + Label string `json:"label"` + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` +} + +// HPPSummary represents summary for HPP section +type HPPSummary struct { + Label string `json:"label"` + Budgeting FinancialMetrics `json:"budgeting"` + Realization FinancialMetrics `json:"realization"` + EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` + EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` +} + +// HPPSection represents HPP data section +type HPPSection struct { + Items []HPPItem `json:"items"` + Summary HPPSummary `json:"summary"` +} + +// ProfitLossItem represents an item in Profit & Loss section +type ProfitLossItem struct { + Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI" + Label string `json:"label"` + Type string `json:"type"` // "income", "purchase", "overhead" + RpPerBird float64 `json:"rp_per_bird"` + RpPerKg float64 `json:"rp_per_kg"` + Amount float64 `json:"amount"` +} + +// ProfitLossSummary represents summary for Profit & Loss section +type ProfitLossSummary struct { + GrossProfit FinancialMetrics `json:"gross_profit"` + SubTotal FinancialMetrics `json:"sub_total"` + NetProfit FinancialMetrics `json:"net_profit"` +} + +// ProfitLossSection represents Profit & Loss data section +type ProfitLossSection struct { + Items []ProfitLossItem `json:"items"` + Summary ProfitLossSummary `json:"summary"` +} + +// ClosingKeuanganData represents the main data structure +type ClosingKeuanganData struct { + HPP HPPSection `json:"hpp"` + ProfitLoss ProfitLossSection `json:"profit_loss"` +} + +// ClosingKeuanganResponse represents the full API response +type ClosingKeuanganResponse struct { + Code int `json:"code"` + Status string `json:"status"` + Message string `json:"message"` + Data ClosingKeuanganData `json:"data"` +} + +// === MAPPER FUNCTIONS === + +// ToFinancialMetrics creates FinancialMetrics from values +func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics { + return FinancialMetrics{ + RpPerBird: rpPerBird, + RpPerKg: rpPerKg, + Amount: amount, + } +} + +// ToHPPItem creates HPP item +func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem { + return HPPItem{ + ID: id, + Category: category, + Code: code, + Label: label, + Budgeting: budgeting, + Realization: realization, + } +} + +// ToHPPSummary creates HPP summary +func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary { + return HPPSummary{ + Label: label, + Budgeting: budgeting, + Realization: realization, + EggBudgeting: eggBudgeting, + EggRealization: eggRealization, + } +} + +// ToHPPSection creates HPP section +func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection { + return HPPSection{ + Items: items, + Summary: summary, + } +} + +// ToProfitLossItem creates Profit & Loss item +func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem { + return ProfitLossItem{ + Code: code, + Label: label, + Type: itemType, + RpPerBird: rpPerBird, + RpPerKg: rpPerKg, + Amount: amount, + } +} + +// ToProfitLossSummary creates Profit & Loss summary +func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary { + return ProfitLossSummary{ + GrossProfit: grossProfit, + SubTotal: subTotal, + NetProfit: netProfit, + } +} + +// ToProfitLossSection creates Profit & Loss section +func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection { + return ProfitLossSection{ + Items: items, + Summary: summary, + } +} + +// ToClosingKeuanganData creates complete closing keuangan data +func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData { + return ClosingKeuanganData{ + HPP: hpp, + ProfitLoss: profitLoss, + } +} + +// ToSuccessClosingKeuanganResponse creates success response +func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse { + return ClosingKeuanganResponse{ + Code: 200, + Status: "success", + Message: "Get closing keuangan successfully", + Data: data, + } +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index a79c9f0b..1079663d 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -25,6 +25,7 @@ type ClosingModule struct{} func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { closingRepo := rClosing.NewClosingRepository(db) + closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) @@ -41,8 +42,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalService := commonSvc.NewApprovalService(approvalRepo) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) + closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) - ClosingRoutes(router, userService, closingService, sapronakService) + ClosingRoutes(router, userService, closingService, sapronakService, closingKeuanganService) } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 7ecc86d8..582a1207 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -33,7 +33,6 @@ 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) } @@ -944,132 +943,6 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return incoming, outgoing, 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 - - purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() - transferStockableKey := fifo.StockableKeyStockTransferIn.String() - - 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.usage_qty, 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.usage_qty, 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.usage_qty, 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.usage_qty, 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.usage_qty, 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 - } - - 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 - } - - 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 diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go new file mode 100644 index 00000000..3763f92b --- /dev/null +++ b/internal/modules/closings/repositories/closingKeuangan.repository.go @@ -0,0 +1,316 @@ +package repository + +import ( + "context" + "fmt" + "sort" + "strings" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "gorm.io/gorm" +) + +// ClosingKeuanganRepository handles database operations for closing keuangan +type ClosingKeuanganRepository interface { + repository.BaseRepository[interface{}] + + // All Product Usage + GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) + + // DB returns the underlying GORM DB instance + DB() *gorm.DB +} + +type ClosingKeuanganRepositoryImpl struct { + *repository.BaseRepositoryImpl[interface{}] +} + +func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository { + return &ClosingKeuanganRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db), + } +} + +// Result Rows + +type ProductUsageRow struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + FlagNames string `gorm:"column:flag_names"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + TotalPengeluaran float64 `gorm:"column:total_pengeluaran"` +} + +// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang +// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments +// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all +func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) { + if projectFlockKandangID == 0 { + return []ProductUsageRow{}, nil + } + + type SubQueryResult struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + } + + type AggregatedResult struct { + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + TotalQty float64 `gorm:"column:total_qty"` + Price float64 `gorm:"column:price"` + PriceCount int `gorm:"-"` // For calculating average price + } + + type FlagResult struct { + ProductID uint `gorm:"column:product_id"` + FlagNames string `gorm:"column:flag_names"` + } + + var allResults []SubQueryResult + + // Subquery 1: Recordings + var recordingsResults []SubQueryResult + err := r.DB().WithContext(ctx). + Table("recordings r"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(CASE "+ + "WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+ + "WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+ + "WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+ + "WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+ + "WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+ + "ELSE 0 END), 0) as total_qty, "+ + "COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price"). + Joins("JOIN recording_stocks rs ON rs.recording_id = r.id"). + Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'"). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'"). + Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'"). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangID). + Where("r.deleted_at IS NULL"). + Group("pw.product_id, p.name"). + Scan(&recordingsResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get recordings product usage: %w", err) + } + fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID) + allResults = append(allResults, recordingsResults...) + + // Subquery 2: Chickins + var chickinsResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("project_chickins pc"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangID). + Where("pc.usage_qty > 0"). + Group("pw.product_id, p.name"). + Scan(&chickinsResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get chickins product usage: %w", err) + } + fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID) + allResults = append(allResults, chickinsResults...) + + // Subquery 3: Marketing Delivery + var marketingResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("marketing_delivery_products mdp"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&marketingResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get marketing product usage: %w", err) + } + fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID) + allResults = append(allResults, marketingResults...) + + // Subquery 4: Laying Transfer Sources + var layingTransferResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("laying_transfer_sources lts"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). + Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&layingTransferResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err) + } + fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID) + allResults = append(allResults, layingTransferResults...) + + // Subquery 5: Stock Transfer Details + var stockTransferResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("stock_transfer_details std"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(std.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id"). + Joins("JOIN products p ON p.id = std.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Group("pw.product_id, p.name"). + Scan(&stockTransferResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err) + } + fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID) + allResults = append(allResults, stockTransferResults...) + + // Subquery 6: Adjustment Stocks + var adjustmentResults []SubQueryResult + err = r.DB().WithContext(ctx). + Table("adjustment_stocks ads"). + Select("pw.product_id, p.name as product_name, "+ + "COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+ + "COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price"). + Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("ads.usage_qty > 0"). + Group("pw.product_id, p.name"). + Scan(&adjustmentResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get adjustment product usage: %w", err) + } + fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID) + allResults = append(allResults, adjustmentResults...) + + fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults)) + + // Aggregate results by product_id + aggregatedMap := make(map[uint]*AggregatedResult) + for _, result := range allResults { + key := result.ProductID + if existing, exists := aggregatedMap[key]; exists { + existing.TotalQty += result.TotalQty + existing.Price += result.Price + existing.PriceCount++ + } else { + aggregatedMap[key] = &AggregatedResult{ + ProductID: result.ProductID, + ProductName: result.ProductName, + TotalQty: result.TotalQty, + Price: result.Price, + PriceCount: 1, + } + } + } + + fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap)) + + // Get flags for all products + productIDs := make([]uint, 0, len(aggregatedMap)) + for id := range aggregatedMap { + productIDs = append(productIDs, id) + } + + var flagResults []FlagResult + if len(productIDs) > 0 { + err = r.DB().WithContext(ctx). + Table("products p"). + Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names"). + Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id"). + Where("p.id IN ?", productIDs). + Group("p.id"). + Scan(&flagResults).Error + + if err != nil { + return nil, fmt.Errorf("failed to get product flags: %w", err) + } + } + fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults)) + + // Build flag map + flagMap := make(map[uint]string) + for _, flag := range flagResults { + flagMap[flag.ProductID] = flag.FlagNames + } + + // Combine results and calculate average price + results := make([]ProductUsageRow, 0, len(aggregatedMap)) + for _, agg := range aggregatedMap { + avgPrice := float64(0) + if agg.PriceCount > 0 { + avgPrice = agg.Price / float64(agg.PriceCount) + } + + flagNames := flagMap[agg.ProductID] + + // Apply flag filters if provided + if len(flagFilters) > 0 { + // Check if any of the flagFilters exist in flagNames + matched := false + for _, filter := range flagFilters { + if containsIgnoreCase(flagNames, filter) { + matched = true + break + } + } + if !matched { + continue // Skip this product if no flag matches + } + } + + results = append(results, ProductUsageRow{ + ProductID: agg.ProductID, + ProductName: agg.ProductName, + FlagNames: flagNames, + TotalQty: agg.TotalQty, + Price: avgPrice, + TotalPengeluaran: agg.TotalQty * avgPrice, + }) + } + + fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results)) + for i, r := range results { + fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n", + i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran) + } + + // Sort by product name + sort.Slice(results, func(i, j int) bool { + return results[i].ProductName < results[j].ProductName + }) + + fmt.Printf("[REPO] Final sorted results: %d items\n", len(results)) + return results, nil +} + +// containsIgnoreCase checks if a string contains a substring (case-insensitive) +func containsIgnoreCase(str, substr string) bool { + return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr)) +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 1cd4559d..89578aeb 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -9,8 +9,8 @@ import ( "github.com/gofiber/fiber/v2" ) -func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { - ctrl := controller.NewClosingController(s, sapronakSvc) +func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService, closingKeuanganSvc closing.ClosingKeuanganService) { + ctrl := controller.NewClosingController(s, sapronakSvc, closingKeuanganSvc) route := v1.Group("/closings") route.Use(m.Auth(u)) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 6b0b56f6..8cda7220 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -40,7 +40,6 @@ type ClosingService interface { GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) - GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -576,82 +575,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl return &result, nil } -func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) { - - if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, - ); err != nil { - return nil, err - } - - projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - - budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") - } - - actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost") - } - - 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") - } - - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product") - }) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") - } - - chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") - } - - totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - 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) - } - - input := dto.ClosingKeuanganInput{ - ProjectFlockCategory: projectFlock.Category, - PurchaseItems: purchaseItems, - Budgets: budgets, - Realizations: realizations, - DeliveryProducts: deliveryProducts, - Chickins: chickins, - TotalWeightProduced: totalWeightProduced, - TotalEggWeightKg: totalEggWeightKg, - TotalDepletion: totalDepletion, - } - - report := dto.ToClosingKeuanganReport(input) - - return &report, nil -} - 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") @@ -1108,52 +1031,3 @@ 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 -} diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go new file mode 100644 index 00000000..d041d765 --- /dev/null +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -0,0 +1,655 @@ +package service + +import ( + "errors" + "strings" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" + expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// ClosingKeuanganService handles closing keuangan business logic +type ClosingKeuanganService interface { + GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) +} + +type closingKeuanganService struct { + Log *logrus.Logger + ClosingKeuanganRepo repository.ClosingKeuanganRepository + ProjectFlockRepo projectflockRepository.ProjectflockRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository + MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository + ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository + ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository + ChickinRepo chickinRepository.ProjectChickinRepository + RecordingRepo recordingRepository.RecordingRepository +} + +func NewClosingKeuanganService( + closingKeuanganRepo repository.ClosingKeuanganRepository, + projectFlockRepo projectflockRepository.ProjectflockRepository, + projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, + marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, + expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, + projectBudgetRepo projectflockRepository.ProjectBudgetRepository, + chickinRepo chickinRepository.ProjectChickinRepository, + recordingRepo recordingRepository.RecordingRepository, +) ClosingKeuanganService { + return &closingKeuanganService{ + Log: utils.Log, + ClosingKeuanganRepo: closingKeuanganRepo, + ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + ExpenseRealizationRepo: expenseRealizationRepo, + ProjectBudgetRepo: projectBudgetRepo, + ChickinRepo: chickinRepo, + RecordingRepo: recordingRepo, + } +} + +func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) { + s.Log.Infof("===== START GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + s.Log.Infof("ProjectFlock: ID=%d, Name=%s, Category=%s", projectFlock.Id, projectFlock.FlockName, projectFlock.Category) + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Preload Nonstock.Flags manually + var budgetIDs []uint + for _, b := range budgets { + budgetIDs = append(budgetIDs, b.Id) + } + if len(budgetIDs) > 0 { + err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). + Preload("Nonstock.Flags"). + Where("id IN ?", budgetIDs). + Find(&budgets).Error + if err != nil { + s.Log.Warnf("Failed to preload Nonstock.Flags: %v", err) + } + } + + s.Log.Infof("Budgets fetched: %d items", len(budgets)) + for i, b := range budgets { + nonstockName := "Unknown" + if b.Nonstock != nil { + nonstockName = b.Nonstock.Name + } + s.Log.Infof(" Budget[%d]: ID=%d, Nonstock=%s, Price=%.2f, Qty=%.2f", i, b.Id, nonstockName, b.Price, b.Qty) + } + + // Get all kandang for this project flock + kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") + } + s.Log.Infof("Kandangs fetched: %d kandangs", len(kandangs)) + for i, k := range kandangs { + s.Log.Infof(" Kandang[%d]: ID=%d, KandangID=%d, Period=%d", i, k.Id, k.KandangId, k.Period) + } + + // Define flag filters + // PAKAN flags: PAKAN, PRE-STARTER, STARTER, FINISHER (priority 1) + // OVK flags: OVK, OBAT, VITAMIN, KIMIA, EKSPEDISI (priority 2) + // AYAM flags: DOC, PULLET, LAYER (priority 3 - only if no PAKAN and OVK) + pakanFilters := []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"} + ovkFilters := []string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"} + ayamFilters := []string{"DOC", "PULLET", "LAYER"} + allFilters := append(pakanFilters, ovkFilters...) + allFilters = append(allFilters, ayamFilters...) + s.Log.Infof("All Filters: %v", allFilters) + + var allProductUsageRows []repository.ProductUsageRow + + // Get ALL product usage once + s.Log.Infof("===== Fetching ALL product usage =====") + for _, kandang := range kandangs { + s.Log.Infof("Fetching ALL for kandang ID=%d", kandang.Id) + rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) + if err != nil { + s.Log.Errorf("Failed to get product usage for kandang %d: %v", kandang.Id, err) + } else { + s.Log.Infof("Kandang %d: Got %d rows", kandang.Id, len(rows)) + for i, row := range rows { + s.Log.Infof(" [%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f", + i, row.ProductID, row.ProductName, row.FlagNames, row.TotalQty, row.Price, row.TotalPengeluaran) + } + allProductUsageRows = append(allProductUsageRows, rows...) + } + } + + // Classify into categories based on flag priority + var pakanProductUsageRows []repository.ProductUsageRow + var ovkProductUsageRows []repository.ProductUsageRow + var ayamProductUsageRows []repository.ProductUsageRow + + s.Log.Infof("===== Classifying products by flag priority =====") + for _, row := range allProductUsageRows { + // Parse flag names from comma-separated string + flagNames := strings.Split(row.FlagNames, ",") + + hasPakanFlag := false + hasOvkFlag := false + hasAyamFlag := false + + for _, flag := range flagNames { + flag = strings.TrimSpace(flag) + if containsItem(pakanFilters, flag) { + hasPakanFlag = true + } + if containsItem(ovkFilters, flag) { + hasOvkFlag = true + } + if containsItem(ayamFilters, flag) { + hasAyamFlag = true + } + } + + // Priority: PAKAN > OVK > AYAM + category := "" + if hasPakanFlag { + pakanProductUsageRows = append(pakanProductUsageRows, row) + category = "PAKAN" + } else if hasOvkFlag { + ovkProductUsageRows = append(ovkProductUsageRows, row) + category = "OVK" + } else if hasAyamFlag { + ayamProductUsageRows = append(ayamProductUsageRows, row) + category = "AYAM" + } else { + s.Log.Warnf("ProductID=%d (%s) has no recognized flags: %s", row.ProductID, row.ProductName, row.FlagNames) + continue + } + + s.Log.Infof("ProductID=%d (%s) → Category: %s (Flags: %s, Pakan=%v, OVK=%v, Ayam=%v)", + row.ProductID, row.ProductName, category, row.FlagNames, hasPakanFlag, hasOvkFlag, hasAyamFlag) + } + + s.Log.Infof("Total ProductUsageRows collected: %d items", len(allProductUsageRows)) + s.Log.Infof(" - PAKAN: %d items", len(pakanProductUsageRows)) + s.Log.Infof(" - OVK: %d items", len(ovkProductUsageRows)) + s.Log.Infof(" - AYAM: %d items", len(ayamProductUsageRows)) + + // Calculate total price for each category + var totalPakanPrice, totalOvkPrice, totalAyamPrice float64 + for _, row := range pakanProductUsageRows { + totalPakanPrice += row.TotalPengeluaran + } + for _, row := range ovkProductUsageRows { + totalOvkPrice += row.TotalPengeluaran + } + for _, row := range ayamProductUsageRows { + totalAyamPrice += row.TotalPengeluaran + } + s.Log.Infof("===== TOTAL PRICE BY CATEGORY =====") + s.Log.Infof(" - PAKAN Total Price: %.2f", totalPakanPrice) + s.Log.Infof(" - OVK Total Price: %.2f", totalOvkPrice) + s.Log.Infof(" - AYAM Total Price: %.2f", totalAyamPrice) + s.Log.Infof(" - ALL Total Price: %.2f", totalPakanPrice+totalOvkPrice+totalAyamPrice) + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") + } + s.Log.Infof("Realizations fetched: %d items", len(realizations)) + for i, r := range realizations { + s.Log.Infof(" Realization[%d]: ID=%d, Price=%.2f, Qty=%.2f", i, r.Id, r.Price, r.Qty) + } + + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { + return db.Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product") + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") + } + s.Log.Infof("DeliveryProducts fetched: %d items", len(deliveryProducts)) + for i, dp := range deliveryProducts { + s.Log.Infof(" DeliveryProduct[%d]: ID=%d, TotalWeight=%.2f, TotalPrice=%.2f", i, dp.Id, dp.TotalWeight, dp.TotalPrice) + } + + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") + } + s.Log.Infof("Chickins fetched: %d items", len(chickins)) + for i, ch := range chickins { + s.Log.Infof(" Chickin[%d]: ID=%d, UsageQty=%.2f", i, ch.Id, ch.UsageQty) + } + + // Get total depletion + totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + totalDepletion = 0 + } + s.Log.Infof("TotalDepletion: %.2f birds", totalDepletion) + + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) + } + s.Log.Infof("TotalWeightProduced (from stub): %.2f kg", totalWeightProduced) + + // Try to get actual weight from uniformity data + totalWeightFromUniformity, err := s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalWeightProducedFromUniformityByProjectFlockID error: %v", err) + } else if totalWeightFromUniformity > 0 { + totalWeightProduced = totalWeightFromUniformity + s.Log.Infof("TotalWeightProduced (from uniformity): %.2f kg", totalWeightProduced) + } + + // Fetch egg data only for Laying category + var totalEggWeightKg float64 + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + s.Log.Infof("===== Fetching EGG data (Laying Category) =====") + // TODO: Replace with actual method to get egg weight from RecordingRepo + // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlockID) + // For now, set to 0 as placeholder + totalEggWeightKg = 0 + s.Log.Infof("TotalEggWeightKg: %.2f kg", totalEggWeightKg) + if err != nil { + s.Log.Warnf("Failed to fetch egg weight: %v", err) + } + } else { + s.Log.Infof("Skipping egg data fetch (Category: %s - not Laying)", projectFlock.Category) + totalEggWeightKg = 0 + } + + // Build new DTO structure + s.Log.Infof("===== BUILDING NEW DTO RESPONSE =====") + + // Calculate totals + var totalPopulation float64 + for _, chickin := range chickins { + totalPopulation += chickin.UsageQty + } + + // Calculate actual population (total population - depletion) + actualPopulation := totalPopulation - totalDepletion + s.Log.Infof("Population - Total: %.2f, Depletion: %.2f, Actual: %.2f", totalPopulation, totalDepletion, actualPopulation) + + // Calculate budget totals by category + calculateBudgetByFlag := func(flags []string) float64 { + var total float64 + for _, budget := range budgets { + if budget.Nonstock != nil { + for _, nonstockFlag := range budget.Nonstock.Flags { + flagName := strings.ToUpper(nonstockFlag.Name) + for _, targetFlag := range flags { + if flagName == strings.ToUpper(targetFlag) { + total += budget.Price * budget.Qty + break + } + } + } + } + } + return total + } + + // Budget per category + budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) + budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"}) + budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"}) + budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"}) + + // Operational budget = total budget - pakan - ovk - ayam - ekspedisi + totalBudgetAmount := 0.0 + for _, budget := range budgets { + totalBudgetAmount += budget.Price * budget.Qty + } + budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi + + s.Log.Infof("Budgets by category:") + s.Log.Infof(" PAKAN: %.2f", budgetPakan) + s.Log.Infof(" OVK: %.2f", budgetOvk) + s.Log.Infof(" AYAM: %.2f", budgetAyam) + s.Log.Infof(" OVERHEAD: %.2f", budgetOperational) + s.Log.Infof(" EKSPEDISI: %.2f", budgetEkspedisi) + + // Calculate realization totals + var totalRealizationAmount float64 + var totalEkspedisiRealization float64 + for _, realization := range realizations { + amount := realization.Price * realization.Qty + totalRealizationAmount += amount + + // Check if this is ekspedisi (need to check nonstock flags) + if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil { + for _, flag := range realization.ExpenseNonstock.Nonstock.Flags { + if flag.Name == "EKSPEDISI" { + totalEkspedisiRealization += amount + break + } + } + } + } + + totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization + + // Filter delivery products based on category + var filteredDeliveryProducts []entity.MarketingDeliveryProduct + for _, delivery := range deliveryProducts { + // Get product from delivery + if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { + continue + } + + product := delivery.MarketingProduct.ProductWarehouse.Product + isEggProduct := false + isChickenProduct := false + + // Check product flags + for _, flag := range product.Flags { + flagName := strings.ToUpper(flag.Name) + + // Egg product flags + if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" || + flagName == "TELURPUTIH" || flagName == "TELURRETAK" { + isEggProduct = true + } + + // Chicken product flags + if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" { + isChickenProduct = true + } + } + + // Filter based on project flock category + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + // Laying: only egg products + if isEggProduct { + filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) + } + } else { + // Growing/Contract Growing: only chicken products + if isChickenProduct || (!isEggProduct && !isChickenProduct) { + // Include if chicken product or if no specific flags (default to chicken) + filteredDeliveryProducts = append(filteredDeliveryProducts, delivery) + } + } + } + + s.Log.Infof("Filtered DeliveryProducts: %d items (from %d total)", len(filteredDeliveryProducts), len(deliveryProducts)) + + // Calculate total weight sold and sales amount from filtered products + var totalWeightSold float64 + var totalSalesAmount float64 + for _, delivery := range filteredDeliveryProducts { + totalWeightSold += delivery.TotalWeight + totalSalesAmount += delivery.TotalPrice + } + + s.Log.Infof("Calculated totals:") + s.Log.Infof(" Total Population: %.2f", totalPopulation) + s.Log.Infof(" Actual Population: %.2f (after depletion)", actualPopulation) + s.Log.Infof(" Total Weight Produced: %.2f kg (ayam)", totalWeightProduced) + s.Log.Infof(" Total Weight Sold: %.2f kg (filtered by category)", totalWeightSold) + s.Log.Infof(" Total Budget: %.2f", totalBudgetAmount) + s.Log.Infof(" Total Realization: %.2f (Operational: %.2f, Ekspedisi: %.2f)", totalRealizationAmount, totalOperationalRealization, totalEkspedisiRealization) + s.Log.Infof(" Total Sales: %.2f", totalSalesAmount) + + // Calculate metrics - always use kg ayam for rp_per_kg + calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if actualPopulation > 0 { + rpPerBird = amount / actualPopulation // Use actual population + } + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced + } + return + } + + // Calculate metrics for profit loss (use total population for sales) + calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { + if totalPopulation > 0 { + rpPerBird = amount / totalPopulation + } + if totalWeightSold > 0 { + rpPerKg = amount / totalWeightSold + } + return + } + + // Build HPP Items + hppItems := []dto.HPPItem{} + + // PAKAN item + pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan) + pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 1, + "purchase", + "PAKAN", + "Pakan", + dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan), + dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice), + )) + + // OVK item + ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk) + ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 2, + "purchase", + "OVK", + "OVK", + dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), + dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), + )) + + // DOC item + docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) + docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) + hppItems = append(hppItems, dto.ToHPPItem( + 3, + "purchase", + "DOC", + "DOC", + dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), + dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), + )) + + // OVERHEAD operational item (before EKSPEDISI) + overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) + overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 4, + "overhead", + "OVERHEAD", + "Pengeluaran Overhead", + dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), + dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), + )) + + // EKSPEDISI item (overhead) + ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) + ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) + hppItems = append(hppItems, dto.ToHPPItem( + 5, + "overhead", + "EKSPEDISI", + "Beban Ekspedisi", + dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi), + dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization), + )) + + // HPP Summary + totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi + totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization + + hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp) + hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp) + + var eggBudgeting, eggRealization *dto.FinancialMetrics + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 { + eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg + eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg + eggBudgeting = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: eggBudgetRpPerKg, + Amount: totalBudgetHpp, + } + eggRealization = &dto.FinancialMetrics{ + RpPerBird: 0, + RpPerKg: eggRealizationRpPerKg, + Amount: totalRealizationHpp, + } + } + + hppSummary := dto.ToHPPSummary( + "HPP", + dto.ToFinancialMetrics(hppBudgetRpPerBird, hppBudgetRpPerKg, totalBudgetHpp), + dto.ToFinancialMetrics(hppRealizationRpPerBird, hppRealizationRpPerKg, totalRealizationHpp), + eggBudgeting, + eggRealization, + ) + + hppSection := dto.ToHPPSection(hppItems, hppSummary) + + // Build Profit Loss Items + plItems := []dto.ProfitLossItem{} + + // SALES item + salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount) + salesLabel := "Penjualan Ayam" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + salesLabel = "Penjualan Telur" + } + plItems = append(plItems, dto.ToProfitLossItem( + "SALES", + salesLabel, + "income", + salesRpPerBird, + salesRpPerKg, + totalSalesAmount, + )) + + // PURCHASE_DOC item + purchaseDocLabel := "Pembelian DOC" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + purchaseDocLabel = "Depresiasi" + } + plItems = append(plItems, dto.ToProfitLossItem( + "PURCHASE_DOC", + purchaseDocLabel, + "purchase", + docRealizationRpPerBird, + docRealizationRpPerKg, + totalAyamPrice, + )) + + // PAKAN item + plItems = append(plItems, dto.ToProfitLossItem( + "PAKAN", + "Pakan", + "purchase", + pakanRealizationRpPerBird, + pakanRealizationRpPerKg, + totalPakanPrice, + )) + + // OVK item + plItems = append(plItems, dto.ToProfitLossItem( + "OVK", + "OVK", + "purchase", + ovkRealizationRpPerBird, + ovkRealizationRpPerKg, + totalOvkPrice, + )) + + // OVERHEAD item + overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) + plItems = append(plItems, dto.ToProfitLossItem( + "OVERHEAD", + "Overhead", + "overhead", + overheadRpPerBird, + overheadRpPerKg, + totalOperationalRealization, + )) + + // EKSPEDISI item + plItems = append(plItems, dto.ToProfitLossItem( + "EKSPEDISI", + "Ekspedisi", + "overhead", + ekspedisiRealizationRpPerBird, + ekspedisiRealizationRpPerKg, + totalEkspedisiRealization, + )) + + // Profit Loss Summary + // Calculate total cost of goods sold (HPP) - use realization + totalCostOfGoodsSold := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization + totalCostOfGoodsSoldRpPerBird := pakanRealizationRpPerBird + ovkRealizationRpPerBird + docRealizationRpPerBird + overheadRpPerBird + ekspedisiRealizationRpPerBird + + // Gross Profit = Sales - Cost of Goods Sold + grossProfit := totalSalesAmount - totalCostOfGoodsSold + grossProfitRpPerBird := salesRpPerBird - totalCostOfGoodsSoldRpPerBird + + // Operating Expenses (already included in COGS above, so this shows the breakdown) + totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization + totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird + + // Net Profit = Gross Profit (COGS already deducted) + netProfit := grossProfit + netProfitRpPerBird := grossProfitRpPerBird + + plSummary := dto.ToProfitLossSummary( + dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), + dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses), + dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit), + ) + + profitLossSection := dto.ToProfitLossSection(plItems, plSummary) + + // Build complete response + data := dto.ToClosingKeuanganData(hppSection, profitLossSection) + response := dto.ToSuccessClosingKeuanganResponse(data) + + s.Log.Infof("===== NEW DTO RESPONSE BUILT SUCCESSFULLY =====") + s.Log.Infof("HPP Items: %d, Profit Loss Items: %d", len(hppItems), len(plItems)) + s.Log.Infof("===== END GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) + + return &response, nil +} + +// containsItem checks if a string exists in a slice +func containsItem(slice []string, item string) bool { + for _, s := range slice { + if strings.EqualFold(s, item) { + return true + } + } + return false +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index fbb628ff..db43b2fa 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -44,6 +44,7 @@ type RecordingRepository interface { GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) + GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) @@ -548,3 +549,31 @@ func nextRecordingDay(days []int) int { return len(normalized) + 1 } + +// GetTotalWeightProducedFromUniformityByProjectFlockID calculates total weight produced from uniformity data +// It takes the latest uniformity record per kandang and calculates: SUM(mean_weight * chick_qty_of_weight / 1000) +func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) { + if projectFlockID == 0 { + return 0, nil + } + + var result struct { + TotalWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("COALESCE(SUM(mean_weight * chick_qty_of_weight / 1000), 0) as total_weight"). + Joins("JOIN ("+ + " SELECT pfku.project_flock_kandang_id, MAX(pfku.id) as latest_id "+ + " FROM project_flock_kandang_uniformity pfku "+ + " JOIN project_flock_kandangs pfk ON pfk.id = pfku.project_flock_kandang_id "+ + " WHERE pfk.project_flock_id = ? "+ + " AND pfku.deleted_at IS NULL "+ + " GROUP BY pfku.project_flock_kandang_id "+ + ") latest ON latest.project_flock_kandang_id = project_flock_kandang_uniformity.project_flock_kandang_id "+ + "AND project_flock_kandang_uniformity.id = latest.latest_id"). + Scan(&result).Error + + return result.TotalWeight, err +} From 31c48ee1da64ef981c6d0a2861515ea25f93a5a9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 20:53:47 +0700 Subject: [PATCH 034/117] feat[BE]: Add GetClosingKeuanganByKandang endpoint and related service methods --- .../controllers/closing.controller.go | 28 ++ .../closings/dto/closingKeuanganNew.dto.go | 24 ++ .../closingKeuangan.repository.go | 49 +++ internal/modules/closings/route.go | 1 + .../services/closingKeuangan.service.go | 317 +++++++++--------- .../repositories/recording.repository.go | 5 +- 6 files changed, 255 insertions(+), 169 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 9dfae460..ed3cfcbc 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -354,6 +354,34 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetClosingKeuanganByKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + result, err := u.ClosingKeuanganService.GetClosingKeuanganByKandang(c, uint(projectFlockID), uint(pfkID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing keuangan by kandang successfully", + Data: result, + }) +} + func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error { param := c.Params("project_flock_id") diff --git a/internal/modules/closings/dto/closingKeuanganNew.dto.go b/internal/modules/closings/dto/closingKeuanganNew.dto.go index 4bef7280..6ca19d5c 100644 --- a/internal/modules/closings/dto/closingKeuanganNew.dto.go +++ b/internal/modules/closings/dto/closingKeuanganNew.dto.go @@ -1,5 +1,29 @@ package dto +// === CLOSING KEUANGAN CODES === + +// Closing HPP Codes +type ClosingHPPCode string + +const ( + HPPCodePakan ClosingHPPCode = "PAKAN" + HPPCodeOVK ClosingHPPCode = "OVK" + HPPCodeDOC ClosingHPPCode = "DOC" + HPPCodeDepresiasi ClosingHPPCode = "DEPRESIASI" + HPPCodeOverhead ClosingHPPCode = "OVERHEAD" + HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" +) + +// Closing Profit Loss Codes +type ClosingProfitLossCode string + +const ( + PLCodeSales ClosingProfitLossCode = "SALES" + PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" + PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" + PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" +) + // === NEW CLOSING KEUANGAN DTO === // FinancialMetrics represents financial metrics with per unit and total amounts diff --git a/internal/modules/closings/repositories/closingKeuangan.repository.go b/internal/modules/closings/repositories/closingKeuangan.repository.go index 3763f92b..dedea807 100644 --- a/internal/modules/closings/repositories/closingKeuangan.repository.go +++ b/internal/modules/closings/repositories/closingKeuangan.repository.go @@ -17,6 +17,12 @@ type ClosingKeuanganRepository interface { // All Product Usage GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) + // Depletion per kandang + GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + + // Weight produced from uniformity per kandang + GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + // DB returns the underlying GORM DB instance DB() *gorm.DB } @@ -310,6 +316,49 @@ func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangI return results, nil } +// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang +func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + var result float64 + err := r.DB().WithContext(ctx). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). + Where("project_flock_kandangs.id = ?", projectFlockKandangID). + Scan(&result).Error + return result, err +} + +// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang +// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000 +func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) { + if projectFlockKandangID == 0 { + return 0, nil + } + + var uniformity struct { + MeanUp float64 + ChickQtyOfWeight float64 + } + + err := r.DB().WithContext(ctx). + Table("project_flock_kandang_uniformity"). + Select("mean_up, chick_qty_of_weight"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("id DESC"). + Limit(1). + Scan(&uniformity).Error + + if err != nil { + return 0, err + } + + // Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000 + totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000 + + return totalWeight, nil +} + // containsIgnoreCase checks if a string contains a substring (case-insensitive) func containsIgnoreCase(str, substr string) bool { return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr)) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 89578aeb..f0a6ca2a 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -34,5 +34,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) + route.Get("/:project_flock_id/:project_flock_kandang_id/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuanganByKandang) } diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index d041d765..ffb7dbf4 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -21,7 +21,8 @@ import ( // ClosingKeuanganService handles closing keuangan business logic type ClosingKeuanganService interface { - GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) + GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) + GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) } type closingKeuanganService struct { @@ -59,8 +60,7 @@ func NewClosingKeuanganService( } } -func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganResponse, error) { - s.Log.Infof("===== START GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) +func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) { if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, @@ -72,7 +72,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - s.Log.Infof("ProjectFlock: ID=%d, Name=%s, Category=%s", projectFlock.Id, projectFlock.FlockName, projectFlock.Category) budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -89,18 +88,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID Preload("Nonstock.Flags"). Where("id IN ?", budgetIDs). Find(&budgets).Error - if err != nil { - s.Log.Warnf("Failed to preload Nonstock.Flags: %v", err) - } - } - - s.Log.Infof("Budgets fetched: %d items", len(budgets)) - for i, b := range budgets { - nonstockName := "Unknown" - if b.Nonstock != nil { - nonstockName = b.Nonstock.Name - } - s.Log.Infof(" Budget[%d]: ID=%d, Nonstock=%s, Price=%.2f, Qty=%.2f", i, b.Id, nonstockName, b.Price, b.Qty) } // Get all kandang for this project flock @@ -108,37 +95,68 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") } - s.Log.Infof("Kandangs fetched: %d kandangs", len(kandangs)) - for i, k := range kandangs { - s.Log.Infof(" Kandang[%d]: ID=%d, KandangID=%d, Period=%d", i, k.Id, k.KandangId, k.Period) + + return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) +} + +func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) { + + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists}, + ); err != nil { + return nil, err } - // Define flag filters - // PAKAN flags: PAKAN, PRE-STARTER, STARTER, FINISHER (priority 1) - // OVK flags: OVK, OBAT, VITAMIN, KIMIA, EKSPEDISI (priority 2) - // AYAM flags: DOC, PULLET, LAYER (priority 3 - only if no PAKAN and OVK) - pakanFilters := []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"} - ovkFilters := []string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"} - ayamFilters := []string{"DOC", "PULLET", "LAYER"} + // Validate and fetch project flock kandang + kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID) + if err != nil { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + if kandang.ProjectFlockId != projectFlockID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock") + } + + projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") + } + + // Preload Nonstock.Flags manually + var budgetIDs []uint + for _, b := range budgets { + budgetIDs = append(budgetIDs, b.Id) + } + if len(budgetIDs) > 0 { + err = s.ProjectBudgetRepo.DB().WithContext(c.Context()). + Preload("Nonstock.Flags"). + Where("id IN ?", budgetIDs). + Find(&budgets).Error + } + + kandangs := []entity.ProjectFlockKandang{*kandang} + + return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID) +} + +func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { + // Define flag filters using constants + pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)} + ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)} + ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} allFilters := append(pakanFilters, ovkFilters...) allFilters = append(allFilters, ayamFilters...) - s.Log.Infof("All Filters: %v", allFilters) var allProductUsageRows []repository.ProductUsageRow - // Get ALL product usage once - s.Log.Infof("===== Fetching ALL product usage =====") + // Get ALL product usage for _, kandang := range kandangs { - s.Log.Infof("Fetching ALL for kandang ID=%d", kandang.Id) rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters) - if err != nil { - s.Log.Errorf("Failed to get product usage for kandang %d: %v", kandang.Id, err) - } else { - s.Log.Infof("Kandang %d: Got %d rows", kandang.Id, len(rows)) - for i, row := range rows { - s.Log.Infof(" [%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f", - i, row.ProductID, row.ProductName, row.FlagNames, row.TotalQty, row.Price, row.TotalPengeluaran) - } + if err == nil { allProductUsageRows = append(allProductUsageRows, rows...) } } @@ -148,7 +166,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID var ovkProductUsageRows []repository.ProductUsageRow var ayamProductUsageRows []repository.ProductUsageRow - s.Log.Infof("===== Classifying products by flag priority =====") for _, row := range allProductUsageRows { // Parse flag names from comma-separated string flagNames := strings.Split(row.FlagNames, ",") @@ -171,29 +188,17 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID } // Priority: PAKAN > OVK > AYAM - category := "" if hasPakanFlag { pakanProductUsageRows = append(pakanProductUsageRows, row) - category = "PAKAN" } else if hasOvkFlag { ovkProductUsageRows = append(ovkProductUsageRows, row) - category = "OVK" } else if hasAyamFlag { ayamProductUsageRows = append(ayamProductUsageRows, row) - category = "AYAM" } else { - s.Log.Warnf("ProductID=%d (%s) has no recognized flags: %s", row.ProductID, row.ProductName, row.FlagNames) continue } - - s.Log.Infof("ProductID=%d (%s) → Category: %s (Flags: %s, Pakan=%v, OVK=%v, Ayam=%v)", - row.ProductID, row.ProductName, category, row.FlagNames, hasPakanFlag, hasOvkFlag, hasAyamFlag) } - s.Log.Infof("Total ProductUsageRows collected: %d items", len(allProductUsageRows)) - s.Log.Infof(" - PAKAN: %d items", len(pakanProductUsageRows)) - s.Log.Infof(" - OVK: %d items", len(ovkProductUsageRows)) - s.Log.Infof(" - AYAM: %d items", len(ayamProductUsageRows)) // Calculate total price for each category var totalPakanPrice, totalOvkPrice, totalAyamPrice float64 @@ -206,85 +211,100 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID for _, row := range ayamProductUsageRows { totalAyamPrice += row.TotalPengeluaran } - s.Log.Infof("===== TOTAL PRICE BY CATEGORY =====") - s.Log.Infof(" - PAKAN Total Price: %.2f", totalPakanPrice) - s.Log.Infof(" - OVK Total Price: %.2f", totalOvkPrice) - s.Log.Infof(" - AYAM Total Price: %.2f", totalAyamPrice) - s.Log.Infof(" - ALL Total Price: %.2f", totalPakanPrice+totalOvkPrice+totalAyamPrice) - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + // Determine if this is per-kandang or per-project-flock scope + isPerKandang := len(kandangs) == 1 + var projectFlockKandangID *uint + if isPerKandang { + kandangID := kandangs[0].Id + projectFlockKandangID = &kandangID + } + + var err error + + // Fetch realizations + var realizations []entity.ExpenseRealization + if isPerKandang && projectFlockKandangID != nil { + realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID) + } else { + realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil) + } if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") } - s.Log.Infof("Realizations fetched: %d items", len(realizations)) - for i, r := range realizations { - s.Log.Infof(" Realization[%d]: ID=%d, Price=%.2f, Qty=%.2f", i, r.Id, r.Price, r.Qty) - } - deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("MarketingProduct"). + deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB { + db = db.Preload("MarketingProduct"). Preload("MarketingProduct.ProductWarehouse"). Preload("MarketingProduct.ProductWarehouse.Product") + return db }) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products") } - s.Log.Infof("DeliveryProducts fetched: %d items", len(deliveryProducts)) - for i, dp := range deliveryProducts { - s.Log.Infof(" DeliveryProduct[%d]: ID=%d, TotalWeight=%.2f, TotalPrice=%.2f", i, dp.Id, dp.TotalWeight, dp.TotalPrice) + + // Filter by kandang if scope is per-kandang (manual filtering after fetch) + if isPerKandang && projectFlockKandangID != nil { + filteredProducts := make([]entity.MarketingDeliveryProduct, 0) + for _, dp := range deliveryProducts { + pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId + if pfKandangID != nil && *pfKandangID == *projectFlockKandangID { + filteredProducts = append(filteredProducts, dp) + } + } + deliveryProducts = filteredProducts } - chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + // Fetch chickins + var chickins []entity.ProjectChickin + if isPerKandang && projectFlockKandangID != nil { + chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id) + } if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins") } - s.Log.Infof("Chickins fetched: %d items", len(chickins)) - for i, ch := range chickins { - s.Log.Infof(" Chickin[%d]: ID=%d, UsageQty=%.2f", i, ch.Id, ch.UsageQty) - } // Get total depletion - totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + var totalDepletion float64 + if isPerKandang && projectFlockKandangID != nil { + totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id) + } if err != nil { - s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) totalDepletion = 0 } - s.Log.Infof("TotalDepletion: %.2f birds", totalDepletion) - totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID) + totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id) if err != nil { - s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) } - s.Log.Infof("TotalWeightProduced (from stub): %.2f kg", totalWeightProduced) // Try to get actual weight from uniformity data - totalWeightFromUniformity, err := s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlockID) + var totalWeightFromUniformity float64 + if isPerKandang && projectFlockKandangID != nil { + totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID) + } else { + totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id) + } if err != nil { - s.Log.Warnf("GetTotalWeightProducedFromUniformityByProjectFlockID error: %v", err) } else if totalWeightFromUniformity > 0 { totalWeightProduced = totalWeightFromUniformity - s.Log.Infof("TotalWeightProduced (from uniformity): %.2f kg", totalWeightProduced) } // Fetch egg data only for Laying category var totalEggWeightKg float64 if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - s.Log.Infof("===== Fetching EGG data (Laying Category) =====") // TODO: Replace with actual method to get egg weight from RecordingRepo - // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlockID) + // totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id) // For now, set to 0 as placeholder totalEggWeightKg = 0 - s.Log.Infof("TotalEggWeightKg: %.2f kg", totalEggWeightKg) - if err != nil { - s.Log.Warnf("Failed to fetch egg weight: %v", err) - } } else { - s.Log.Infof("Skipping egg data fetch (Category: %s - not Laying)", projectFlock.Category) totalEggWeightKg = 0 } // Build new DTO structure - s.Log.Infof("===== BUILDING NEW DTO RESPONSE =====") // Calculate totals var totalPopulation float64 @@ -294,7 +314,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID // Calculate actual population (total population - depletion) actualPopulation := totalPopulation - totalDepletion - s.Log.Infof("Population - Total: %.2f, Depletion: %.2f, Actual: %.2f", totalPopulation, totalDepletion, actualPopulation) // Calculate budget totals by category calculateBudgetByFlag := func(flags []string) float64 { @@ -328,12 +347,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID } budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi - s.Log.Infof("Budgets by category:") - s.Log.Infof(" PAKAN: %.2f", budgetPakan) - s.Log.Infof(" OVK: %.2f", budgetOvk) - s.Log.Infof(" AYAM: %.2f", budgetAyam) - s.Log.Infof(" OVERHEAD: %.2f", budgetOperational) - s.Log.Infof(" EKSPEDISI: %.2f", budgetEkspedisi) // Calculate realization totals var totalRealizationAmount float64 @@ -398,7 +411,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID } } - s.Log.Infof("Filtered DeliveryProducts: %d items (from %d total)", len(filteredDeliveryProducts), len(deliveryProducts)) // Calculate total weight sold and sales amount from filtered products var totalWeightSold float64 @@ -408,14 +420,6 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID totalSalesAmount += delivery.TotalPrice } - s.Log.Infof("Calculated totals:") - s.Log.Infof(" Total Population: %.2f", totalPopulation) - s.Log.Infof(" Actual Population: %.2f (after depletion)", actualPopulation) - s.Log.Infof(" Total Weight Produced: %.2f kg (ayam)", totalWeightProduced) - s.Log.Infof(" Total Weight Sold: %.2f kg (filtered by category)", totalWeightSold) - s.Log.Infof(" Total Budget: %.2f", totalBudgetAmount) - s.Log.Infof(" Total Realization: %.2f (Operational: %.2f, Ekspedisi: %.2f)", totalRealizationAmount, totalOperationalRealization, totalEkspedisiRealization) - s.Log.Infof(" Total Sales: %.2f", totalSalesAmount) // Calculate metrics - always use kg ayam for rp_per_kg calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { @@ -439,7 +443,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID return } - // Build HPP Items + // Build HPP Items using constants hppItems := []dto.HPPItem{} // PAKAN item @@ -448,8 +452,8 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID hppItems = append(hppItems, dto.ToHPPItem( 1, "purchase", - "PAKAN", - "Pakan", + string(dto.HPPCodePakan), + "Pembelian Pakan", dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan), dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice), )) @@ -460,43 +464,49 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID hppItems = append(hppItems, dto.ToHPPItem( 2, "purchase", - "OVK", - "OVK", + string(dto.HPPCodeOVK), + "Pembelian OVK", dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk), dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice), )) - // DOC item + // DOC/DEPRESIASI item + docCode := string(dto.HPPCodeDOC) + docLabel := "Pembelian DOC" + if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { + docCode = string(dto.HPPCodeDepresiasi) + docLabel = "Depresiasi" + } docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam) docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice) hppItems = append(hppItems, dto.ToHPPItem( 3, "purchase", - "DOC", - "DOC", + docCode, + docLabel, dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam), dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice), )) - // OVERHEAD operational item (before EKSPEDISI) + // OVERHEAD item overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational) overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization) hppItems = append(hppItems, dto.ToHPPItem( 4, "overhead", - "OVERHEAD", + string(dto.HPPCodeOverhead), "Pengeluaran Overhead", dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational), dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization), )) - // EKSPEDISI item (overhead) + // EKSPEDISI item ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi) ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization) hppItems = append(hppItems, dto.ToHPPItem( 5, "overhead", - "EKSPEDISI", + string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi), dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization), @@ -535,7 +545,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID hppSection := dto.ToHPPSection(hppItems, hppSummary) - // Build Profit Loss Items + // Build Profit Loss Items using constants plItems := []dto.ProfitLossItem{} // SALES item @@ -545,7 +555,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID salesLabel = "Penjualan Telur" } plItems = append(plItems, dto.ToProfitLossItem( - "SALES", + string(dto.PLCodeSales), salesLabel, "income", salesRpPerBird, @@ -553,44 +563,24 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID totalSalesAmount, )) - // PURCHASE_DOC item - purchaseDocLabel := "Pembelian DOC" - if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - purchaseDocLabel = "Depresiasi" - } + // SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK + totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice + sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird + sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg + sapronakLabel := "Pengeluaran Sapronak" plItems = append(plItems, dto.ToProfitLossItem( - "PURCHASE_DOC", - purchaseDocLabel, + string(dto.PLCodeSapronak), + sapronakLabel, "purchase", - docRealizationRpPerBird, - docRealizationRpPerKg, - totalAyamPrice, - )) - - // PAKAN item - plItems = append(plItems, dto.ToProfitLossItem( - "PAKAN", - "Pakan", - "purchase", - pakanRealizationRpPerBird, - pakanRealizationRpPerKg, - totalPakanPrice, - )) - - // OVK item - plItems = append(plItems, dto.ToProfitLossItem( - "OVK", - "OVK", - "purchase", - ovkRealizationRpPerBird, - ovkRealizationRpPerKg, - totalOvkPrice, + sapronakRpPerBird, + sapronakRpPerKg, + totalSapronakAmount, )) // OVERHEAD item overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization) plItems = append(plItems, dto.ToProfitLossItem( - "OVERHEAD", + string(dto.PLCodeOverhead), "Overhead", "overhead", overheadRpPerBird, @@ -600,7 +590,7 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID // EKSPEDISI item plItems = append(plItems, dto.ToProfitLossItem( - "EKSPEDISI", + string(dto.PLCodeEkspedisi), "Ekspedisi", "overhead", ekspedisiRealizationRpPerBird, @@ -609,21 +599,21 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID )) // Profit Loss Summary - // Calculate total cost of goods sold (HPP) - use realization - totalCostOfGoodsSold := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization - totalCostOfGoodsSoldRpPerBird := pakanRealizationRpPerBird + ovkRealizationRpPerBird + docRealizationRpPerBird + overheadRpPerBird + ekspedisiRealizationRpPerBird + // Gross Profit = Sales - (DOC + PAKAN + OVK) only + // Gross Profit should NOT include overhead and ekspedisi + costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice + costOfGoodsSoldRpPerBird := sapronakRpPerBird - // Gross Profit = Sales - Cost of Goods Sold - grossProfit := totalSalesAmount - totalCostOfGoodsSold - grossProfitRpPerBird := salesRpPerBird - totalCostOfGoodsSoldRpPerBird + grossProfit := totalSalesAmount - costOfGoodsSold + grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird - // Operating Expenses (already included in COGS above, so this shows the breakdown) + // Operating Expenses (Overhead + Ekspedisi) totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird - // Net Profit = Gross Profit (COGS already deducted) - netProfit := grossProfit - netProfitRpPerBird := grossProfitRpPerBird + // Net Profit = Gross Profit - Operating Expenses + netProfit := grossProfit - totalOperatingExpenses + netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird plSummary := dto.ToProfitLossSummary( dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit), @@ -635,13 +625,8 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID // Build complete response data := dto.ToClosingKeuanganData(hppSection, profitLossSection) - response := dto.ToSuccessClosingKeuanganResponse(data) - s.Log.Infof("===== NEW DTO RESPONSE BUILT SUCCESSFULLY =====") - s.Log.Infof("HPP Items: %d, Profit Loss Items: %d", len(hppItems), len(plItems)) - s.Log.Infof("===== END GetClosingKeuangan for ProjectFlockID: %d =====", projectFlockID) - - return &response, nil + return &data, nil } // containsItem checks if a string exists in a slice diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index db43b2fa..703c05f0 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -563,16 +563,15 @@ func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectF err := r.DB().WithContext(ctx). Table("project_flock_kandang_uniformity"). - Select("COALESCE(SUM(mean_weight * chick_qty_of_weight / 1000), 0) as total_weight"). + Select("COALESCE(SUM((mean_up / 1.10) * chick_qty_of_weight / 1000), 0) as total_weight"). Joins("JOIN ("+ " SELECT pfku.project_flock_kandang_id, MAX(pfku.id) as latest_id "+ " FROM project_flock_kandang_uniformity pfku "+ " JOIN project_flock_kandangs pfk ON pfk.id = pfku.project_flock_kandang_id "+ " WHERE pfk.project_flock_id = ? "+ - " AND pfku.deleted_at IS NULL "+ " GROUP BY pfku.project_flock_kandang_id "+ ") latest ON latest.project_flock_kandang_id = project_flock_kandang_uniformity.project_flock_kandang_id "+ - "AND project_flock_kandang_uniformity.id = latest.latest_id"). + "AND project_flock_kandang_uniformity.id = latest.latest_id", projectFlockID). Scan(&result).Error return result.TotalWeight, err From 302f0ed87706bd249e14fa5648c569a0cdfd3765 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 21:34:49 +0700 Subject: [PATCH 035/117] fix:[BE] Remove unnecessary filters and update profit loss calculation logic in ClosingKeuanganService --- .../closings/services/closingKeuangan.service.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/modules/closings/services/closingKeuangan.service.go b/internal/modules/closings/services/closingKeuangan.service.go index ffb7dbf4..0f3351f7 100644 --- a/internal/modules/closings/services/closingKeuangan.service.go +++ b/internal/modules/closings/services/closingKeuangan.service.go @@ -146,7 +146,7 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) { // Define flag filters using constants pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)} - ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), string(utils.FlagEkspedisi)} + ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)} ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)} allFilters := append(pakanFilters, ovkFilters...) allFilters = append(allFilters, ayamFilters...) @@ -336,7 +336,7 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl // Budget per category budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}) - budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA", "EKSPEDISI"}) + budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"}) budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"}) budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"}) @@ -432,13 +432,13 @@ func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFl return } - // Calculate metrics for profit loss (use total population for sales) + // Calculate metrics for profit loss (use total population and total weight produced) calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { if totalPopulation > 0 { rpPerBird = amount / totalPopulation } - if totalWeightSold > 0 { - rpPerKg = amount / totalWeightSold + if totalWeightProduced > 0 { + rpPerKg = amount / totalWeightProduced } return } From ef482dd1b9f347511fc3bb2fb3f3b5d5a1d0e97a Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 16 Jan 2026 21:37:51 +0700 Subject: [PATCH 036/117] feat[BE]: Add new ClosingKeuangan DTO and related mapper functions --- .../dto/{closingKeuanganNew.dto.go => closingKeuangan.dto.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/modules/closings/dto/{closingKeuanganNew.dto.go => closingKeuangan.dto.go} (100%) diff --git a/internal/modules/closings/dto/closingKeuanganNew.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go similarity index 100% rename from internal/modules/closings/dto/closingKeuanganNew.dto.go rename to internal/modules/closings/dto/closingKeuangan.dto.go From fbeccf4cdc3899f83fe8ae9da3096846d4cd37b7 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 17 Jan 2026 11:01:08 +0700 Subject: [PATCH 037/117] fix query outgoing sapronak --- .../modules/closings/repositories/closing.repository.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 582a1207..507d7c88 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -534,6 +534,13 @@ JOIN products prod ON prod.id = pw.product_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 ? + AND EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_id = pw.product_id + AND f.flagable_type = 'products' + AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET') + ) ` ) From dcfb5e10b478a2b7f11af0c451e848d5ca41dbf0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 17 Jan 2026 11:34:12 +0700 Subject: [PATCH 038/117] adjust max limit to 1000 --- internal/modules/repports/validations/repport.validation.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index bddb5ab1..85df9aa5 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -56,7 +56,7 @@ type DebtSupplierQuery struct { type HppPerKandangQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` Period string `query:"period" validate:"required"` ShowUnrecorded bool `query:"show_unrecorded"` AreaIDs []int64 `query:"-"` @@ -68,7 +68,7 @@ type HppPerKandangQuery struct { type ProductionResultQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` } From 533e9aca6f3504d65f57d53ed85bfc7eb37ee8e4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 17 Jan 2026 12:20:40 +0700 Subject: [PATCH 039/117] FIX[BE]: Fixing filter area and location --- .../salesorder_delivery_product.repository.go | 16 ++++++++++++++-- .../repports/controllers/repport.controller.go | 2 ++ .../repports/validations/repport.validation.go | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index f14988b1..286206e0 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -140,7 +140,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Where("marketing_delivery_products.delivery_date IS NOT NULL") - if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" { + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") } @@ -178,6 +178,19 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) } + if filters.AreaId > 0 || filters.LocationId > 0 { + db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id") + + if filters.AreaId > 0 { + db = db.Where("project_flocks.area_id = ?", filters.AreaId) + } + + if filters.LocationId > 0 { + db = db.Where("project_flocks.location_id = ?", filters.LocationId) + } + } + if filters.MarketingType != "" { db = db.Joins("LEFT JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Group("marketing_delivery_products.id") @@ -186,7 +199,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C case "ayam": db = db.Where("flags.name IN (?)", []string{ string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), - string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati), }) case "telur": db = db.Where("flags.name IN (?)", []string{ diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index f83f0902..c1982279 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -82,6 +82,8 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { ProductId: int64(ctx.QueryInt("product_id", 0)), WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)), SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)), + AreaId: int64(ctx.QueryInt("area_id", 0)), + LocationId: int64(ctx.QueryInt("location_id", 0)), MarketingType: ctx.Query("marketing_type", ""), FilterBy: ctx.Query("filter_by", ""), StartDate: ctx.Query("start_date", ""), diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index bddb5ab1..1ef5e0fe 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -23,6 +23,8 @@ type MarketingQuery struct { ProductId int64 `query:"product_id" validate:"omitempty"` WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` + AreaId int64 `query:"area_id" validate:"omitempty"` + LocationId int64 `query:"location_id" validate:"omitempty"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` From 7d3602d829b30400430ca9020aa4aaf42276bc5d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 17 Jan 2026 13:22:01 +0700 Subject: [PATCH 040/117] feat[BE]: Enhance CreateOne method to validate project flock closing status and handle warehouse without kandang_id --- .../transfers/services/transfer.service.go | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..1c085976 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -159,12 +159,15 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } - 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") + // Hanya validasi closing jika ada project flock kandang (warehouse punya kandang_id) + if destPfkID > 0 { + 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) @@ -256,11 +259,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if err != nil { return err } + + // Set ProjectFlockKandangId hanya jika ada kandang + var pfkID *uint + if projectFlockKandangID > 0 { + pfkID = &projectFlockKandangID + } + destPW = &entity.ProductWarehouse{ ProductId: uint(product.ProductID), WarehouseId: uint(req.DestinationWarehouseID), Quantity: 0, - ProjectFlockKandangId: &projectFlockKandangID, + ProjectFlockKandangId: pfkID, } if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") @@ -477,8 +487,9 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } + // Jika warehouse tidak punya kandang_id, return 0 tanpa error if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + return 0, nil } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) From 687d02313bd926500961aaf27f099637190946fd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 19:46:09 +0700 Subject: [PATCH 041/117] feat[BE]: Update TransferRelationDTO and service search logic to include warehouse names --- .../inventory/transfers/dto/transfer.dto.go | 77 ++++--------------- .../transfers/services/transfer.service.go | 6 +- 2 files changed, 19 insertions(+), 64 deletions(-) diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 8fa4d158..73b1a66c 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -4,20 +4,17 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) type TransferRelationDTO struct { - Id uint64 `json:"id"` - TransferReason string `json:"transfer_reason"` - TransferDate string `json:"transfer_date"` - SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` - DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` -} - -type WarehouseSimpleDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint64 `json:"id"` + MovementNumber string `json:"movement_number"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"` } type ProductSimpleDTO struct { @@ -25,16 +22,6 @@ type ProductSimpleDTO struct { Name string `json:"name"` } -type AreaDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - type SupplierSimpleDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -48,13 +35,6 @@ type DocumentDTO struct { Size float64 `json:"size"` } -type WarehouseDetailDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Location *LocationDTO `json:"location"` - Area *AreaDTO `json:"area"` -} - type TransferListDTO struct { TransferRelationDTO CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` @@ -97,16 +77,19 @@ type TransferDeliveryItemDTO struct { } func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { - var sourceWarehouse *WarehouseDetailDTO + var sourceWarehouse *warehouseDTO.WarehouseRelationDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { - sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) + mapped := warehouseDTO.ToWarehouseRelationDTO(*e.FromWarehouse) + sourceWarehouse = &mapped } - var destinationWarehouse *WarehouseDetailDTO + var destinationWarehouse *warehouseDTO.WarehouseRelationDTO if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { - destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) + mapped := warehouseDTO.ToWarehouseRelationDTO(*e.ToWarehouse) + destinationWarehouse = &mapped } return TransferRelationDTO{ Id: e.Id, + MovementNumber: e.MovementNumber, TransferReason: e.Reason, TransferDate: e.CreatedAt.Format("2006-01-02"), SourceWarehouse: sourceWarehouse, @@ -114,38 +97,6 @@ func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { } } -func toAreaDTO(a *entity.Area) *AreaDTO { - if a == nil { - return nil - } - return &AreaDTO{ - Id: a.Id, - Name: a.Name, - } -} - -func toLocationDTO(l *entity.Location) *LocationDTO { - if l == nil { - return nil - } - return &LocationDTO{ - Id: l.Id, - Name: l.Name, - } -} - -func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { - if w == nil { - return nil - } - return &WarehouseDetailDTO{ - Id: w.Id, - Name: w.Name, - Location: toLocationDTO(w.Location), - Area: toAreaDTO(&w.Area), - } -} - func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var createdUser *userDTO.UserRelationDTO if e.CreatedUser != nil { diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3f12b444..35ddb4b1 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -99,7 +99,11 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - db = db.Where("movement_number ILIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + searchTerm := "%" + strings.TrimSpace(params.Search) + "%" + db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id"). + Joins("LEFT JOIN warehouses AS to_warehouses ON to_warehouses.id = stock_transfers.to_warehouse_id"). + Where("movement_number ILIKE ? OR from_warehouses.name ILIKE ? OR to_warehouses.name ILIKE ?", + searchTerm, searchTerm, searchTerm) } return db.Order("created_at DESC").Order("updated_at DESC") }) From bac36b4f00ff68d4111c791f6e7235cd974dd80f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 20:36:33 +0700 Subject: [PATCH 042/117] fix[BE]: Update error messages in TransferService to provide clearer context in Indonesian --- .../transfers/services/transfer.service.go | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 35ddb4b1..482916cb 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -122,9 +122,9 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id)) } return transferPtr, nil @@ -140,12 +140,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek stok produk di gudang asal") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID)) } if sourcePW.Quantity < product.ProductQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) } pwIDs = append(pwIDs, sourcePW.Id) } @@ -165,10 +165,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") } if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) } actorID, err := m.ActorIDFromContext(c) @@ -196,16 +196,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID)) } if supplier.Category != string(utils.SupplierCategoryBOP) { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category)) } } movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement transfer") } transferDate, _ := utils.ParseDateString(req.TransferDate) @@ -243,16 +243,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ) 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.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID)) } destPW, err := productWarehouseRepoTX.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") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } if errors.Is(err, gorm.ErrRecordNotFound) { ctx := c.Context() @@ -267,7 +267,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: &projectFlockKandangID, } if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID)) } } @@ -313,7 +313,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques for _, prod := range item.Products { detail, ok := detailMap[uint64(prod.ProductID)] if !ok { - return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan dalam daftar transfer untuk delivery #%d", prod.ProductID, i+1)) } deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ StockTransferDeliveryId: delivery.Id, @@ -376,7 +376,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Tx: tx, }) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) } if err := tx.Model(&entity.StockTransferDetail{}). @@ -385,7 +385,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques "usage_qty": consumeResult.UsageQuantity, "pending_qty": consumeResult.PendingQuantity, }).Error; err != nil { - return fmt.Errorf("gagal update usage tracking: %w", err) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID)) } note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) @@ -398,7 +398,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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)) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err)) } if err := tx.Model(&entity.StockTransferDetail{}). @@ -406,7 +406,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Updates(map[string]interface{}{ "total_qty": replenishResult.AddedQuantity, }).Error; err != nil { - return fmt.Errorf("gagal update total tracking: %w", err) + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID)) } } @@ -440,7 +440,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err)) } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -450,8 +450,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if len(expensePayloads) > 0 { if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { - s.Log.Errorf("Failed to sync expense for transfer %d: %+v", entityTransfer.Id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync expense: %v", err)) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber)) } } @@ -478,19 +477,19 @@ 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)) } - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID)) } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang '%s' (ID: %d) belum terhubung ke kandang", warehouse.Name, warehouseID)) } projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId)) } - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock kandang yang aktif") } return uint(projectFlockKandang.Id), nil From af7aabdec8511d61a7e913744773be65e7f20b81 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 20:50:52 +0700 Subject: [PATCH 043/117] fix[BE]: Adjust validation for MarketingQuery limit to remove max constraint --- internal/modules/repports/validations/repport.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index bddb5ab1..b178202f 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -17,7 +17,7 @@ type ExpenseQuery struct { type MarketingQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` Search string `query:"search" validate:"omitempty,max=100"` CustomerId int64 `query:"customer_id" validate:"omitempty"` ProductId int64 `query:"product_id" validate:"omitempty"` From fb193fc61f2aa1d29a4fb653dd86ae80e5ffad04 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sun, 18 Jan 2026 21:26:31 +0700 Subject: [PATCH 044/117] fix[BE]: Update GetAllWithFilters to enhance search functionality and join conditions --- .../salesorder_delivery_product.repository.go | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index f14988b1..8c0bad3d 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -141,25 +141,37 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Where("marketing_delivery_products.delivery_date IS NOT NULL") if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" { - db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") + db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_delivery_products.product_warehouse_id") } if filters.ProductId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") } - if filters.WarehouseId > 0 { + if filters.WarehouseId > 0 || filters.Search != "" { db = db.Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id") } if filters.Search != "" { db = db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id") - } + db = db.Joins("LEFT JOIN users AS sales_users ON sales_users.id = marketings.sales_person_id") - if filters.Search != "" { searchPattern := "%" + filters.Search + "%" - db = db.Where("marketing_delivery_products.vehicle_number ILIKE ? OR marketings.so_number ILIKE ? OR customers.name ILIKE ? OR products.name ILIKE ?", - searchPattern, searchPattern, searchPattern, searchPattern) + db = db.Where(`( + marketing_delivery_products.vehicle_number ILIKE ? OR + customers.name ILIKE ? OR + warehouses.name ILIKE ? OR + products.name ILIKE ? OR + sales_users.name ILIKE ? OR + CONCAT( + marketings.so_number, + '-', + COALESCE(TO_CHAR(marketing_delivery_products.delivery_date, 'YYYYMMDD'), ''), + '-', + COALESCE(product_warehouses.warehouse_id::text, '') + ) ILIKE ? + )`, + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern) } if filters.CustomerId > 0 { From 378d633ea49967af3f447ffb1e61e001b8c82fc8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 09:27:37 +0700 Subject: [PATCH 045/117] feat[BE]: Enhance payment allocation logic to support FIFO consumption for sales transactions --- .../repports/services/repport.service.go | 78 ++++++++++++++++--- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 2e07e212..48f9e205 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -464,9 +464,13 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C if err != nil { return nil, 0, err } - result = append(result, item) + + if len(item.Rows) > 0 { + result = append(result, item) + } } + totalCustomers = int64(len(result)) return result, totalCustomers, nil } @@ -503,14 +507,8 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID row.Status = status if status == "LUNAS" { - if previousBalance >= tx.TotalPrice { - days := 0 - row.AgingDay = &days - } else if paymentDate != nil { + if paymentDate != nil { days := int(paymentDate.Sub(tx.TransDate).Hours() / 24) - if days < 0 { - days = 0 - } row.AgingDay = &days } else { days := 0 @@ -518,9 +516,6 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID } } else { days := int(time.Since(tx.TransDate).Hours() / 24) - if days < 0 { - days = 0 - } row.AgingDay = &days } } else if tx.TransactionType == "PAYMENT" { @@ -586,6 +581,67 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo // 4. BELUM LUNAS: no payment at all if previousBalance >= currentSales.TotalPrice { + // Cari payment yang digunakan untuk melunasi sales ini dengan FIFO + // Track payment allocations that are consumed by previous sales + type paymentAllocation struct { + date time.Time + amount float64 + consumed float64 + } + allocations := []paymentAllocation{} + runningBalance := 0.0 + + // Process all transactions before current sales to build allocation map + for i := 0; i < currentIndex; i++ { + if transactions[i].TransactionType == "PAYMENT" { + allocations = append(allocations, paymentAllocation{ + date: transactions[i].TransDate, + amount: transactions[i].PaymentAmount, + consumed: 0, + }) + runningBalance += transactions[i].PaymentAmount + } else if transactions[i].TransactionType == "SALES" { + salesAmount := transactions[i].TotalPrice + remainingToConsume := salesAmount + + // Consume from oldest allocations first (FIFO) + for j := range allocations { + if remainingToConsume <= 0 { + break + } + available := allocations[j].amount - allocations[j].consumed + if available > 0 { + consume := available + if consume > remainingToConsume { + consume = remainingToConsume + } + allocations[j].consumed += consume + remainingToConsume -= consume + } + } + runningBalance -= salesAmount + } + } + + // Now find which allocation covers the current sales + amountNeeded := currentSales.TotalPrice + for _, alloc := range allocations { + available := alloc.amount - alloc.consumed + if available > 0 { + if amountNeeded <= available { + // This allocation fully covers the sales + return "LUNAS", &alloc.date + } else { + // This allocation partially covers, continue to next + amountNeeded -= available + } + } + } + + // If we get here, use the oldest allocation + if len(allocations) > 0 { + return "LUNAS", &allocations[0].date + } return "LUNAS", nil } From 8cd9627a511bfcc7f5d935d5cb149da28bd65f04 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 14:34:08 +0700 Subject: [PATCH 046/117] feat[BE]: Add requested_qty field to LayingTransferSource and update related logic for transfer operations --- ...ed_qty_to_laying_transfer_sources.down.sql | 4 ++ ...sted_qty_to_laying_transfer_sources.up.sql | 9 +++ internal/entities/laying_transfer_source.go | 1 + internal/modules/inventory/transfers/route.go | 6 +- .../chickins/services/chickin.service.go | 4 +- .../project_flock_population_repository.go | 23 +++++++- .../dto/transfer_laying.dto.go | 12 +++- .../services/transfer_laying.service.go | 58 ++++++++++++++++--- .../validations/transfer_laying.validation.go | 12 ++-- 9 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql create mode 100644 internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql diff --git a/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql new file mode 100644 index 00000000..7dd06499 --- /dev/null +++ b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.down.sql @@ -0,0 +1,4 @@ +-- Rollback: Remove requested_qty column from laying_transfer_sources table + +ALTER TABLE laying_transfer_sources +DROP COLUMN IF EXISTS requested_qty; diff --git a/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql new file mode 100644 index 00000000..dc28ca74 --- /dev/null +++ b/internal/database/migrations/20260119055524_add_requested_qty_to_laying_transfer_sources.up.sql @@ -0,0 +1,9 @@ +-- Add requested_qty column to laying_transfer_sources table +-- This field stores the quantity requested by user during create/update +-- Separate from UsageQty (FIFO consumed) and PendingUsageQty (FIFO pending) + +ALTER TABLE laying_transfer_sources +ADD COLUMN requested_qty NUMERIC(15,3) DEFAULT 0 NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN laying_transfer_sources.requested_qty IS 'Quantity requested by user during create/update'; diff --git a/internal/entities/laying_transfer_source.go b/internal/entities/laying_transfer_source.go index e0b85774..b284746d 100644 --- a/internal/entities/laying_transfer_source.go +++ b/internal/entities/laying_transfer_source.go @@ -11,6 +11,7 @@ type LayingTransferSource struct { LayingTransferId uint `gorm:"index;not null"` SourceProjectFlockKandangId uint `gorm:"not null"` ProductWarehouseId *uint `gorm:""` + RequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // Quantity requested by user UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field Note string `gorm:"type:text"` diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index d24dbcb4..f754148c 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -15,8 +15,8 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ route := v1.Group("/transfers") route.Use(m.Auth(u)) - route.Get("/",m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) - route.Post("/",m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) - route.Get("/:id",m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) + route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) + route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) + route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 84e98f2d..b39dca78 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -200,9 +200,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti newChikins = append(newChikins, newChickin) - totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) + totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), chickinReq.ProductWarehouseId) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for project_flock_kandang %d", req.ProjectFlockKandangId)) + return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for product warehouse %d", chickinReq.ProductWarehouseId)) } availableQty := productWarehouse.Quantity - totalPopulationQty diff --git a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go index 022da6a3..36fe8cbc 100644 --- a/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go +++ b/internal/modules/production/project_flocks/repositories/project_flock_population_repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "math" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -16,6 +17,7 @@ type ProjectFlockPopulationRepository interface { GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) + GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error @@ -111,7 +113,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(c err := r.DB().WithContext(ctx). Model(&entity.ProjectFlockPopulation{}). Where("product_warehouse_id = ?", productWarehouseID). - Select("COALESCE(SUM(total_qty), 0)"). + Select("COALESCE(SUM(total_qty - total_used_qty), 0)"). Scan(&total).Error if err != nil { return 0, err @@ -135,3 +137,22 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand } return total, nil } + +func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) { + var total float64 + err := r.DB().WithContext(ctx). + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID). + Scan(&total).Error + if err != nil { + return 0, err + } + + if total < 0 { + total = 0 + } + + return int64(math.Round(total)), nil +} diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index e81d6cc5..dfc5e5d9 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -162,9 +162,19 @@ func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouse } func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO { + // Tampilkan requested qty sebelum approve, consumed qty setelah approve + var displayQty float64 + if source.UsageQty > 0 { + // Sudah di-approve dan di-consume, tampilkan actual consumed quantity + displayQty = source.UsageQty + } else { + // Belum di-approve, tampilkan requested quantity + displayQty = source.RequestedQty + } + return LayingTransferSourceDTO{ SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), - Qty: source.UsageQty, // Ambil dari UsageQty (FIFO consumed quantity) + Qty: displayQty, ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), Note: source.Note, } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 9732ad75..3fe0b0b7 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -110,8 +110,32 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ offset := (params.Page - 1) * params.Limit transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + // Apply search and filters + if params.Search != "" { + searchPattern := "%" + params.Search + "%" + db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id"). + Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id"). + Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?", + searchPattern, searchPattern, searchPattern, searchPattern) + } + + if params.TransferDate != "" { + db = db.Where("transfer_date::date = ?::date", params.TransferDate) + } + + if params.FlockSource > 0 { + db = db.Where("from_project_flock_id = ?", params.FlockSource) + } + + if params.FlockDestination > 0 { + db = db.Where("to_project_flock_id = ?", params.FlockDestination) + } + db = db.Order("created_at DESC") + + // Apply relations for eager loading + db = s.withRelations(db) + return db }) @@ -216,7 +240,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, sourceDetail := range req.SourceKandangs { if sourceDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang sumber harus lebih dari 0") + continue } totalSourceQty += sourceDetail.Quantity @@ -247,11 +271,18 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) for _, targetDetail := range req.TargetKandangs { if targetDetail.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan harus lebih dari 0") + continue } totalTargetQty += targetDetail.Quantity } + if totalSourceQty == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0") + } + if totalTargetQty == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0") + } + if totalSourceQty != totalTargetQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) } @@ -279,11 +310,16 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } for _, sourceDetail := range req.SourceKandangs { + if sourceDetail.Quantity == 0 { + continue + } + productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] source := entity.LayingTransferSource{ LayingTransferId: createBody.Id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, + RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user UsageQty: 0, PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, @@ -295,6 +331,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) } for _, targetDetail := range req.TargetKandangs { + if targetDetail.Quantity == 0 { + continue + } targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) if err != nil { @@ -463,8 +502,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, source := entity.LayingTransferSource{ LayingTransferId: id, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, + RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user UsageQty: 0, - PendingUsageQty: sourceDetail.Quantity, + PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval ProductWarehouseId: &productWarehouseId, } if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil { @@ -700,7 +740,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) } - note := fmt.Sprintf("Transfer to Laying #%s - Target Kandang", transfer.TransferNumber) + note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyTransferToLayingIn, StockableID: target.Id, @@ -814,15 +854,15 @@ func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, project kandangAvailableQty := make(map[uint]float64) for _, kandang := range kandangs { - - totalQty, err := s.ProjectFlockPopulationRepo.GetTotalQtyByProjectFlockKandangID(ctx.Context(), kandang.Id) + // Gunakan fungsi repository yang sama dengan recording service + totalAvailable, err := s.ProjectFlockPopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), kandang.Id) if err != nil { - s.Log.Warnf("Failed to get total qty for kandang %d: %+v", kandang.Id, err) + s.Log.Warnf("Failed to get available qty for kandang %d: %+v", kandang.Id, err) kandangAvailableQty[kandang.Id] = 0 continue } - kandangAvailableQty[kandang.Id] = totalQty + kandangAvailableQty[kandang.Id] = totalAvailable } return pf, kandangAvailableQty, nil diff --git a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go index 45a73e48..06d52316 100644 --- a/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go +++ b/internal/modules/production/transfer_layings/validations/transfer_laying.validation.go @@ -2,12 +2,12 @@ package validation type SourceKandangDetail struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + Quantity float64 `json:"quantity"` } type TargetKandangDetail struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` + Quantity float64 `json:"quantity"` } type Create struct { @@ -29,8 +29,12 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + 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"` + TransferDate string `query:"transfer_date" validate:"omitempty"` + FlockSource uint `query:"flock_source" validate:"omitempty,number"` + FlockDestination uint `query:"flock_destination" validate:"omitempty,number"` } type Approve struct { From 768961d7d6baf7d9ba48b1fbc6274fabb384c49c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 14:39:43 +0700 Subject: [PATCH 047/117] fix[BE]: Refactor GetAll method to improve query parameter handling and formatting --- .../controllers/transfer_laying.controller.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index d2ab6d0a..13c39334 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -25,8 +25,12 @@ func NewTransferLayingController(transferLayingService service.TransferLayingSer func (u *TransferLayingController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + TransferDate: c.Query("transfer_date", ""), + FlockSource: uint(c.QueryInt("flock_source", 0)), + FlockDestination: uint(c.QueryInt("flock_destination", 0)), } if query.Page < 1 || query.Limit < 1 { @@ -179,7 +183,6 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error { }) } - func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) if err != nil { From 71c62c5e0235a5d05792f0d318036c5f2f2c0c9e Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 19 Jan 2026 16:19:47 +0700 Subject: [PATCH 048/117] [FIX][BE]: LSS390 --- .../dto/project_flock_kandang.dto.go | 14 +++ .../modules/repports/dto/repportHpp.dto.go | 6 +- .../hpp_per_kandang.repository.go | 71 +++++++----- .../repports/services/repport.service.go | 103 ++++++++++-------- 4 files changed, 121 insertions(+), 73 deletions(-) diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index 452cc7b3..c8faf761 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -1,6 +1,7 @@ package dto import ( + "strconv" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -53,6 +54,7 @@ type ProjectFlockKandangListDTO struct { ProjectFlockKandangRelationDTO ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + NameWithPeriod string `json:"name_with_period"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` @@ -104,6 +106,7 @@ func ToProjectFlockKandangDetailDTOWithAvailableQty(e entity.ProjectFlockKandang ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e), ProjectFlock: toProjectFlockDTO(projectFlockSummary), Kandang: toKandangRelation(e.Kandang), + NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), @@ -126,6 +129,16 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO { return &mapped } +func toNameWithPeriod(kandang entity.Kandang, period int) string { + if kandang.Name == "" { + return "" + } + if period == 0 { + return kandang.Name + } + return kandang.Name + " Period " + strconv.Itoa(period) +} + func toApprovalDTOSelector( e entity.ProjectFlockKandang, selector func(entity.ProjectFlockKandang) *entity.Approval) *approvalDTO.ApprovalRelationDTO { approval := selector(e) @@ -147,6 +160,7 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e), ProjectFlock: toProjectFlockDTO(projectFlockSummary), Kandang: toKandangRelation(e.Kandang), + NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period), CreatedAt: e.CreatedAt, CreatedUser: toCreatedUserDTO(e.ProjectFlock), Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index f790244c..15b6d51b 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -31,6 +31,8 @@ type HppPerKandangRowDTO struct { AvgWeightKg float64 `json:"avg_weight_kg"` EggProductionPieces int64 `json:"egg_production_pieces"` EggProductionKg float64 `json:"egg_production_kg"` + // EggProductionTotalWeightKg float64 `json:"egg_production_total_weight_kg"` + // EggProductionTotalPieces int64 `json:"egg_production_total_pieces"` // FeedCostRp float64 `json:"feed_cost_rp"` // OvkCostRp float64 `json:"ovk_cost_rp"` EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` @@ -38,8 +40,8 @@ type HppPerKandangRowDTO struct { FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` AverageDocPriceRp int64 `json:"average_doc_price_rp"` - HppRp float64 `json:"hpp_rp"` - RemainingValueRp int64 `json:"remaining_value_rp"` + // HppRp float64 `json:"hpp_rp"` + // RemainingValueRp int64 `json:"remaining_value_rp"` } type HppPerKandangRowKandangDTO struct { diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 64676ca8..37a37d45 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -11,19 +11,21 @@ import ( ) type HppPerKandangRow struct { - ProjectFlockKandangID uint - KandangID uint - KandangName string - KandangStatus string - LocationID uint - LocationName string - PicID uint - PicName string - RecordingCount int64 - RemainingChickenBirds float64 - RemainingChickenWeight float64 - EggProductionWeightKg float64 - EggProductionPieces float64 + ProjectFlockKandangID uint + KandangID uint + KandangName string + KandangStatus string + LocationID uint + LocationName string + PicID uint + PicName string + RecordingCount int64 + // RemainingChickenBirds float64 + // RemainingChickenWeight float64 + EggProductionWeightKgRemaining float64 + EggProductionPiecesRemaining float64 + EggProductionTotalWeightKg float64 + EggProductionTotalPieces float64 } type HppPerKandangCostRow struct { @@ -97,13 +99,22 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en COALESCE(MAX(vr.total_chick_qty), 0) AS remaining_chicken_birds, 0 AS remaining_chicken_weight, 0 AS egg_production_weight_kg, - 0 AS egg_production_pieces`). + 0 AS egg_production_pieces, + 0 AS egg_production_total_weight_kg, + 0 AS egg_production_total_pieces`). Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id"). + Joins(` + LEFT JOIN ( + SELECT project_flock_kandang_id, MIN(chick_in_date) AS chick_in_date + FROM project_chickins + GROUP BY project_flock_kandang_id + ) AS pc ON pc.project_flock_kandang_id = pfk.id`). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN users AS pic ON pic.id = k.pic_id"). Joins("LEFT JOIN (?) AS vr ON vr.project_flock_kandangs_id = pfk.id", validRecordings). - Where("pfk.closed_at IS NULL") + Where("pf.category = ?", utils.ProjectFlockCategoryLaying). + Where("(pfk.closed_at IS NULL OR ? BETWEEN pc.chick_in_date AND pfk.closed_at)", start) query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) @@ -164,7 +175,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, 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 ?", projectFlockKandangIDs). - // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.record_datetime < ?", end). Where("r.deleted_at IS NULL"). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) @@ -349,7 +360,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.record_datetime < ?", end). Where("r.deleted_at IS NULL") // feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) @@ -391,9 +402,11 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c ) type eggRow struct { - ProjectFlockKandangID uint - EggProductionWeightKg float64 - EggProductionPieces float64 + ProjectFlockKandangID uint + EggProductionWeightKgRemaining float64 + EggProductionPiecesRemaining float64 + EggProductionTotalWeightKg float64 + EggProductionTotalPieces float64 } eggRows := make([]eggRow, 0) @@ -401,12 +414,14 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, - COALESCE(SUM(re.weight), 0) AS egg_production_weight_kg, - COALESCE(SUM(re.qty), 0) AS egg_production_pieces`). + COALESCE(SUM((re.total_qty - re.total_used) * re.weight / 1000), 0) AS egg_production_weight_kg_remaining, + COALESCE(SUM(re.total_qty - re.total_used), 0) AS egg_production_pieces_remaining, + COALESCE(SUM(re.weight / 1000), 0) AS egg_production_total_weight_kg, + COALESCE(SUM(re.total_qty), 0) AS egg_production_total_pieces`). Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). - // Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). + Where("r.record_datetime < ?", end). Where("r.deleted_at IS NULL"). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). Group("r.project_flock_kandangs_id") @@ -418,9 +433,11 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c result := make(map[uint]HppPerKandangRow, len(eggRows)) for _, row := range eggRows { result[row.ProjectFlockKandangID] = HppPerKandangRow{ - ProjectFlockKandangID: row.ProjectFlockKandangID, - EggProductionWeightKg: row.EggProductionWeightKg, - EggProductionPieces: row.EggProductionPieces, + ProjectFlockKandangID: row.ProjectFlockKandangID, + EggProductionWeightKgRemaining: row.EggProductionWeightKgRemaining, + EggProductionPiecesRemaining: row.EggProductionPiecesRemaining, + EggProductionTotalWeightKg: row.EggProductionTotalWeightKg, + EggProductionTotalPieces: row.EggProductionTotalPieces, } } @@ -435,7 +452,7 @@ func applyLocationFilters(query *gorm.DB, areaIDs, locationIDs, kandangIDs []int query = query.Where("k.location_id IN ?", locationIDs) } if len(kandangIDs) > 0 { - query = query.Where("k.id IN ?", kandangIDs) + query = query.Where("pfk.id IN ?", kandangIDs) } return query } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 2e07e212..9c0c600f 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1370,8 +1370,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } for pfkID, egg := range eggMap { if rowIdx, ok := pfkIndex[pfkID]; ok { - repoRows[rowIdx].EggProductionWeightKg = egg.EggProductionWeightKg - repoRows[rowIdx].EggProductionPieces = egg.EggProductionPieces + repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining + repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining + repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg + repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces } } } @@ -1442,12 +1444,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 - var totalWeight float64 + // var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 - var totalRemainingValueRp int64 + // var totalRemainingValueRp int64 var totalEggValueRp int64 - var totalHppSum float64 + // var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int @@ -1461,26 +1463,34 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - birdsFloat := row.RemainingChickenBirds - if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - birdsFloat = 0 + // birdsFloat := row.RemainingChickenBirds + // if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { + // birdsFloat = 0 + // } + // weightFloat := row.RemainingChickenWeight + // if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { + // weightFloat = 0 + // } + eggPiecesFloatRemaining := row.EggProductionPiecesRemaining + if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { + eggPiecesFloatRemaining = 0 } - weightFloat := row.RemainingChickenWeight - if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - weightFloat = 0 + eggTotalPiecesFloat := row.EggProductionTotalPieces + if math.IsNaN(eggTotalPiecesFloat) || math.IsInf(eggTotalPiecesFloat, 0) { + eggTotalPiecesFloat = 0 } - eggPiecesFloat := row.EggProductionPieces - if math.IsNaN(eggPiecesFloat) || math.IsInf(eggPiecesFloat, 0) { - eggPiecesFloat = 0 + eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining + if math.IsNaN(eggRemainingWeightFloatRemaining) || math.IsInf(eggRemainingWeightFloatRemaining, 0) { + eggRemainingWeightFloatRemaining = 0 } - eggWeightFloat := row.EggProductionWeightKg + eggWeightFloat := row.EggProductionTotalWeightKg if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { eggWeightFloat = 0 } avgWeight := 0.0 - if eggPiecesFloat > 0 { - avgWeight = eggWeightFloat / eggPiecesFloat + if eggTotalPiecesFloat > 0 { + avgWeight = eggWeightFloat / eggTotalPiecesFloat } if params.WeightMin != nil && avgWeight < *params.WeightMin { continue @@ -1496,21 +1506,21 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - rowBirds := int64(math.Round(birdsFloat)) + // rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.KandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - hppRp := 0.0 - if weightFloat > 0 { - hppRp = totalCost / weightFloat - } + // hppRp := 0.0 + // if weightFloat > 0 { + // hppRp = totalCost / weightFloat + // } eggHpp := 0.0 if eggWeightFloat > 0 { - eggHpp = totalCost / eggWeightFloat + eggHpp = (totalCost / eggWeightFloat) / 1000 } - rowEggPieces := int64(math.Round(eggPiecesFloat)) - rowEggValue := int64(eggHpp * eggWeightFloat) - rowRemainingValue := int64(hppRp * weightFloat) + rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) + rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) + // rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) @@ -1540,27 +1550,29 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes // OvkCostRp: costEntry.OvkCost, DocSuppliers: docSupplierMap[row.KandangID], FeedSuppliers: feedSupplierMap[row.KandangID], - EggProductionPieces: rowEggPieces, - EggProductionKg: eggWeightFloat, - AverageDocPriceRp: avgDocPrice, + EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), + EggProductionKg: eggRemainingWeightFloatRemaining, + // EggProductionTotalWeightKg: eggWeightFloat, + // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), + AverageDocPriceRp: avgDocPrice, // HppRp: hppRp, - EggHppRpPerKg: eggHpp, - RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + EggHppRpPerKg: eggHpp, + // RemainingValueRp: rowRemainingValue, + EggValueRp: rowEggValue, }) - totalBirds += rowBirds - totalWeight += weightFloat + // totalBirds += rowBirds + // totalWeight += weightFloat totalEggPieces += rowEggPieces - totalEggKg += eggWeightFloat - totalRemainingValueRp += rowRemainingValue + totalEggKg += eggRemainingWeightFloatRemaining + // totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue totalAvgWeightSum += avgWeight totalAvgWeightCount++ - if weightFloat > 0 { - totalHppSum += hppRp - totalHppCount++ - } + // if weightFloat > 0 { + // totalHppSum += hppRp + // totalHppCount++ + // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1587,8 +1599,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary := rangeAgg.Summary - rangeAgg.RemainingBirds += rowBirds - rangeAgg.RemainingWeightKg += row.RemainingChickenWeight + // rangeAgg.RemainingBirds += rowBirds + // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.KandangID] { @@ -1602,8 +1614,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } } rangeSummary.EggProductionPieces += rowEggPieces - rangeSummary.EggProductionKg += eggWeightFloat - rangeSummary.RemainingValueRp += rowRemainingValue + rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining + // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp @@ -1750,6 +1762,9 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp if err != nil { return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) } + if weightMin != nil && weightMax != nil && *weightMin > *weightMax { + return nil, dto.HppPerKandangFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, "weight_min must be less than or equal to weight_max") + } params := &validation.HppPerKandangQuery{ Page: page, From 3052497fc0538c17123c599d32bb6ad2471b07b0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 19 Jan 2026 17:05:43 +0700 Subject: [PATCH 049/117] adjust grouping by project flock kandang --- .../modules/repports/dto/repportHpp.dto.go | 1 + .../hpp_per_kandang.repository.go | 112 +++++++++--------- .../repports/services/repport.service.go | 29 +++-- 3 files changed, 74 insertions(+), 68 deletions(-) diff --git a/internal/modules/repports/dto/repportHpp.dto.go b/internal/modules/repports/dto/repportHpp.dto.go index 15b6d51b..dc0b81d4 100644 --- a/internal/modules/repports/dto/repportHpp.dto.go +++ b/internal/modules/repports/dto/repportHpp.dto.go @@ -27,6 +27,7 @@ type HppPerKandangResponseData struct { type HppPerKandangRowDTO struct { ID int `json:"id"` Kandang HppPerKandangRowKandangDTO `json:"kandang"` + NameWithPeriode string `json:"name_with_periode"` WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` AvgWeightKg float64 `json:"avg_weight_kg"` EggProductionPieces int64 `json:"egg_production_pieces"` diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 37a37d45..1135efbf 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -12,6 +12,7 @@ import ( type HppPerKandangRow struct { ProjectFlockKandangID uint + ProjectFlockPeriod int KandangID uint KandangName string KandangStatus string @@ -29,21 +30,21 @@ type HppPerKandangRow struct { } type HppPerKandangCostRow struct { - KandangID uint - FeedCost float64 - OvkCost float64 - DocCost float64 - DocQty float64 - BudgetCost float64 - ExpenseCost float64 + ProjectFlockKandangID uint + FeedCost float64 + OvkCost float64 + DocCost float64 + DocQty float64 + BudgetCost float64 + ExpenseCost float64 } type HppPerKandangSupplierRow struct { - KandangID uint - SupplierID uint - SupplierName string - SupplierAlias string - Category string + ProjectFlockKandangID uint + SupplierID uint + SupplierName string + SupplierAlias string + Category string } type HppPerKandangRepository interface { @@ -88,6 +89,7 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en Table("project_flocks AS pf"). Select(` pfk.id AS project_flock_kandang_id, + pfk.period AS project_flock_period, k.id AS kandang_id, k.name AS kandang_name, k.status AS kandang_status, @@ -118,8 +120,8 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en query = applyLocationFilters(query, areaIDs, locationIDs, kandangIDs) - query = query.Group("pfk.id, k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). - Order("k.id ASC") + query = query.Group("pfk.id, pfk.period, k.id, k.name, k.status, loc.id, loc.name, pic.id, pic.name"). + Order("pfk.id ASC") if err := query.Scan(&rows).Error; err != nil { return nil, err @@ -150,7 +152,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, query := r.db.WithContext(ctx). Table("recordings AS r"). Select(` - k.id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM(CASE WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) @@ -179,25 +181,25 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Where("r.deleted_at IS NULL"). Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) - query = query.Group("k.id").Order("k.id ASC") + query = query.Group("pfk.id").Order("pfk.id ASC") if err := query.Scan(&rows).Error; err != nil { return nil, nil, err } docRows := make([]struct { - KandangID uint - DocCost float64 - DocQty float64 - SupplierID *uint - SupplierName *string - SupplierAlias *string + ProjectFlockKandangID uint + DocCost float64 + DocQty float64 + SupplierID *uint + SupplierName *string + SupplierAlias *string }, 0) docQuery := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - pfk.kandang_id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, s.id AS supplier_id, @@ -210,7 +212,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). - Group("pfk.kandang_id, s.id, s.name, s.alias") + Group("pfk.id, s.id, s.name, s.alias") if err := docQuery.Scan(&docRows).Error; err != nil { return nil, nil, err @@ -219,28 +221,28 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, costMap := make(map[uint]*HppPerKandangCostRow, len(rows)) for i := range rows { row := rows[i] - costMap[row.KandangID] = &rows[i] + costMap[row.ProjectFlockKandangID] = &rows[i] } docSuppliers := make([]HppPerKandangSupplierRow, 0) docSeen := make(map[uint]map[uint]bool) for _, doc := range docRows { - entry, ok := costMap[doc.KandangID] + entry, ok := costMap[doc.ProjectFlockKandangID] if !ok { rows = append(rows, HppPerKandangCostRow{ - KandangID: doc.KandangID, + ProjectFlockKandangID: doc.ProjectFlockKandangID, }) entry = &rows[len(rows)-1] - costMap[doc.KandangID] = entry + costMap[doc.ProjectFlockKandangID] = entry } entry.DocCost += doc.DocCost entry.DocQty += doc.DocQty if doc.SupplierID != nil { - if docSeen[doc.KandangID] == nil { - docSeen[doc.KandangID] = make(map[uint]bool) + if docSeen[doc.ProjectFlockKandangID] == nil { + docSeen[doc.ProjectFlockKandangID] = make(map[uint]bool) } - if !docSeen[doc.KandangID][*doc.SupplierID] { - docSeen[doc.KandangID][*doc.SupplierID] = true + if !docSeen[doc.ProjectFlockKandangID][*doc.SupplierID] { + docSeen[doc.ProjectFlockKandangID][*doc.SupplierID] = true supplierName := "" if doc.SupplierName != nil { supplierName = *doc.SupplierName @@ -250,19 +252,19 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, supplierAlias = *doc.SupplierAlias } docSuppliers = append(docSuppliers, HppPerKandangSupplierRow{ - KandangID: doc.KandangID, - SupplierID: *doc.SupplierID, - SupplierName: supplierName, - SupplierAlias: supplierAlias, - Category: "DOC", + ProjectFlockKandangID: doc.ProjectFlockKandangID, + SupplierID: *doc.SupplierID, + SupplierName: supplierName, + SupplierAlias: supplierAlias, + Category: "DOC", }) } } } budgetRows := make([]struct { - KandangID uint - BudgetCost float64 + ProjectFlockKandangID uint + BudgetCost float64 }, 0) pfkUsageSub := r.db. @@ -283,7 +285,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, budgetQuery := r.db.WithContext(ctx). Table("project_flock_kandangs AS pfk"). Select(` - k.id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). @@ -291,7 +293,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). Where("pfk.id IN (?)", projectFlockKandangIDs). - Group("k.id") + Group("pfk.id") // budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) if err := budgetQuery.Scan(&budgetRows).Error; err != nil { @@ -299,33 +301,33 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } for _, budget := range budgetRows { - entry, ok := costMap[budget.KandangID] + entry, ok := costMap[budget.ProjectFlockKandangID] if !ok { rows = append(rows, HppPerKandangCostRow{ - KandangID: budget.KandangID, + ProjectFlockKandangID: budget.ProjectFlockKandangID, }) entry = &rows[len(rows)-1] - costMap[budget.KandangID] = entry + costMap[budget.ProjectFlockKandangID] = entry } entry.BudgetCost += budget.BudgetCost } expenseRows := make([]struct { - KandangID uint - ExpenseCost float64 + ProjectFlockKandangID uint + ExpenseCost float64 }, 0) expenseQuery := r.db.WithContext(ctx). Table("project_flock_kandangs AS pfk"). Select(` - k.id AS kandang_id, + pfk.id AS project_flock_kandang_id, COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). Where("pfk.id IN (?)", projectFlockKandangIDs). - Group("k.id") + Group("pfk.id") // expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) if err := expenseQuery.Scan(&expenseRows).Error; err != nil { @@ -333,13 +335,13 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } for _, exp := range expenseRows { - entry, ok := costMap[exp.KandangID] + entry, ok := costMap[exp.ProjectFlockKandangID] if !ok { rows = append(rows, HppPerKandangCostRow{ - KandangID: exp.KandangID, + ProjectFlockKandangID: exp.ProjectFlockKandangID, }) entry = &rows[len(rows)-1] - costMap[exp.KandangID] = entry + costMap[exp.ProjectFlockKandangID] = entry } entry.ExpenseCost += exp.ExpenseCost } @@ -348,7 +350,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, feedQuery := r.db.WithContext(ctx). Table("recordings AS r"). - Select("DISTINCT k.id AS kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). + Select("DISTINCT pfk.id AS project_flock_kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). @@ -369,11 +371,11 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } for i := range feedSuppliers { - if _, exists := costMap[feedSuppliers[i].KandangID]; !exists { + if _, exists := costMap[feedSuppliers[i].ProjectFlockKandangID]; !exists { rows = append(rows, HppPerKandangCostRow{ - KandangID: feedSuppliers[i].KandangID, + ProjectFlockKandangID: feedSuppliers[i].ProjectFlockKandangID, }) - costMap[feedSuppliers[i].KandangID] = &rows[len(rows)-1] + costMap[feedSuppliers[i].ProjectFlockKandangID] = &rows[len(rows)-1] } feedSuppliers[i].Category = "FEED" } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9c0c600f..452d46b6 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1380,7 +1380,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes costMap := make(map[uint]HppCostAggregate, len(costRows)) for _, row := range costRows { - costMap[row.KandangID] = HppCostAggregate{ + costMap[row.ProjectFlockKandangID] = HppCostAggregate{ FeedCost: row.FeedCost, OvkCost: row.OvkCost, DocCost: row.DocCost, @@ -1409,15 +1409,15 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes category = "DOC" } - if seen[sup.KandangID] == nil { - seen[sup.KandangID] = make(map[uint]bool) + if seen[sup.ProjectFlockKandangID] == nil { + seen[sup.ProjectFlockKandangID] = make(map[uint]bool) } - if seen[sup.KandangID][sup.SupplierID] { + if seen[sup.ProjectFlockKandangID][sup.SupplierID] { continue } - seen[sup.KandangID][sup.SupplierID] = true + seen[sup.ProjectFlockKandangID][sup.SupplierID] = true - targetMap[sup.KandangID] = append(targetMap[sup.KandangID], dto.HppPerKandangSupplierDTO{ + targetMap[sup.ProjectFlockKandangID] = append(targetMap[sup.ProjectFlockKandangID], dto.HppPerKandangSupplierDTO{ ID: int64(sup.SupplierID), Name: sup.SupplierName, Alias: sup.SupplierAlias, @@ -1507,7 +1507,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} // rowBirds := int64(math.Round(birdsFloat)) - costEntry := costMap[row.KandangID] + costEntry := costMap[row.ProjectFlockKandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost // hppRp := 0.0 // if weightFloat > 0 { @@ -1526,8 +1526,10 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) } + nameWithPeriod := fmt.Sprintf("%s Period %d", row.KandangName, row.ProjectFlockPeriod) + dataRows = append(dataRows, dto.HppPerKandangRowDTO{ - ID: int(row.KandangID), + ID: int(row.ProjectFlockKandangID), Kandang: dto.HppPerKandangRowKandangDTO{ ID: int64(row.KandangID), Name: row.KandangName, @@ -1545,11 +1547,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - AvgWeightKg: avgWeight, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, // FeedCostRp: costEntry.FeedCost, // OvkCostRp: costEntry.OvkCost, - DocSuppliers: docSupplierMap[row.KandangID], - FeedSuppliers: feedSupplierMap[row.KandangID], + DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], + FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), EggProductionKg: eggRemainingWeightFloatRemaining, // EggProductionTotalWeightKg: eggWeightFloat, @@ -1603,12 +1606,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ - for _, supplier := range feedSupplierMap[row.KandangID] { + for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { if _, ok := rangeAgg.FeedSuppliers[supplier.ID]; !ok { rangeAgg.FeedSuppliers[supplier.ID] = supplier } } - for _, supplier := range docSupplierMap[row.KandangID] { + for _, supplier := range docSupplierMap[row.ProjectFlockKandangID] { if _, ok := rangeAgg.DocSuppliers[supplier.ID]; !ok { rangeAgg.DocSuppliers[supplier.ID] = supplier } From b240478ed5e19504cb5663cec8c29f6ba5b6de7b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 19 Jan 2026 17:44:10 +0700 Subject: [PATCH 050/117] feat[BE]: Add notes field to Update validation and update approval logic in expense services --- .../modules/expenses/services/expense.service.go | 16 ++++++++++++++-- .../expenses/validations/expense.validation.go | 1 + .../services/transfer_expense_bridge.go | 15 ++++++++++++--- .../services/transfer_laying.service.go | 1 - .../modules/purchases/services/expense_bridge.go | 15 ++++++++++++--- 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 3bf2db55..8b42fbdf 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -396,6 +396,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["supplier_id"] = *req.SupplierID } + if req.Notes != nil { + updateBody["notes"] = *req.Notes + } + if req.LocationID != nil { locationID := uint(*req.LocationID) updateBody["location_id"] = locationID @@ -568,20 +572,28 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if err != nil { return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") } - if *latestApproval.Action != entity.ApprovalActionUpdated { + + if *latestApproval.Action != entity.ApprovalActionUpdated && latestApproval.StepNumber > uint16(utils.ExpenseStepPengajuan) { approvalAction := entity.ApprovalActionUpdated + previousStep := approvalutils.ApprovalStep(latestApproval.StepNumber) - 1 + + if previousStep < utils.ExpenseStepPengajuan { + previousStep = utils.ExpenseStepPengajuan + } + if _, err := approvalSvcTx.CreateApproval( c.Context(), utils.ApprovalWorkflowExpense, id, - utils.ExpenseStepPengajuan, + previousStep, &approvalAction, actorID, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") } + } if s.DocumentSvc != nil && len(req.Documents) > 0 { diff --git a/internal/modules/expenses/validations/expense.validation.go b/internal/modules/expenses/validations/expense.validation.go index 4501b87d..3fb9ccd5 100644 --- a/internal/modules/expenses/validations/expense.validation.go +++ b/internal/modules/expenses/validations/expense.validation.go @@ -31,6 +31,7 @@ type Update struct { 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"` + Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"` 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/inventory/transfers/services/transfer_expense_bridge.go b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go index c4f28354..7b69c22a 100644 --- a/internal/modules/inventory/transfers/services/transfer_expense_bridge.go +++ b/internal/modules/inventory/transfers/services/transfer_expense_bridge.go @@ -140,12 +140,21 @@ func (b *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expense if actorID == 0 { actorID = 1 } - svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - action := entity.ApprovalActionUpdated + approvalRepo := commonRepo.NewApprovalRepository(b.db) + svc := commonSvc.NewApprovalService(approvalRepo) + action := entity.ApprovalActionCreated + for id := range expenseIDs { - if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } + + if latestApproval == nil { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } } return nil } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 3fe0b0b7..e64b9cc2 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -133,7 +133,6 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ db = db.Order("created_at DESC") - // Apply relations for eager loading db = s.withRelations(db) return db diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 56097a90..1210b3a1 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -167,12 +167,21 @@ func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[ if actorID == 0 { actorID = 1 } - svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) - action := entity.ApprovalActionUpdated + approvalRepo := commonRepo.NewApprovalRepository(b.db) + svc := commonSvc.NewApprovalService(approvalRepo) + action := entity.ApprovalActionCreated + for id := range expenseIDs { - if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } + + if latestApproval == nil { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } } return nil } From bc771660be77fa128a5cf1af231f589477be5715 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 10:03:57 +0700 Subject: [PATCH 051/117] adjust closing tap sapronak; add api summart total kuantitas per category and uom --- .../controllers/closing.controller.go | 54 +++++++- .../closings/dto/closingSapronak.dto.go | 11 ++ .../repositories/closing.repository.go | 120 +++++++++++++++++- .../closings/services/closing.service.go | 71 ++++++++++- .../validations/closing.validation.go | 1 + .../repositories/stock_transfer.repository.go | 2 +- 6 files changed, 245 insertions(+), 14 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index ed3cfcbc..a43687ac 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -236,9 +236,8 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { } query := &validation.ClosingSapronakQuery{ - Type: strings.ToLower(c.Query("type")), - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Type: strings.ToLower(c.Query("type")), + Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { kandangInt, convErr := strconv.Atoi(raw) @@ -249,10 +248,6 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { query.KandangID = &kandangUint } - if query.Page < 1 || query.Limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") - } - if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } @@ -277,6 +272,51 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + query := &validation.ClosingSapronakQuery{ + Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search"), + } + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + query.KandangID = &kandangUint + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { + return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + result, err := u.ClosingService.GetClosingSapronakSummary(c, uint(id), query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing report (sapronak summary) successfully", + Data: result, + }) +} + func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { param := c.Params("project_flock_id") flag := c.Query("flag", "") diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 768c727e..6d59294e 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -114,6 +114,17 @@ type ClosingSapronakDTO struct { OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` } +type ClosingSapronakSummaryItemDTO struct { + Category string `json:"category"` + TotalQty int64 `json:"total_qty"` + Uom UomSummaryDTO `json:"uom"` +} + +type UomSummaryDTO struct { + ID uint `json:"id"` + Name string `json:"name"` +} + // === Mapper Functions for Aggregated Sapronak Response === func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 582a1207..7b1fb6cf 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -17,6 +17,7 @@ import ( type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) + GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) @@ -59,10 +60,18 @@ type SapronakRow struct { DestinationWarehouse string `gorm:"column:destination_warehouse"` Destination string `gorm:"column:destination"` Quantity float64 `gorm:"column:quantity"` + UnitID uint `gorm:"column:unit_id"` Unit string `gorm:"column:unit"` Notes string `gorm:"column:notes"` } +type SapronakSummaryRow struct { + Category string `gorm:"column:category"` + TotalQty int64 `gorm:"column:total_qty"` + UomID uint `gorm:"column:uom_id"` + UomName string `gorm:"column:uom_name"` +} + type ExpeditionHPPRow struct { SupplierName string `gorm:"column:supplier_name"` TotalAmount float64 `gorm:"column:total_amount"` @@ -74,6 +83,7 @@ type SapronakQueryParams struct { ProjectFlockKandangIDs []uint Limit int Offset int + Search string } func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { @@ -109,14 +119,36 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak unionSQL := strings.Join(unionParts, " UNION ALL ") + search := strings.TrimSpace(params.Search) + searchClause := "" + var searchArgs []any + if search != "" { + searchClause = ` + WHERE ( + reference_number ILIKE ? + OR product_name ILIKE ? + OR product_category ILIKE ? + OR source_warehouse ILIKE ? + OR destination_warehouse ILIKE ? + OR CAST(quantity AS TEXT) ILIKE ? + OR unit ILIKE ? + OR notes ILIKE ? + OR transaction_type ILIKE ? + )` + like := "%" + search + "%" + searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) + } + var totalResults int64 - countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) - if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) + countArgs := append(append([]any{}, args...), searchArgs...) + if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil { return nil, 0, err } - dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) - dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) + dataArgs := append(append([]any{}, args...), searchArgs...) + dataArgs = append(dataArgs, params.Limit, params.Offset) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause) var rows []SapronakRow if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { @@ -126,6 +158,79 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak return rows, totalResults, nil } +func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) { + db := r.DB().WithContext(ctx) + + var ( + unionParts []string + args []any + ) + + switch params.Type { + case validation.SapronakTypeIncoming: + if len(params.WarehouseIDs) == 0 { + return []SapronakSummaryRow{}, nil + } + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) + case validation.SapronakTypeOutgoing: + if len(params.WarehouseIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingTransfersSQL) + args = append(args, params.WarehouseIDs) + } + if len(params.ProjectFlockKandangIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) + args = append(args, params.ProjectFlockKandangIDs) + } + if len(unionParts) == 0 { + return []SapronakSummaryRow{}, nil + } + default: + return nil, fmt.Errorf("invalid sapronak type: %s", params.Type) + } + + unionSQL := strings.Join(unionParts, " UNION ALL ") + + search := strings.TrimSpace(params.Search) + searchClause := "" + var searchArgs []any + if search != "" { + searchClause = ` + WHERE ( + reference_number ILIKE ? + OR product_name ILIKE ? + OR product_category ILIKE ? + OR source_warehouse ILIKE ? + OR destination_warehouse ILIKE ? + OR CAST(quantity AS TEXT) ILIKE ? + OR unit ILIKE ? + OR notes ILIKE ? + OR transaction_type ILIKE ? + )` + like := "%" + search + "%" + searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like) + } + + querySQL := fmt.Sprintf(` +SELECT + product_category AS category, + CAST(COALESCE(SUM(quantity), 0) AS BIGINT) AS total_qty, + unit_id AS uom_id, + unit AS uom_name +FROM (%s) AS combined%s +GROUP BY product_category, unit_id, unit +ORDER BY product_category ASC, unit ASC +`, unionSQL, searchClause) + queryArgs := append(append([]any{}, args...), searchArgs...) + + var rows []SapronakSummaryRow + if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { if len(projectFlockKandangIDs) == 0 { return 0, 0, nil @@ -379,6 +484,7 @@ SELECT w.name AS destination_warehouse, '' AS destination, pi.total_qty AS quantity, + u.id AS unit_id, u.name AS unit, COALESCE(p.notes, '') AS notes FROM purchase_items pi @@ -427,6 +533,7 @@ SELECT COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, std.usage_qty AS quantity, + u.id AS unit_id, u.name AS unit, 'Stock Refill' AS notes FROM stock_transfer_details std @@ -476,6 +583,7 @@ SELECT COALESCE(tw.name, '') AS destination_warehouse, '' AS destination, std.usage_qty AS quantity, + u.id AS unit_id, u.name AS unit, 'Transfer to other unit' AS notes FROM stock_transfer_details std @@ -522,13 +630,15 @@ SELECT WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id ), '') AS product_sub_category, w.name AS source_warehouse, - 'RETAIL CUSTOMER' AS destination_warehouse, + COALESCE(c.name, '') AS destination_warehouse, '' AS destination, mp.qty AS quantity, + u.id AS unit_id, u.name AS unit, m.notes AS notes FROM marketing_products mp JOIN marketings m ON m.id = mp.marketing_id +LEFT JOIN customers c ON c.id = m.customer_id JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id JOIN products prod ON prod.id = pw.product_id JOIN uoms u ON u.id = prod.uom_id diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 8cda7220..443eec7f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -40,6 +40,7 @@ type ClosingService interface { GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) + GetClosingSapronakSummary(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) } @@ -353,6 +354,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa ProjectFlockKandangIDs: projectFlockKandangIDs, Limit: params.Limit, Offset: offset, + Search: params.Search, }) if err != nil { s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) @@ -387,6 +389,74 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa return items, totalResults, nil } +func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.ClosingSapronakQuery{} + } + + if err := s.Validate.Struct(params); err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing { + return nil, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + s.Log.Errorf("Failed get project flock %d for sapronak closing summary: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") + } + + var projectFlockKandangIDs []uint + if params.KandangID != nil && *params.KandangID > 0 { + projectFlockKandangIDs = []uint{*params.KandangID} + } else if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + } + + rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{ + Type: params.Type, + WarehouseIDs: warehouseIDs, + ProjectFlockKandangIDs: projectFlockKandangIDs, + Search: params.Search, + }) + if err != nil { + s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak summary data") + } + + items := make([]dto.ClosingSapronakSummaryItemDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, dto.ClosingSapronakSummaryItemDTO{ + Category: row.Category, + TotalQty: row.TotalQty, + Uom: dto.UomSummaryDTO{ + ID: row.UomID, + Name: row.UomName, + }, + }) + } + + return items, nil +} + func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { var kandangIDs []uint db := s.Repository.DB().WithContext(ctx) @@ -1030,4 +1100,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl return closest.Mortality, closest.FcrNumber } - diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 0c738407..454bbdfc 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -24,4 +24,5 @@ type ClosingSapronakQuery 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"` KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` } diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go index cd314901..9d9d6aeb 100644 --- a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context if err != nil { return "", err } - movementNumber := fmt.Sprintf("ST-%05d", seq) + movementNumber := fmt.Sprintf("PND-LTI-%05d", seq) return movementNumber, nil } From 9fb5395469ae640a0a2ac943522360a45c090291 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 10:13:58 +0700 Subject: [PATCH 052/117] [FIX/BE-US] recording,reporting,closing and uniformity --- ...650_add_fifo_recording_depletions.down.sql | 3 + ...95650_add_fifo_recording_depletions.up.sql | 17 ++ internal/entities/recording_depletion.go | 10 +- .../closings/dto/closingSapronak.dto.go | 46 ++++- .../repositories/closing.repository.go | 99 ++++++++++ .../closings/services/sapronak.service.go | 64 +++++- .../controllers/recording.controller.go | 5 +- .../recordings/dto/recording.dto.go | 95 ++++++--- .../modules/production/recordings/module.go | 22 +++ .../repositories/recording.repository.go | 70 +++++-- .../recordings/services/recording.service.go | 186 ++++++++++++++---- .../validations/recording.validation.go | 28 +-- .../services/uniformity.service.go | 78 +++++++- .../repports/dto/repportPurchase.dto.go | 32 ++- .../repositories/debt_supplier.repository.go | 82 +++++++- .../purchase_supplier.repository.go | 29 ++- .../repports/services/repport.service.go | 72 ++++++- internal/utils/fifo/constants.go | 23 +-- internal/utils/recording/util.recording.go | 5 - 19 files changed, 805 insertions(+), 161 deletions(-) create mode 100644 internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql create mode 100644 internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql diff --git a/internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql new file mode 100644 index 00000000..4daff87b --- /dev/null +++ b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS pending_qty, + DROP COLUMN IF EXISTS source_product_warehouse_id; diff --git a/internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql new file mode 100644 index 00000000..4e29e129 --- /dev/null +++ b/internal/database/migrations/20260119095650_add_fifo_recording_depletions.up.sql @@ -0,0 +1,17 @@ +ALTER TABLE recording_depletions + ADD COLUMN IF NOT EXISTS pending_qty numeric(15,3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS source_product_warehouse_id bigint; + +UPDATE recording_depletions rd +SET source_product_warehouse_id = src.product_warehouse_id +FROM recordings r +JOIN LATERAL ( + SELECT pfp.product_warehouse_id + FROM project_chickins pc + JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id + WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + ORDER BY pfp.created_at ASC, pfp.id ASC + LIMIT 1 +) AS src ON true +WHERE r.id = rd.recording_id + AND rd.source_product_warehouse_id IS NULL; diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index 53af300d..8e0c7afe 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -1,10 +1,12 @@ package entities type RecordingDepletion struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - Qty float64 `gorm:"column:qty;not null"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` + Qty float64 `gorm:"column:qty;not null"` + PendingQty float64 `gorm:"column:pending_qty"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index 768c727e..0067b9d2 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -201,18 +201,48 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin switch strings.ToLower(item.JenisTransaksi) { case "pembelian", "adjustment masuk", "mutasi masuk": row.QtyIn += item.QtyMasuk - row.TotalAmount += item.Nilai + if row.UnitPrice == 0 { + if item.QtyMasuk > 0 && item.Nilai > 0 { + row.UnitPrice = item.Nilai / item.QtyMasuk + } else if item.Harga > 0 { + row.UnitPrice = item.Harga + } + } + if strings.ToLower(item.JenisTransaksi) == "mutasi masuk" { + ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) + if strings.HasPrefix(ref, "TL-") { + row.Notes = "TRANSFER LAYING" + } else if strings.HasPrefix(ref, "ST-") { + row.Notes = "TRANSFER STOCK" + } + } case "pemakaian", "adjustment keluar": + price := row.UnitPrice + if price == 0 { + price = item.Harga + } row.QtyUsed += item.QtyKeluar - case "mutasi keluar": + row.TotalAmount += item.QtyKeluar * price + case "mutasi keluar", "penjualan": + price := row.UnitPrice + if price == 0 { + price = item.Harga + } row.QtyOut += item.QtyKeluar + if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" { + ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi)) + if strings.HasPrefix(ref, "TL-") { + row.Notes = "TRANSFER LAYING" + } else if strings.HasPrefix(ref, "ST-") { + row.Notes = "TRANSFER STOCK" + } + } default: row.QtyIn += item.QtyMasuk row.TotalAmount += item.Nilai - } - - if row.QtyIn > 0 { - row.UnitPrice = row.TotalAmount / row.QtyIn + if row.QtyIn > 0 { + row.UnitPrice = row.TotalAmount / row.QtyIn + } } } @@ -233,8 +263,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin total += r.TotalAmount } avg := 0.0 - if qtyIn > 0 { - avg = total / qtyIn + if qtyUsed > 0 { + avg = total / qtyUsed } cat.Total = SapronakCategoryTotalDTO{ Label: label, diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 507d7c88..6d7df1cc 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -33,6 +33,7 @@ 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) + FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -909,17 +910,50 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand COALESCE(p.product_price, 0) AS price `). Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("w.kandang_id = ?", kandangID). + Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll) incoming, err := scanAndGroupDetails(incomingQuery) if err != nil { return nil, nil, err } + incomingLayingQuery := r.withCtx(ctx). + Table("laying_transfer_targets AS ltt"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + lt.transfer_date::timestamp AS date, + COALESCE(lt.transfer_number, '') AS reference, + COALESCE(ltt.total_qty, 0) AS qty_in, + 0 AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id"). + Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id"). + Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("w.kandang_id = ?", kandangID). + Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)"). + Where("f.name IN ?", sapronakFlagsAll) + incomingLaying, err := scanAndGroupDetails(incomingLayingQuery) + if err != nil { + return nil, nil, err + } + for pid, rows := range incomingLaying { + incoming[pid] = append(incoming[pid], rows...) + } + outgoingQuery := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -936,10 +970,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id"). + Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("sa.status = ?", entity.StockAllocationStatusActive). Where("w.kandang_id = ?", kandangID). + Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") outgoing, err := scanAndGroupDetails(outgoingQuery) @@ -947,9 +984,71 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return nil, nil, err } + outgoingLayingQuery := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + lt.transfer_date::timestamp AS date, + COALESCE(lt.transfer_number, '') AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(p.product_price, 0) AS price + `). + Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()). + Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id"). + Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id"). + Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). + Where("f.name IN ?", sapronakFlagsAll). + Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price") + outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery) + if err != nil { + return nil, nil, err + } + for pid, rows := range outgoingLaying { + outgoing[pid] = append(outgoing[pid], rows...) + } + return incoming, outgoing, nil } +func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { + query := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(mdp.delivery_date, mdp.created_at) AS date, + COALESCE(m.so_number, '') AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(mdp.unit_price, mp.unit_price, 0) AS price + `). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN marketings m ON m.id = mp.marketing_id"). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") + + return scanAndGroupDetails(query) +} + func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { if len(productIDs) == 0 { return []entity.Product{}, nil diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index fc354f46..930c1bc5 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "strings" "github.com/go-playground/validator/v10" @@ -111,7 +112,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val } // We no longer filter by date for closing sapronak report; pass nil pointers. - items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") @@ -262,6 +263,7 @@ type sapronakDetailMaps struct { AdjOutgoing map[uint][]dto.SapronakDetailDTO TransferIn map[uint][]dto.SapronakDetailDTO TransferOut map[uint][]dto.SapronakDetailDTO + SalesOut map[uint][]dto.SapronakDetailDTO } func buildSapronakDetails( @@ -271,6 +273,7 @@ func buildSapronakDetails( adjOutgoingRows map[uint][]repository.SapronakDetailRow, transferInRows map[uint][]repository.SapronakDetailRow, transferOutRows map[uint][]repository.SapronakDetailRow, + salesOutRows map[uint][]repository.SapronakDetailRow, ) sapronakDetailMaps { result := sapronakDetailMaps{ Incoming: make(map[uint][]dto.SapronakDetailDTO), @@ -279,6 +282,7 @@ func buildSapronakDetails( AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), TransferIn: make(map[uint][]dto.SapronakDetailDTO), TransferOut: make(map[uint][]dto.SapronakDetailDTO), + SalesOut: make(map[uint][]dto.SapronakDetailDTO), } addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { @@ -311,6 +315,7 @@ func buildSapronakDetails( addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) + addRows(result.SalesOut, salesOutRows, "Penjualan", false) return result } @@ -350,6 +355,10 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if err != nil { return nil, nil, 0, 0, err } + salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) matchesFlag := func(f string) bool { @@ -362,6 +371,34 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } return candidate == filterFlag } + dedupTransfers := func(src map[uint][]dto.SapronakDetailDTO) map[uint][]dto.SapronakDetailDTO { + result := make(map[uint][]dto.SapronakDetailDTO, len(src)) + seen := make(map[string]struct{}) + for pid, rows := range src { + for _, d := range rows { + dateKey := "" + if d.Tanggal != nil { + dateKey = d.Tanggal.Format("2006-01-02") + } + qtyKey := d.QtyMasuk + if qtyKey == 0 { + qtyKey = d.QtyKeluar + } + + ref := strings.TrimSpace(d.NoReferensi) + key := fmt.Sprintf("%d|%s|%s|%.3f", pid, ref, dateKey, qtyKey) + if ref == "" { + key = fmt.Sprintf("%d|%s|%s|%.3f|%s", pid, ref, dateKey, qtyKey, strings.ToUpper(strings.TrimSpace(d.Flag))) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result[pid] = append(result[pid], d) + } + } + return result + } // For project flocks with category GROWING, pullet usage from chickin // should not be counted yet. Only when category is LAYING we allow @@ -400,13 +437,17 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) } - detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows) + detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows, salesOutRows) incomingDetails := detailMaps.Incoming usageDetails := detailMaps.Usage adjIncoming := detailMaps.AdjIncoming adjOutgoing := detailMaps.AdjOutgoing transIncoming := detailMaps.TransferIn transOutgoing := detailMaps.TransferOut + salesOutgoing := detailMaps.SalesOut + + transIncoming = dedupTransfers(transIncoming) + transOutgoing = dedupTransfers(transOutgoing) ensureGroup := func(flag string) *dto.SapronakGroupDTO { if g, ok := groupMap[flag]; ok { @@ -683,6 +724,25 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } } + for productID, details := range salesOutgoing { + flag, name := resolveFlagName(productID, details) + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + if d.Flag == "" { + d.Flag = flag + } + if d.ProductName == "" { + d.ProductName = name + } + group.Items = append(group.Items, d) + group.TotalKeluar += d.QtyKeluar + group.SaldoAkhir -= d.QtyKeluar + } + } + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, *g) diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index c0f1737b..7edb7b9a 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -26,8 +26,9 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search"), } if projectFlockID > 0 { query.ProjectFlockKandangId = uint(projectFlockID) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index ebb093ba..0fa14e97 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -15,13 +15,13 @@ import ( // === DTO Structs === type RecordingProjectFlockDTO struct { - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - FlockName string `json:"flock_name"` - ProjectFlockCategory string `json:"project_flock_category"` - Period int `json:"period"` - ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"` - Fcr *RecordingFcrDTO `json:"fcr,omitempty"` - TotalChickQty float64 `json:"total_chick_qty"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + FlockName string `json:"flock_name"` + ProjectFlockCategory string `json:"project_flock_category"` + Period int `json:"period"` + ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"` + Fcr *RecordingFcrDTO `json:"fcr,omitempty"` + TotalChickQty float64 `json:"total_chick_qty"` } type RecordingProductionStandardDTO struct { @@ -53,6 +53,13 @@ type RecordingLocationDTO struct { Address string `json:"address"` } +type RecordingKandangDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Capacity float64 `json:"capacity"` +} + type RecordingWarehouseDTO struct { Id uint `json:"id"` Name string `json:"name"` @@ -82,12 +89,14 @@ type RecordingListDTO struct { CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` + Kandang *RecordingKandangDTO `json:"kandang,omitempty"` + Location *RecordingLocationDTO `json:"location,omitempty"` } type RecordingDetailDTO struct { RecordingListDTO - ProductCategory string `json:"product_category"` + ProductCategory string `json:"product_category"` + Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` Depletions []RecordingDepletionDTO `json:"depletions"` Stocks []RecordingStockDTO `json:"stocks"` Eggs []RecordingEggDTO `json:"eggs"` @@ -133,10 +142,11 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { return RecordingDetailDTO{ RecordingListDTO: listDTO, - ProductCategory: recordingProductCategory(e), - Depletions: ToRecordingDepletionDTOs(e.Depletions), - Stocks: ToRecordingStockDTOs(e.Stocks), - Eggs: ToRecordingEggDTOs(e.Eggs), + ProductCategory: recordingProductCategory(e), + Warehouse: recordingWarehouseDTO(e), + Depletions: ToRecordingDepletionDTOs(e.Depletions), + Stocks: ToRecordingStockDTOs(e.Stocks), + Eggs: ToRecordingEggDTOs(e.Eggs), } } @@ -202,7 +212,8 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO { CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, - Warehouse: recordingWarehouseDTO(e), + Kandang: recordingKandangDTO(e), + Location: recordingKandangLocationDTO(e), } } @@ -214,20 +225,20 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { } return RecordingRelationDTO{ - Id: e.Id, - ProjectFlock: toRecordingProjectFlockDTO(e), - RecordDatetime: e.RecordDatetime, - Day: intValue(e.Day), - TotalDepletionQty: floatValue(e.TotalDepletionQty), - CumDepletionRate: floatValue(e.CumDepletionRate), - CumIntake: intValue(e.CumIntake), - FcrValue: floatValue(e.FcrValue), - HenDay: floatValue(e.HenDay), - HenHouse: floatValue(e.HenHouse), - FeedIntake: floatValue(e.FeedIntake), - EggMass: floatValue(e.EggMass), - EggWeight: floatValue(e.EggWeight), - Approval: latestApproval, + Id: e.Id, + ProjectFlock: toRecordingProjectFlockDTO(e), + RecordDatetime: e.RecordDatetime, + Day: intValue(e.Day), + TotalDepletionQty: floatValue(e.TotalDepletionQty), + CumDepletionRate: floatValue(e.CumDepletionRate), + CumIntake: intValue(e.CumIntake), + FcrValue: floatValue(e.FcrValue), + HenDay: floatValue(e.HenDay), + HenHouse: floatValue(e.HenHouse), + FeedIntake: floatValue(e.FeedIntake), + EggMass: floatValue(e.EggMass), + EggWeight: floatValue(e.EggWeight), + Approval: latestApproval, } } @@ -321,6 +332,34 @@ func recordingWarehouseDTO(e entity.Recording) *RecordingWarehouseDTO { return mapWarehouseDTO(&pw.Warehouse) } +func recordingKandangDTO(e entity.Recording) *RecordingKandangDTO { + if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 { + return nil + } + kandang := e.ProjectFlockKandang.Kandang + return &RecordingKandangDTO{ + Id: kandang.Id, + Name: kandang.Name, + Status: kandang.Status, + Capacity: kandang.Capacity, + } +} + +func recordingKandangLocationDTO(e entity.Recording) *RecordingLocationDTO { + if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 { + return nil + } + location := e.ProjectFlockKandang.Kandang.Location + if location.Id == 0 { + return nil + } + return &RecordingLocationDTO{ + Id: location.Id, + Name: location.Name, + Address: location.Address, + } +} + func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse { if len(e.Stocks) > 0 { pw := e.Stocks[0].ProductWarehouse diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 11a1e152..71981a9e 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -74,6 +74,28 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) } } + if err := fifoService.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyRecordingDepletion, + Table: "recording_depletions", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "source_product_warehouse_id", + UsageQuantity: "qty", + PendingQuantity: "pending_qty", + CreatedAt: "id", + }, + ExcludedStockables: []fifo.StockableKey{ + fifo.StockableKeyTransferToLayingIn, + fifo.StockableKeyStockTransferIn, + fifo.StockableKeyAdjustmentIn, + fifo.StockableKeyPurchaseItems, + fifo.StockableKeyRecordingEgg, + }, + }); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "already registered") { + panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err)) + } + } approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 703c05f0..9e783134 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 + ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) @@ -24,6 +25,7 @@ type RecordingRepository interface { DeleteStocks(tx *gorm.DB, recordingID uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error + UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error @@ -84,6 +86,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser"). Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang.Kandang"). + Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). @@ -107,6 +110,42 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs.ProductWarehouse.Warehouse.Location") } +func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { + normalized := strings.ToLower(strings.TrimSpace(rawSearch)) + if normalized == "" { + return db + } + + likeQuery := "%" + normalized + "%" + subQuery := db.Session(&gorm.Session{NewDB: true}). + Table("recordings"). + Select("recordings.id"). + Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id"). + Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id"). + Joins("LEFT JOIN locations l ON l.id = k.location_id"). + Joins("LEFT JOIN recording_stocks rs ON rs.recording_id = recordings.id"). + Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = recordings.id"). + Joins("LEFT JOIN recording_eggs re ON re.recording_id = recordings.id"). + Joins("LEFT JOIN product_warehouses pws ON pws.id = rs.product_warehouse_id"). + Joins("LEFT JOIN product_warehouses pwd ON pwd.id = rd.product_warehouse_id"). + Joins("LEFT JOIN product_warehouses pwe ON pwe.id = re.product_warehouse_id"). + Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id"). + Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id"). + Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id"). + Where(` + LOWER(pf.flock_name) LIKE ? + OR LOWER(k.name) LIKE ? + OR LOWER(l.name) LIKE ? + OR LOWER(l.address) LIKE ? + OR LOWER(ws.name) LIKE ? + OR LOWER(wd.name) LIKE ? + OR LOWER(we.name) LIKE ?`, + likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, + ) + return db.Where("recordings.id IN (?)", subQuery) +} + 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") @@ -167,6 +206,12 @@ func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, us }).Error } +func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error { + return tx.Model(&entity.RecordingDepletion{}). + Where("id = ?", depletionID). + Update("pending_qty", pendingQty).Error +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil @@ -322,38 +367,25 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm. } func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { - var rows []struct { + var result struct { TotalQty float64 - UomName string } if err := tx. Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name"). + Select("COALESCE(SUM(COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0)), 0) AS total_qty"). Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN uoms ON uoms.id = products.uom_id"). Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN"). Where("recording_stocks.recording_id = ?", recordingID). - Scan(&rows).Error; err != nil { + Scan(&result).Error; err != nil { return 0, err } - var total float64 - for _, row := range rows { - if row.TotalQty <= 0 { - continue - } - switch strings.TrimSpace(row.UomName) { - case "kilogram", "kg", "kilograms", "kilo": - total += row.TotalQty * 1000 - case "gram", "g", "grams": - total += row.TotalQty - default: - total += row.TotalQty - } + if result.TotalQty <= 0 { + return 0, nil } - return total, nil + return result.TotalQty * 1000, nil } func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5dabad9f..80611109 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -44,6 +44,7 @@ type RecordingFIFOIntegrationService interface { } var recordingStockUsableKey = fifo.UsableKeyRecordingStock +var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion type recordingService struct { Log *logrus.Logger @@ -116,7 +117,8 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if params.ProjectFlockKandangId != 0 { db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) } - return db.Order("record_datetime DESC").Order("created_at DESC") + db = s.Repository.ApplySearchFilters(db, params.Search) + return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC") }) if err != nil { @@ -209,9 +211,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if !isLaying && len(req.Eggs) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } - if isLaying && len(req.Eggs) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") - } if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { return nil, err @@ -280,10 +279,24 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) + if s.FifoSvc != nil && len(mappedDepletions) > 0 { + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId) + if err != nil { + return err + } + for i := range mappedDepletions { + mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID + } + } if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to persist depletions: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + } mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { @@ -297,11 +310,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } var warehouseDeltas map[uint]float64 - if s.FifoSvc != nil { - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil) - } else { - warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) - } + warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs) if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil { s.Log.Errorf("Failed to adjust product warehouses: %+v", err) return err @@ -407,9 +416,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if !isLaying && len(req.Eggs) > 0 { return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } - if isLaying && len(req.Eggs) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") - } } if hasStockChanges { @@ -431,17 +437,38 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasDepletionChanges { + if s.FifoSvc != nil { + if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions); err != nil { + return err + } + } + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear depletions: %+v", err) return err } mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if s.FifoSvc != nil && len(mappedDepletions) > 0 { + sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId) + if err != nil { + return err + } + for i := range mappedDepletions { + mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID + } + } if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { s.Log.Errorf("Failed to update depletions: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + return err + } + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) return err @@ -647,6 +674,11 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { s.Log.Errorf("Failed to list depletions before delete: %+v", err) return err } + if s.FifoSvc != nil { + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions); err != nil { + return err + } + } oldEggs, err := s.Repository.ListEggs(tx, id) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -765,6 +797,46 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. return nil } +func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + desired := depletion.Qty + depletion.PendingQty + result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + ProductWarehouseID: sourceWarehouseID, + Quantity: desired, + AllowPending: false, + Tx: tx, + }) + if err != nil { + s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { + return err + } + } + + return nil +} + func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { return s.consumeRecordingStocks(ctx, tx, stocks) } @@ -796,10 +868,67 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. return nil } +func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { + if len(depletions) == 0 || s.FifoSvc == nil { + return nil + } + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ + UsableKey: recordingDepletionUsableKey, + UsableID: depletion.Id, + Tx: tx, + }); err != nil { + s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + + if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + return err + } + } + + return nil +} + func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { return s.releaseRecordingStocks(ctx, tx, stocks) } +func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { + return pop.ProductWarehouseId, nil + } + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 { + return pop.ProductWarehouseId, nil + } + } + return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, oldEggs, newEggs []entity.RecordingEgg, @@ -941,10 +1070,8 @@ func (s *recordingService) syncRecordingStocks( desired := item.Qty stock.UsageQty = &desired - if item.PendingQty != nil { - pending := *item.PendingQty - stock.PendingQty = &pending - } + zero := 0.0 + stock.PendingQty = &zero stocksToConsume = append(stocksToConsume, stock) } @@ -990,43 +1117,20 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { } func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { - hasPending := false - for _, item := range incoming { - if item.PendingQty != nil { - hasPending = true - break - } - } - existingUsage := make(map[uint]float64) - existingTotal := make(map[uint]float64) for _, stock := range existing { var usage float64 - var pending float64 if stock.UsageQty != nil { usage = *stock.UsageQty } - if stock.PendingQty != nil { - pending = *stock.PendingQty - } existingUsage[stock.ProductWarehouseId] += usage - existingTotal[stock.ProductWarehouseId] += usage + pending } incomingUsage := make(map[uint]float64) - incomingTotal := make(map[uint]float64) for _, item := range incoming { - var pending float64 - if item.PendingQty != nil { - pending = *item.PendingQty - } incomingUsage[item.ProductWarehouseId] += item.Qty - incomingTotal[item.ProductWarehouseId] += item.Qty + pending } - if hasPending { - return floatMapsMatch(existingTotal, incomingTotal) - } return floatMapsMatch(existingUsage, incomingUsage) } @@ -1224,7 +1328,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = (totalEggWeightGrams / remainingChick) * 1000 + eggMass = totalEggWeightGrams / remainingChick updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { @@ -1234,7 +1338,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggWeight float64 if totalEggQty > 0 && totalEggWeightGrams > 0 { - eggWeight = (totalEggWeightGrams / totalEggQty) * 1000 + eggWeight = totalEggWeightGrams / totalEggQty updates["egg_weight"] = eggWeight recording.EggWeight = &eggWeight } else { diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 8b4eab57..dbbd4f30 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -2,9 +2,8 @@ package validation type ( Stock struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty float64 `json:"qty" validate:"required,gte=0"` - PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty float64 `json:"qty" validate:"required,gte=0"` } Depletion struct { @@ -20,23 +19,24 @@ type ( ) type Create struct { - ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` - RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` - Stocks []Stock `json:"stocks" validate:"dive"` - Depletions []Depletion `json:"depletions" validate:"dive"` - Eggs []Egg `json:"eggs" validate:"omitempty,dive"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` + Stocks []Stock `json:"stocks" validate:"dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs" validate:"omitempty,dive"` } type Update struct { - Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` - Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` - Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` + Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` + Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` + Search string `query:"search" validate:"omitempty,max=50"` } type Approve struct { diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 92db84a3..41611ac3 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -345,7 +345,52 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file ); err != nil { return nil, err } - if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil { + + pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") + } + return nil, err + } + category := strings.TrimSpace(pfk.ProjectFlock.Category) + if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { + if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { + if strings.TrimSpace(standard.ProjectCategory) != "" { + category = standard.ProjectCategory + } + } + } + weekBase := 1 + if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { + weekBase = 18 + } + if req.Week < weekBase { + if weekBase == 18 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + } + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + } + + var latestWeek int + if err := s.Repository.DB().WithContext(c.Context()). + Model(&entity.ProjectFlockKandangUniformity{}). + Where("project_flock_kandang_id = ? AND deleted_at IS NULL", req.ProjectFlockKandangId). + Select("COALESCE(MAX(week), 0)"). + Scan(&latestWeek).Error; err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") + } + if latestWeek == 0 && req.Week != weekBase { + if weekBase == 18 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + } + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + } + if latestWeek > 0 && req.Week > latestWeek+1 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") + } + + if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil { return nil, err } @@ -487,8 +532,35 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui if req.ProjectFlockKandangId != nil { targetPFKID = *req.ProjectFlockKandangId } + if targetPFKID != 0 && targetWeek > 0 { + pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") + } + return nil, err + } + category := strings.TrimSpace(pfk.ProjectFlock.Category) + if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 { + if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil { + if strings.TrimSpace(standard.ProjectCategory) != "" { + category = standard.ProjectCategory + } + } + } + weekBase := 1 + if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) { + weekBase = 18 + } + if targetWeek < weekBase { + if weekBase == 18 { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects") + } + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + } + } if targetDate != nil { - if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil { + if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil { return nil, err } } @@ -604,7 +676,7 @@ 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 { +func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int) error { if projectFlockKandangID == 0 || week == 0 { return nil } diff --git a/internal/modules/repports/dto/repportPurchase.dto.go b/internal/modules/repports/dto/repportPurchase.dto.go index 830a076f..f776121b 100644 --- a/internal/modules/repports/dto/repportPurchase.dto.go +++ b/internal/modules/repports/dto/repportPurchase.dto.go @@ -27,12 +27,12 @@ type PurchaseSupplierRowDTO struct { } type PurchaseSupplierSummaryDTO struct { - TotalQty float64 `json:"total_qty"` - TotalPurchaseValue float64 `json:"total_purchase_value"` - TotalTransportValue float64 `json:"total_transport_value"` - TotalAmount float64 `json:"total_amount"` - TotalUnitPrice float64 `json:"total_unit_price"` - TotalTransportUnitPrice float64 `json:"total_transport_unit_price"` + TotalQty float64 `json:"total_qty"` + TotalPurchaseValue float64 `json:"total_purchase_value"` + TotalTransportValue float64 `json:"total_transport_value"` + TotalAmount float64 `json:"total_amount"` + TotalUnitPrice float64 `json:"total_unit_price"` + TotalTransportUnitPrice float64 `json:"total_transport_unit_price"` } type PurchaseSupplierDTO struct { @@ -122,11 +122,6 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem rows := make([]PurchaseSupplierRowDTO, 0, len(items)) summary := PurchaseSupplierSummaryDTO{} - var unitPriceSum float64 - var unitPriceCount int - var transportUnitPriceSum float64 - var transportUnitPriceCount int - for i := range items { row := ToPurchaseSupplierRowDTO(&items[i]) rows = append(rows, row) @@ -136,19 +131,16 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem summary.TotalTransportValue += row.TransportValue summary.TotalAmount += row.TotalAmount - unitPriceSum += row.UnitPrice - unitPriceCount++ - - transportUnitPriceSum += row.TransportUnitPrice - transportUnitPriceCount++ } - if unitPriceCount > 0 { - summary.TotalUnitPrice = math.Round(unitPriceSum / float64(unitPriceCount)) + if summary.TotalQty > 0 { + avg := summary.TotalPurchaseValue / summary.TotalQty + summary.TotalUnitPrice = math.Round(avg) } - if transportUnitPriceCount > 0 { - summary.TotalTransportUnitPrice = math.Round(transportUnitPriceSum / float64(transportUnitPriceCount)) + if summary.TotalQty > 0 { + avg := summary.TotalTransportValue / summary.TotalQty + summary.TotalTransportUnitPrice = math.Round(avg) } return PurchaseSupplierDTO{ diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index e8f548d6..977db610 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" @@ -17,6 +18,8 @@ type DebtSupplierRepository interface { GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) + GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) + GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) } @@ -25,6 +28,11 @@ type debtSupplierRepositoryImpl struct { db *gorm.DB } +type PaymentReferenceSummary struct { + Total float64 + LatestPaymentDate time.Time +} + func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { return &debtSupplierRepositoryImpl{db: db} } @@ -167,7 +175,8 @@ func (r *debtSupplierRepositoryImpl) GetPaymentsBySuppliers(ctx context.Context, Model(&entity.Payment{}). Where("party_type = ?", string(utils.PaymentPartySupplier)). Where("direction = ?", "OUT"). - Where("party_id IN ?", supplierIDs) + Where("party_id IN ?", supplierIDs). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)) if strings.TrimSpace(filters.StartDate) != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { @@ -238,6 +247,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co Where("direction = ?", "OUT"). Where("party_id IN ?", supplierIDs). Where("reference_number IN ?", references). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). Group("reference_number"). Scan(&rows).Error; err != nil { return nil, err @@ -254,6 +264,75 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co return result, nil } +func (r *debtSupplierRepositoryImpl) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) { + if len(supplierIDs) == 0 || len(references) == 0 { + return map[string]PaymentReferenceSummary{}, nil + } + + type paymentRow struct { + ReferenceNumber *string `gorm:"column:reference_number"` + Total float64 `gorm:"column:total"` + LatestPaymentDate time.Time `gorm:"column:latest_payment_date"` + } + + rows := make([]paymentRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("reference_number, SUM(nominal) AS total, MAX(payment_date) AS latest_payment_date"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("direction = ?", "OUT"). + Where("party_id IN ?", supplierIDs). + Where("reference_number IN ?", references). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). + Group("reference_number"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[string]PaymentReferenceSummary, len(rows)) + for _, row := range rows { + if row.ReferenceNumber == nil || strings.TrimSpace(*row.ReferenceNumber) == "" { + continue + } + result[*row.ReferenceNumber] = PaymentReferenceSummary{ + Total: row.Total, + LatestPaymentDate: row.LatestPaymentDate, + } + } + + return result, nil +} + +func (r *debtSupplierRepositoryImpl) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) { + if len(supplierIDs) == 0 { + return map[uint]float64{}, nil + } + + type balanceRow struct { + SupplierID uint `gorm:"column:supplier_id"` + Total float64 `gorm:"column:total"` + } + + rows := make([]balanceRow, 0) + if err := r.db.WithContext(ctx). + Model(&entity.Payment{}). + Select("party_id AS supplier_id, SUM(nominal) AS total"). + Where("party_type = ?", string(utils.PaymentPartySupplier)). + Where("party_id IN ?", supplierIDs). + Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)). + Group("party_id"). + Scan(&rows).Error; err != nil { + return nil, err + } + + result := make(map[uint]float64, len(rows)) + for _, row := range rows { + result[row.SupplierID] = row.Total + } + + return result, nil +} + func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { return map[uint]float64{}, nil @@ -313,6 +392,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont Where("party_type = ?", string(utils.PaymentPartySupplier)). Where("direction = ?", "OUT"). Where("party_id IN ?", supplierIDs). + Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)). Where("DATE(payment_date) < ?", dateFrom). Group("party_id"). Scan(&rows).Error; err != nil { diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index 979623fc..6a07c555 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -25,6 +25,21 @@ func NewPurchaseSupplierRepository(db *gorm.DB) PurchaseSupplierRepository { return &purchaseSupplierRepositoryImpl{db: db} } +func (r *purchaseSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.step_number, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowPurchase), + ) +} + func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.PurchaseSupplierQuery) *gorm.DB { dateColumn := "purchase_items.received_date" switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) { @@ -34,10 +49,16 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, dateColumn = "purchase_items.received_date" } + latestApproval := r.latestPurchaseApproval(ctx) + db := r.db.WithContext(ctx). Model(&entity.Supplier{}). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). - Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", latestApproval). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if filters.SupplierId > 0 { db = db.Where("suppliers.id = ?", filters.SupplierId) @@ -152,7 +173,11 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context Preload("ExpenseNonstock.Expense"). Preload("ExpenseNonstock.Expense.Supplier"). Joins("JOIN purchases ON purchases.id = purchase_items.purchase_id"). - Where("purchases.supplier_id IN ?", supplierIDs) + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). + Where("purchases.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if filters.ProductId > 0 { db = db.Where("purchase_items.product_id = ?", filters.ProductId) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 41a66731..a0e0f350 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1129,6 +1129,17 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } + initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs) + if err != nil { + return nil, 0, err + } + + references := collectDebtSupplierReferences(purchases) + paymentSummaries, err := s.DebtSupplierRepo.GetPaymentSummariesByReferences(c.Context(), supplierIDs, references) + if err != nil { + return nil, 0, err + } + location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") @@ -1150,7 +1161,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu continue } - initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] + initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]) items := purchasesBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID] total := dto.DebtSupplierTotalDTO{} @@ -1158,6 +1169,16 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { row := buildDebtSupplierRow(purchase, now, location) + if reference := resolveDebtSupplierReference(purchase); reference != "" { + if summary, ok := paymentSummaries[reference]; ok { + if isDebtSupplierPaid(row.TotalPrice, summary.Total) { + row.Status = "Lunas" + if !summary.LatestPaymentDate.IsZero() { + row.Aging = calculateDebtSupplierAging(purchase, summary.LatestPaymentDate, location) + } + } + } + } sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) combinedRows = append(combinedRows, debtSupplierRowItem{ Row: row, @@ -1374,6 +1395,55 @@ func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc return purchase.CreatedAt.In(loc) } +func collectDebtSupplierReferences(purchases []entity.Purchase) []string { + if len(purchases) == 0 { + return nil + } + seen := make(map[string]struct{}, len(purchases)) + result := make([]string, 0, len(purchases)) + for _, purchase := range purchases { + ref := resolveDebtSupplierReference(purchase) + if ref == "" { + continue + } + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + result = append(result, ref) + } + return result +} + +func resolveDebtSupplierReference(purchase entity.Purchase) string { + if purchase.PoNumber != nil { + if ref := strings.TrimSpace(*purchase.PoNumber); ref != "" { + return ref + } + } + if ref := strings.TrimSpace(purchase.PrNumber); ref != "" { + return ref + } + return "" +} + +func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool { + if totalPrice <= 0 { + return true + } + return paymentTotal >= totalPrice-0.000001 +} + +func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int { + prDate := purchase.CreatedAt.In(loc) + startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) + stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc) + if stopDate.Before(startDate) { + return 0 + } + return int(stopDate.Sub(startDate).Hours() / 24) +} + func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) { params, filters, err := s.parseHppPerKandangQuery(ctx) if err != nil { diff --git a/internal/utils/fifo/constants.go b/internal/utils/fifo/constants.go index 076d960d..840ba8e1 100644 --- a/internal/utils/fifo/constants.go +++ b/internal/utils/fifo/constants.go @@ -2,18 +2,19 @@ package fifo const ( // Usable Keys - UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" - UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" - UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" - UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT" - UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" - UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" + UsableKeyRecordingStock UsableKey = "RECORDING_STOCK" + UsableKeyRecordingDepletion UsableKey = "RECORDING_DEPLETION" + UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN" + UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY" + UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT" + UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT" + UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT" // Stockable Keys - StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN" - StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" - StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" - StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" + StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN" + StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN" + StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN" + StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS" StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION" - StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" + StockableKeyRecordingEgg StockableKey = "RECORDING_EGG" ) diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 91c9cc4b..f40818bf 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -14,15 +14,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto for _, item := range items { usagePtr := new(float64) *usagePtr = item.Qty - pending := item.PendingQty - if pending == nil { - pending = new(float64) - } result = append(result, entity.RecordingStock{ RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, UsageQty: usagePtr, - PendingQty: pending, }) } return result From 2aaaab91f7aa502040d499ceabdebc0f9882c76c Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 12:07:16 +0700 Subject: [PATCH 053/117] fix endpoint not found --- .../closings/controllers/closing.controller.go | 12 ++++++------ internal/modules/closings/route.go | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a43687ac..d348fd34 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -237,6 +237,8 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { query := &validation.ClosingSapronakQuery{ Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { @@ -248,6 +250,10 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { query.KandangID = &kandangUint } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } @@ -282,8 +288,6 @@ func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { query := &validation.ClosingSapronakQuery{ Type: strings.ToLower(c.Query("type")), - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), Search: c.Query("search"), } if raw := c.Query("kandang_id"); raw != "" { @@ -295,10 +299,6 @@ func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error { query.KandangID = &kandangUint } - if query.Page < 1 || query.Limit < 1 { - return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") - } - if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") } diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index f0a6ca2a..c507b042 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -30,6 +30,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService 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("/:projectFlockId/sapronak/summary", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronakSummary) 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/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi) From 1b7ce3c62c99704cd41d74a2bbe24959221c58b6 Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 20 Jan 2026 12:08:41 +0700 Subject: [PATCH 054/117] Revert "Merge branch 'staging' into 'development'" This reverts merge request !212 --- .air.toml | 13 +++ .gitlab-ci.yml | 245 ++++++++++++++++--------------------------------- 2 files changed, 94 insertions(+), 164 deletions(-) create mode 100644 .air.toml diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..c463b5b2 --- /dev/null +++ b/.air.toml @@ -0,0 +1,13 @@ +# .air.toml +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "APP_ENV=dev ./tmp/main" +include_ext = ["go", "tpl", "tmpl", "html"] +exclude_dir = ["vendor", "tmp"] + +[log] +time = true diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 41aa41be..53f28b3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,173 +1,90 @@ stages: - - build - - migrate - deploy - - seed -default: - tags: - - self-hosted-stg - -workflow: - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - when: always - - when: never - -variables: - DOCKER_BUILDKIT: "1" - - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" - IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - - DEPLOY_DIR: "/opt/deploy/stg-lti-api" - COMPOSE_FILE: "docker-compose.yaml" - -# ========================= -# BUILD (AUTO) -# ========================= -build_staging: - stage: build - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - script: | - set -e - docker info - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - echo "✅ Build image: $IMAGE_NAME" - docker build -t "$IMAGE_NAME" -f Dockerfile . - - echo "✅ Push image: $IMAGE_NAME" - docker push "$IMAGE_NAME" - - echo "✅ Tag latest: $IMAGE_LATEST" - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - docker push "$IMAGE_LATEST" - - -# ========================= -# MIGRATE (AUTO) -# ========================= -migrate_staging: - stage: migrate - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: build_staging - artifacts: false - script: | - set -e - echo "✅ Running migrations (staging) ..." - - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - # ✅ load env dari server - set -a - . ./.env - set +a - - # ✅ validasi - test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) - test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) - test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) - test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) - test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) - - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL=$DATABASE_URL" - - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) - echo "✅ Ensuring postgres & redis running ..." - docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true - - # ✅ Ambil network key dari compose - COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" - - # ✅ Cari network name yang dipakai docker - NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) - - echo "✅ Docker network detected: $NETWORK_NAME" - - # ✅ Migrations dari repo (CI workspace) - echo "✅ Checking migrations from repo..." - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - - echo "✅ Running migrations via migrate/migrate container" - set +e - out=$(docker run --rm \ - --network "$NETWORK_NAME" \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up 2>&1) - code=$? - set -e - - echo "$out" - - # ✅ Handle no change dengan benar (tidak false-success) - if echo "$out" | grep -qi "no change"; then - echo "✅ No change (already up to date)" - exit 0 - fi - - if [ $code -ne 0 ]; then - echo "❌ Migration failed with exit code $code" - exit $code - fi - - echo "✅ Migration applied successfully" - - -# ========================= -# DEPLOY (AUTO) -# ========================= -deploy_staging: +deploy-dev: stage: deploy - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: migrate_staging - artifacts: false - - job: build_staging - artifacts: false - script: | - set -e - docker info - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash - docker compose -f "$COMPOSE_FILE" pull - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - docker image prune -f + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_rsa + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts -# ========================= -# SEED (MANUAL) -# ========================= -seed_staging: - stage: seed - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: deploy_staging - artifacts: false - when: manual - allow_failure: false - script: | - set -e - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) - test -f .env || (echo "❌ .env not found" && exit 1) + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development \ No newline at end of file From fc06b3e4dbc467af50b13f005edac8c7cc460713 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 14:19:02 +0700 Subject: [PATCH 055/117] [FIX/BE-US] purchase edit qty approval staf --- .../purchases/services/purchase.service.go | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index f6337c8a..b0914853 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -751,6 +751,9 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if receivedQty > item.SubQty { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } + if receivedQty < item.TotalUsed { + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) + } if _, dup := visitedItems[payload.PurchaseItemID]; dup { return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) @@ -835,6 +838,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) + totalQtyDeltas := make(map[uint]float64) fifoAdds := make([]struct { itemID uint pwID uint @@ -862,14 +866,20 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation deltaQty := prep.receivedQty - item.TotalQty switch { case deltaQty > 0 && newPWID != nil: - fifoAdds = append(fifoAdds, struct { - itemID uint - pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + if s.FifoSvc != nil { + fifoAdds = append(fifoAdds, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + } else { + deltas[*newPWID] += deltaQty + totalQtyDeltas[item.Id] += deltaQty + } case deltaQty < 0 && newPWID != nil: deltas[*newPWID] += deltaQty // negative affected[*newPWID] = struct{}{} + totalQtyDeltas[item.Id] += deltaQty } dateCopy := prep.receivedDate @@ -892,7 +902,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation updates = append(updates, update) - if item.Price > 0 && prep.receivedQty >= 0 { + if prep.receivedQty >= 0 { priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ ItemID: item.Id, Price: item.Price, @@ -919,6 +929,19 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } + if len(totalQtyDeltas) > 0 { + for itemID, delta := range totalQtyDeltas { + if delta == 0 { + continue + } + if err := tx.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchase.Id, itemID). + Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; 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) @@ -1371,10 +1394,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload( qtyCopy := effectiveQty update.Quantity = &qtyCopy } - if syncReceiving { - qtyCopy := effectiveQty - update.TotalQty = &qtyCopy - } updates = append(updates, update) delete(requestItems, item.Id) From edd77c52653e128dcb1cb9a4357b8ba657f26ff0 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 16:40:37 +0700 Subject: [PATCH 056/117] [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system --- .../common/service/common.fifo.service.go | 41 +++++++++++++++++ .../purchases/services/purchase.service.go | 44 ++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index b99e6c35..14cbb5c1 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -25,6 +25,7 @@ type FifoService interface { Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error + AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error } type fifoService struct { @@ -95,6 +96,15 @@ type StockReplenishRequest struct { Tx *gorm.DB } +type StockAdjustRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + type PendingResolution struct { UsableKey fifo.UsableKey UsableID uint @@ -137,6 +147,37 @@ type StockReleaseRequest struct { Reason *string Tx *gorm.DB } +func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error { + if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { + return errors.New("stockable key and id are required") + } + if req.ProductWarehouseID == 0 { + return errors.New("product warehouse id is required") + } + if req.Quantity == 0 { + return nil + } + if req.Quantity > 0 { + return errors.New("quantity must be negative") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil { + return err + } + + return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0914853..b7efbc05 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -844,6 +844,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwID uint qty float64 }, 0, len(prepared)) + fifoSubs := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -877,9 +882,18 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation totalQtyDeltas[item.Id] += deltaQty } case deltaQty < 0 && newPWID != nil: - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty + if s.FifoSvc != nil { + fifoSubs = append(fifoSubs, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + affected[*newPWID] = struct{}{} + } else { + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} + totalQtyDeltas[item.Id] += deltaQty + } } dateCopy := prep.receivedDate @@ -919,10 +933,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { - return err - } - if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err @@ -967,6 +977,26 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } } + for _, adj := range fifoSubs { + if adj.pwID == 0 || adj.qty >= 0 { + continue + } + if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyPurchaseItems, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } + } + + if len(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { + return err + } } return nil From e4e17f16f9129c893aa4061011ac751d5591f534 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 18:18:41 +0700 Subject: [PATCH 057/117] fix data produksi not show response --- internal/modules/closings/dto/closing.dto.go | 40 +++++++++---------- .../closings/services/closing.service.go | 8 ++-- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 1c191d29..82e11f49 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct { } type ClosingPerformanceDTO struct { - Depletion float64 `json:"depletion"` - Age float64 `json:"age_day"` - MortalityStd float64 `json:"mor_std"` - MortalityAct float64 `json:"mor_act"` - DeffMortality float64 `json:"mor_diff"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"fcr_diff"` - AwgAct float64 `json:"awg_act"` - AwgStd float64 `json:"awg_std"` - FeedIntake float64 `json:"feed_intake"` - FeedIntakeStd float64 `json:"feed_intake_std"` - HenDayAct *float64 `json:"hen_day_act,omitempty"` - HendayStd float64 `json:"hen_day_std"` - EggMass *float64 `json:"egg_mass,omitempty"` - EggMassStd float64 `json:"egg_mass_std"` - EggWeight *float64 `json:"egg_weight,omitempty"` - EggWeightStd float64 `json:"egg_weight_std"` - HenHouseAct *float64 `json:"hen_housed_act,omitempty"` - HenHouseStd float64 `json:"hen_housed_std"` + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` + HenDayAct float64 `json:"hen_day_act,omitempty"` + HendayStd float64 `json:"hen_day_std"` + EggMass float64 `json:"egg_mass,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` + EggWeight float64 `json:"egg_weight,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` + HenHouseAct float64 `json:"hen_housed_act,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 443eec7f..daca980f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -930,19 +930,19 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint if !isGrowing { if targetAverages.HenDayCount > 0 { henDayAct := targetAverages.HenDayAvg - performance.HenDayAct = &henDayAct + performance.HenDayAct = henDayAct } if targetAverages.HenHouseCount > 0 { henHouseAct := targetAverages.HenHouseAvg - performance.HenHouseAct = &henHouseAct + performance.HenHouseAct = henHouseAct } if targetAverages.EggWeightCount > 0 { eggWeight := targetAverages.EggWeightAvg - performance.EggWeight = &eggWeight + performance.EggWeight = eggWeight } if targetAverages.EggMassCount > 0 { eggMass := targetAverages.EggMassAvg - performance.EggMass = &eggMass + performance.EggMass = eggMass } } performance.DeffFcr = performance.FcrStd - performance.FcrAct From 95965cb26a68f71d34ae8d54b212defcc2767e28 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 18:21:49 +0700 Subject: [PATCH 058/117] add common service and repo for calculate hpp --- .../repository/common.hpp.repository.go | 150 ++++++++++++++++++ internal/common/service/common.hpp.service.go | 89 +++++++++++ 2 files changed, 239 insertions(+) create mode 100644 internal/common/repository/common.hpp.repository.go create mode 100644 internal/common/service/common.hpp.service.go diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go new file mode 100644 index 00000000..f9bbaa3c --- /dev/null +++ b/internal/common/repository/common.hpp.repository.go @@ -0,0 +1,150 @@ +package repository + +import ( + "context" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type HppCostRepository interface { + GetDocCost(ctx context.Context, projectFlockKandangId uint) (float64, error) + GetBudgetCost(ctx context.Context, projectFlockKandangId uint) (float64, error) + GetExpedisionCost(ctx context.Context, projectFlockKandangId uint) (float64, error) + GetFeedCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) + GetOvkCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) +} + +type HppRepositoryImpl struct { + db *gorm.DB +} + +func NewHppCostRepository(db *gorm.DB) HppCostRepository { + return &HppRepositoryImpl{db: db} +} + +func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppRepositoryImpl) GetBudgetCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { + pfkUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pc.project_flock_kandang_id, + SUM(pc.usage_qty) AS kandang_usage_qty`). + Group("pc.project_flock_kandang_id") + + projectUsageSub := r.db. + Table("project_chickins AS pc"). + Select(` + pfk.project_flock_id, + SUM(pc.usage_qty) AS project_usage_qty`). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). + Group("pfk.project_flock_id") + + var total float64 + err := r.db.WithContext(ctx). + Table("project_flock_kandangs AS pfk"). + Select(` + COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0)`). + Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). + Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). + Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). + Where("pfk.id = ?", projectFlockKandangId). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("expense_nonstocks AS en"). + Select("COALESCE(SUM(er.qty * er.price), 0)"). + Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). + Where("en.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + var total float64 + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + 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 flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("r.record_datetime <= ?", *date). + Where("f.name = ?", utils.FlagPakan). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + flags := []utils.FlagType{ + utils.FlagOVK, + utils.FlagObat, + utils.FlagVitamin, + utils.FlagKimia, + } + + var total float64 + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + 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 flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). + Where("r.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("r.record_datetime <= ?", *date). + Where("f.name IN ?", flags). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go new file mode 100644 index 00000000..5ef9091e --- /dev/null +++ b/internal/common/service/common.hpp.service.go @@ -0,0 +1,89 @@ +package service + +import ( + "context" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" +) + +type HppService interface { + CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) + GetTotalDepresiasi(projectFlockKandangId uint, date *time.Time) (float64, error) +} + +type HppCostResponse struct { + Estimation HppCostDetail `json:"estimation"` + Real HppCostDetail `json:"real"` +} + +type HppCostDetail struct { + HargaKg float64 `json:"harga_kg"` + HargaButir float64 `json:"harga_butir"` + Total float64 `json:"total"` + Kg float64 `json:"kg"` + Butir float64 `json:"butir"` +} + +type hppService struct { + hppRepo commonRepo.HppCostRepository +} + +func NewHppService(hppRepo commonRepo.HppCostRepository) HppService { + return &hppService{hppRepo: hppRepo} +} + +func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { + if date == nil { + now := time.Now() + date = &now + } + + // _ = projectFlockKandangId + _ = date + + return &HppCostResponse{ + Estimation: HppCostDetail{}, + Real: HppCostDetail{}, + }, nil +} + +func (s *hppService) GetTotalDepresiasi(projectFlockKandangId uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + if s.hppRepo == nil { + return 0, nil + } + + docCost, err := s.hppRepo.GetDocCost(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + } + + budgetCost, err := s.hppRepo.GetBudgetCost(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + } + + expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + } + + feedCost, err := s.hppRepo.GetFeedCost(context.Background(), projectFlockKandangId, date) + if err != nil { + return 0, err + } + + ovkCost, err := s.hppRepo.GetOvkCost(context.Background(), projectFlockKandangId, date) + if err != nil { + return 0, err + } + + _ = date + + return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil +} From aa4da6868067bc189a70974e2bf9fdce41f326b0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:07:07 +0700 Subject: [PATCH 059/117] refactor: unify GetOne method to return approval alongside transfer laying --- .../controllers/transfer_laying.controller.go | 2 +- .../services/transfer_laying.service.go | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index 13c39334..d0ee5061 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -70,7 +70,7 @@ func (u *TransferLayingController) GetOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id)) + result, approval, err := u.TransferLayingService.GetOne(c, uint(id)) if err != nil { return err } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index e64b9cc2..8e0269cf 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -28,8 +28,7 @@ import ( type TransferLayingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) - GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -156,14 +155,15 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return transferLayings, total, nil } -func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { +func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + return nil, nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") } if err != nil { s.Log.Errorf("Failed get transferLaying by id: %+v", err) - return nil, err + return nil, nil, err } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -174,15 +174,6 @@ func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTran transferLaying.LatestApproval = latestApproval } - return transferLaying, nil -} - -func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { - transferLaying, err := s.GetOne(c, id) - if err != nil { - return nil, nil, err - } - return transferLaying, transferLaying.LatestApproval, nil } @@ -406,7 +397,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") } - return s.GetOne(c, createBody.Id) + laying_transfer, _, err := s.GetOne(c, createBody.Id) + if err != nil { + return nil, err + } + return laying_transfer, nil + } func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { @@ -582,7 +578,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, err } - return s.GetOne(c, id) + layingTransfer, _, err := s.GetOne(c, id) + + return layingTransfer, err } func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -773,7 +771,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( updated := make([]entity.LayingTransfer, 0, len(approvableIDs)) for _, approvableID := range approvableIDs { - transfer, err := s.GetOne(c, approvableID) + transfer, _, err := s.GetOne(c, approvableID) if err != nil { return nil, err } From dd4dcc1c39573eef867d0f71e32b3a193a07d421 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:10:47 +0700 Subject: [PATCH 060/117] FEAT[BE[: add avg weight and avg amount on get penjualan harian --- .../repports/dto/repportMarketing.dto.go | 296 +++++++----------- .../repports/services/repport.service.go | 2 +- 2 files changed, 118 insertions(+), 180 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 92ee9a77..edb2887f 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -40,99 +40,24 @@ type RepportMarketingItemDTO struct { type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + AverageSalesAmount float64 `json:"average_sales_amount"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` } -type RepportMarketingResponseDTO struct { - Items []RepportMarketingItemDTO `json:"items"` - Total *Summary `json:"total,omitempty"` -} - type ProductRelationDTOFixed struct { productDTO.ProductRelationDTO ProductPrice float64 `json:"product_price"` SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { - soDate := time.Time{} - agingDays := 0 - if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { - soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) - } - - realizationDate := time.Time{} - if mdp.DeliveryDate != nil { - realizationDate = *mdp.DeliveryDate - } - - doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - - totalWeightKg := mdp.UsageQty * mdp.AvgWeight - salesAmount := totalWeightKg * mdp.UnitPrice - - var hpp float64 - var hppAmount float64 - if isProductEligibleForHpp(mdp, category) { - hpp = hppPricePerKg - hppAmount = totalWeightKg * hppPricePerKg - } - - item := RepportMarketingItemDTO{ - ID: int(mdp.Id), - SoDate: soDate, - RealizationDate: realizationDate, - AgingDays: agingDays, - DoNumber: doNumber, - MarketingType: getMarketingType(mdp), - Qty: mdp.UsageQty, - AverageWeightKg: mdp.AvgWeight, - TotalWeightKg: totalWeightKg, - SalesPricePerKg: mdp.UnitPrice, - HppPricePerKg: hpp, - SalesAmount: salesAmount, - HppAmount: hppAmount, - } - - if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { - mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) - item.Warehouse = &mapped - } - - if mdp.MarketingProduct.Marketing.CustomerId != 0 { - mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) - item.Customer = &mapped - } - - if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { - mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) - item.Sales = &mapped - } - - item.VehicleNumber = mdp.VehicleNumber - - if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { - mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) - item.Product = newProductRelationDTOFixedPtr(&mapped) - } - - return item -} - -func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) - for _, mdp := range mdps { - items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category)) - } - return items -} -func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { - items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { + // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -142,101 +67,111 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct category = projectFlockKandang.ProjectFlock.Category } - item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + // Calculate dates + soDate := time.Time{} + agingDays := 0 + if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { + soDate = mdp.MarketingProduct.Marketing.SoDate + agingDays = int(time.Since(soDate).Hours() / 24) + } + + realizationDate := time.Time{} + if mdp.DeliveryDate != nil { + realizationDate = *mdp.DeliveryDate + } + + totalWeightKg := mdp.UsageQty * mdp.AvgWeight + salesAmount := totalWeightKg * mdp.UnitPrice + + var hpp float64 + var hppAmount float64 + + var hasAyam, hasTelur, hasTrading bool + for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags { + ft := utils.FlagType(flag.Name) + + if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || + ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { + hasAyam = true + } + + if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || + ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { + hasTelur = true + } + + if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || + ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { + hasTrading = true + } + } + + // Determine marketing type + marketingType := "trading" + if hasTrading { + marketingType = "trading" + } else if hasTelur { + marketingType = "telur" + } else if hasAyam { + marketingType = "ayam" + } + + eligibleForHpp := false + + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { + eligibleForHpp = hasAyam + } else { + eligibleForHpp = hasAyam || hasTelur + } + + if eligibleForHpp { + hpp = hppPerKg + hppAmount = totalWeightKg * hppPerKg + } + + item := RepportMarketingItemDTO{ + ID: int(mdp.Id), + SoDate: soDate, + RealizationDate: realizationDate, + AgingDays: agingDays, + DoNumber: marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId), + MarketingType: marketingType, + Qty: mdp.UsageQty, + AverageWeightKg: mdp.AvgWeight, + TotalWeightKg: totalWeightKg, + SalesPricePerKg: mdp.UnitPrice, + HppPricePerKg: hpp, + SalesAmount: salesAmount, + HppAmount: hppAmount, + VehicleNumber: mdp.VehicleNumber, + } + + if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { + mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) + item.Warehouse = &mapped + } + + if mdp.MarketingProduct.Marketing.CustomerId != 0 { + mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) + item.Customer = &mapped + } + + if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { + mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) + item.Sales = &mapped + } + + if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { + mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) + item.Product = newProductRelationDTOFixedPtr(&mapped) + } + items = append(items, item) } + return items } -func getMarketingType(mdp entity.MarketingDeliveryProduct) string { - hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if hasAyam { - return "ayam" - } - if hasTelur { - return "telur" - } - if hasTrading { - return "trading" - } - return "trading" // default to trading if no flags found -} - -func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) { - if len(flags) == 0 { - return false, false, false - } - - for _, flag := range flags { - ft := utils.FlagType(flag.Name) - - if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || - ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { - hasAyam = true - } - - if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || - ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { - hasTelur = true - } - - if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || - ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { - hasTrading = true - } - } - - return hasAyam, hasTelur, hasTrading -} - -func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { - hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { - return hasAyam - } - - return hasAyam || hasTelur -} - -func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary { - if len(mdps) == 0 { - return nil - } - - totalQty := 0 - totalWeightKg := 0.0 - totalEligibleWeightKg := 0.0 - totalSalesAmount := int64(0) - totalHppAmount := int64(0) - - for _, mdp := range mdps { - calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight - totalQty += int(mdp.UsageQty) - totalWeightKg += calculatedTotalWeight - totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) - - if isProductEligibleForHpp(mdp, category) { - totalEligibleWeightKg += calculatedTotalWeight - totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg) - } - } - - totalHppPricePerKg := float64(0) - if totalEligibleWeightKg > 0 { - totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg - } - - return &Summary{ - TotalQty: totalQty, - TotalWeightKg: totalWeightKg, - TotalSalesAmount: totalSalesAmount, - TotalHppAmount: totalHppAmount, - TotalHppPricePerKg: totalHppPricePerKg, - } -} - func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { if len(items) == 0 { return nil @@ -244,6 +179,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 + avgSalesAmount := 0.0 + avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -259,25 +196,26 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) + } + + if totalQty > 0 { + avgWeightKg = totalWeightKg / float64(totalQty) + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + } + return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, + AverageWeightKg: avgWeightKg, + AverageSalesAmount: avgSalesAmount, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, } } -func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO { - items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category) - total := ToSummary(mdps, hppPricePerKg, category) - - return RepportMarketingResponseDTO{ - Items: items, - Total: total, - } -} - func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { if original == nil { return nil diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a0e0f350..090a284b 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -181,7 +181,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap) return items, total, nil } From ad3bb0e29a348e5e8f2fb021663b01a331a47984 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:28:34 +0700 Subject: [PATCH 061/117] FEAT[BE[: enhance marketing report items with aging days calculation --- .../repports/dto/repportMarketing.dto.go | 17 ++- .../repports/services/repport.service.go | 104 ++++++++---------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index edb2887f..751796e9 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,11 +53,10 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { - // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -67,12 +66,15 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u category = projectFlockKandang.ProjectFlock.Category } - // Calculate dates soDate := time.Time{} agingDays := 0 if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) + if ag, exists := agingMap[int(mdp.Id)]; exists { + agingDays = ag + } else { + agingDays = int(time.Since(soDate).Hours() / 24) + } } realizationDate := time.Time{} @@ -106,7 +108,6 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u } } - // Determine marketing type marketingType := "trading" if hasTrading { marketingType = "trading" @@ -196,13 +197,9 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } - if len(items) > 0 { - avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) - } - if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 090a284b..579436eb 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -165,6 +165,47 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing return nil, 0, err } + customerGroups := make(map[uint][]entity.MarketingDeliveryProduct) + for _, dp := range deliveryProducts { + customerID := dp.MarketingProduct.Marketing.CustomerId + customerGroups[customerID] = append(customerGroups[customerID], dp) + } + + agingMap := make(map[int]int) + for customerID := range customerGroups { + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) + if err != nil { + s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) + continue + } + + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(c.Context(), customerID) + if err != nil { + initialBalance = 0 + } + + runningBalance := initialBalance + for i, tx := range transactions { + if tx.TransactionType == "SALES" { + previousBalance := runningBalance + runningBalance -= tx.TotalPrice + currentBalance := runningBalance + + _, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, currentBalance) + + if paymentDate != nil { + agingDays := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } else { + agingDays := int(time.Since(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + } + } + } + projectFlockIDMap := make(map[uint]bool) hppMap := make(map[uint]float64) @@ -181,7 +222,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) return items, total, nil } @@ -422,12 +463,10 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return nil, 0, err } - // Determine customer IDs to process var customerIDs []uint var totalCustomers int64 if len(params.CustomerIDs) > 0 { - // Specific customer IDs mode (no pagination) customerIDs = params.CustomerIDs totalCustomers = int64(len(customerIDs)) @@ -435,7 +474,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return []dto.CustomerPaymentReportItem{}, 0, nil } } else { - // Multiple customers mode with pagination page := params.Page limit := params.Limit if page < 1 { @@ -574,15 +612,7 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { currentSales := transactions[currentIndex] - // Status Logic: - // 1. LUNAS: previousBalance >= salesAmount (paid from deposit) - // 2. LUNAS: future payments make AR >= 0 (eventually paid) - // 3. DIBAYAR SEBAGIAN: has payment but not enough - // 4. BELUM LUNAS: no payment at all - if previousBalance >= currentSales.TotalPrice { - // Cari payment yang digunakan untuk melunasi sales ini dengan FIFO - // Track payment allocations that are consumed by previous sales type paymentAllocation struct { date time.Time amount float64 @@ -591,7 +621,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo allocations := []paymentAllocation{} runningBalance := 0.0 - // Process all transactions before current sales to build allocation map for i := 0; i < currentIndex; i++ { if transactions[i].TransactionType == "PAYMENT" { allocations = append(allocations, paymentAllocation{ @@ -604,7 +633,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo salesAmount := transactions[i].TotalPrice remainingToConsume := salesAmount - // Consume from oldest allocations first (FIFO) for j := range allocations { if remainingToConsume <= 0 { break @@ -623,22 +651,18 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo } } - // Now find which allocation covers the current sales amountNeeded := currentSales.TotalPrice for _, alloc := range allocations { available := alloc.amount - alloc.consumed if available > 0 { if amountNeeded <= available { - // This allocation fully covers the sales return "LUNAS", &alloc.date } else { - // This allocation partially covers, continue to next amountNeeded -= available } } } - // If we get here, use the oldest allocation if len(allocations) > 0 { return "LUNAS", &allocations[0].date } @@ -690,7 +714,6 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe if record.Day != nil { result.Woa = float64(*record.Day) } - // avgWeight := calculateAverageBodyWeight(record.BodyWeights) avgWeight := 1.0 if avgWeight > 0 { result.Bw = avgWeight @@ -1570,12 +1593,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 - // var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 - // var totalRemainingValueRp int64 var totalEggValueRp int64 - // var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int @@ -1589,14 +1609,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - // birdsFloat := row.RemainingChickenBirds - // if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - // birdsFloat = 0 - // } - // weightFloat := row.RemainingChickenWeight - // if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - // weightFloat = 0 - // } eggPiecesFloatRemaining := row.EggProductionPiecesRemaining if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { eggPiecesFloatRemaining = 0 @@ -1632,13 +1644,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - // rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.ProjectFlockKandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - // hppRp := 0.0 - // if weightFloat > 0 { - // hppRp = totalCost / weightFloat - // } eggHpp := 0.0 if eggWeightFloat > 0 { eggHpp = (totalCost / eggWeightFloat) / 1000 @@ -1646,7 +1653,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) - // rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) @@ -1673,35 +1679,22 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - AvgWeightKg: avgWeight, - NameWithPeriode: nameWithPeriod, - // FeedCostRp: costEntry.FeedCost, - // OvkCostRp: costEntry.OvkCost, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), EggProductionKg: eggRemainingWeightFloatRemaining, - // EggProductionTotalWeightKg: eggWeightFloat, - // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), - AverageDocPriceRp: avgDocPrice, - // HppRp: hppRp, - EggHppRpPerKg: eggHpp, - // RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + AverageDocPriceRp: avgDocPrice, + EggHppRpPerKg: eggHpp, + EggValueRp: rowEggValue, }) - // totalBirds += rowBirds - // totalWeight += weightFloat totalEggPieces += rowEggPieces totalEggKg += eggRemainingWeightFloatRemaining - // totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue totalAvgWeightSum += avgWeight totalAvgWeightCount++ - // if weightFloat > 0 { - // totalHppSum += hppRp - // totalHppCount++ - // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1728,8 +1721,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary := rangeAgg.Summary - // rangeAgg.RemainingBirds += rowBirds - // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { @@ -1744,7 +1735,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining - // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp From d50ab7cc977c0724dada0abc7050dbdadd43d881 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:42:16 +0700 Subject: [PATCH 062/117] FEAT[BE]: add default filterby become so_date in report markeing --- .../salesorder_delivery_product.repository.go | 10 +++++++--- internal/modules/repports/services/repport.service.go | 1 - .../modules/repports/validations/repport.validation.go | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index e219b041..1ec0bddf 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -225,8 +225,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C } } - if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { - if filters.FilterBy == "so_date" { + if filters.StartDate != "" || filters.EndDate != "" { + filterBy := filters.FilterBy + if filterBy == "" { + filterBy = "so_date" + } + if filterBy == "so_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketings.so_date >= ?", startDate) @@ -238,7 +242,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("marketings.so_date < ?", nextDate) } } - } else if filters.FilterBy == "realization_date" { + } else if filterBy == "realization_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 579436eb..03b1b370 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -175,7 +175,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing for customerID := range customerGroups { transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) if err != nil { - s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) continue } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index e0161b5c..8047f718 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -26,7 +26,7 @@ type MarketingQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` From 67ecdbc1ddef985e2854fa196ccc8292224a81f2 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 23:01:58 +0700 Subject: [PATCH 063/117] fix duplicate name, time type and phase id create phase activity --- .../services/phase-activity.service.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/modules/master/phase-activities/services/phase-activity.service.go b/internal/modules/master/phase-activities/services/phase-activity.service.go index 1c6b15ce..c34e6a31 100644 --- a/internal/modules/master/phase-activities/services/phase-activity.service.go +++ b/internal/modules/master/phase-activities/services/phase-activity.service.go @@ -110,6 +110,17 @@ func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) ( return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") } + existing, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB { + return db.Where("phase_id = ? AND name = ? AND time_type = ?", phase.Id, name, timeType) + }) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to check phaseActivity uniqueness: %+v", err) + return nil, err + } + if existing != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "phase activity with same name and time_type already exists") + } + createBody := &entity.PhaseActivity{ PhaseId: phase.Id, Name: name, From d0625e7d21d128c3bfb894c2895bc476c6605d9e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:45:19 +0700 Subject: [PATCH 064/117] FIX[BE]: fixing closing penjualan add sumary --- .../controllers/closing.controller.go | 4 +- .../closings/dto/closingMarketing.dto.go | 99 +++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a43687ac..8b79fc92 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -160,7 +160,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } @@ -190,7 +190,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan by project flock kandang successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 1a790ad6..223b9d11 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -12,23 +12,31 @@ import ( // === Response DTO === type SalesDTO struct { - Id uint `json:"id"` - RealizationDate time.Time `json:"realization_date"` - Age int `json:"age"` - DoNumber string `json:"do_number"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AvgWeight float64 `json:"avg_weight"` - Price float64 `json:"price"` - TotalPrice float64 `json:"total_price"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - PaymentStatus string `json:"payment_status"` + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + SalesPrice float64 `json:"sales_price"` + TotalSalesPrice float64 `json:"total_sales_price"` + ActualPrice float64 `json:"actual_price"` + TotalActualPrice float64 `json:"total_actual_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` +} +type SummaryDTO struct { + TotalSalesPrice float64 `json:"total_sales_price"` + AvgSalesPrice float64 `json:"avg_sales_price"` + TotalActualPrice float64 `json:"total_actual_price"` + AvgActualPrice float64 `json:"avg_actual_price"` } type PenjualanRealisasiResponseDTO struct { - Sales []SalesDTO `json:"sales"` + Sales []SalesDTO `json:"sales"` + Summary SummaryDTO `json:"summary"` } // === Mapper Functions === @@ -63,19 +71,38 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) return SalesDTO{ - Id: e.Id, - RealizationDate: realizationDate, - Age: age, - DoNumber: doNumber, - Product: product, - Customer: customer, - Qty: e.UsageQty, - Weight: e.TotalWeight, - AvgWeight: e.AvgWeight, - Price: e.UnitPrice, - TotalPrice: e.TotalPrice, - Kandang: kandang, - PaymentStatus: "Paid", + Id: e.Id, + RealizationDate: realizationDate, + Age: age, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.UsageQty, + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + SalesPrice: e.MarketingProduct.UnitPrice, + TotalSalesPrice: e.MarketingProduct.TotalPrice, + ActualPrice: e.UnitPrice, + TotalActualPrice: e.TotalPrice, + Kandang: kandang, + } +} + +func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { + + var totalSalesPrice, totalActualPrice float64 + count := len(e) + + for _, item := range e { + totalSalesPrice += item.MarketingProduct.TotalPrice + totalActualPrice += item.TotalPrice + } + + return SummaryDTO{ + TotalSalesPrice: totalSalesPrice, + TotalActualPrice: totalActualPrice, + AvgSalesPrice: totalSalesPrice / float64(count), + AvgActualPrice: totalActualPrice / float64(count), } } @@ -87,25 +114,13 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { return result } -func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { - +func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { return PenjualanRealisasiResponseDTO{ - - Sales: ToSalesDTOs(e), + Sales: ToSalesDTOs(e), + Summary: ToSummaryDto(e), } } -func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { - if len(realisasi) > 0 { - for _, item := range realisasi { - if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period - } - } - } - return 0 -} - func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0 From 894efa7aa54ccdcc88173eb35b19eea6af11c678 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:46:03 +0700 Subject: [PATCH 065/117] FIX[BE]: fixing report penjualan add avg weight and price to response --- .../modules/repports/dto/repportMarketing.dto.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 751796e9..336b6576 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -41,7 +41,7 @@ type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` AverageWeightKg float64 `json:"average_weight_kg"` - AverageSalesAmount float64 `json:"average_sales_amount"` + AverageSalesPrice float64 `json:"average_sales_price"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` @@ -180,7 +180,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 - avgSalesAmount := 0.0 + avgSalesPrice := 0.0 avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -190,6 +190,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalWeightKg += item.TotalWeightKg totalSalesAmount += int64(item.SalesAmount) totalHppAmount += int64(item.HppAmount) + avgSalesPrice += item.SalesPricePerKg } totalHppPricePerKg := float64(0) @@ -197,16 +198,19 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesPrice = avgSalesPrice / float64(len(items)) + } + if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, AverageWeightKg: avgWeightKg, - AverageSalesAmount: avgSalesAmount, + AverageSalesPrice: avgSalesPrice, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, From c2d2701d728c8b33a8cfce0858d5f86d2c3c13c0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:57:44 +0700 Subject: [PATCH 066/117] FIX[BE] fix wrong calculation on summary report marketing --- internal/modules/closings/dto/closingMarketing.dto.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 223b9d11..eb6ff23f 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -90,19 +90,22 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice float64 + var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 count := len(e) for _, item := range e { totalSalesPrice += item.MarketingProduct.TotalPrice totalActualPrice += item.TotalPrice + sumSales += item.MarketingProduct.UnitPrice + sumActual += item.UnitPrice + } return SummaryDTO{ TotalSalesPrice: totalSalesPrice, TotalActualPrice: totalActualPrice, - AvgSalesPrice: totalSalesPrice / float64(count), - AvgActualPrice: totalActualPrice / float64(count), + AvgSalesPrice: sumSales / float64(count), + AvgActualPrice: sumActual / float64(count), } } From 16a0b848bcf33babb0656ad45eff9376ba1c25cb Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 21 Jan 2026 13:06:45 +0700 Subject: [PATCH 067/117] [FIX/BE-US] adjustment recording --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- .../recordings/repositories/recording.repository.go | 5 +++-- .../production/recordings/services/recording.service.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 7582680b..828dd96c 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -285,7 +285,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Select("COALESCE(SUM(re.weight * 1000), 0)"). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). @@ -648,7 +648,7 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s Table("recording_eggs AS re"). Select(` ((r.day - 1) / 7 + 1) AS week, - COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 9e783134..6cb65c6c 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -171,6 +171,7 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda var days []int if err := tx.Model(&entity.Recording{}). Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("deleted_at IS NULL"). Where("day IS NOT NULL"). Pluck("day", &days).Error; err != nil { return 0, err @@ -399,7 +400,7 @@ func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordin } err = tx. Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). + Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(COALESCE(recording_eggs.weight, 0) * 1000), 0) AS total_weight_grams"). Where("recording_eggs.recording_id = ?", recordingID). Scan(&result).Error if err != nil { @@ -485,7 +486,7 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct var result float64 err := r.DB().WithContext(ctx). Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000"). + Select("COALESCE(SUM(recording_eggs.weight), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 80611109..a5486ab7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1157,7 +1157,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := existingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight existingTotals[egg.ProductWarehouseId] = current } @@ -1169,7 +1169,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := incomingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight incomingTotals[egg.ProductWarehouseId] = current } @@ -1328,7 +1328,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = totalEggWeightGrams / remainingChick + eggMass = (totalEggWeightGrams / remainingChick) / 1000 updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { From d96a12776a61c4e9bae90dc00480293dae872a67 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 21 Jan 2026 13:38:59 +0700 Subject: [PATCH 068/117] next commit --- .../repository/common.hpp.repository.go | 133 +++++++++++++----- internal/common/service/common.hpp.service.go | 69 +++++++-- 2 files changed, 156 insertions(+), 46 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index f9bbaa3c..74ed5261 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -11,11 +11,15 @@ import ( ) type HppCostRepository interface { - GetDocCost(ctx context.Context, projectFlockKandangId uint) (float64, error) - GetBudgetCost(ctx context.Context, projectFlockKandangId uint) (float64, error) - GetExpedisionCost(ctx context.Context, projectFlockKandangId uint) (float64, error) - GetFeedCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) - GetOvkCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) + GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) + GetDocCost(ctx context.Context, kandangIDs []uint) (float64, error) + GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) + GetExpedisionCost(ctx context.Context, kandangIDs []uint) (float64, error) + GetFeedCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) + GetOvkCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) + GetTotalPopulation(ctx context.Context, kandangIDs []uint) (float64, error) + GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) + GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) } type HppRepositoryImpl struct { @@ -26,14 +30,28 @@ func NewHppCostRepository(db *gorm.DB) HppCostRepository { return &HppRepositoryImpl{db: db} } -func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { +func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) { + var ids []uint + err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("id"). + Where("project_flock_id = ?", projectFlockId). + Scan(&ids).Error + if err != nil { + return nil, err + } + + return ids, nil +} + +func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, kandangIDs []uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). - Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). + Where("pc.project_flock_kandang_id IN (?)", kandangIDs). Scan(&total).Error if err != nil { return 0, err @@ -42,31 +60,12 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI return total, nil } -func (r *HppRepositoryImpl) GetBudgetCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { - pfkUsageSub := r.db. - Table("project_chickins AS pc"). - Select(` - pc.project_flock_kandang_id, - SUM(pc.usage_qty) AS kandang_usage_qty`). - Group("pc.project_flock_kandang_id") - - projectUsageSub := r.db. - Table("project_chickins AS pc"). - Select(` - pfk.project_flock_id, - SUM(pc.usage_qty) AS project_usage_qty`). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). - Group("pfk.project_flock_id") - +func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). - Table("project_flock_kandangs AS pfk"). - Select(` - COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0)`). - Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). - Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). - Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). - Where("pfk.id = ?", projectFlockKandangId). + Table("project_chickin_details AS pcd"). + Select("COALESCE(SUM(pcd.qty * pcd.price), 0)"). + Where("pcd.project_flock_id = ?", projectFlockId). Scan(&total).Error if err != nil { return 0, err @@ -75,13 +74,15 @@ func (r *HppRepositoryImpl) GetBudgetCost(ctx context.Context, projectFlockKanda return total, nil } -func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { +func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, kandangIDs []uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). Table("expense_nonstocks AS en"). Select("COALESCE(SUM(er.qty * er.price), 0)"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). - Where("en.project_flock_kandang_id = ?", projectFlockKandangId). + Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("en.project_flock_kandang_id IN (?)", kandangIDs). + Where("f.name = ?", utils.FlagEkspedisi). Scan(&total).Error if err != nil { return 0, err @@ -90,7 +91,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockK return total, nil } -func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) { +func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() date = &now @@ -105,7 +106,7 @@ func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, projectFlockKandang Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). - Where("r.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("r.project_flock_kandangs_id IN (?)", kandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name = ?", utils.FlagPakan). Scan(&total).Error @@ -116,7 +117,7 @@ func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, projectFlockKandang return total, nil } -func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, projectFlockKandangId uint, date *time.Time) (float64, error) { +func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() date = &now @@ -138,7 +139,7 @@ func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, projectFlockKandangI Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). - Where("r.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("r.project_flock_kandangs_id IN (?)", kandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name IN ?", flags). Scan(&total).Error @@ -148,3 +149,63 @@ func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, projectFlockKandangI return total, nil } + +func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, kandangIDs []uint) (float64, error) { + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select("COALESCE(SUM(pc.usage_qty), 0)"). + Where("pc.project_flock_kandang_id IN (?)", kandangIDs). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) { + stockablePurchase := fifo.StockableKeyPurchaseItems.String() + stockableTransferIn := fifo.StockableKeyStockTransferIn.String() + usableProjectChickin := fifo.UsableKeyProjectChickin.String() + + var total float64 + err := r.db.WithContext(ctx). + Table("project_chickins AS pc"). + Select(` + COALESCE(SUM(pc.usage_qty * CASE + WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) + WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) + ELSE 0 + END), 0)`, + stockablePurchase, stockableTransferIn). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). + Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase). + Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). + Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error + if err != nil { + return 0, err + } + + return total, nil +} + +func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) { + var summary struct { + ProjectFlockID uint + TotalQty float64 + } + err := r.db.WithContext(ctx). + Table("laying_transfer_targets AS ltt"). + Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). + Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). + Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&summary).Error + if err != nil { + return 0, 0, err + } + + return summary.ProjectFlockID, summary.TotalQty, nil +} diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index 5ef9091e..8a78aded 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -9,7 +9,8 @@ import ( type HppService interface { CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) - GetTotalDepresiasi(projectFlockKandangId uint, date *time.Time) (float64, error) + GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) + GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, totalDepresiasiGrowing float64) (float64, error) } type HppCostResponse struct { @@ -39,8 +40,36 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim date = &now } - // _ = projectFlockKandangId - _ = date + var sourceProjectFlockID uint + var transferTotalQty float64 + var err error + sourceProjectFlockID, transferTotalQty, err = s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return nil, err + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + + if err != nil { + return nil, err + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return nil, err + } + + totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, date) + if err != nil { + return nil, err + } + + depresiasiTransfer := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing + + _, err = s.GetTotalProductionCost(projectFlockKandangId, date, depresiasiTransfer) + if err != nil { + return nil, err + } return &HppCostResponse{ Estimation: HppCostDetail{}, @@ -48,7 +77,7 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim }, nil } -func (s *hppService) GetTotalDepresiasi(projectFlockKandangId uint, date *time.Time) (float64, error) { +func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() date = &now @@ -58,32 +87,52 @@ func (s *hppService) GetTotalDepresiasi(projectFlockKandangId uint, date *time.T return 0, nil } - docCost, err := s.hppRepo.GetDocCost(context.Background(), projectFlockKandangId) + kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) if err != nil { return 0, err } - budgetCost, err := s.hppRepo.GetBudgetCost(context.Background(), projectFlockKandangId) + docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs) if err != nil { return 0, err } - expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), projectFlockKandangId) + budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID) if err != nil { return 0, err } - feedCost, err := s.hppRepo.GetFeedCost(context.Background(), projectFlockKandangId, date) + expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs) if err != nil { return 0, err } - ovkCost, err := s.hppRepo.GetOvkCost(context.Background(), projectFlockKandangId, date) + feedCost, err := s.hppRepo.GetFeedCost(context.Background(), kandangIDs, date) if err != nil { return 0, err } + ovkCost, err := s.hppRepo.GetOvkCost(context.Background(), kandangIDs, date) + if err != nil { + return 0, err + } + + return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil +} + +func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, depresiasiTransfer float64) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + + } + _ = date - return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil + return depresiasiTransfer + costPullet, nil } From e8a89f0f17aa8ecb465f421ee985a71bf22b41a1 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 13:52:46 +0700 Subject: [PATCH 069/117] FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload --- .../dto/product_warehouse.dto.go | 143 ++++-------------- .../services/product_warehouse.service.go | 1 + .../marketing/dto/deliveryorder.dto.go | 11 +- 3 files changed, 35 insertions(+), 120 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 57a13021..b8f51c52 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -5,6 +5,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" ) // === DTO Structs === @@ -16,60 +17,29 @@ type ProductWarehouseRelationDTO struct { Quantity float64 `json:"quantity"` } -type ProductWarehousNestedDTO struct { - Id uint `json:"id"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` -} - type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - 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 { - Id uint `json:"id"` - Username string `json:"username"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.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 ProductWarehouseDetailDTO struct { ProductWarehouseListDTO } -// Nested DTOs for relations -type ProductRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` - Flags []string `json:"flags"` +type ProductWarehousNestedDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` } -type WarehouseRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Kandang *KandangRelationDTO `json:"kandang,omitempty"` - Location *LocationRelationDTO `json:"location,omitempty"` - Area *AreaRelationDTO `json:"area,omitempty"` -} - -type KandangRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type AreaRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` +type UserRelationDTO struct { + Id uint `json:"id"` + Username string `json:"username"` } type ProjectFlockKandangRelationDTO struct { @@ -96,65 +66,28 @@ func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRe } } -func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { - product := productDTO.ToProductRelationDTO(e.Product) - - return ProductWarehousNestedDTO{ - Id: e.Id, - Product: &product, - Warehouse: &WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - }, - } -} - func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), - // CreatedAt: e.CreatedAt, - // UpdatedAt: e.UpdatedAt, } // 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 + // Create a copy with flock name appended if exists if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { - product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + productCopy := product + productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + dto.Product = &productCopy + } else { + dto.Product = &product } - - dto.Product = &product } // Map Warehouse relation jika ada if e.Warehouse.Id != 0 { - warehouse := WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - } - // Map Kandang jika ada - if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 { - warehouse.Kandang = &KandangRelationDTO{ - Id: e.Warehouse.Kandang.Id, - Name: e.Warehouse.Kandang.Name, - } - } - // Map Location jika ada - if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 { - warehouse.Location = &LocationRelationDTO{ - Id: e.Warehouse.Location.Id, - Name: e.Warehouse.Location.Name, - } - } - - if e.Warehouse.Area.Id != 0 { - warehouse.Area = &AreaRelationDTO{ - Id: e.Warehouse.Area.Id, - Name: e.Warehouse.Area.Name, - } - } + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) dto.Warehouse = &warehouse } @@ -168,7 +101,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT Period: e.ProjectFlockKandang.Period, } - // Map ProjectFlock jika ada if e.ProjectFlockKandang.ProjectFlock.Id != 0 { pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ Id: e.ProjectFlockKandang.ProjectFlock.Id, @@ -179,15 +111,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.ProjectFlockKandang = pfkDTO } - // Map CreatedUser relation jika ada - // if e.CreatedUser.Id != 0 { - // user := UserRelationDTO{ - // Id: e.CreatedUser.Id, - // Username: e.CreatedUser.Name, - // } - // dto.CreatedUser = &user - // } - return dto } @@ -205,23 +128,13 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta } } -func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { - return KandangRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} +func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { + product := productDTO.ToProductRelationDTO(e.Product) + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) -func ToLocationRelationDTO(e entity.Location) LocationRelationDTO { - return LocationRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} - -func ToAreaRelationDTO(e entity.Area) AreaRelationDTO { - return AreaRelationDTO{ - Id: e.Id, - Name: e.Name, + return ProductWarehousNestedDTO{ + Id: e.Id, + Product: &product, + Warehouse: &warehouse, } } 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 152bfa24..ea194c36 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -40,6 +40,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("Product.Flags"). Preload("Product"). + Preload("Product.Uom"). Preload("Warehouse"). Preload("Warehouse.Location"). Preload("Warehouse.Area"). diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index a6eea180..4bcbacca 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -9,6 +9,7 @@ import ( approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -68,10 +69,10 @@ type DeliveryItemDTO struct { } type DeliveryGroupDTO struct { - DoNumber string `json:"do_number"` - DeliveryDate *time.Time `json:"delivery_date"` - Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` - Deliveries []DeliveryItemDTO `json:"deliveries"` + DoNumber string `json:"do_number"` + DeliveryDate *time.Time `json:"delivery_date"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + Deliveries []DeliveryItemDTO `json:"deliveries"` } type DeliveryMarketingProductDTO struct { @@ -286,7 +287,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri if !exists { group = &DeliveryGroupDTO{ DeliveryDate: product.DeliveryDate, - Warehouse: &productwarehouseDTO.WarehouseRelationDTO{ + Warehouse: &warehouseDTO.WarehouseRelationDTO{ Id: warehouseId, Name: warehouseName, }, From ca6d0b160b4db8248f028e22dddb5ba5fdeb6b01 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 21 Jan 2026 14:17:43 +0700 Subject: [PATCH 070/117] fix --- .../validations/daily-checklist.validation.go | 2 +- .../services/config-checklist.service.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/modules/daily-checklists/validations/daily-checklist.validation.go b/internal/modules/daily-checklists/validations/daily-checklist.validation.go index 35ef8bb9..9157c4e2 100644 --- a/internal/modules/daily-checklists/validations/daily-checklist.validation.go +++ b/internal/modules/daily-checklists/validations/daily-checklist.validation.go @@ -52,7 +52,7 @@ type SummaryQuery struct { type ReportQuery struct { Page int `query:"page" validate:"required,number,min=1,gt=0"` - Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"` + Limit int `query:"limit" validate:"required,number,min=1,gt=0"` Month int `query:"bulan" validate:"required,number,min=1,max=12"` Year int `query:"tahun" validate:"required,number,min=1900"` AreaID *uint `query:"area_id" validate:"omitempty"` diff --git a/internal/modules/master/config-checklists/services/config-checklist.service.go b/internal/modules/master/config-checklists/services/config-checklist.service.go index 0c96e3d5..97cd42c7 100644 --- a/internal/modules/master/config-checklists/services/config-checklist.service.go +++ b/internal/modules/master/config-checklists/services/config-checklist.service.go @@ -76,6 +76,9 @@ func (s *configChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.PercentageThresholdBad > req.PercentageThresholdEnough { + return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough") + } date, err := time.Parse("2006-01-02", req.Date) if err != nil { @@ -100,6 +103,11 @@ func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, if err := s.Validate.Struct(req); err != nil { return nil, err } + if req.PercentageThresholdBad != nil && req.PercentageThresholdEnough != nil { + if *req.PercentageThresholdBad > *req.PercentageThresholdEnough { + return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough") + } + } updateBody := make(map[string]any) From a73b44808ff2e6bebbc090d7e309f7016c9ede6d Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 15:16:30 +0700 Subject: [PATCH 071/117] FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table --- internal/entities/adjustment_stock.go | 29 ++---- .../adjustments/dto/adjustment.dto.go | 30 +++--- .../adjustment_stock.repository.go | 8 ++ .../services/adjustment.service.go | 99 ++++++++++++------- .../services/product_warehouse.service.go | 7 +- 5 files changed, 99 insertions(+), 74 deletions(-) diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index bbc93167..ef27d0c2 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -2,28 +2,17 @@ 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"` + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty;default:0"` + TotalUsed float64 `gorm:"column:total_used;default:0"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` + PendingQty float64 `gorm:"column:pending_qty;default:0"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - // === 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/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 008f9966..1ce3da1b 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -100,38 +100,42 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { } } -func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { +func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { return AdjustmentRelationDTO{ Id: e.Id, - Note: e.Notes, - Increase: e.Increase, - Decrease: e.Decrease, + Note: e.StockLog.Notes, + Increase: e.TotalQty, + Decrease: e.UsageQty, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } } -func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { +func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO { var createdUser *userDTO.UserRelationDTO - if e.CreatedUser != nil { + if e.StockLog != nil && e.StockLog.CreatedUser != nil { createdUser = &userDTO.UserRelationDTO{ - Id: e.CreatedUser.Id, - IdUser: e.CreatedUser.IdUser, - Email: e.CreatedUser.Email, - Name: e.CreatedUser.Name, + Id: e.StockLog.CreatedUser.Id, + IdUser: e.StockLog.CreatedUser.IdUser, + Email: e.StockLog.CreatedUser.Email, + Name: e.StockLog.CreatedUser.Name, } } + createdAt := time.Time{} + if e.StockLog != nil { + createdAt = e.StockLog.CreatedAt + } + return AdjustmentListDTO{ AdjustmentRelationDTO: ToAdjustmentRelationDTO(e), CreatedUser: createdUser, - CreatedAt: e.CreatedAt, + CreatedAt: createdAt, } } -func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { +func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO { return AdjustmentDetailDTO{ AdjustmentListDTO: ToAdjustmentListDTO(e), - // UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index 8d62b05c..fa2685e7 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -33,6 +33,14 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { var record entity.AdjustmentStock err := r.db.WithContext(ctx). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse"). Where("stock_log_id = ?", stockLogID). First(&record).Error if err != nil { diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 71b985c2..c92d059b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -25,9 +25,9 @@ import ( ) type AdjustmentService interface { - Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error) - AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) + Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) + AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) } type adjustmentService struct { @@ -73,10 +73,8 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser") } -func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { - stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") - }) +func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { + adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -85,14 +83,10 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { - return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") - } - - return stockLog, nil + return adjustmentStock, nil } -func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) { +func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -111,12 +105,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if req.Quantity <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } + transactionType := strings.ToUpper(req.TransactionType) if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } - var createdLogId uint + var createdAdjustmentStockId uint var projectFlockKandangID *uint pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) @@ -151,7 +146,8 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) + + productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") @@ -171,14 +167,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { - return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Current: %.2f, Requested: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity newLog.Decrease = afterQuantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log: %+v", err) + return err } @@ -187,7 +183,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e ProductWarehouseId: productWarehouse.Id, } 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") } @@ -212,7 +208,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, - AllowPending: false, // Don't allow pending for adjustment + AllowPending: false, Tx: tx, }) if err != nil { @@ -220,24 +216,27 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - // Update ProductWarehouse quantity (for backward compatibility/reporting) - + // LEGACY: 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) return err } - createdLogId = newLog.Id + createdAdjustmentStockId = adjustmentStock.Id return nil }) if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + return nil, err + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction") } - return s.GetOne(c, createdLogId) + return s.GetOne(c, createdAdjustmentStockId) } func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { @@ -266,13 +265,15 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, return uint(projectFlockKandang.Id), nil } -func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { +func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err } offset := (query.Page - 1) * query.Limit + var isProductsExist bool isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) + if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } @@ -280,7 +281,8 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } - isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + if err != nil { s.Log.Errorf("Failed to check product existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") @@ -289,28 +291,51 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } - stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { + var adjustmentStocks []entity.AdjustmentStock + var total int64 - db = s.withRelations(db) + q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse") - db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) + if query.ProductID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.product_id = ?", query.ProductID) + } - if query.TransactionType != "" { - db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) - } - db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) + if query.WarehouseID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.warehouse_id = ?", query.WarehouseID) + } - return db.Order("created_at DESC") - }) + if query.TransactionType != "" { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType)) + } + + if err = q.Count(&total).Error; err != nil { + s.Log.Errorf("Failed to get adjustments: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") + } + + err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") } - result := make([]*entity.StockLog, len(stockLogs)) - for i, v := range stockLogs { - result[i] = &v + result := make([]*entity.AdjustmentStock, len(adjustmentStocks)) + for i := range adjustmentStocks { + result[i] = &adjustmentStocks[i] } return result, total, nil 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 ea194c36..5b89808c 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -3,15 +3,14 @@ package service import ( "errors" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) From 32a8557a3b52324d9a677a291a61711765d6f02c Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Wed, 21 Jan 2026 15:56:58 +0700 Subject: [PATCH 072/117] Change rules cicd no conflicts --- .gitlab-ci.yml | 104 ++++++----------------------------- ci/development.yml | 90 ++++++++++++++++++++++++++++++ ci/production.yml | 133 +++++++++++++++++++++++++++++++++++++++++++++ ci/staging.yml | 133 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 87 deletions(-) create mode 100644 ci/development.yml create mode 100644 ci/production.yml create mode 100644 ci/staging.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..aa0dc969 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,20 @@ -stages: - - deploy +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "development"' + - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "production"' + - when: never -deploy-dev: - stage: deploy - image: alpine:3.20 - variables: - DEPLOY_APP: "LTI-MBUGROUP" - # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga - GIT_SUBMODULE_STRATEGY: recursive - GIT_DEPTH: "1" +include: + - local: "ci/development.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "development"' - before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash + - local: "ci/staging.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' - # Setup SSH di runner - - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa - - eval "$(ssh-agent -s)" - - ssh-add ~/.ssh/id_rsa - - # Trust host keys (server + gitlab) biar SSH gak nanya interaktif - - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts - - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - script: - - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - - - > - if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " - set -e - - cd /home/devops/docker/deployment/development/lti-api - - # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) - git remote set-url origin git@gitlab.com:mbugroup/lti-api.git - - # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) - mkdir -p ~/.ssh - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts - - # Fetch/reset pakai SSH - GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development - git reset --hard origin/development - - docker compose restart dev-api-lti || docker compose up -d dev-api-lti - "; then - STATUS='success'; - else - STATUS='failed'; - fi; - - RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; - - if [ "$STATUS" = "success" ]; then - COLOR=3066993; - TITLE="✅ Deployment API Succeeded"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; - else - COLOR=15158332; - TITLE="❌ Deployment API Failed Gaes"; - DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; - fi; - - echo "{ - \"username\": \"CI Bot\", - \"embeds\": [{ - \"title\": \"$TITLE\", - \"description\": \"$DESC\", - \"color\": $COLOR, - \"fields\": [ - {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, - {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, - {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, - {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} - ] - }] - }" > payload.json; - - echo "📡 Sending notification to Discord..."; - curl -sS -H "Content-Type: application/json" \ - -d @payload.json "$DISCORD_WEBHOOK_URL"; - - only: - - development - - environment: - name: development \ No newline at end of file + - local: "ci/production.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "production"' diff --git a/ci/development.yml b/ci/development.yml new file mode 100644 index 00000000..43d574b9 --- /dev/null +++ b/ci/development.yml @@ -0,0 +1,90 @@ +stages: + - deploy + +deploy-dev: + stage: deploy + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" + + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash + + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_rsa + + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" + + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development diff --git a/ci/production.yml b/ci/production.yml new file mode 100644 index 00000000..511a9eff --- /dev/null +++ b/ci/production.yml @@ -0,0 +1,133 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +migrate_production: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false + needs: + - job: build_production + artifacts: false + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + set -a + . ./.env + set +a + + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" + + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + + echo "✅ Running migrations (production)..." + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: + - job: migrate_production + artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed + + diff --git a/ci/staging.yml b/ci/staging.yml new file mode 100644 index 00000000..511a9eff --- /dev/null +++ b/ci/staging.yml @@ -0,0 +1,133 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +migrate_production: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false + needs: + - job: build_production + artifacts: false + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + set -a + . ./.env + set +a + + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" + + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + + echo "✅ Running migrations (production)..." + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: + - job: migrate_production + artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed + + From 2ad0c17fbe095c7954ff43a357309883d6f74e64 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 10:00:24 +0700 Subject: [PATCH 073/117] add filter project status and location id --- .../controllers/closing.controller.go | 27 ++++++++++++++++--- .../closings/services/closing.service.go | 22 +++++++++++++++ .../validations/closing.validation.go | 8 +++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 29c89f33..b1b02886 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -28,10 +28,31 @@ func NewClosingController(closingService service.ClosingService, sapronakService } func (u *ClosingController) GetAll(c *fiber.Ctx) error { + var projectStatus *int + if raw := c.Query("project_status"); raw != "" { + statusValue, err := strconv.Atoi(raw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_status") + } + projectStatus = &statusValue + } + + var locationID *uint + if raw := c.Query("location_id"); raw != "" { + locationValue, err := strconv.Atoi(raw) + if err != nil || locationValue <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id") + } + locationUint := uint(locationValue) + locationID = &locationUint + } + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectStatus: projectStatus, + LocationID: locationID, } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index daca980f..372d38fd 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -99,9 +99,31 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl } offset := (params.Page - 1) * params.Limit + statusFilter := "" + if params.ProjectStatus != nil { + switch *params.ProjectStatus { + case 1: + statusFilter = "Pengajuan" + case 2: + statusFilter = "Aktif" + } + } closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) + if params.LocationID != nil { + db = db.Where("location_id = ?", *params.LocationID) + } + if statusFilter != "" { + latestApprovalSubQuery := s.Repository.DB(). + WithContext(c.Context()). + Table("approvals"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, id"). + Where("approvable_type = ?", utils.ApprovalWorkflowProjectFlock.String()). + Order("approvable_id, id DESC") + db = db.Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery). + Where("LOWER(latest_approval.step_name) = LOWER(?)", statusFilter) + } if params.Search != "" { return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 454bbdfc..9d3ad573 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -9,9 +9,11 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + 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"` + ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"` + LocationID *uint `query:"location_id" validate:"omitempty,gt=0"` } const ( From cf37822a07f6baecdc7c1c57f88db4e2d2986662 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Wed, 21 Jan 2026 15:56:58 +0700 Subject: [PATCH 074/117] Change rules cicd no conflicts --- .gitlab-ci.yml | 140 +++++---------------------------------------- ci/development.yml | 90 +++++++++++++++++++++++++++++ ci/production.yml | 133 ++++++++++++++++++++++++++++++++++++++++++ ci/staging.yml | 133 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 126 deletions(-) create mode 100644 ci/development.yml create mode 100644 ci/production.yml create mode 100644 ci/staging.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3cca12bf..aa0dc969 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,132 +1,20 @@ -stages: - - build - - migrate - - deploy - - seed - -default: - tags: - - self-hosted-prod - workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: always + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - if: '$CI_COMMIT_BRANCH == "development"' + - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_COMMIT_BRANCH == "production"' - when: never -variables: - DOCKER_BUILDKIT: "1" +include: + - local: "ci/development.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "development"' - IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" - IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" - - DEPLOY_DIR: "/opt/deploy/lti" - COMPOSE_FILE: "docker-compose.yaml" - -# ========================= -# BUILD (AUTO) -# ========================= -build_production: - stage: build - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - script: | - set -e - docker info - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - echo "✅ Build image: $IMAGE_NAME" - docker build -t "$IMAGE_NAME" -f Dockerfile . - - echo "✅ Push image: $IMAGE_NAME" - docker push "$IMAGE_NAME" - - echo "✅ Tag latest: $IMAGE_LATEST" - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - docker push "$IMAGE_LATEST" - - -# ========================= -# MIGRATE (PRODUCTION - MANUAL) -# ========================= -migrate_production: - stage: migrate - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: manual - allow_failure: false - needs: - - job: build_production - artifacts: false - script: | - set -e - cd /opt/deploy/lti - test -f .env || (echo "❌ .env not found" && exit 1) - - set -a - . ./.env - set +a - - # Validasi env wajib - : "${DB_HOST:?DB_HOST not set}" - : "${DB_PORT:?DB_PORT not set}" - : "${DB_USER:?DB_USER not set}" - : "${DB_PASSWORD:?DB_PASSWORD not set}" - : "${DB_NAME:?DB_NAME not set}" - - DB_SSLMODE="${DB_SSLMODE:-require}" - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" - - echo "✅ Running migrations (production)..." - docker run --rm \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up - - -# ========================= -# DEPLOY (AUTO) -# ========================= -deploy_production: - stage: deploy - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - needs: - - job: migrate_production - artifacts: false - - job: build_production - artifacts: false - script: | - set -e - docker info - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - docker compose -f "$COMPOSE_FILE" pull - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - docker image prune -f - - -# ========================= -# SEED (MANUAL) -# ========================= -seed_production: - stage: seed - rules: - - if: '$CI_COMMIT_BRANCH == "production"' - when: manual - script: | - set -e - cd /opt/deploy/lti - test -f .env || (echo "❌ .env not found" && exit 1) - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - docker compose --env-file .env pull seed - docker compose --env-file .env run --rm seed + - local: "ci/staging.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' + - local: "ci/production.yml" + rules: + - if: '$CI_COMMIT_BRANCH == "production"' diff --git a/ci/development.yml b/ci/development.yml new file mode 100644 index 00000000..43d574b9 --- /dev/null +++ b/ci/development.yml @@ -0,0 +1,90 @@ +stages: + - deploy + +deploy-dev: + stage: deploy + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" + + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash + + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_rsa + + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" + + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development diff --git a/ci/production.yml b/ci/production.yml new file mode 100644 index 00000000..511a9eff --- /dev/null +++ b/ci/production.yml @@ -0,0 +1,133 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +migrate_production: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false + needs: + - job: build_production + artifacts: false + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + set -a + . ./.env + set +a + + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" + + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + + echo "✅ Running migrations (production)..." + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: + - job: migrate_production + artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed + + diff --git a/ci/staging.yml b/ci/staging.yml new file mode 100644 index 00000000..511a9eff --- /dev/null +++ b/ci/staging.yml @@ -0,0 +1,133 @@ +stages: + - build + - migrate + - deploy + - seed + +default: + tags: + - self-hosted-prod + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: always + - when: never + +variables: + DOCKER_BUILDKIT: "1" + + IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + + DEPLOY_DIR: "/opt/deploy/lti" + COMPOSE_FILE: "docker-compose.yaml" + +# ========================= +# BUILD (AUTO) +# ========================= +build_production: + stage: build + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + script: | + set -e + docker info + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + echo "✅ Build image: $IMAGE_NAME" + docker build -t "$IMAGE_NAME" -f Dockerfile . + + echo "✅ Push image: $IMAGE_NAME" + docker push "$IMAGE_NAME" + + echo "✅ Tag latest: $IMAGE_LATEST" + docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + docker push "$IMAGE_LATEST" + + +# ========================= +# MIGRATE (PRODUCTION - MANUAL) +# ========================= +migrate_production: + stage: migrate + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + when: manual + allow_failure: false + needs: + - job: build_production + artifacts: false + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + set -a + . ./.env + set +a + + # Validasi env wajib + : "${DB_HOST:?DB_HOST not set}" + : "${DB_PORT:?DB_PORT not set}" + : "${DB_USER:?DB_USER not set}" + : "${DB_PASSWORD:?DB_PASSWORD not set}" + : "${DB_NAME:?DB_NAME not set}" + + DB_SSLMODE="${DB_SSLMODE:-require}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + + echo "✅ Running migrations (production)..." + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_production: + stage: deploy + rules: + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + needs: + - job: migrate_production + artifacts: false + - job: build_production + artifacts: false + script: | + set -e + docker info + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose -f "$COMPOSE_FILE" pull + docker compose -f "$COMPOSE_FILE" up -d --force-recreate + docker image prune -f + + +# ========================= +# SEED (MANUAL) +# ========================= +seed_production: + stage: seed + rules: + - if: '$CI_COMMIT_BRANCH == "production"' + when: manual + script: | + set -e + cd /opt/deploy/lti + test -f .env || (echo "❌ .env not found" && exit 1) + + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + + docker compose --env-file .env pull seed + docker compose --env-file .env run --rm seed + + From 32153f02b8b79d93dea259fb6a199beefaa80b78 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 20 Jan 2026 16:40:37 +0700 Subject: [PATCH 075/117] [FIX/BE-US] purchase edit qty approval staf add adjustment fifo system --- .../common/service/common.fifo.service.go | 41 +++++++++++++++++ .../purchases/services/purchase.service.go | 44 ++++++++++++++++--- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index b99e6c35..14cbb5c1 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -25,6 +25,7 @@ type FifoService interface { Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error + AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error } type fifoService struct { @@ -95,6 +96,15 @@ type StockReplenishRequest struct { Tx *gorm.DB } +type StockAdjustRequest struct { + StockableKey fifo.StockableKey + StockableID uint + ProductWarehouseID uint + Quantity float64 + Note *string + Tx *gorm.DB +} + type PendingResolution struct { UsableKey fifo.UsableKey UsableID uint @@ -137,6 +147,37 @@ type StockReleaseRequest struct { Reason *string Tx *gorm.DB } +func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error { + if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { + return errors.New("stockable key and id are required") + } + if req.ProductWarehouseID == 0 { + return errors.New("product warehouse id is required") + } + if req.Quantity == 0 { + return nil + } + if req.Quantity > 0 { + return errors.New("quantity must be negative") + } + + cfg, ok := fifo.Stockable(req.StockableKey) + if !ok { + return fmt.Errorf("stockable %q is not registered", req.StockableKey) + } + + return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil { + return err + } + + return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{ + req.ProductWarehouseID: req.Quantity, + }, func(db *gorm.DB) *gorm.DB { + return s.txOrDB(tx, db) + }) + }) +} func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b0914853..b7efbc05 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -844,6 +844,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwID uint qty float64 }, 0, len(prepared)) + fifoSubs := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -877,9 +882,18 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation totalQtyDeltas[item.Id] += deltaQty } case deltaQty < 0 && newPWID != nil: - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty + if s.FifoSvc != nil { + fifoSubs = append(fifoSubs, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + affected[*newPWID] = struct{}{} + } else { + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} + totalQtyDeltas[item.Id] += deltaQty + } } dateCopy := prep.receivedDate @@ -919,10 +933,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { - return err - } - if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err @@ -967,6 +977,26 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } } + for _, adj := range fifoSubs { + if adj.pwID == 0 || adj.qty >= 0 { + continue + } + if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyPurchaseItems, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } + } + + if len(affected) > 0 { + if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { + return err + } } return nil From 0b708cd57b29f8e186e8f2ba751ccf36524d186b Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 20 Jan 2026 18:18:41 +0700 Subject: [PATCH 076/117] fix data produksi not show response --- internal/modules/closings/dto/closing.dto.go | 40 +++++++++---------- .../closings/services/closing.service.go | 8 ++-- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 1c191d29..82e11f49 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct { } type ClosingPerformanceDTO struct { - Depletion float64 `json:"depletion"` - Age float64 `json:"age_day"` - MortalityStd float64 `json:"mor_std"` - MortalityAct float64 `json:"mor_act"` - DeffMortality float64 `json:"mor_diff"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"fcr_diff"` - AwgAct float64 `json:"awg_act"` - AwgStd float64 `json:"awg_std"` - FeedIntake float64 `json:"feed_intake"` - FeedIntakeStd float64 `json:"feed_intake_std"` - HenDayAct *float64 `json:"hen_day_act,omitempty"` - HendayStd float64 `json:"hen_day_std"` - EggMass *float64 `json:"egg_mass,omitempty"` - EggMassStd float64 `json:"egg_mass_std"` - EggWeight *float64 `json:"egg_weight,omitempty"` - EggWeightStd float64 `json:"egg_weight_std"` - HenHouseAct *float64 `json:"hen_housed_act,omitempty"` - HenHouseStd float64 `json:"hen_housed_std"` + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` + HenDayAct float64 `json:"hen_day_act,omitempty"` + HendayStd float64 `json:"hen_day_std"` + EggMass float64 `json:"egg_mass,omitempty"` + EggMassStd float64 `json:"egg_mass_std"` + EggWeight float64 `json:"egg_weight,omitempty"` + EggWeightStd float64 `json:"egg_weight_std"` + HenHouseAct float64 `json:"hen_housed_act,omitempty"` + HenHouseStd float64 `json:"hen_housed_std"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 443eec7f..daca980f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -930,19 +930,19 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint if !isGrowing { if targetAverages.HenDayCount > 0 { henDayAct := targetAverages.HenDayAvg - performance.HenDayAct = &henDayAct + performance.HenDayAct = henDayAct } if targetAverages.HenHouseCount > 0 { henHouseAct := targetAverages.HenHouseAvg - performance.HenHouseAct = &henHouseAct + performance.HenHouseAct = henHouseAct } if targetAverages.EggWeightCount > 0 { eggWeight := targetAverages.EggWeightAvg - performance.EggWeight = &eggWeight + performance.EggWeight = eggWeight } if targetAverages.EggMassCount > 0 { eggMass := targetAverages.EggMassAvg - performance.EggMass = &eggMass + performance.EggMass = eggMass } } performance.DeffFcr = performance.FcrStd - performance.FcrAct From ad0504f49e65e2439cde1f07d8f1b5a03ba6217f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:07:07 +0700 Subject: [PATCH 077/117] refactor: unify GetOne method to return approval alongside transfer laying --- .../controllers/transfer_laying.controller.go | 2 +- .../services/transfer_laying.service.go | 32 +++++++++---------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index 13c39334..d0ee5061 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -70,7 +70,7 @@ func (u *TransferLayingController) GetOne(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") } - result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id)) + result, approval, err := u.TransferLayingService.GetOne(c, uint(id)) if err != nil { return err } diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index e64b9cc2..8e0269cf 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -28,8 +28,7 @@ import ( type TransferLayingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) - GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -156,14 +155,15 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([ return transferLayings, total, nil } -func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { +func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") + return nil, nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found") } if err != nil { s.Log.Errorf("Failed get transferLaying by id: %+v", err) - return nil, err + return nil, nil, err } approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) @@ -174,15 +174,6 @@ func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTran transferLaying.LatestApproval = latestApproval } - return transferLaying, nil -} - -func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) { - transferLaying, err := s.GetOne(c, id) - if err != nil { - return nil, nil, err - } - return transferLaying, transferLaying.LatestApproval, nil } @@ -406,7 +397,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") } - return s.GetOne(c, createBody.Id) + laying_transfer, _, err := s.GetOne(c, createBody.Id) + if err != nil { + return nil, err + } + return laying_transfer, nil + } func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { @@ -582,7 +578,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, return nil, err } - return s.GetOne(c, id) + layingTransfer, _, err := s.GetOne(c, id) + + return layingTransfer, err } func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -773,7 +771,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( updated := make([]entity.LayingTransfer, 0, len(approvableIDs)) for _, approvableID := range approvableIDs { - transfer, err := s.GetOne(c, approvableID) + transfer, _, err := s.GetOne(c, approvableID) if err != nil { return nil, err } From 96ba9479526a0001bdbadcadbd6f5f9aa91bd4d0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:10:47 +0700 Subject: [PATCH 078/117] FEAT[BE[: add avg weight and avg amount on get penjualan harian --- .../repports/dto/repportMarketing.dto.go | 296 +++++++----------- .../repports/services/repport.service.go | 2 +- 2 files changed, 118 insertions(+), 180 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 92ee9a77..edb2887f 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -40,99 +40,24 @@ type RepportMarketingItemDTO struct { type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` + AverageWeightKg float64 `json:"average_weight_kg"` + AverageSalesAmount float64 `json:"average_sales_amount"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` } -type RepportMarketingResponseDTO struct { - Items []RepportMarketingItemDTO `json:"items"` - Total *Summary `json:"total,omitempty"` -} - type ProductRelationDTOFixed struct { productDTO.ProductRelationDTO ProductPrice float64 `json:"product_price"` SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { - soDate := time.Time{} - agingDays := 0 - if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { - soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) - } - - realizationDate := time.Time{} - if mdp.DeliveryDate != nil { - realizationDate = *mdp.DeliveryDate - } - - doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId) - - totalWeightKg := mdp.UsageQty * mdp.AvgWeight - salesAmount := totalWeightKg * mdp.UnitPrice - - var hpp float64 - var hppAmount float64 - if isProductEligibleForHpp(mdp, category) { - hpp = hppPricePerKg - hppAmount = totalWeightKg * hppPricePerKg - } - - item := RepportMarketingItemDTO{ - ID: int(mdp.Id), - SoDate: soDate, - RealizationDate: realizationDate, - AgingDays: agingDays, - DoNumber: doNumber, - MarketingType: getMarketingType(mdp), - Qty: mdp.UsageQty, - AverageWeightKg: mdp.AvgWeight, - TotalWeightKg: totalWeightKg, - SalesPricePerKg: mdp.UnitPrice, - HppPricePerKg: hpp, - SalesAmount: salesAmount, - HppAmount: hppAmount, - } - - if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { - mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) - item.Warehouse = &mapped - } - - if mdp.MarketingProduct.Marketing.CustomerId != 0 { - mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) - item.Customer = &mapped - } - - if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { - mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) - item.Sales = &mapped - } - - item.VehicleNumber = mdp.VehicleNumber - - if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { - mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) - item.Product = newProductRelationDTOFixedPtr(&mapped) - } - - return item -} - -func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) - for _, mdp := range mdps { - items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category)) - } - return items -} -func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { - items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { + // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -142,101 +67,111 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct category = projectFlockKandang.ProjectFlock.Category } - item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) + // Calculate dates + soDate := time.Time{} + agingDays := 0 + if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { + soDate = mdp.MarketingProduct.Marketing.SoDate + agingDays = int(time.Since(soDate).Hours() / 24) + } + + realizationDate := time.Time{} + if mdp.DeliveryDate != nil { + realizationDate = *mdp.DeliveryDate + } + + totalWeightKg := mdp.UsageQty * mdp.AvgWeight + salesAmount := totalWeightKg * mdp.UnitPrice + + var hpp float64 + var hppAmount float64 + + var hasAyam, hasTelur, hasTrading bool + for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags { + ft := utils.FlagType(flag.Name) + + if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || + ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { + hasAyam = true + } + + if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || + ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { + hasTelur = true + } + + if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || + ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { + hasTrading = true + } + } + + // Determine marketing type + marketingType := "trading" + if hasTrading { + marketingType = "trading" + } else if hasTelur { + marketingType = "telur" + } else if hasAyam { + marketingType = "ayam" + } + + eligibleForHpp := false + + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { + eligibleForHpp = hasAyam + } else { + eligibleForHpp = hasAyam || hasTelur + } + + if eligibleForHpp { + hpp = hppPerKg + hppAmount = totalWeightKg * hppPerKg + } + + item := RepportMarketingItemDTO{ + ID: int(mdp.Id), + SoDate: soDate, + RealizationDate: realizationDate, + AgingDays: agingDays, + DoNumber: marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId), + MarketingType: marketingType, + Qty: mdp.UsageQty, + AverageWeightKg: mdp.AvgWeight, + TotalWeightKg: totalWeightKg, + SalesPricePerKg: mdp.UnitPrice, + HppPricePerKg: hpp, + SalesAmount: salesAmount, + HppAmount: hppAmount, + VehicleNumber: mdp.VehicleNumber, + } + + if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 { + mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse) + item.Warehouse = &mapped + } + + if mdp.MarketingProduct.Marketing.CustomerId != 0 { + mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer) + item.Customer = &mapped + } + + if mdp.MarketingProduct.Marketing.SalesPersonId != 0 { + mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson) + item.Sales = &mapped + } + + if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { + mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) + item.Product = newProductRelationDTOFixedPtr(&mapped) + } + items = append(items, item) } + return items } -func getMarketingType(mdp entity.MarketingDeliveryProduct) string { - hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if hasAyam { - return "ayam" - } - if hasTelur { - return "telur" - } - if hasTrading { - return "trading" - } - return "trading" // default to trading if no flags found -} - -func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) { - if len(flags) == 0 { - return false, false, false - } - - for _, flag := range flags { - ft := utils.FlagType(flag.Name) - - if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati || - ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer { - hasAyam = true - } - - if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah || - ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak { - hasTelur = true - } - - if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia || - ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher { - hasTrading = true - } - } - - return hasAyam, hasTelur, hasTrading -} - -func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { - hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) - - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { - return hasAyam - } - - return hasAyam || hasTelur -} - -func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary { - if len(mdps) == 0 { - return nil - } - - totalQty := 0 - totalWeightKg := 0.0 - totalEligibleWeightKg := 0.0 - totalSalesAmount := int64(0) - totalHppAmount := int64(0) - - for _, mdp := range mdps { - calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight - totalQty += int(mdp.UsageQty) - totalWeightKg += calculatedTotalWeight - totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice) - - if isProductEligibleForHpp(mdp, category) { - totalEligibleWeightKg += calculatedTotalWeight - totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg) - } - } - - totalHppPricePerKg := float64(0) - if totalEligibleWeightKg > 0 { - totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg - } - - return &Summary{ - TotalQty: totalQty, - TotalWeightKg: totalWeightKg, - TotalSalesAmount: totalSalesAmount, - TotalHppAmount: totalHppAmount, - TotalHppPricePerKg: totalHppPricePerKg, - } -} - func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { if len(items) == 0 { return nil @@ -244,6 +179,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 + avgSalesAmount := 0.0 + avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -259,25 +196,26 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) + } + + if totalQty > 0 { + avgWeightKg = totalWeightKg / float64(totalQty) + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + } + return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, + AverageWeightKg: avgWeightKg, + AverageSalesAmount: avgSalesAmount, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, } } -func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO { - items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category) - total := ToSummary(mdps, hppPricePerKg, category) - - return RepportMarketingResponseDTO{ - Items: items, - Total: total, - } -} - func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { if original == nil { return nil diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index a0e0f350..090a284b 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -181,7 +181,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap) return items, total, nil } From 1d726afa6f9eb99596a717f31e8901af9e2b333c Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:28:34 +0700 Subject: [PATCH 079/117] FEAT[BE[: enhance marketing report items with aging days calculation --- .../repports/dto/repportMarketing.dto.go | 17 ++- .../repports/services/repport.service.go | 104 ++++++++---------- 2 files changed, 54 insertions(+), 67 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index edb2887f..751796e9 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -53,11 +53,10 @@ type ProductRelationDTOFixed struct { SellingPrice *float64 `json:"selling_price,omitempty"` } -func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO { +func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO { items := make([]RepportMarketingItemDTO, 0, len(mdps)) for _, mdp := range mdps { - // Get HPP and category from map hppPerKg := float64(0) category := "" if projectFlockKandang := mdp.MarketingProduct.ProductWarehouse.ProjectFlockKandang; projectFlockKandang != nil { @@ -67,12 +66,15 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u category = projectFlockKandang.ProjectFlock.Category } - // Calculate dates soDate := time.Time{} agingDays := 0 if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 { soDate = mdp.MarketingProduct.Marketing.SoDate - agingDays = int(time.Since(soDate).Hours() / 24) + if ag, exists := agingMap[int(mdp.Id)]; exists { + agingDays = ag + } else { + agingDays = int(time.Since(soDate).Hours() / 24) + } } realizationDate := time.Time{} @@ -106,7 +108,6 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u } } - // Determine marketing type marketingType := "trading" if hasTrading { marketingType = "trading" @@ -196,13 +197,9 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } - if len(items) > 0 { - avgSalesAmount = float64(totalSalesAmount) / float64(len(items)) - } - if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) // ← TAMBAHAN INI + avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 090a284b..579436eb 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -165,6 +165,47 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing return nil, 0, err } + customerGroups := make(map[uint][]entity.MarketingDeliveryProduct) + for _, dp := range deliveryProducts { + customerID := dp.MarketingProduct.Marketing.CustomerId + customerGroups[customerID] = append(customerGroups[customerID], dp) + } + + agingMap := make(map[int]int) + for customerID := range customerGroups { + transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) + if err != nil { + s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) + continue + } + + initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(c.Context(), customerID) + if err != nil { + initialBalance = 0 + } + + runningBalance := initialBalance + for i, tx := range transactions { + if tx.TransactionType == "SALES" { + previousBalance := runningBalance + runningBalance -= tx.TotalPrice + currentBalance := runningBalance + + _, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, currentBalance) + + if paymentDate != nil { + agingDays := int(paymentDate.Sub(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } else { + agingDays := int(time.Since(tx.TransDate).Hours() / 24) + agingMap[int(tx.TransactionID)] = agingDays + } + } else if tx.TransactionType == "PAYMENT" { + runningBalance += tx.PaymentAmount + } + } + } + projectFlockIDMap := make(map[uint]bool) hppMap := make(map[uint]float64) @@ -181,7 +222,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing } } - items := dto.ToMarketingReportItems(deliveryProducts, hppMap) + items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap) return items, total, nil } @@ -422,12 +463,10 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return nil, 0, err } - // Determine customer IDs to process var customerIDs []uint var totalCustomers int64 if len(params.CustomerIDs) > 0 { - // Specific customer IDs mode (no pagination) customerIDs = params.CustomerIDs totalCustomers = int64(len(customerIDs)) @@ -435,7 +474,6 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C return []dto.CustomerPaymentReportItem{}, 0, nil } } else { - // Multiple customers mode with pagination page := params.Page limit := params.Limit if page < 1 { @@ -574,15 +612,7 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) { currentSales := transactions[currentIndex] - // Status Logic: - // 1. LUNAS: previousBalance >= salesAmount (paid from deposit) - // 2. LUNAS: future payments make AR >= 0 (eventually paid) - // 3. DIBAYAR SEBAGIAN: has payment but not enough - // 4. BELUM LUNAS: no payment at all - if previousBalance >= currentSales.TotalPrice { - // Cari payment yang digunakan untuk melunasi sales ini dengan FIFO - // Track payment allocations that are consumed by previous sales type paymentAllocation struct { date time.Time amount float64 @@ -591,7 +621,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo allocations := []paymentAllocation{} runningBalance := 0.0 - // Process all transactions before current sales to build allocation map for i := 0; i < currentIndex; i++ { if transactions[i].TransactionType == "PAYMENT" { allocations = append(allocations, paymentAllocation{ @@ -604,7 +633,6 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo salesAmount := transactions[i].TotalPrice remainingToConsume := salesAmount - // Consume from oldest allocations first (FIFO) for j := range allocations { if remainingToConsume <= 0 { break @@ -623,22 +651,18 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo } } - // Now find which allocation covers the current sales amountNeeded := currentSales.TotalPrice for _, alloc := range allocations { available := alloc.amount - alloc.consumed if available > 0 { if amountNeeded <= available { - // This allocation fully covers the sales return "LUNAS", &alloc.date } else { - // This allocation partially covers, continue to next amountNeeded -= available } } } - // If we get here, use the oldest allocation if len(allocations) > 0 { return "LUNAS", &allocations[0].date } @@ -690,7 +714,6 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe if record.Day != nil { result.Woa = float64(*record.Day) } - // avgWeight := calculateAverageBodyWeight(record.BodyWeights) avgWeight := 1.0 if avgWeight > 0 { result.Bw = avgWeight @@ -1570,12 +1593,9 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows)) perRangeMap := make(map[weightRangeKey]*weightRangeAggregate) var totalBirds int64 - // var totalWeight float64 var totalEggPieces int64 var totalEggKg float64 - // var totalRemainingValueRp int64 var totalEggValueRp int64 - // var totalHppSum float64 var totalHppCount int var totalDocPriceSum float64 var totalDocPriceCount int @@ -1589,14 +1609,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - // birdsFloat := row.RemainingChickenBirds - // if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) { - // birdsFloat = 0 - // } - // weightFloat := row.RemainingChickenWeight - // if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) { - // weightFloat = 0 - // } eggPiecesFloatRemaining := row.EggProductionPiecesRemaining if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { eggPiecesFloatRemaining = 0 @@ -1632,13 +1644,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes weightMax := weightMin + 0.09 rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} - // rowBirds := int64(math.Round(birdsFloat)) costEntry := costMap[row.ProjectFlockKandangID] totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - // hppRp := 0.0 - // if weightFloat > 0 { - // hppRp = totalCost / weightFloat - // } eggHpp := 0.0 if eggWeightFloat > 0 { eggHpp = (totalCost / eggWeightFloat) / 1000 @@ -1646,7 +1653,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) - // rowRemainingValue := int64(hppRp * weightFloat) avgDocPrice := int64(0) if costEntry.DocQty > 0 { avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty)) @@ -1673,35 +1679,22 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes WeightMin: weightMin, WeightMax: weightMax, }, - AvgWeightKg: avgWeight, - NameWithPeriode: nameWithPeriod, - // FeedCostRp: costEntry.FeedCost, - // OvkCostRp: costEntry.OvkCost, + AvgWeightKg: avgWeight, + NameWithPeriode: nameWithPeriod, DocSuppliers: docSupplierMap[row.ProjectFlockKandangID], FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID], EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)), EggProductionKg: eggRemainingWeightFloatRemaining, - // EggProductionTotalWeightKg: eggWeightFloat, - // EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)), - AverageDocPriceRp: avgDocPrice, - // HppRp: hppRp, - EggHppRpPerKg: eggHpp, - // RemainingValueRp: rowRemainingValue, - EggValueRp: rowEggValue, + AverageDocPriceRp: avgDocPrice, + EggHppRpPerKg: eggHpp, + EggValueRp: rowEggValue, }) - // totalBirds += rowBirds - // totalWeight += weightFloat totalEggPieces += rowEggPieces totalEggKg += eggRemainingWeightFloatRemaining - // totalRemainingValueRp += rowRemainingValue totalEggValueRp += rowEggValue totalAvgWeightSum += avgWeight totalAvgWeightCount++ - // if weightFloat > 0 { - // totalHppSum += hppRp - // totalHppCount++ - // } if avgDocPrice > 0 { totalDocPriceSum += float64(avgDocPrice) totalDocPriceCount++ @@ -1728,8 +1721,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary := rangeAgg.Summary - // rangeAgg.RemainingBirds += rowBirds - // rangeAgg.RemainingWeightKg += row.RemainingChickenWeight rangeAgg.AvgWeightSum += avgWeight rangeAgg.AvgWeightCount++ for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] { @@ -1744,7 +1735,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } rangeSummary.EggProductionPieces += rowEggPieces rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining - // rangeSummary.RemainingValueRp += rowRemainingValue rangeSummary.EggValueRp += rowEggValue if eggWeightFloat > 0 { rangeAgg.EggHppSum += eggHpp From 30f5ed417c18d2e162861e419e8eb9c88c598b72 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 20 Jan 2026 22:42:16 +0700 Subject: [PATCH 080/117] FEAT[BE]: add default filterby become so_date in report markeing --- .../salesorder_delivery_product.repository.go | 10 +++++++--- internal/modules/repports/services/repport.service.go | 1 - .../modules/repports/validations/repport.validation.go | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index e219b041..1ec0bddf 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -225,8 +225,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C } } - if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { - if filters.FilterBy == "so_date" { + if filters.StartDate != "" || filters.EndDate != "" { + filterBy := filters.FilterBy + if filterBy == "" { + filterBy = "so_date" + } + if filterBy == "so_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketings.so_date >= ?", startDate) @@ -238,7 +242,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("marketings.so_date < ?", nextDate) } } - } else if filters.FilterBy == "realization_date" { + } else if filterBy == "realization_date" { if filters.StartDate != "" { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 579436eb..03b1b370 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -175,7 +175,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing for customerID := range customerGroups { transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID) if err != nil { - s.Log.Warnf("Failed to get transactions for customer %d: %v", customerID, err) continue } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index e0161b5c..8047f718 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -26,7 +26,7 @@ type MarketingQuery struct { AreaId int64 `query:"area_id" validate:"omitempty"` LocationId int64 `query:"location_id" validate:"omitempty"` MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"` From 8f7fc622f64b622391dff9898faf056bc2d913e0 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:45:19 +0700 Subject: [PATCH 081/117] FIX[BE]: fixing closing penjualan add sumary --- .../controllers/closing.controller.go | 4 +- .../closings/dto/closingMarketing.dto.go | 99 +++++++++++-------- 2 files changed, 59 insertions(+), 44 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index d348fd34..29c89f33 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -160,7 +160,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } @@ -190,7 +190,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan by project flock kandang successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(result), }) } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 1a790ad6..223b9d11 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -12,23 +12,31 @@ import ( // === Response DTO === type SalesDTO struct { - Id uint `json:"id"` - RealizationDate time.Time `json:"realization_date"` - Age int `json:"age"` - DoNumber string `json:"do_number"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` - Qty float64 `json:"qty"` - Weight float64 `json:"weight"` - AvgWeight float64 `json:"avg_weight"` - Price float64 `json:"price"` - TotalPrice float64 `json:"total_price"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` - PaymentStatus string `json:"payment_status"` + Id uint `json:"id"` + RealizationDate time.Time `json:"realization_date"` + Age int `json:"age"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + Qty float64 `json:"qty"` + Weight float64 `json:"weight"` + AvgWeight float64 `json:"avg_weight"` + SalesPrice float64 `json:"sales_price"` + TotalSalesPrice float64 `json:"total_sales_price"` + ActualPrice float64 `json:"actual_price"` + TotalActualPrice float64 `json:"total_actual_price"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` +} +type SummaryDTO struct { + TotalSalesPrice float64 `json:"total_sales_price"` + AvgSalesPrice float64 `json:"avg_sales_price"` + TotalActualPrice float64 `json:"total_actual_price"` + AvgActualPrice float64 `json:"avg_actual_price"` } type PenjualanRealisasiResponseDTO struct { - Sales []SalesDTO `json:"sales"` + Sales []SalesDTO `json:"sales"` + Summary SummaryDTO `json:"summary"` } // === Mapper Functions === @@ -63,19 +71,38 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) return SalesDTO{ - Id: e.Id, - RealizationDate: realizationDate, - Age: age, - DoNumber: doNumber, - Product: product, - Customer: customer, - Qty: e.UsageQty, - Weight: e.TotalWeight, - AvgWeight: e.AvgWeight, - Price: e.UnitPrice, - TotalPrice: e.TotalPrice, - Kandang: kandang, - PaymentStatus: "Paid", + Id: e.Id, + RealizationDate: realizationDate, + Age: age, + DoNumber: doNumber, + Product: product, + Customer: customer, + Qty: e.UsageQty, + Weight: e.TotalWeight, + AvgWeight: e.AvgWeight, + SalesPrice: e.MarketingProduct.UnitPrice, + TotalSalesPrice: e.MarketingProduct.TotalPrice, + ActualPrice: e.UnitPrice, + TotalActualPrice: e.TotalPrice, + Kandang: kandang, + } +} + +func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { + + var totalSalesPrice, totalActualPrice float64 + count := len(e) + + for _, item := range e { + totalSalesPrice += item.MarketingProduct.TotalPrice + totalActualPrice += item.TotalPrice + } + + return SummaryDTO{ + TotalSalesPrice: totalSalesPrice, + TotalActualPrice: totalActualPrice, + AvgSalesPrice: totalSalesPrice / float64(count), + AvgActualPrice: totalActualPrice / float64(count), } } @@ -87,25 +114,13 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { return result } -func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { - +func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { return PenjualanRealisasiResponseDTO{ - - Sales: ToSalesDTOs(e), + Sales: ToSalesDTOs(e), + Summary: ToSummaryDto(e), } } -func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { - if len(realisasi) > 0 { - for _, item := range realisasi { - if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil { - return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period - } - } - } - return 0 -} - func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0 From f6bdb176993c3c043e5bbef8f9784a934965ed8e Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:46:03 +0700 Subject: [PATCH 082/117] FIX[BE]: fixing report penjualan add avg weight and price to response --- .../modules/repports/dto/repportMarketing.dto.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 751796e9..336b6576 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -41,7 +41,7 @@ type Summary struct { TotalQty int `json:"total_qty"` TotalWeightKg float64 `json:"total_weight_kg"` AverageWeightKg float64 `json:"average_weight_kg"` - AverageSalesAmount float64 `json:"average_sales_amount"` + AverageSalesPrice float64 `json:"average_sales_price"` TotalSalesAmount int64 `json:"total_sales_amount"` TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` @@ -180,7 +180,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalQty := 0 totalWeightKg := 0.0 - avgSalesAmount := 0.0 + avgSalesPrice := 0.0 avgWeightKg := 0.0 totalSalesAmount := int64(0) totalHppAmount := int64(0) @@ -190,6 +190,7 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalWeightKg += item.TotalWeightKg totalSalesAmount += int64(item.SalesAmount) totalHppAmount += int64(item.HppAmount) + avgSalesPrice += item.SalesPricePerKg } totalHppPricePerKg := float64(0) @@ -197,16 +198,19 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg } + if len(items) > 0 { + avgSalesPrice = avgSalesPrice / float64(len(items)) + } + if totalQty > 0 { avgWeightKg = totalWeightKg / float64(totalQty) - avgSalesAmount = float64(totalSalesAmount) / float64(totalQty) } return &Summary{ TotalQty: totalQty, TotalWeightKg: totalWeightKg, AverageWeightKg: avgWeightKg, - AverageSalesAmount: avgSalesAmount, + AverageSalesPrice: avgSalesPrice, TotalSalesAmount: totalSalesAmount, TotalHppAmount: totalHppAmount, TotalHppPricePerKg: totalHppPricePerKg, From e99af367962bbd0a068da5bcf45c8c7924b92695 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 09:57:44 +0700 Subject: [PATCH 083/117] FIX[BE] fix wrong calculation on summary report marketing --- internal/modules/closings/dto/closingMarketing.dto.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 223b9d11..eb6ff23f 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -90,19 +90,22 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { - var totalSalesPrice, totalActualPrice float64 + var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 count := len(e) for _, item := range e { totalSalesPrice += item.MarketingProduct.TotalPrice totalActualPrice += item.TotalPrice + sumSales += item.MarketingProduct.UnitPrice + sumActual += item.UnitPrice + } return SummaryDTO{ TotalSalesPrice: totalSalesPrice, TotalActualPrice: totalActualPrice, - AvgSalesPrice: totalSalesPrice / float64(count), - AvgActualPrice: totalActualPrice / float64(count), + AvgSalesPrice: sumSales / float64(count), + AvgActualPrice: sumActual / float64(count), } } From 132e043597556afe71e39ebaf5b3a0a2af0a46d4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 13:52:46 +0700 Subject: [PATCH 084/117] FEAT[BE]: update warehouse DTO references in product warehouse and add UOM preload --- .../dto/product_warehouse.dto.go | 143 ++++-------------- .../services/product_warehouse.service.go | 1 + .../marketing/dto/deliveryorder.dto.go | 11 +- 3 files changed, 35 insertions(+), 120 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 57a13021..b8f51c52 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -5,6 +5,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" ) // === DTO Structs === @@ -16,60 +17,29 @@ type ProductWarehouseRelationDTO struct { Quantity float64 `json:"quantity"` } -type ProductWarehousNestedDTO struct { - Id uint `json:"id"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` -} - type ProductWarehouseListDTO struct { ProductWarehouseRelationDTO - 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 { - Id uint `json:"id"` - Username string `json:"username"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.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 ProductWarehouseDetailDTO struct { ProductWarehouseListDTO } -// Nested DTOs for relations -type ProductRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` - Flags []string `json:"flags"` +type ProductWarehousNestedDTO struct { + Id uint `json:"id"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` } -type WarehouseRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Kandang *KandangRelationDTO `json:"kandang,omitempty"` - Location *LocationRelationDTO `json:"location,omitempty"` - Area *AreaRelationDTO `json:"area,omitempty"` -} - -type KandangRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type LocationRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` -} - -type AreaRelationDTO struct { - Id uint `json:"id"` - Name string `json:"name"` +type UserRelationDTO struct { + Id uint `json:"id"` + Username string `json:"username"` } type ProjectFlockKandangRelationDTO struct { @@ -96,65 +66,28 @@ func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRe } } -func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { - product := productDTO.ToProductRelationDTO(e.Product) - - return ProductWarehousNestedDTO{ - Id: e.Id, - Product: &product, - Warehouse: &WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - }, - } -} - func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO { dto := ProductWarehouseListDTO{ ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), - // CreatedAt: e.CreatedAt, - // UpdatedAt: e.UpdatedAt, } // 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 + // Create a copy with flock name appended if exists if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { - product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + productCopy := product + productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" + dto.Product = &productCopy + } else { + dto.Product = &product } - - dto.Product = &product } // Map Warehouse relation jika ada if e.Warehouse.Id != 0 { - warehouse := WarehouseRelationDTO{ - Id: e.Warehouse.Id, - Name: e.Warehouse.Name, - } - // Map Kandang jika ada - if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 { - warehouse.Kandang = &KandangRelationDTO{ - Id: e.Warehouse.Kandang.Id, - Name: e.Warehouse.Kandang.Name, - } - } - // Map Location jika ada - if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 { - warehouse.Location = &LocationRelationDTO{ - Id: e.Warehouse.Location.Id, - Name: e.Warehouse.Location.Name, - } - } - - if e.Warehouse.Area.Id != 0 { - warehouse.Area = &AreaRelationDTO{ - Id: e.Warehouse.Area.Id, - Name: e.Warehouse.Area.Name, - } - } + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) dto.Warehouse = &warehouse } @@ -168,7 +101,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT Period: e.ProjectFlockKandang.Period, } - // Map ProjectFlock jika ada if e.ProjectFlockKandang.ProjectFlock.Id != 0 { pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ Id: e.ProjectFlockKandang.ProjectFlock.Id, @@ -179,15 +111,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT dto.ProjectFlockKandang = pfkDTO } - // Map CreatedUser relation jika ada - // if e.CreatedUser.Id != 0 { - // user := UserRelationDTO{ - // Id: e.CreatedUser.Id, - // Username: e.CreatedUser.Name, - // } - // dto.CreatedUser = &user - // } - return dto } @@ -205,23 +128,13 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta } } -func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { - return KandangRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} +func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { + product := productDTO.ToProductRelationDTO(e.Product) + warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) -func ToLocationRelationDTO(e entity.Location) LocationRelationDTO { - return LocationRelationDTO{ - Id: e.Id, - Name: e.Name, - } -} - -func ToAreaRelationDTO(e entity.Area) AreaRelationDTO { - return AreaRelationDTO{ - Id: e.Id, - Name: e.Name, + return ProductWarehousNestedDTO{ + Id: e.Id, + Product: &product, + Warehouse: &warehouse, } } 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 152bfa24..ea194c36 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -40,6 +40,7 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("Product.Flags"). Preload("Product"). + Preload("Product.Uom"). Preload("Warehouse"). Preload("Warehouse.Location"). Preload("Warehouse.Area"). diff --git a/internal/modules/marketing/dto/deliveryorder.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go index a6eea180..4bcbacca 100644 --- a/internal/modules/marketing/dto/deliveryorder.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -9,6 +9,7 @@ import ( approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -68,10 +69,10 @@ type DeliveryItemDTO struct { } type DeliveryGroupDTO struct { - DoNumber string `json:"do_number"` - DeliveryDate *time.Time `json:"delivery_date"` - Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` - Deliveries []DeliveryItemDTO `json:"deliveries"` + DoNumber string `json:"do_number"` + DeliveryDate *time.Time `json:"delivery_date"` + Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` + Deliveries []DeliveryItemDTO `json:"deliveries"` } type DeliveryMarketingProductDTO struct { @@ -286,7 +287,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri if !exists { group = &DeliveryGroupDTO{ DeliveryDate: product.DeliveryDate, - Warehouse: &productwarehouseDTO.WarehouseRelationDTO{ + Warehouse: &warehouseDTO.WarehouseRelationDTO{ Id: warehouseId, Name: warehouseName, }, From ec4b849778e6bbbe25aeb33f431d459a196fa9d3 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 21 Jan 2026 15:16:30 +0700 Subject: [PATCH 085/117] FIX[BE]: fixing wrong index data on adjustment. change get from stocklogs to adjustment table --- internal/entities/adjustment_stock.go | 29 ++---- .../adjustments/dto/adjustment.dto.go | 30 +++--- .../adjustment_stock.repository.go | 8 ++ .../services/adjustment.service.go | 99 ++++++++++++------- .../services/product_warehouse.service.go | 7 +- 5 files changed, 99 insertions(+), 74 deletions(-) diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index bbc93167..ef27d0c2 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -2,28 +2,17 @@ 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"` + Id uint `gorm:"primaryKey"` + StockLogId uint `gorm:"column:stock_log_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TotalQty float64 `gorm:"column:total_qty;default:0"` + TotalUsed float64 `gorm:"column:total_used;default:0"` + UsageQty float64 `gorm:"column:usage_qty;default:0"` + PendingQty float64 `gorm:"column:pending_qty;default:0"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` - // === 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/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 008f9966..1ce3da1b 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -100,38 +100,42 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { } } -func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { +func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { return AdjustmentRelationDTO{ Id: e.Id, - Note: e.Notes, - Increase: e.Increase, - Decrease: e.Decrease, + Note: e.StockLog.Notes, + Increase: e.TotalQty, + Decrease: e.UsageQty, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } } -func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { +func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO { var createdUser *userDTO.UserRelationDTO - if e.CreatedUser != nil { + if e.StockLog != nil && e.StockLog.CreatedUser != nil { createdUser = &userDTO.UserRelationDTO{ - Id: e.CreatedUser.Id, - IdUser: e.CreatedUser.IdUser, - Email: e.CreatedUser.Email, - Name: e.CreatedUser.Name, + Id: e.StockLog.CreatedUser.Id, + IdUser: e.StockLog.CreatedUser.IdUser, + Email: e.StockLog.CreatedUser.Email, + Name: e.StockLog.CreatedUser.Name, } } + createdAt := time.Time{} + if e.StockLog != nil { + createdAt = e.StockLog.CreatedAt + } + return AdjustmentListDTO{ AdjustmentRelationDTO: ToAdjustmentRelationDTO(e), CreatedUser: createdUser, - CreatedAt: e.CreatedAt, + CreatedAt: createdAt, } } -func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { +func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO { return AdjustmentDetailDTO{ AdjustmentListDTO: ToAdjustmentListDTO(e), - // UpdatedAt: e.UpdatedAt, } } diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index 8d62b05c..fa2685e7 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -33,6 +33,14 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { var record entity.AdjustmentStock err := r.db.WithContext(ctx). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse"). Where("stock_log_id = ?", stockLogID). First(&record).Error if err != nil { diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 71b985c2..c92d059b 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -25,9 +25,9 @@ import ( ) type AdjustmentService interface { - Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) - GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error) - AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) + Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) + AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) } type adjustmentService struct { @@ -73,10 +73,8 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { Preload("CreatedUser") } -func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { - stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") - }) +func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { + adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -85,14 +83,10 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err return nil, err } - if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { - return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") - } - - return stockLog, nil + return adjustmentStock, nil } -func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) { +func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -111,12 +105,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if req.Quantity <= 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") } + transactionType := strings.ToUpper(req.TransactionType) if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") } - var createdLogId uint + var createdAdjustmentStockId uint var projectFlockKandangID *uint pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) @@ -151,7 +146,8 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) + + productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") @@ -171,14 +167,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { - return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Current: %.2f, Requested: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity newLog.Decrease = afterQuantity } if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { - s.Log.Errorf("Failed to create stock log: %+v", err) + return err } @@ -187,7 +183,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e ProductWarehouseId: productWarehouse.Id, } 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") } @@ -212,7 +208,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e UsableID: adjustmentStock.Id, ProductWarehouseID: uint(productWarehouse.Id), Quantity: req.Quantity, - AllowPending: false, // Don't allow pending for adjustment + AllowPending: false, Tx: tx, }) if err != nil { @@ -220,24 +216,27 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } } - // Update ProductWarehouse quantity (for backward compatibility/reporting) - + // LEGACY: 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) return err } - createdLogId = newLog.Id + createdAdjustmentStockId = adjustmentStock.Id return nil }) if err != nil { s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + var fiberErr *fiber.Error + if errors.As(err, &fiberErr) { + return nil, err + } return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction") } - return s.GetOne(c, createdLogId) + return s.GetOne(c, createdAdjustmentStockId) } func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { @@ -266,13 +265,15 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, return uint(projectFlockKandang.Id), nil } -func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { +func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err } offset := (query.Page - 1) * query.Limit + var isProductsExist bool isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) + if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } @@ -280,7 +281,8 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") } - isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID)) + if err != nil { s.Log.Errorf("Failed to check product existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") @@ -289,28 +291,51 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } - stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { + var adjustmentStocks []entity.AdjustmentStock + var total int64 - db = s.withRelations(db) + q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). + Preload("StockLog"). + Preload("StockLog.ProductWarehouse"). + Preload("StockLog.ProductWarehouse.Product"). + Preload("StockLog.ProductWarehouse.Warehouse"). + Preload("StockLog.CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse") - db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) + if query.ProductID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.product_id = ?", query.ProductID) + } - if query.TransactionType != "" { - db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) - } - db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) + if query.WarehouseID > 0 { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). + Where("product_warehouses.warehouse_id = ?", query.WarehouseID) + } - return db.Order("created_at DESC") - }) + if query.TransactionType != "" { + q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id"). + Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType)) + } + + if err = q.Count(&total).Error; err != nil { + s.Log.Errorf("Failed to get adjustments: %+v", err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") + } + + err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") } - result := make([]*entity.StockLog, len(stockLogs)) - for i, v := range stockLogs { - result[i] = &v + result := make([]*entity.AdjustmentStock, len(adjustmentStocks)) + for i := range adjustmentStocks { + result[i] = &adjustmentStocks[i] } return result, total, nil 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 ea194c36..5b89808c 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -3,15 +3,14 @@ package service import ( "errors" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) From f04cbd24bd36ac26f340c437dad41ad94a965ac2 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 21 Jan 2026 13:06:45 +0700 Subject: [PATCH 086/117] [FIX/BE-US] adjustment recording --- .../dashboards/repositories/dashboard_stats.repository.go | 4 ++-- .../recordings/repositories/recording.repository.go | 5 +++-- .../production/recordings/services/recording.service.go | 6 +++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 7582680b..828dd96c 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -285,7 +285,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex db := r.DB().WithContext(ctx). Table("recording_eggs AS re"). - Select("COALESCE(SUM(re.qty * re.weight), 0)"). + Select("COALESCE(SUM(re.weight * 1000), 0)"). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). @@ -648,7 +648,7 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s Table("recording_eggs AS re"). Select(` ((r.day - 1) / 7 + 1) AS week, - COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). + COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`). Joins("JOIN recordings AS r ON r.id = re.recording_id"). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 9e783134..6cb65c6c 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -171,6 +171,7 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda var days []int if err := tx.Model(&entity.Recording{}). Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("deleted_at IS NULL"). Where("day IS NOT NULL"). Pluck("day", &days).Error; err != nil { return 0, err @@ -399,7 +400,7 @@ func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordin } err = tx. Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). + Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(COALESCE(recording_eggs.weight, 0) * 1000), 0) AS total_weight_grams"). Where("recording_eggs.recording_id = ?", recordingID). Scan(&result).Error if err != nil { @@ -485,7 +486,7 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct var result float64 err := r.DB().WithContext(ctx). Table("recording_eggs"). - Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000"). + Select("COALESCE(SUM(recording_eggs.weight), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 80611109..a5486ab7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1157,7 +1157,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := existingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight existingTotals[egg.ProductWarehouseId] = current } @@ -1169,7 +1169,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool { } current := incomingTotals[egg.ProductWarehouseId] current.Qty += egg.Qty - current.Weight += float64(egg.Qty) * weight + current.Weight += weight incomingTotals[egg.ProductWarehouseId] = current } @@ -1328,7 +1328,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var eggMass float64 if remainingChick > 0 && totalEggWeightGrams > 0 { - eggMass = totalEggWeightGrams / remainingChick + eggMass = (totalEggWeightGrams / remainingChick) / 1000 updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { From d7ed768d14b1619b2be1f399ff770c54b6c85cad Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 10:00:24 +0700 Subject: [PATCH 087/117] add filter project status and location id --- .../controllers/closing.controller.go | 27 ++++++++++++++++--- .../closings/services/closing.service.go | 22 +++++++++++++++ .../validations/closing.validation.go | 8 +++--- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 29c89f33..b1b02886 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -28,10 +28,31 @@ func NewClosingController(closingService service.ClosingService, sapronakService } func (u *ClosingController) GetAll(c *fiber.Ctx) error { + var projectStatus *int + if raw := c.Query("project_status"); raw != "" { + statusValue, err := strconv.Atoi(raw) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_status") + } + projectStatus = &statusValue + } + + var locationID *uint + if raw := c.Query("location_id"); raw != "" { + locationValue, err := strconv.Atoi(raw) + if err != nil || locationValue <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id") + } + locationUint := uint(locationValue) + locationID = &locationUint + } + query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + ProjectStatus: projectStatus, + LocationID: locationID, } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index daca980f..372d38fd 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -99,9 +99,31 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl } offset := (params.Page - 1) * params.Limit + statusFilter := "" + if params.ProjectStatus != nil { + switch *params.ProjectStatus { + case 1: + statusFilter = "Pengajuan" + case 2: + statusFilter = "Aktif" + } + } closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withClosingRelations(db) + if params.LocationID != nil { + db = db.Where("location_id = ?", *params.LocationID) + } + if statusFilter != "" { + latestApprovalSubQuery := s.Repository.DB(). + WithContext(c.Context()). + Table("approvals"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, id"). + Where("approvable_type = ?", utils.ApprovalWorkflowProjectFlock.String()). + Order("approvable_id, id DESC") + db = db.Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery). + Where("LOWER(latest_approval.step_name) = LOWER(?)", statusFilter) + } if params.Search != "" { return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") } diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 454bbdfc..9d3ad573 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -9,9 +9,11 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + 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"` + ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"` + LocationID *uint `query:"location_id" validate:"omitempty,gt=0"` } const ( From 06e92d1c77530641b0af3fcf58a92a6759e8155b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 22 Jan 2026 11:17:02 +0700 Subject: [PATCH 088/117] [FEAT][BE}: add umur week and day on closing penjualan --- .../closings/dto/closingMarketing.dto.go | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index eb6ff23f..d725b430 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -15,6 +15,7 @@ type SalesDTO struct { Id uint `json:"id"` RealizationDate time.Time `json:"realization_date"` Age int `json:"age"` + Week int `json:"week"` DoNumber string `json:"do_number"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` @@ -43,7 +44,7 @@ type PenjualanRealisasiResponseDTO struct { func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { - age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) + ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) var product *productDTO.ProductRelationDTO if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { @@ -73,7 +74,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { return SalesDTO{ Id: e.Id, RealizationDate: realizationDate, - Age: age, + Age: ageInDay, + Week: ageInWeeks, DoNumber: doNumber, Product: product, Customer: customer, @@ -124,9 +126,9 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua } } -func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { +func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) (int, int) { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { - return 0 + return 0, 0 } earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate @@ -136,7 +138,16 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de } } - ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) - ageInWeeks := ageInDays / 7 - return ageInWeeks + diff := deliveryDate.Sub(earliestChickinDate) + ageInDays := int(diff.Hours() / 24) + + var ageInWeeks int + if ageInDays <= 0 { + ageInWeeks = 0 + } else { + + ageInWeeks = ((ageInDays - 1) / 7) + 1 + } + + return ageInDays, ageInWeeks } From f2a46843c88501b6c30a2685114432b30ebca1e6 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 12:53:02 +0700 Subject: [PATCH 089/117] continue common service hpp --- .../repository/common.hpp.repository.go | 97 +++++++++++-- internal/common/service/common.hpp.service.go | 132 ++++++++++++++---- 2 files changed, 187 insertions(+), 42 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 74ed5261..6c50708a 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -12,13 +12,16 @@ import ( type HppCostRepository interface { GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) - GetDocCost(ctx context.Context, kandangIDs []uint) (float64, error) + GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) - GetExpedisionCost(ctx context.Context, kandangIDs []uint) (float64, error) - GetFeedCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) - GetOvkCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) - GetTotalPopulation(ctx context.Context, kandangIDs []uint) (float64, error) + GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) + GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) + GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) + GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) + GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) + GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) + GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) } @@ -44,14 +47,14 @@ func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, proje return ids, nil } -func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, kandangIDs []uint) (float64, error) { +func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). - Where("pc.project_flock_kandang_id IN (?)", kandangIDs). + Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error if err != nil { return 0, err @@ -74,14 +77,14 @@ func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, p return total, nil } -func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, kandangIDs []uint) (float64, error) { +func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). Table("expense_nonstocks AS en"). Select("COALESCE(SUM(er.qty * er.price), 0)"). Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock). - Where("en.project_flock_kandang_id IN (?)", kandangIDs). + Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Where("f.name = ?", utils.FlagEkspedisi). Scan(&total).Error if err != nil { @@ -91,7 +94,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, kandangIDs [] return total, nil } -func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) { +func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() date = &now @@ -106,7 +109,7 @@ func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, kandangIDs []uint, Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). - Where("r.project_flock_kandangs_id IN (?)", kandangIDs). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name = ?", utils.FlagPakan). Scan(&total).Error @@ -117,7 +120,7 @@ func (r *HppRepositoryImpl) GetFeedCost(ctx context.Context, kandangIDs []uint, return total, nil } -func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, kandangIDs []uint, date *time.Time) (float64, error) { +func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) { if date == nil { now := time.Now() date = &now @@ -139,7 +142,7 @@ func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, kandangIDs []uint, d Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). - Where("r.project_flock_kandangs_id IN (?)", kandangIDs). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). Where("f.name IN ?", flags). Scan(&total).Error @@ -150,12 +153,12 @@ func (r *HppRepositoryImpl) GetOvkCost(ctx context.Context, kandangIDs []uint, d return total, nil } -func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, kandangIDs []uint) (float64, error) { +func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select("COALESCE(SUM(pc.usage_qty), 0)"). - Where("pc.project_flock_kandang_id IN (?)", kandangIDs). + Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error if err != nil { return 0, err @@ -192,6 +195,70 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda return total, nil } +func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + var totals struct { + TotalPieces float64 + TotalWeightKg float64 + } + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0) / 1000 AS total_weight_kg"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Scan(&totals).Error + if err != nil { + return 0, 0, err + } + + return totals.TotalPieces, totals.TotalWeightKg, nil +} + +func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + var totals struct { + TotalPieces float64 + TotalWeight float64 + } + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("COALESCE(SUM(mdp.usage_qty), 0) AS total_pieces, COALESCE(SUM(mdp.total_weight), 0) AS total_weight"). + Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). + Joins("JOIN stock_allocations AS sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", fifo.StockableKeyRecordingEgg.String(), fifo.UsableKeyMarketingDelivery.String()). + Joins("JOIN marketing_delivery_products AS mdp ON mdp.id = sa.usable_id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date). + Scan(&totals).Error + if err != nil { + return 0, 0, err + } + + return totals.TotalPieces, totals.TotalWeight, nil +} + +func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) { + var projectFlockID uint + err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("project_flock_id"). + Where("id = ?", projectFlockKandangId). + Scan(&projectFlockID).Error + if err != nil { + return 0, err + } + + return projectFlockID, nil +} + func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) { var summary struct { ProjectFlockID uint diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index 8a78aded..8210fc17 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -11,6 +11,8 @@ type HppService interface { CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, totalDepresiasiGrowing float64) (float64, error) + GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error) + GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error) } type HppCostResponse struct { @@ -40,36 +42,17 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim date = &now } - var sourceProjectFlockID uint - var transferTotalQty float64 - var err error - sourceProjectFlockID, transferTotalQty, err = s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, date) if err != nil { return nil, err } - kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) - + totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, date, depresiasiTransfer) if err != nil { return nil, err } - totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) - if err != nil { - return nil, err - } - - totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, date) - if err != nil { - return nil, err - } - - depresiasiTransfer := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing - - _, err = s.GetTotalProductionCost(projectFlockKandangId, date, depresiasiTransfer) - if err != nil { - return nil, err - } + _ = totalProductionCost return &HppCostResponse{ Estimation: HppCostDetail{}, @@ -107,12 +90,12 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d return 0, err } - feedCost, err := s.hppRepo.GetFeedCost(context.Background(), kandangIDs, date) + feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date) if err != nil { return 0, err } - ovkCost, err := s.hppRepo.GetOvkCost(context.Background(), kandangIDs, date) + ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date) if err != nil { return 0, err } @@ -129,10 +112,105 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, date *ti costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId) if err != nil { return 0, err - } - _ = date + costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, date) + if err != nil { + return 0, err + } - return depresiasiTransfer + costPullet, nil + costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, date) + if err != nil { + return 0, err + } + + costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId}) + if err != nil { + return 0, err + } + + costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, date) + if err != nil { + return 0, err + } + + return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil +} + +func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + if s.hppRepo == nil { + return 0, nil + } + + projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + } + + projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId) + if err != nil { + return 0, err + } + + eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, date) + if err != nil { + return 0, err + } + + eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date) + if err != nil { + return 0, err + } + + totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId) + if err != nil { + return 0, err + } + + if eggProduksiPiecesFlock == 0 { + return 0, nil + } + + return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil +} + +func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error) { + if date == nil { + now := time.Now() + date = &now + } + + if s.hppRepo == nil { + return 0, nil + } + + sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId) + if err != nil { + return 0, err + } + + kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID) + if err != nil { + return 0, err + } + + totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing) + if err != nil { + return 0, err + } + if totalPopulationFlockGrowing == 0 { + return 0, nil + } + + totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, date) + if err != nil { + return 0, err + } + + return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil } From 04ec8560a765d339a98f19893c12529d32f7e779 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 13:35:46 +0700 Subject: [PATCH 090/117] [FIX/BE-US] adjustment recording and purchase stock log --- .../repositories/constant.repository.go | 2 +- .../modules/production/recordings/module.go | 3 + .../recordings/services/recording.service.go | 256 ++++++++++++++++-- .../purchases/services/purchase.service.go | 47 ++++ internal/utils/constant.go | 2 + 5 files changed, 285 insertions(+), 25 deletions(-) diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index 493f4cb9..b9c9cc48 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -83,7 +83,7 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "KANDANG", }, "stock_log": map[string][]string{ - "log_types": []string{"TRANSFER", "ADJUSTMENT"}, + "log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"}, "transaction_types": []string{"INCREASE", "DECREASE"}, }, "supplier_categories": []string{ diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 71981a9e..23c97788 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -16,6 +16,7 @@ import ( rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + 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" @@ -31,6 +32,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) @@ -113,6 +115,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo, approvalService, fifoService, + stockLogRepo, productionStandardService, validate, ) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a5486ab7..4465a039 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -13,6 +13,7 @@ import ( sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -39,8 +40,8 @@ type RecordingService interface { } type RecordingFIFOIntegrationService interface { - ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error - ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error + ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error + ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error } var recordingStockUsableKey = fifo.UsableKeyRecordingStock @@ -57,6 +58,7 @@ type recordingService struct { ApprovalSvc commonSvc.ApprovalService ProductionStandardSvc sProductionStandard.ProductionStandardService FifoSvc commonSvc.FifoService + StockLogRepo rStockLogs.StockLogRepository } func NewRecordingService( @@ -67,6 +69,7 @@ func NewRecordingService( approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, + stockLogRepo rStockLogs.StockLogRepository, productionStandardSvc sProductionStandard.ProductionStandardService, validate *validator.Validate, ) RecordingService { @@ -81,6 +84,7 @@ func NewRecordingService( ApprovalSvc: approvalSvc, ProductionStandardSvc: productionStandardSvc, FifoSvc: fifoSvc, + StockLogRepo: stockLogRepo, } } @@ -88,12 +92,14 @@ func NewRecordingFIFOIntegrationService( repo repository.RecordingRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, fifoSvc commonSvc.FifoService, + stockLogRepo rStockLogs.StockLogRepository, ) RecordingFIFOIntegrationService { return &recordingService{ Log: utils.Log, Repository: repo, ProductWarehouseRepo: productWarehouseRepo, FifoSvc: fifoSvc, + StockLogRepo: stockLogRepo, } } @@ -274,7 +280,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) - if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { + note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil { return err } @@ -293,7 +300,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } if s.FifoSvc != nil { - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { return err } } @@ -304,7 +312,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } if s.FifoSvc != nil { - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id) + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { return err } } @@ -346,6 +355,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } ctx := c.Context() + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } var recordingEntity *entity.Recording var updatedRecording *entity.Recording @@ -431,14 +444,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if hasStockChanges { - if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { return err } } if hasDepletionChanges { if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { return err } } @@ -464,7 +479,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if s.FifoSvc != nil { - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { return err } } @@ -480,6 +496,28 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := ensureRecordingEggsUnused(existingEggs); err != nil { return err } + if s.StockLogRepo != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + logs := make([]*entity.StockLog, 0, len(existingEggs)) + for _, egg := range existingEggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + logs = append(logs, &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: recordingEntity.Id, + Notes: note, + }) + } + if len(logs) > 0 { + if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); err != nil { + return err + } + } + } if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err @@ -498,7 +536,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } if s.FifoSvc != nil { - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { + note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id) + if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { return err } } else { @@ -675,7 +714,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } if s.FifoSvc != nil { - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions); err != nil { + if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil { return err } } @@ -697,7 +736,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil { return err } @@ -756,10 +795,19 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { +func (s *recordingService) consumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { if len(stocks) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, stock := range stocks { if stock.Id == 0 { @@ -792,15 +840,42 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm. if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { return err } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil } -func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { +func (s *recordingService) consumeRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { if len(depletions) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, depletion := range depletions { if depletion.Id == 0 { @@ -832,19 +907,67 @@ func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *g if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { return err } + + logDecrease := result.UsageQuantity + if result.PendingQuantity > 0 { + logDecrease += result.PendingQuantity + } + if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Increase: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil } -func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { - return s.consumeRecordingStocks(ctx, tx, stocks) +func (s *recordingService) ConsumeRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID) } -func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { +func (s *recordingService) releaseRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { if len(stocks) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, stock := range stocks { if stock.Id == 0 { @@ -863,15 +986,38 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm. if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { return err } + + if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Increase: *stock.UsageQty, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil } -func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { +func (s *recordingService) releaseRecordingDepletions( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { if len(depletions) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, depletion := range depletions { if depletion.Id == 0 { @@ -898,13 +1044,52 @@ func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *g if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { return err } + + logIncrease := depletion.Qty + if depletion.PendingQty > 0 { + logIncrease += depletion.PendingQty + } + if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Increase: logIncrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } + + destDelta := depletion.Qty + depletion.PendingQty + if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Decrease: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil } -func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { - return s.releaseRecordingStocks(ctx, tx, stocks) +func (s *recordingService) ReleaseRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID) } func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { @@ -963,27 +1148,48 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } -func (s *recordingService) replenishRecordingEggs(ctx context.Context, tx *gorm.DB, eggs []entity.RecordingEgg) error { +func (s *recordingService) replenishRecordingEggs( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { if len(eggs) == 0 || s.FifoSvc == nil { return nil } + if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } for _, egg := range eggs { if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { continue } - note := fmt.Sprintf("Recording egg #%d", egg.Id) if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ StockableKey: fifo.StockableKeyRecordingEgg, StockableID: egg.Id, ProductWarehouseID: egg.ProductWarehouseId, Quantity: float64(egg.Qty), - Note: ¬e, Tx: tx, }); err != nil { s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) return err } + + if strings.TrimSpace(note) != "" && actorID != 0 { + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Increase: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } } return nil @@ -1034,6 +1240,8 @@ func (s *recordingService) syncRecordingStocks( recordingID uint, existing []entity.RecordingStock, incoming []validation.Stock, + note string, + actorID uint, ) error { if s.FifoSvc == nil { if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { @@ -1080,7 +1288,7 @@ func (s *recordingService) syncRecordingStocks( leftovers = append(leftovers, list...) } if len(leftovers) > 0 { - if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { + if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { return err } ids := make([]uint, 0, len(leftovers)) @@ -1099,7 +1307,7 @@ func (s *recordingService) syncRecordingStocks( if len(stocksToConsume) == 0 { return nil } - return s.consumeRecordingStocks(ctx, tx, stocksToConsume) + return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) } type eggTotals struct { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index b7efbc05..6b423d33 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -18,6 +18,7 @@ import ( rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -830,9 +831,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation receivingAction = entity.ApprovalActionUpdated } } + noteSuffix := "receive" + if receivingAction == entity.ApprovalActionUpdated { + noteSuffix = "edit-receive" + } + receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix) + transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { repoTx := rPurchase.NewPurchaseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) deltas := make(map[uint]float64) affected := make(map[uint]struct{}) @@ -849,6 +857,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwID uint qty float64 }, 0, len(prepared)) + logEntries := make([]struct { + itemID uint + pwID uint + delta float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -869,6 +882,13 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty + if newPWID != nil && deltaQty != 0 { + logEntries = append(logEntries, struct { + itemID uint + pwID uint + delta float64 + }{itemID: item.Id, pwID: *newPWID, delta: deltaQty}) + } switch { case deltaQty > 0 && newPWID != nil: if s.FifoSvc != nil { @@ -993,6 +1013,33 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } + if len(logEntries) > 0 { + logs := make([]*entity.StockLog, 0, len(logEntries)) + for _, entry := range logEntries { + if entry.pwID == 0 || entry.delta == 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: entry.pwID, + CreatedBy: actorID, + LoggableType: string(utils.StockLogTypePurchase), + LoggableId: purchase.Id, + Notes: receiveNote, + } + if entry.delta > 0 { + log.Increase = entry.delta + } else { + log.Decrease = -entry.delta + } + logs = append(logs, log) + } + if len(logs) > 0 { + if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil { + return err + } + } + } + if len(affected) > 0 { if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { return err diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 7d12f5c6..d27b07ef 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -113,6 +113,8 @@ const ( StockLogTypeTransfer StockLogType = "TRANSFER" StockLogTypeMarketing StockLogType = "MARKETING" StockLogTypeChikin StockLogType = "CHICKIN" + StockLogTypePurchase StockLogType = "PURCHASE" + StockLogTypeRecording StockLogType = "RECORDING" ) // ------------------------------------------------------------------- From 645a97b460db1e30ecee7ceb0ab1447dcedc3cf0 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 13:42:36 +0700 Subject: [PATCH 091/117] remove max limit production result --- internal/modules/repports/validations/repport.validation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 8047f718..48024dbc 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -70,7 +70,7 @@ type HppPerKandangQuery struct { type ProductionResultQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` } From bac0361df5b4bca4d58dee3cc2a4a6574fb93144 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 13:53:43 +0700 Subject: [PATCH 092/117] [FIX/BE-US] debt-supplier only show receive purchase --- .../repositories/debt_supplier.repository.go | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 977db610..74039ebf 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -37,6 +37,21 @@ func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { return &debtSupplierRepositoryImpl{db: db} } +func (r *debtSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx). + Table("approvals AS a"). + Select("a.approvable_id, a.step_number, a.action"). + Joins(` + JOIN ( + SELECT approvable_id, MAX(action_at) AS latest_action_at + FROM approvals + WHERE approvable_type = ? + GROUP BY approvable_id + ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, + string(utils.ApprovalWorkflowPurchase), + ) +} + func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { case "po_date": @@ -54,7 +69,11 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt db := r.db.WithContext(ctx). Model(&entity.Supplier{}). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). - Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") + Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if len(filters.SupplierIDs) > 0 { db = db.Where("suppliers.id IN ?", filters.SupplierIDs) @@ -207,7 +226,11 @@ func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplie Table("purchases"). Select("DISTINCT purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). - Where("purchases.supplier_id IN ?", supplierIDs) + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). + Where("purchases.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL") if filters.StartDate != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { @@ -355,7 +378,11 @@ func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Con Table("purchases"). Select("purchases.supplier_id AS supplier_id, SUM(purchase_items.total_price) AS total"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). + Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)). Where("purchases.supplier_id IN ?", supplierIDs). + Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)). + Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)). + Where("purchase_items.received_date IS NOT NULL"). Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom). Group("purchases.supplier_id"). Scan(&rows).Error; err != nil { From 58b29501c039c7a9483a5cba995f3c9946168224 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 13:54:32 +0700 Subject: [PATCH 093/117] finishing common service calculate hpp --- internal/common/service/common.hpp.service.go | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index 8210fc17..1b94e791 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -13,6 +13,7 @@ type HppService interface { GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, totalDepresiasiGrowing float64) (float64, error) GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error) GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error) + GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) } type HppCostResponse struct { @@ -52,12 +53,8 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim return nil, err } - _ = totalProductionCost + return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, date) - return &HppCostResponse{ - Estimation: HppCostDetail{}, - Real: HppCostDetail{}, - }, nil } func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) { @@ -214,3 +211,53 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, date *tim return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil } + +func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) { + if date == nil { + now := time.Now() + date = &now + } + + if s.hppRepo == nil { + return &HppCostResponse{}, nil + } + + estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date) + if err != nil { + return nil, err + } + + realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date) + if err != nil { + return nil, err + } + + estimation := HppCostDetail{ + Total: totalProductionCost, + Kg: estimWeightKg, + Butir: estimPieces, + } + if estimWeightKg > 0 { + estimation.HargaKg = totalProductionCost / estimWeightKg + } + if estimPieces > 0 { + estimation.HargaButir = totalProductionCost / estimPieces + } + + real := HppCostDetail{ + Total: totalProductionCost, + Kg: realWeightKg, + Butir: realPieces, + } + if realWeightKg > 0 { + real.HargaKg = totalProductionCost / realWeightKg + } + if realPieces > 0 { + real.HargaButir = totalProductionCost / realPieces + } + + return &HppCostResponse{ + Estimation: estimation, + Real: real, + }, nil +} From 78a45b11e78fce15b649ac226cccbe30bacbcbb4 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 22 Jan 2026 13:59:52 +0700 Subject: [PATCH 094/117] fixing pipeline --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aa0dc969..6a4778a3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,6 +9,7 @@ workflow: include: - local: "ci/development.yml" rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == "development"' - local: "ci/staging.yml" From 1ca6c6a104fb9bb8669b5b4abea66049a81658b5 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 22 Jan 2026 14:12:20 +0700 Subject: [PATCH 095/117] Fixing staging cicd --- ci/staging.yml | 122 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/ci/staging.yml b/ci/staging.yml index 511a9eff..e3eaabb0 100644 --- a/ci/staging.yml +++ b/ci/staging.yml @@ -6,31 +6,31 @@ stages: default: tags: - - self-hosted-prod + - self-hosted-stg workflow: rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' when: always - when: never variables: DOCKER_BUILDKIT: "1" - IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - DEPLOY_DIR: "/opt/deploy/lti" + DEPLOY_DIR: "/opt/deploy/stg-lti-api" COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= -build_production: +build_staging: stage: build rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' script: | set -e docker info @@ -49,54 +49,93 @@ build_production: # ========================= -# MIGRATE (PRODUCTION - MANUAL) +# MIGRATE (AUTO) # ========================= -migrate_production: +migrate_staging: stage: migrate rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: manual - allow_failure: false + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' needs: - - job: build_production + - job: build_staging artifacts: false script: | set -e - cd /opt/deploy/lti - test -f .env || (echo "❌ .env not found" && exit 1) + echo "✅ Running migrations (staging) ..." + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + # ✅ load env dari server set -a . ./.env set +a - # Validasi env wajib - : "${DB_HOST:?DB_HOST not set}" - : "${DB_PORT:?DB_PORT not set}" - : "${DB_USER:?DB_USER not set}" - : "${DB_PASSWORD:?DB_PASSWORD not set}" - : "${DB_NAME:?DB_NAME not set}" + # ✅ validasi + test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) + test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) + test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) + test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) + test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) - DB_SSLMODE="${DB_SSLMODE:-require}" - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL=$DATABASE_URL" - echo "✅ Running migrations (production)..." - docker run --rm \ + # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) + echo "✅ Ensuring postgres & redis running ..." + docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true + + # ✅ Ambil network key dari compose + COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" + echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" + + # ✅ Cari network name yang dipakai docker + NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" + test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) + + echo "✅ Docker network detected: $NETWORK_NAME" + + # ✅ Migrations dari repo (CI workspace) + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + + echo "✅ Running migrations via migrate/migrate container" + set +e + out=$(docker run --rm \ + --network "$NETWORK_NAME" \ -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up + -path=/migrations -database "$DATABASE_URL" up 2>&1) + code=$? + set -e + + echo "$out" + + # ✅ Handle no change dengan benar (tidak false-success) + if echo "$out" | grep -qi "no change"; then + echo "✅ No change (already up to date)" + exit 0 + fi + + if [ $code -ne 0 ]; then + echo "❌ Migration failed with exit code $code" + exit $code + fi + + echo "✅ Migration applied successfully" # ========================= # DEPLOY (AUTO) # ========================= -deploy_production: +deploy_staging: stage: deploy rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' needs: - - job: migrate_production + - job: migrate_staging artifacts: false - - job: build_production + - job: build_staging artifacts: false script: | set -e @@ -115,19 +154,20 @@ deploy_production: # ========================= # SEED (MANUAL) # ========================= -seed_production: +seed_staging: stage: seed rules: - - if: '$CI_COMMIT_BRANCH == "production"' - when: manual + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' + needs: + - job: deploy_staging + artifacts: false + when: manual + allow_failure: false script: | set -e - cd /opt/deploy/lti + cd "$DEPLOY_DIR" + test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) test -f .env || (echo "❌ .env not found" && exit 1) - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - docker compose --env-file .env pull seed - docker compose --env-file .env run --rm seed - - + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed% From 87973a6c9f7365551793ec9a6d9f298dad3529f8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 22 Jan 2026 14:15:03 +0700 Subject: [PATCH 096/117] HOTFIX[BE]: update total price calculation based on product flags for delivery and sales orders --- .../salesorder_product.repository.go | 5 +- .../services/deliveryorder.service.go | 52 ++++++++++++++-- .../marketing/services/salesorder.service.go | 59 +++++++++++++++++-- 3 files changed, 106 insertions(+), 10 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_product.repository.go b/internal/modules/marketing/repositories/salesorder_product.repository.go index 4d5eb43f..95003939 100644 --- a/internal/modules/marketing/repositories/salesorder_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_product.repository.go @@ -26,7 +26,10 @@ func NewMarketingProductRepository(db *gorm.DB) MarketingProductRepository { func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) { var products []entity.MarketingProduct - if err := r.DB().WithContext(ctx).Where("marketing_id = ?", marketingID).Find(&products).Error; err != nil { + if err := r.DB().WithContext(ctx). + Preload("ProductWarehouse.Product.Flags"). + Where("marketing_id = ?", marketingID). + Find(&products).Error; err != nil { return nil, err } if len(products) == 0 { diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index a521e5bc..b4e3eea0 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -247,9 +247,27 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - // Hitung total_weight dan total_price otomatis + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { + for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + + // Hitung total_weight dan total_price berdasarkan flag totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * requestedProduct.UnitPrice + } deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -361,9 +379,27 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty - // Hitung total_weight dan total_price otomatis + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { + for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + + // Hitung total_weight dan total_price berdasarkan flag totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight - totalPrice := requestedProduct.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * requestedProduct.UnitPrice + } deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.UnitPrice = requestedProduct.UnitPrice @@ -435,7 +471,13 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor } 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)) + 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 { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index e73184dd..e2cfcabb 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -292,9 +292,35 @@ 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 + // Get product untuk cek flag PAKAN atau OVK + productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) + if err != nil { + return err + } + + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 { + for _, flag := range productWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + + // Hitung total_weight dan total_price berdasarkan flag totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = rp.Qty * rp.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * rp.UnitPrice + } updateBody := map[string]any{ "product_warehouse_id": rp.ProductWarehouseId, @@ -592,9 +618,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 + // Get product untuk cek flag PAKAN atau OVK + productWarehouse, err := s.ProductWarehouseRepo.GetByID(ctx, rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB { + return db.Preload("Product.Flags") + }) + if err != nil { + return err + } + + // Cek apakah product punya flag PAKAN atau OVK + isPakanOrOVK := false + if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 { + for _, flag := range productWarehouse.Product.Flags { + if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { + isPakanOrOVK = true + break + } + } + } + totalWeight := rp.Qty * rp.AvgWeight - totalPrice := rp.UnitPrice * totalWeight + var totalPrice float64 + if isPakanOrOVK { + // PAKAN atau OVK: qty × unit_price + totalPrice = rp.Qty * rp.UnitPrice + } else { + // Produk lain: total_weight × unit_price + totalPrice = totalWeight * rp.UnitPrice + } marketingProduct := &entity.MarketingProduct{ MarketingId: marketingId, From 2e0827dec529f45a260c88bc743da2595791e506 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 14:15:26 +0700 Subject: [PATCH 097/117] [FIX/BE-US] debt-supplier only show receive and changes lunas --- .../repports/services/repport.service.go | 93 +++++++++++++++---- 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 03b1b370..92434b00 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1156,12 +1156,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return nil, 0, err } - references := collectDebtSupplierReferences(purchases) - paymentSummaries, err := s.DebtSupplierRepo.GetPaymentSummariesByReferences(c.Context(), supplierIDs, references) - if err != nil { - return nil, 0, err - } - location, err := time.LoadLocation("Asia/Jakarta") if err != nil { return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration") @@ -1176,6 +1170,16 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance float64 CountTotals bool } + type debtSupplierAllocation struct { + RowIndex int + SortTime time.Time + Amount float64 + Purchase entity.Purchase + } + type paymentAllocation struct { + Date time.Time + Amount float64 + } for _, supplierID := range supplierIDs { supplier, exists := supplierMap[supplierID] @@ -1189,19 +1193,11 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu total := dto.DebtSupplierTotalDTO{} combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) + purchaseAllocations := make([]debtSupplierAllocation, 0, len(items)) for _, purchase := range items { row := buildDebtSupplierRow(purchase, now, location) - if reference := resolveDebtSupplierReference(purchase); reference != "" { - if summary, ok := paymentSummaries[reference]; ok { - if isDebtSupplierPaid(row.TotalPrice, summary.Total) { - row.Status = "Lunas" - if !summary.LatestPaymentDate.IsZero() { - row.Aging = calculateDebtSupplierAging(purchase, summary.LatestPaymentDate, location) - } - } - } - } sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) + rowIndex := len(combinedRows) combinedRows = append(combinedRows, debtSupplierRowItem{ Row: row, SortTime: sortTime, @@ -1209,6 +1205,24 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: -row.TotalPrice, CountTotals: true, }) + purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{ + RowIndex: rowIndex, + SortTime: sortTime, + Amount: row.TotalPrice, + Purchase: purchase, + }) + } + + paymentAllocations := make([]paymentAllocation, 0, len(paymentItems)+1) + initialAllocation := initialBalanceTotals[supplierID] + initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] + paymentCarry := 0.0 + if initialAllocation > 0 && len(purchaseAllocations) > 0 { + paymentAllocations = append(paymentAllocations, paymentAllocation{ + Date: purchaseAllocations[0].SortTime, + Amount: initialAllocation, + }) + } else if initialAllocation < 0 { + paymentCarry = -initialAllocation } for _, payment := range paymentItems { @@ -1221,6 +1235,53 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu DeltaBalance: payment.Nominal, CountTotals: false, }) + paymentAllocations = append(paymentAllocations, paymentAllocation{ + Date: sortTime, + Amount: payment.Nominal, + }) + } + + if len(purchaseAllocations) > 0 && len(paymentAllocations) > 0 { + sort.SliceStable(purchaseAllocations, func(i, j int) bool { + return purchaseAllocations[i].SortTime.Before(purchaseAllocations[j].SortTime) + }) + sort.SliceStable(paymentAllocations, func(i, j int) bool { + return paymentAllocations[i].Date.Before(paymentAllocations[j].Date) + }) + remaining := make([]float64, len(purchaseAllocations)) + for i := range purchaseAllocations { + remaining[i] = purchaseAllocations[i].Amount + } + purchaseIndex := 0 + for _, pay := range paymentAllocations { + amount := pay.Amount + if amount <= 0 { + continue + } + if paymentCarry > 0 { + used := math.Min(amount, paymentCarry) + paymentCarry -= used + amount -= used + } + for amount > 0 && purchaseIndex < len(remaining) { + if remaining[purchaseIndex] <= 0 { + purchaseIndex++ + continue + } + used := math.Min(amount, remaining[purchaseIndex]) + remaining[purchaseIndex] -= used + amount -= used + if remaining[purchaseIndex] <= 0.000001 { + allocation := purchaseAllocations[purchaseIndex] + combinedRows[allocation.RowIndex].Row.Status = "Lunas" + combinedRows[allocation.RowIndex].Row.Aging = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location) + purchaseIndex++ + } + } + if purchaseIndex >= len(remaining) { + break + } + } } sort.SliceStable(combinedRows, func(i, j int) bool { From 6bc5e7d2931f4aca15df93adbe098faa1a261759 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 14:19:16 +0700 Subject: [PATCH 098/117] fix get average bw --- internal/modules/repports/services/repport.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 03b1b370..effec76a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -860,7 +860,7 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa var rows []entity.ProjectFlockKandangUniformity if err := s.DB.WithContext(ctx). Model(&entity.ProjectFlockKandangUniformity{}). - Select("week, uniformity, uniform_date, id"). + Select("week, uniformity, uniform_date, id, chart_data"). Where("project_flock_kandang_id = ?", projectFlockKandangID). Where("week IN ?", weeks). Order("uniform_date DESC"). From 202a8ffc6673a780a21e40ebcf7f73c03a00bbab Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 22 Jan 2026 14:29:56 +0700 Subject: [PATCH 099/117] HOTFIX[BE]: filter closing overhead by expense category "BOP" --- .../expenses/repositories/expense_realization.repository.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 60ec97a7..0ccab661 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -70,7 +70,8 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). - Where("expenses.realization_date IS NOT NULL") + Where("expenses.realization_date IS NOT NULL"). + Where("expenses.category = ?", "BOP") if projectFlockKandangID != nil { db = db.Where(`( From 12ed9cd753fef32429fb54b4d01ac9c48e95c7c4 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 14:44:53 +0700 Subject: [PATCH 100/117] [FIX/BE-US] fix day in recording --- .../recordings/services/recording.service.go | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a5486ab7..c0d0f78b 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -159,14 +159,13 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) ( return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } - db := s.Repository.DB().WithContext(c.Context()) - next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId) + day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, time.Now().UTC()) if err != nil { - s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) + s.Log.Errorf("Failed to compute recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) return 0, err } - return next, nil + return day, nil } func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { @@ -208,6 +207,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } } + day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime) + if err != nil { + return nil, err + } + if !isLaying && len(req.Eggs) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } @@ -221,13 +225,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } var createdRecording entity.Recording transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) - if err != nil { - s.Log.Errorf("Failed to determine recording day: %+v", err) - return err - } if s.ProductionStandardSvc != nil { - if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil { + if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, day); err != nil { return err } } @@ -241,7 +240,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") } - day := nextDay createdRecording = entity.Recording{ ProjectFlockKandangId: req.ProjectFlockKandangId, RecordDatetime: recordTime, @@ -929,6 +927,40 @@ func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, pro return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") } +func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + + var chickinDate time.Time + for _, pop := range populations { + if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() { + continue + } + if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) { + chickinDate = pop.ProjectChickin.ChickInDate + } + } + if chickinDate.IsZero() { + return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan") + } + + chickinDay := time.Date(chickinDate.Year(), chickinDate.Month(), chickinDate.Day(), 0, 0, 0, 0, time.UTC) + recordDay := time.Date(recordTime.Year(), recordTime.Month(), recordTime.Day(), 0, 0, 0, 0, time.UTC) + diff := int(recordDay.Sub(chickinDay).Hours() / 24) + if diff < 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in") + } + + return diff + 1, nil +} + func buildWarehouseDeltas( oldDepletions, newDepletions []entity.RecordingDepletion, oldEggs, newEggs []entity.RecordingEgg, From c085888ca96f25a2370f0db5f0159dde2450d9d3 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Thu, 22 Jan 2026 15:57:16 +0700 Subject: [PATCH 101/117] Update cicd no migrate prod --- ci/production.yml | 66 +++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/ci/production.yml b/ci/production.yml index 511a9eff..16626929 100644 --- a/ci/production.yml +++ b/ci/production.yml @@ -1,6 +1,6 @@ stages: - build - - migrate +# - migrate - deploy - seed @@ -25,7 +25,7 @@ variables: COMPOSE_FILE: "docker-compose.yaml" # ========================= -# BUILD (AUTO) +# BUILD (AUTO) # ========================= build_production: stage: build @@ -51,39 +51,39 @@ build_production: # ========================= # MIGRATE (PRODUCTION - MANUAL) # ========================= -migrate_production: - stage: migrate - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - when: manual - allow_failure: false - needs: - - job: build_production - artifacts: false - script: | - set -e - cd /opt/deploy/lti - test -f .env || (echo "❌ .env not found" && exit 1) +#migrate_production: +# stage: migrate +# rules: +# - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' +# when: manual +# allow_failure: false +# needs: +# - job: build_production +# artifacts: false +# script: | +# set -e +# cd /opt/deploy/lti +# test -f .env || (echo "❌ .env not found" && exit 1) - set -a - . ./.env - set +a +# set -a +# . ./.env +# set +a # Validasi env wajib - : "${DB_HOST:?DB_HOST not set}" - : "${DB_PORT:?DB_PORT not set}" - : "${DB_USER:?DB_USER not set}" - : "${DB_PASSWORD:?DB_PASSWORD not set}" - : "${DB_NAME:?DB_NAME not set}" +# : "${DB_HOST:?DB_HOST not set}" +# : "${DB_PORT:?DB_PORT not set}" +# : "${DB_USER:?DB_USER not set}" +# : "${DB_PASSWORD:?DB_PASSWORD not set}" +# : "${DB_NAME:?DB_NAME not set}" - DB_SSLMODE="${DB_SSLMODE:-require}" - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" +# DB_SSLMODE="${DB_SSLMODE:-require}" +# export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" - echo "✅ Running migrations (production)..." - docker run --rm \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up +# echo "✅ Running migrations (production)..." +# docker run --rm \ +# -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ +# migrate/migrate:v4.15.2 \ +# -path=/migrations -database "$DATABASE_URL" up # ========================= @@ -94,8 +94,8 @@ deploy_production: rules: - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' needs: - - job: migrate_production - artifacts: false +# - job: migrate_production +# artifacts: false - job: build_production artifacts: false script: | @@ -129,5 +129,3 @@ seed_production: docker compose --env-file .env pull seed docker compose --env-file .env run --rm seed - - From 0d585a99a62928df2807f44b2382916d62c99bc7 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 22 Jan 2026 17:37:28 +0700 Subject: [PATCH 102/117] adjust api hpp per kandang and implement common service hpp --- .../repository/common.hpp.repository.go | 7 +- internal/modules/repports/module.go | 4 +- .../hpp_per_kandang.repository.go | 177 +----------------- .../repports/services/repport.service.go | 66 ++++--- 4 files changed, 47 insertions(+), 207 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 6c50708a..fd51e329 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -66,9 +66,9 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) { var total float64 err := r.db.WithContext(ctx). - Table("project_chickin_details AS pcd"). - Select("COALESCE(SUM(pcd.qty * pcd.price), 0)"). - Where("pcd.project_flock_id = ?", projectFlockId). + Table("project_budgets AS pb"). + Select("COALESCE(SUM(pb.qty * pb.price), 0)"). + Where("pb.project_flock_id = ?", projectFlockId). Scan(&total).Error if err != nil { return 0, err @@ -269,6 +269,7 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). + Group("lt.from_project_flock_id"). Scan(&summary).Error if err != nil { return 0, 0, err diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 60345d5b..9a64b806 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -32,6 +32,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * chickinRepository := chickinRepo.NewChickinRepository(db) recordingRepository := recordingRepo.NewRecordingRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) + hppCostRepository := commonRepo.NewHppCostRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) @@ -43,7 +44,8 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * userRepository := rUser.NewUserRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository) + hppSvc := approvalService.NewHppService(hppCostRepository) + repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, hppSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository) userService := sUser.NewUserService(userRepository, validate) RepportRoutes(router, userService, repportService) diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index 1135efbf..a0d96863 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -6,7 +6,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -133,60 +132,6 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) { var rows []HppPerKandangCostRow - purchaseStockableKey := fifo.StockableKeyPurchaseItems.String() - transferStockableKey := fifo.StockableKeyStockTransferIn.String() - - latestApproval := r.db.WithContext(ctx). - Table("approvals AS a"). - Select("a.approvable_id, a.action"). - Joins(` - JOIN ( - SELECT approvable_id, MAX(action_at) AS latest_action_at - FROM approvals - WHERE approvable_type = ? - GROUP BY approvable_id - ) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`, - string(utils.ApprovalWorkflowRecording), - ) - - query := r.db.WithContext(ctx). - Table("recordings AS r"). - Select(` - pfk.id AS project_flock_kandang_id, - COALESCE(SUM(CASE - WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) - ELSE 0 - END), 0) AS feed_cost, - COALESCE(SUM(CASE - WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0) - WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0) - ELSE 0 - END), 0) AS ovk_cost`, - utils.FlagPakan, transferStockableKey, utils.FlagPakan, - utils.FlagOVK, transferStockableKey, utils.FlagOVK). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). - Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). - Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), 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 ?", projectFlockKandangIDs). - Where("r.record_datetime < ?", end). - Where("r.deleted_at IS NULL"). - Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)) - - query = query.Group("pfk.id").Order("pfk.id ASC") - - if err := query.Scan(&rows).Error; err != nil { - return nil, nil, err - } - docRows := make([]struct { ProjectFlockKandangID uint DocCost float64 @@ -262,127 +207,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, } } - budgetRows := make([]struct { - ProjectFlockKandangID uint - BudgetCost float64 - }, 0) - - pfkUsageSub := r.db. - Table("project_chickins AS pc"). - Select(` - pc.project_flock_kandang_id, - SUM(pc.usage_qty) AS kandang_usage_qty`). - Group("pc.project_flock_kandang_id") - - projectUsageSub := r.db. - Table("project_chickins AS pc"). - Select(` - pfk.project_flock_id, - SUM(pc.usage_qty) AS project_usage_qty`). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). - Group("pfk.project_flock_id") - - budgetQuery := r.db.WithContext(ctx). - Table("project_flock_kandangs AS pfk"). - Select(` - pfk.id AS project_flock_kandang_id, - COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id"). - Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub). - Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub). - Where("pfk.id IN (?)", projectFlockKandangIDs). - Group("pfk.id") - // budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs) - - if err := budgetQuery.Scan(&budgetRows).Error; err != nil { - return nil, nil, err - } - - for _, budget := range budgetRows { - entry, ok := costMap[budget.ProjectFlockKandangID] - if !ok { - rows = append(rows, HppPerKandangCostRow{ - ProjectFlockKandangID: budget.ProjectFlockKandangID, - }) - entry = &rows[len(rows)-1] - costMap[budget.ProjectFlockKandangID] = entry - } - entry.BudgetCost += budget.BudgetCost - } - - expenseRows := make([]struct { - ProjectFlockKandangID uint - ExpenseCost float64 - }, 0) - - expenseQuery := r.db.WithContext(ctx). - Table("project_flock_kandangs AS pfk"). - Select(` - pfk.id AS project_flock_kandang_id, - COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id"). - Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id"). - Where("pfk.id IN (?)", projectFlockKandangIDs). - Group("pfk.id") - // expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs) - - if err := expenseQuery.Scan(&expenseRows).Error; err != nil { - return nil, nil, err - } - - for _, exp := range expenseRows { - entry, ok := costMap[exp.ProjectFlockKandangID] - if !ok { - rows = append(rows, HppPerKandangCostRow{ - ProjectFlockKandangID: exp.ProjectFlockKandangID, - }) - entry = &rows[len(rows)-1] - costMap[exp.ProjectFlockKandangID] = entry - } - entry.ExpenseCost += exp.ExpenseCost - } - - feedSuppliers := make([]HppPerKandangSupplierRow, 0) - - feedQuery := r.db.WithContext(ctx). - Table("recordings AS r"). - Select("DISTINCT pfk.id AS project_flock_kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias"). - Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). - Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). - Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id"). - Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive). - Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey). - Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). - Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). - Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - Where("r.record_datetime < ?", end). - Where("r.deleted_at IS NULL") - // feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs) - - if err := feedQuery.Scan(&feedSuppliers).Error; err != nil { - return nil, nil, err - } - - for i := range feedSuppliers { - if _, exists := costMap[feedSuppliers[i].ProjectFlockKandangID]; !exists { - rows = append(rows, HppPerKandangCostRow{ - ProjectFlockKandangID: feedSuppliers[i].ProjectFlockKandangID, - }) - costMap[feedSuppliers[i].ProjectFlockKandangID] = &rows[len(rows)-1] - } - feedSuppliers[i].Category = "FEED" - } - - supplierRows := append(docSuppliers, feedSuppliers...) - - return rows, supplierRows, nil + return rows, docSuppliers, nil } func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) { diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 03b1b370..0073b8ba 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -57,6 +57,7 @@ type repportService struct { ChickinRepo chickinRepo.ProjectChickinRepository RecordingRepo recordingRepo.RecordingRepository ApprovalSvc approvalService.ApprovalService + HppSvc approvalService.HppService PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository DebtSupplierRepo repportRepo.DebtSupplierRepository HppPerKandangRepo repportRepo.HppPerKandangRepository @@ -85,6 +86,7 @@ func NewRepportService( chickinRepo chickinRepo.ProjectChickinRepository, recordingRepo recordingRepo.RecordingRepository, approvalSvc approvalService.ApprovalService, + hppSvc approvalService.HppService, purchaseSupplierRepo repportRepo.PurchaseSupplierRepository, debtSupplierRepo repportRepo.DebtSupplierRepository, hppPerKandangRepo repportRepo.HppPerKandangRepository, @@ -104,6 +106,7 @@ func NewRepportService( ChickinRepo: chickinRepo, RecordingRepo: recordingRepo, ApprovalSvc: approvalSvc, + HppSvc: hppSvc, PurchaseSupplierRepo: purchaseSupplierRepo, DebtSupplierRepo: debtSupplierRepo, HppPerKandangRepo: hppPerKandangRepo, @@ -1512,29 +1515,29 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes return nil, nil, err } - eggMap, err := s.HppPerKandangRepo.GetEggProductionByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) - if err != nil { - return nil, nil, err - } - for pfkID, egg := range eggMap { - if rowIdx, ok := pfkIndex[pfkID]; ok { - repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining - repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining - repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg - repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces - } - } + // eggMap, err := s.HppPerKandangRepo.GetEggProductionByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + // if err != nil { + // return nil, nil, err + // } + // for pfkID, egg := range eggMap { + // if rowIdx, ok := pfkIndex[pfkID]; ok { + // repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining + // repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining + // repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg + // repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces + // } + // } } costMap := make(map[uint]HppCostAggregate, len(costRows)) for _, row := range costRows { costMap[row.ProjectFlockKandangID] = HppCostAggregate{ - FeedCost: row.FeedCost, - OvkCost: row.OvkCost, - DocCost: row.DocCost, - DocQty: row.DocQty, - BudgetCost: row.BudgetCost, - ExpenseCost: row.ExpenseCost, + // FeedCost: row.FeedCost, + // OvkCost: row.OvkCost, + DocCost: row.DocCost, + DocQty: row.DocQty, + // BudgetCost: row.BudgetCost, + // ExpenseCost: row.ExpenseCost, } } @@ -1608,19 +1611,33 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes continue } - eggPiecesFloatRemaining := row.EggProductionPiecesRemaining + var eggPiecesFloatRemaining float64 + var eggRemainingWeightFloatRemaining float64 + var eggTotalPiecesFloat float64 + var eggWeightFloat float64 + eggHpp := 0.0 + if s.HppSvc != nil { + hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &endOfDay) + if err != nil { + return nil, nil, err + } + if hppCost != nil { + eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg + eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir + eggHpp = hppCost.Estimation.HargaKg + eggTotalPiecesFloat = hppCost.Estimation.Butir + eggWeightFloat = hppCost.Estimation.Kg + } + } if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) { eggPiecesFloatRemaining = 0 } - eggTotalPiecesFloat := row.EggProductionTotalPieces if math.IsNaN(eggTotalPiecesFloat) || math.IsInf(eggTotalPiecesFloat, 0) { eggTotalPiecesFloat = 0 } - eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining if math.IsNaN(eggRemainingWeightFloatRemaining) || math.IsInf(eggRemainingWeightFloatRemaining, 0) { eggRemainingWeightFloatRemaining = 0 } - eggWeightFloat := row.EggProductionTotalWeightKg if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) { eggWeightFloat = 0 } @@ -1644,11 +1661,6 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes rangeKey := weightRangeKey{Min: weightMin, Max: weightMax} costEntry := costMap[row.ProjectFlockKandangID] - totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost - eggHpp := 0.0 - if eggWeightFloat > 0 { - eggHpp = (totalCost / eggWeightFloat) / 1000 - } rowEggPieces := int64(math.Round(eggPiecesFloatRemaining)) rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining) From 8c58cc41036760a4a3084a0a67d56fa625f726e2 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 17:49:46 +0700 Subject: [PATCH 103/117] [FIX/BE-US] fix uniformity relation chickin date --- .../production/uniformities/services/uniformity.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 41611ac3..79e4d3e7 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -375,7 +375,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file var latestWeek int if err := s.Repository.DB().WithContext(c.Context()). Model(&entity.ProjectFlockKandangUniformity{}). - Where("project_flock_kandang_id = ? AND deleted_at IS NULL", req.ProjectFlockKandangId). + Where("project_flock_kandang_id = ?", req.ProjectFlockKandangId). Select("COALESCE(MAX(week), 0)"). Scan(&latestWeek).Error; err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") From 9928b4c97065a2aeff70eb5c80d574f5f5bdeb8a Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 22 Jan 2026 17:50:04 +0700 Subject: [PATCH 104/117] [FIX/BE-US] fix uniformity relation chickin date --- .../controllers/projectflock.controller.go | 5 +++ .../dto/projectflock_kandang.dto.go | 3 ++ .../services/projectflock.service.go | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index e82d3af5..8c5a9298 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -287,6 +287,11 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { } else { dtoResult.AvailableQuantity = population } + if chickinDate, err := u.ProjectflockService.GetProjectFlockKandangChickinDate(c, result.Id); err != nil { + return err + } else if chickinDate != nil { + dtoResult.ChickInDate = chickinDate + } if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { return werr } else if warehouse != nil { diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index c18f3f65..39abfe62 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -1,6 +1,8 @@ package dto import ( + "time" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" @@ -38,6 +40,7 @@ type ProjectFlockKandangDTO struct { ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` AvailableQuantity float64 `json:"available_quantity"` Population *float64 `json:"population,omitempty"` + ChickInDate *time.Time `json:"chick_in_date,omitempty"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 05e21894..21925a24 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -6,6 +6,7 @@ import ( "fmt" "strconv" "strings" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -42,6 +43,7 @@ type ProjectflockService interface { DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) + GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) @@ -459,6 +461,35 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr return total, nil } +func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) { + if s.PopulationRepo == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured") + } + if projectFlockKandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } + + populations, err := s.PopulationRepo.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project flock kandang %d: %+v", projectFlockKandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang chick in date") + } + + var earliest *time.Time + for _, pop := range populations { + if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() { + continue + } + chickinDate := pop.ProjectChickin.ChickInDate + if earliest == nil || chickinDate.Before(*earliest) { + copy := chickinDate + earliest = © + } + } + + return earliest, 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) From fb565ef7285fa99346dec2a70ad0250156b38e1e Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 23 Jan 2026 10:01:48 +0700 Subject: [PATCH 105/117] fix hpp harian kandang --- .../repository/common.hpp.repository.go | 2 +- .../hpp_per_kandang.repository.go | 27 ++++++++---------- .../repports/services/repport.service.go | 28 +++++++++---------- 3 files changed, 27 insertions(+), 30 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index fd51e329..37094c16 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -207,7 +207,7 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang } err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0) / 1000 AS total_weight_kg"). + Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg"). Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index a0d96863..03d56fc6 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -23,9 +23,9 @@ type HppPerKandangRow struct { // RemainingChickenBirds float64 // RemainingChickenWeight float64 EggProductionWeightKgRemaining float64 - EggProductionPiecesRemaining float64 - EggProductionTotalWeightKg float64 - EggProductionTotalPieces float64 + // EggProductionPiecesRemaining float64 + // EggProductionTotalWeightKg float64 + // EggProductionTotalPieces float64 } type HppPerKandangCostRow struct { @@ -49,7 +49,7 @@ type HppPerKandangSupplierRow struct { type HppPerKandangRepository interface { GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) - GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) + GetWeightRemainingByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) } type hppPerKandangRepository struct { @@ -210,7 +210,7 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, return rows, docSuppliers, nil } -func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) { +func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) { if len(projectFlockKandangIDs) == 0 { return map[uint]HppPerKandangRow{}, nil } @@ -231,9 +231,9 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c type eggRow struct { ProjectFlockKandangID uint EggProductionWeightKgRemaining float64 - EggProductionPiecesRemaining float64 - EggProductionTotalWeightKg float64 - EggProductionTotalPieces float64 + // EggProductionPiecesRemaining float64 + // EggProductionTotalWeightKg float64 + // EggProductionTotalPieces float64 } eggRows := make([]eggRow, 0) @@ -241,10 +241,7 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, - COALESCE(SUM((re.total_qty - re.total_used) * re.weight / 1000), 0) AS egg_production_weight_kg_remaining, - COALESCE(SUM(re.total_qty - re.total_used), 0) AS egg_production_pieces_remaining, - COALESCE(SUM(re.weight / 1000), 0) AS egg_production_total_weight_kg, - COALESCE(SUM(re.total_qty), 0) AS egg_production_total_pieces`). + COALESCE((SUM(re.weight) / NULLIF(SUM(re.total_qty), 0)) * SUM(re.total_qty - re.total_used), 0) AS egg_production_weight_kg_remaining`). Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval). Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id"). Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs). @@ -262,9 +259,9 @@ func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx c result[row.ProjectFlockKandangID] = HppPerKandangRow{ ProjectFlockKandangID: row.ProjectFlockKandangID, EggProductionWeightKgRemaining: row.EggProductionWeightKgRemaining, - EggProductionPiecesRemaining: row.EggProductionPiecesRemaining, - EggProductionTotalWeightKg: row.EggProductionTotalWeightKg, - EggProductionTotalPieces: row.EggProductionTotalPieces, + // EggProductionPiecesRemaining: row.EggProductionPiecesRemaining, + // EggProductionTotalWeightKg: row.EggProductionTotalWeightKg, + // EggProductionTotalPieces: row.EggProductionTotalPieces, } } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 0073b8ba..ba0e2098 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1515,18 +1515,18 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes return nil, nil, err } - // eggMap, err := s.HppPerKandangRepo.GetEggProductionByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) - // if err != nil { - // return nil, nil, err - // } - // for pfkID, egg := range eggMap { - // if rowIdx, ok := pfkIndex[pfkID]; ok { - // repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining - // repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining - // repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg - // repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces - // } - // } + eggMap, err := s.HppPerKandangRepo.GetWeightRemainingByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs) + if err != nil { + return nil, nil, err + } + for pfkID, egg := range eggMap { + if rowIdx, ok := pfkIndex[pfkID]; ok { + repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining + // repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining + // repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg + // repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces + } + } } costMap := make(map[uint]HppCostAggregate, len(costRows)) @@ -1612,7 +1612,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes } var eggPiecesFloatRemaining float64 - var eggRemainingWeightFloatRemaining float64 + eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining var eggTotalPiecesFloat float64 var eggWeightFloat float64 eggHpp := 0.0 @@ -1622,7 +1622,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes return nil, nil, err } if hppCost != nil { - eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg + // eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir eggHpp = hppCost.Estimation.HargaKg eggTotalPiecesFloat = hppCost.Estimation.Butir From d54911f8b40b2d8d5d1b1a10380ecc94e5c98410 Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 23 Jan 2026 10:29:48 +0700 Subject: [PATCH 106/117] adjust value hpp --- internal/common/service/common.hpp.service.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/common/service/common.hpp.service.go b/internal/common/service/common.hpp.service.go index 1b94e791..44f2dd5f 100644 --- a/internal/common/service/common.hpp.service.go +++ b/internal/common/service/common.hpp.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "math" "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -238,10 +239,10 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p Butir: estimPieces, } if estimWeightKg > 0 { - estimation.HargaKg = totalProductionCost / estimWeightKg + estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg) } if estimPieces > 0 { - estimation.HargaButir = totalProductionCost / estimPieces + estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces) } real := HppCostDetail{ @@ -250,10 +251,10 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p Butir: realPieces, } if realWeightKg > 0 { - real.HargaKg = totalProductionCost / realWeightKg + real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg) } if realPieces > 0 { - real.HargaButir = totalProductionCost / realPieces + real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces) } return &HppCostResponse{ @@ -261,3 +262,7 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p Real: real, }, nil } + +func roundToTwoDecimals(value float64) float64 { + return math.Round(value*100) / 100 +} From 6b4eb758e4cf6726471e46095fefbc1efe65db84 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 23 Jan 2026 10:32:40 +0700 Subject: [PATCH 107/117] FIX[BE]: fixing umur on closing penjualan for penjualan OVK and PAKAN --- .../modules/closings/dto/closingMarketing.dto.go | 15 +++++++++++++-- .../marketing/services/salesorder.service.go | 3 --- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index d725b430..72523b69 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -44,7 +44,12 @@ type PenjualanRealisasiResponseDTO struct { func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { - ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) + productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) + for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags { + productFlags[i] = f.Name + } + + ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags) var product *productDTO.ProductRelationDTO if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { @@ -126,11 +131,17 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua } } -func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) (int, int) { +func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string) (int, int) { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { return 0, 0 } + for _, flag := range productFlags { + if flag == "OVK" || flag == "PAKAN" { + return 0, 0 // + } + } + earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate for _, chickin := range projectFlockKandang.Chickins { if chickin.ChickInDate.Before(earliestChickinDate) { diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index e2cfcabb..dbf99219 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -311,14 +311,11 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } - // Hitung total_weight dan total_price berdasarkan flag totalWeight := rp.Qty * rp.AvgWeight var totalPrice float64 if isPakanOrOVK { - // PAKAN atau OVK: qty × unit_price totalPrice = rp.Qty * rp.UnitPrice } else { - // Produk lain: total_weight × unit_price totalPrice = totalWeight * rp.UnitPrice } From f1787d3375aab16e786c85976dfb909f54fe09d5 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 23 Jan 2026 11:07:11 +0700 Subject: [PATCH 108/117] FIX[BE]: Fix wrong calculation avg sales on report penjualan harian --- internal/modules/repports/dto/repportMarketing.dto.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 336b6576..b12fdfeb 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -190,16 +190,13 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { totalWeightKg += item.TotalWeightKg totalSalesAmount += int64(item.SalesAmount) totalHppAmount += int64(item.HppAmount) - avgSalesPrice += item.SalesPricePerKg } totalHppPricePerKg := float64(0) + if totalWeightKg > 0 { totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg - } - - if len(items) > 0 { - avgSalesPrice = avgSalesPrice / float64(len(items)) + avgSalesPrice = float64(totalSalesAmount) / totalWeightKg } if totalQty > 0 { From 8dc88b97a49319e599c8d794ac8bb3cc8ba2b00e Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 23 Jan 2026 11:43:50 +0700 Subject: [PATCH 109/117] [FIX/BE-US] fix closing counting sapronak --- .../closings/dto/closingSapronak.dto.go | 2 +- .../repositories/closing.repository.go | 119 +++++++++++++++++- .../closings/services/sapronak.service.go | 10 +- 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/internal/modules/closings/dto/closingSapronak.dto.go b/internal/modules/closings/dto/closingSapronak.dto.go index ad66d5a7..81fe7ebd 100644 --- a/internal/modules/closings/dto/closingSapronak.dto.go +++ b/internal/modules/closings/dto/closingSapronak.dto.go @@ -196,7 +196,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin } for idx, item := range group.Items { - productKey := strings.ToUpper(flagKey + "|" + item.ProductName) + productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + item.NoReferensi + "|" + formatDate(item.Tanggal)) baseRow := SapronakCategoryRowDTO{ ID: idx + 1, Date: formatDate(item.Tanggal), diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 120c3e5c..daff5d35 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -32,9 +32,10 @@ type ClosingRepository interface { FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID 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) - FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) } @@ -872,6 +873,74 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con ) } +func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { + if projectFlockKandangID == 0 { + return map[uint][]SapronakDetailRow{}, nil + } + + query := r.withCtx(ctx). + Table("stock_allocations AS sa"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE( + pi.received_date, + st.transfer_date, + lt.transfer_date, + sl.created_at, + pc.chick_in_date, + r.record_datetime + ) AS date, + COALESCE( + po.po_number, + st.movement_number, + lt.transfer_number, + CONCAT('ADJ-', ast.id), + CONCAT('CHICKIN-', pc.id), + CAST(r.id AS TEXT), + '' + ) AS reference, + 0 AS qty_in, + COALESCE(SUM(sa.qty), 0) AS qty_out, + COALESCE(pi.price, p.product_price, 0) AS price + `). + Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()). + Joins("LEFT JOIN recordings r ON r.id = rs.recording_id"). + Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()). + Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id"). + Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()). + Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id"). + Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()). + Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). + Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). + Joins("LEFT JOIN stock_logs sl ON sl.id = ast.stock_log_id"). + Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()). + Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("f.name IN ?", sapronakFlagsAll). + Where(` + (sa.usable_type = ? AND r.project_flock_kandangs_id = ?) + OR + (sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?) + `, + fifo.UsableKeyRecordingStock.String(), projectFlockKandangID, + fifo.UsableKeyProjectChickin.String(), projectFlockKandangID, + ). + Group(` + pw.product_id, p.name, f.name, + pi.received_date, st.transfer_date, lt.transfer_date, sl.created_at, pc.chick_in_date, r.record_datetime, + po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id, + pi.price, p.product_price + `) + + return scanAndGroupDetails(query) +} + func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { return r.withCtx(ctx). Table("purchase_items AS pi"). @@ -1131,7 +1200,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand return incoming, outgoing, nil } -func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { +func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) { query := r.withCtx(ctx). Table("stock_allocations AS sa"). Select(` @@ -1148,15 +1217,55 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, kandangI Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). - Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Where("sa.status = ?", entity.StockAllocationStatusActive). - Where("w.kandang_id = ?", kandangID). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") - return scanAndGroupDetails(query) + sales, err := scanAndGroupDetails(query) + if err != nil { + return nil, err + } + + nonFifoQuery := r.withCtx(ctx). + Table("marketing_delivery_products AS mdp"). + Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(mdp.delivery_date, mdp.created_at) AS date, + COALESCE(m.so_number, '') AS reference, + 0 AS qty_in, + COALESCE(mdp.usage_qty, 0) AS qty_out, + COALESCE(mdp.unit_price, mp.unit_price, 0) AS price + `). + Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id"). + Joins("JOIN marketings m ON m.id = mp.marketing_id"). + Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?", + fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + ). + Where("mdp.usage_qty > 0"). + Where("sa.id IS NULL"). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). + Where("f.name IN ?", sapronakFlagsAll). + Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") + + nonFifoSales, err := scanAndGroupDetails(nonFifoQuery) + if err != nil { + return nil, err + } + + for pid, rows := range nonFifoSales { + sales[pid] = append(sales[pid], rows...) + } + + return sales, nil } func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 930c1bc5..9501cfbc 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -347,6 +347,14 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if err != nil { return nil, nil, 0, 0, err } + usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + if len(usageAllocatedDetails) > 0 { + usageDetailsRows = usageAllocatedDetails + chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{} + } adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err @@ -355,7 +363,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj if err != nil { return nil, nil, 0, 0, err } - salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.KandangId) + salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } From f82ac01e7c54d46e4c6b3b45a6fbe1bff57a53ce Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 23 Jan 2026 12:02:26 +0700 Subject: [PATCH 110/117] [FIX/BE-US] fix recording date --- .../recordings/controllers/recording.controller.go | 13 ++++++++++++- .../recordings/services/recording.service.go | 11 +++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 7edb7b9a..51e3100d 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -3,6 +3,8 @@ package controller import ( "math" "strconv" + "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" @@ -82,7 +84,16 @@ func (u *RecordingController) GetNextDay(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } - nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID)) + recordTime := time.Now().UTC() + if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" { + parsed, err := time.Parse("2006-01-02", recordDate) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format") + } + recordTime = parsed.UTC() + } + + nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime) if err != nil { return err } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index b408995f..d490185d 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -13,8 +13,8 @@ import ( sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" - rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -32,7 +32,7 @@ import ( type RecordingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) - GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint) (int, error) + GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error @@ -160,12 +160,15 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro return recording, nil } -func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (int, error) { +func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) { if projectFlockKandangId == 0 { return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") } - day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, time.Now().UTC()) + if recordTime.IsZero() { + recordTime = time.Now().UTC() + } + day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, recordTime) if err != nil { s.Log.Errorf("Failed to compute recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) return 0, err From f060da1cd3b643cd001e742f242ef46d69d1585f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 23 Jan 2026 12:35:34 +0700 Subject: [PATCH 111/117] FIX[BE]: Integrate StockLogRepository into deliveryOrdersService for stock logging functionality --- internal/modules/marketing/module.go | 4 +- .../services/deliveryorder.service.go | 71 +++++++++++++++---- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index b93c6129..2f8ea4fb 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -16,6 +16,7 @@ import ( rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rShared "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" @@ -32,6 +33,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockLogRepo := rShared.NewStockLogRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) @@ -63,7 +65,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) userService := sUser.NewUserService(userRepo, validate) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index b4e3eea0..7e60662d 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -14,6 +14,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" + rShared "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" @@ -34,6 +35,7 @@ type deliveryOrdersService struct { MarketingRepo marketingRepo.MarketingRepository MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository + StockLogRepo rShared.StockLogRepository ApprovalSvc commonSvc.ApprovalService FifoSvc commonSvc.FifoService } @@ -42,6 +44,7 @@ func NewDeliveryOrdersService( marketingRepo marketingRepo.MarketingRepository, marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, + stockLogRepo rShared.StockLogRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, validate *validator.Validate, @@ -51,6 +54,7 @@ func NewDeliveryOrdersService( MarketingRepo: marketingRepo, MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, + StockLogRepo: stockLogRepo, ApprovalSvc: approvalSvc, FifoSvc: fifoSvc, } @@ -247,7 +251,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery itemDeliveryDate = &parsedDate } - // Cek apakah product punya flag PAKAN atau OVK isPakanOrOVK := false if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { @@ -258,14 +261,13 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery } } - // Hitung total_weight dan total_price berdasarkan flag totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight var totalPrice float64 if isPakanOrOVK { - // PAKAN atau OVK: qty × unit_price + totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice } else { - // Produk lain: total_weight × unit_price + totalPrice = totalWeight * requestedProduct.UnitPrice } @@ -279,7 +281,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery if requestedProduct.Qty > 0 { - if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil { return err } } @@ -327,7 +329,12 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return nil, err } - err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) @@ -390,14 +397,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO } } - // Hitung total_weight dan total_price berdasarkan flag totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight var totalPrice float64 if isPakanOrOVK { - // PAKAN atau OVK: qty × unit_price + totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice } else { - // Produk lain: total_weight × unit_price + totalPrice = totalWeight * requestedProduct.UnitPrice } @@ -412,13 +418,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO if requestedProduct.Qty != oldRequestedQty { if oldRequestedQty > 0 { - if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { + if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, actorID); err != nil { return err } } if requestedProduct.Qty > 0 { - if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { + if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil { return err } } @@ -443,7 +449,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return s.getMarketingWithDeliveries(c, id) } -func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { +func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") } @@ -463,6 +469,20 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + if err == nil && result.UsageQuantity > 0 { + if actorID > 0 { + decreaseLog := &entity.StockLog{ + Decrease: result.UsageQuantity, + LoggableType: string(utils.StockLogTypeMarketing), + LoggableId: deliveryProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + CreatedBy: actorID, + Notes: "", + } + s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil) + } + } + if err != nil { pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) @@ -483,6 +503,19 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } + + if actorID > 0 { + decreaseLog := &entity.StockLog{ + Decrease: requestedQty, + LoggableType: string(utils.StockLogTypeMarketing), + LoggableId: deliveryProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + CreatedBy: actorID, + Notes: "", + } + s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil) + } + return nil } @@ -493,7 +526,7 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor return nil } -func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { +func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, actorID uint) error { if deliveryProduct == nil || deliveryProduct.Id == 0 { return nil } @@ -520,6 +553,18 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return err } + if actorID > 0 && currentUsage > 0 { + increaseLog := &entity.StockLog{ + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeMarketing), + LoggableId: deliveryProduct.Id, + ProductWarehouseId: marketingProduct.ProductWarehouseId, + CreatedBy: actorID, + Notes: "", + } + s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil) + } + if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { return err } From e1ab5a90cbf7028e2123e370112633d0e31fb87f Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 23 Jan 2026 14:20:11 +0700 Subject: [PATCH 112/117] fix value all standard --- internal/modules/closings/services/closing.service.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 372d38fd..02942f44 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -743,6 +743,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week") } + if !isGrowing && currentWeek != 0 { + currentWeek = currentWeek + 17 + } + targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing) if err != nil { s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err) From 43286cead10258d13009b9ef845597061218f297 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 23 Jan 2026 19:45:13 +0700 Subject: [PATCH 113/117] Fix[BE]: fixing transfer to non kandang warehouse --- .../transfers/services/transfer.service.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 86ace0c2..eb096257 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -163,12 +163,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } - projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") - } - if projectFlockKandang.ClosedAt != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) + if destPfkID > 0 { + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") + } + if projectFlockKandang.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) + } } actorID, err := m.ActorIDFromContext(c) @@ -261,7 +263,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - // Set ProjectFlockKandangId hanya jika ada kandang var pfkID *uint if projectFlockKandangID > 0 { pfkID = &projectFlockKandangID @@ -480,7 +481,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID)) } - // Jika warehouse tidak punya kandang_id, return 0 tanpa error if warehouse.KandangId == nil || *warehouse.KandangId == 0 { return 0, nil } From 507d6c42932c69223a9634d5dc41eea5d7196570 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 23 Jan 2026 22:02:19 +0700 Subject: [PATCH 114/117] FEAT[BE]: implement HPP on penjualan harian --- .../repports/services/repport.service.go | 94 ++++--------------- 1 file changed, 17 insertions(+), 77 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 9c976138..2b6ab745 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -218,8 +218,23 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing projectFlockIDMap[projectFlockID] = true category := projectFlockKandang.ProjectFlock.Category - hppPerKg := s.calculateHppPricePerKg(c.Context(), projectFlockID, category) - hppMap[projectFlockID] = hppPerKg + if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryLaying { + if s.HppSvc != nil { + hppCost, err := s.HppSvc.CalculateHppCost(projectFlockID, nil) + if err != nil { + hppMap[projectFlockID] = 0.0 + } else if hppCost != nil { + hppMap[projectFlockID] = hppCost.Real.HargaKg + } else { + hppMap[projectFlockID] = 0.0 + } + } else { + hppMap[projectFlockID] = 0.0 + } + } else { + + hppMap[projectFlockID] = 0.0 + } } } } @@ -228,81 +243,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing return items, total, nil } -func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 { - totalCost := s.getTotalProjectCost(ctx, projectFlockID) - if totalCost == 0 { - return 0 - } - - chickinQty, err := s.ChickinRepo.GetTotalChickinQtyByProjectFlockID(ctx, projectFlockID) - if err != nil { - s.Log.Warnf("HPP calculation: Failed to get chickin qty for project flock ID %d: %v", projectFlockID, err) - } - - depletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(ctx, projectFlockID) - if err != nil { - s.Log.Warnf("HPP calculation: Failed to get depletion for project flock ID %d: %v", projectFlockID, err) - } - - avgWeight, err := s.RecordingRepo.GetLatestAvgWeightByProjectFlockID(ctx, projectFlockID) - if err != nil { - s.Log.Warnf("HPP calculation: Failed to get avg weight for project flock ID %d: %v", projectFlockID, err) - } - - var totalWeight float64 - if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { - totalWeight = (chickinQty - depletion) * avgWeight - } else { - eggWeight, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(ctx, projectFlockID) - if err != nil { - s.Log.Warnf("HPP calculation: Failed to get egg weight for project flock ID %d: %v", projectFlockID, err) - } - totalWeight = (chickinQty-depletion)*avgWeight + eggWeight - } - - if totalWeight == 0 { - return 0 - } - - hppPricePerKg := totalCost / totalWeight - return hppPricePerKg -} - -func (s *repportService) getTotalProjectCost(ctx context.Context, projectFlockID uint) float64 { - if projectFlockID == 0 { - return 0 - } - - purchases, err := s.PurchaseRepo.GetItemsByProjectFlockID(ctx, projectFlockID) - if err != nil { - s.Log.Errorf("getTotalProjectCost: GetItemsByProjectFlockID error for project flock ID %d: %v", projectFlockID, err) - return 0 - } - - cost := float64(0) - purchaseCost := float64(0) - for _, p := range purchases { - purchaseCost += p.TotalPrice - } - cost += purchaseCost - - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(ctx, projectFlockID) - if err != nil { - s.Log.Warnf("getTotalProjectCost: GetByProjectFlockID error for project flock ID %d: %v", projectFlockID, err) - } - - bopCost := float64(0) - for _, r := range realizations { - if r.ExpenseNonstock != nil && r.ExpenseNonstock.Expense != nil && - r.ExpenseNonstock.Expense.Category == string(utils.ExpenseCategoryBOP) { - bopCost += r.Price * r.Qty - } - } - cost += bopCost - - return cost -} - func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err From 699a6e9289a674b2946f836ac38a66c646e315fd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 24 Jan 2026 09:40:00 +0700 Subject: [PATCH 115/117] Fix[BE]: update error message for insufficient stock in adjustment service --- .../inventory/adjustments/services/adjustment.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index c92d059b..bec0ef74 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -167,7 +167,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e newLog.Increase = afterQuantity } else { if productWarehouse.Quantity < req.Quantity { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Current: %.2f, Requested: %.2f", productWarehouse.Quantity, req.Quantity)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) } afterQuantity -= req.Quantity newLog.Decrease = afterQuantity From 74158138c0309f3cc2e4e543f14455c26018b9bd Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Sat, 24 Jan 2026 10:38:52 +0700 Subject: [PATCH 116/117] Fix[BE]: enhance error logging and messages in transfer service --- .../transfers/services/transfer.service.go | 53 ++++++++++++------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index eb096257..ea1041ea 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -124,7 +124,8 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id)) + s.Log.Errorf("Failed to fetch transfer by ID %d: %+v", id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer") } return transferPtr, nil @@ -142,7 +143,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID)) + s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk") } if sourcePW.Quantity < product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty)) @@ -166,7 +168,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if destPfkID > 0 { projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan") + s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") } if projectFlockKandang.ClosedAt != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02"))) @@ -198,7 +201,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) } - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID)) + s.Log.Errorf("Failed to fetch supplier by ID %d: %+v", delivery.SupplierID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data supplier") } if supplier.Category != string(utils.SupplierCategoryBOP) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category)) @@ -207,7 +211,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement transfer") + s.Log.Errorf("Failed to generate movement number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer") } transferDate, _ := utils.ParseDateString(req.TransferDate) @@ -247,14 +252,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID)) } - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID)) + s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal") } destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID)) + s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan") } if errors.Is(err, gorm.ErrRecordNotFound) { ctx := c.Context() @@ -275,7 +282,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques ProjectFlockKandangId: pfkID, } if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID)) + s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan") } } @@ -365,9 +373,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Files: documentFiles, }) if err != nil { - s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", - deliveryIdx+1, delivery.Id, file.Filename) - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err)) + s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v", + deliveryIdx+1, delivery.Id, file.Filename, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen") } } } @@ -393,7 +401,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques "usage_qty": consumeResult.UsageQuantity, "pending_qty": consumeResult.PendingQuantity, }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID)) + s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) @@ -406,7 +415,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Tx: tx, }) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err)) + s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan") } if err := tx.Model(&entity.StockTransferDetail{}). @@ -414,7 +424,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques Updates(map[string]interface{}{ "total_qty": replenishResult.AddedQuantity, }).Error; err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID)) + s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } } @@ -448,7 +459,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques }) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err)) + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error") } result, err := s.GetOne(c, uint(entityTransfer.Id)) @@ -458,7 +472,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if len(expensePayloads) > 0 { if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber)) + s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense") } } @@ -478,7 +493,8 @@ 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)) } - return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID)) + s.Log.Errorf("Failed to fetch warehouse by ID %d: %+v", warehouseID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { @@ -490,7 +506,8 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa if errors.Is(err, gorm.ErrRecordNotFound) { return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId)) } - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock kandang yang aktif") + s.Log.Errorf("Failed to fetch active project flock kandang for kandang_id=%d: %+v", *warehouse.KandangId, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") } return uint(projectFlockKandang.Id), nil From f69321d9cd0c0f549d281273c083975a7605c2db Mon Sep 17 00:00:00 2001 From: giovanni Date: Sat, 24 Jan 2026 11:25:11 +0700 Subject: [PATCH 117/117] fix get egg sales pieces and weight --- .../repository/common.hpp.repository.go | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index 37094c16..c005e24e 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -219,25 +219,53 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang return totals.TotalPieces, totals.TotalWeightKg, nil } -func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { +func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds( + ctx context.Context, + projectFlockKandangIDs []uint, + date *time.Time, +) (float64, float64, error) { + if date == nil { now := time.Now() date = &now } + type subResult struct { + UsableID uint + MdpUsageQty float64 + MdpWeight float64 + } + + subQuery := r.db.WithContext(ctx). + Table("recordings AS r"). + Select(` + DISTINCT sa.usable_id, + mdp.usage_qty AS mdp_usage_qty, + mdp.total_weight AS mdp_weight + `). + Joins("JOIN recording_eggs re ON re.recording_id = r.id"). + Joins( + "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", + fifo.StockableKeyRecordingEgg.String(), + fifo.UsableKeyMarketingDelivery.String(), + ). + Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). + Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). + Where("r.record_datetime <= ?", *date) + var totals struct { TotalPieces float64 TotalWeight float64 } + err := r.db.WithContext(ctx). - Table("recordings AS r"). - Select("COALESCE(SUM(mdp.usage_qty), 0) AS total_pieces, COALESCE(SUM(mdp.total_weight), 0) AS total_weight"). - Joins("JOIN recording_eggs AS re ON re.recording_id = r.id"). - Joins("JOIN stock_allocations AS sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", fifo.StockableKeyRecordingEgg.String(), fifo.UsableKeyMarketingDelivery.String()). - Joins("JOIN marketing_delivery_products AS mdp ON mdp.id = sa.usable_id"). - Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). - Where("r.record_datetime <= ?", *date). + Table("(?) AS x", subQuery). + Select(` + COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces, + COALESCE(SUM(x.mdp_weight), 0) AS total_weight + `). Scan(&totals).Error + if err != nil { return 0, 0, err }