diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index be6f22d7..00000000 Binary files a/.DS_Store and /dev/null differ 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 diff --git a/.gitignore b/.gitignore index 82524f71..4a814ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,8 @@ bin/ Makefile docker-compose.local.yml docker-compose.yaml -Dockerfile.local +Dockerfile +.gitlab-ci.yml # Go build cache .gocache/ vendor @@ -27,3 +28,4 @@ coverage/ .vscode/ .idea/ *.swp +.DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..b0e3883e 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 diff --git a/Dockerfile b/Dockerfile index 87781228..32e0688d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,38 @@ -FROM golang:1.23-alpine +# ========================= +# Builder stage +# ========================= +FROM golang:1.23-alpine AS builder -# Install dependensi dasar -RUN apk add --no-cache git curl bash build-base +RUN apk add --no-cache git ca-certificates tzdata +WORKDIR /app -# Install Air (pakai repo baru air-verse) -RUN go install github.com/air-verse/air@v1.52.3 - -WORKDIR /lti-api - -# Cache dependencies COPY go.mod go.sum ./ RUN go mod download -# Copy source code COPY . . +# Build API binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api + +# Build SEED binary (pastikan cmd/seed ada) +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed + +# ========================= +# Runtime stage +# ========================= +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \ + && adduser -D -H -u 10001 appuser + +WORKDIR /app + +COPY --from=builder /app/lti-api /app/lti-api +COPY --from=builder /app/lti-seed /app/lti-seed + +USER appuser EXPOSE 8081 -CMD ["air", "-c", ".air.toml"] +CMD ["/app/lti-api"] diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index cdc4652d..00000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,77 +0,0 @@ -services: - postgresdb: - image: postgres:alpine - restart: always - ports: - - "${DB_PORT_HOST:-5542}:5432" - environment: - POSTGRES_USER: ${DB_USER:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} - POSTGRES_DB: ${DB_NAME:-db_lti_erp} - volumes: - - dbdata:/var/lib/postgresql/data - - ./internal/database/init:/docker-entrypoint-initdb.d - networks: [go-network] - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}", - ] - interval: 10s - timeout: 5s - retries: 5 - redis: - image: redis:7-alpine - restart: unless-stopped - ports: - - "${REDIS_PORT_HOST:-6381}:6379" - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 5s - timeout: 3s - retries: 10 - networks: [go-network] - - app: - build: - context: . - dockerfile: Dockerfile.local - image: cosmtrek/air:v1.52.3 - working_dir: /lti-api - volumes: - - .:/lti-api - - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key - - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub - command: air -c .air.toml - env_file: - - .env - environment: - DB_HOST: postgresdb - DB_PORT: 5432 - DB_USER: ${DB_USER:-postgres} - DB_PASSWORD: ${DB_PASSWORD:-postgres} - DB_NAME: ${DB_NAME:-db_lti_erp} - REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} - ports: - - "${APP_PORT:-8081}:8081" - depends_on: - postgresdb: - condition: service_healthy - networks: [go-network] - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 10s - -volumes: - dbdata: - go-mod-cache: - go-build-cache: - -networks: - go-network: - name: lti-api_go-network - driver: bridge diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index ab6daeba..00000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,98 +0,0 @@ -services: - dev-api-lti: - build: - context: . - dockerfile: Dockerfile - container_name: dev-api-lti - working_dir: /lti-api - command: ["/bin/sh", "scripts/entrypoint.sh"] - ports: - - "8081:8081" - env_file: - - .env - environment: - # override agar koneksi ke container internal - DB_HOST: dev-postgres-lti - DB_PORT: 5432 - REDIS_URL: redis://dev-redis-lti:6379/0 - volumes: - - .:/lti-api - - ./.air.toml:/lti-api/.air.toml:ro - - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key - - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub - depends_on: - - dev-postgres-lti - - dev-redis-lti - networks: - - lti-network - healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"] - interval: 10s - timeout: 3s - retries: 10 - start_period: 10s - deploy: - resources: - limits: - cpus: "2.0" - memory: 2G - reservations: - cpus: "1.0" - memory: 512M - - dev-postgres-lti: - image: postgres:15-alpine - container_name: dev-postgres-lti - restart: always - env_file: - - credential/.env.db - ports: - - "5433:5432" - volumes: - - dev-postgres-lti-data:/var/lib/postgresql/data - - ./credential:/docker-entrypoint-initdb.d:ro - networks: - - lti-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 5s - deploy: - resources: - limits: - cpus: "1.0" - memory: 2G - reservations: - cpus: "0.5" - memory: 512M - - dev-redis-lti: - image: redis:7-alpine - container_name: dev-redis-lti - restart: always - ports: - - "6380:6379" - networks: - - lti-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 3s - retries: 10 - deploy: - resources: - limits: - cpus: "0.5" - memory: 512M - reservations: - cpus: "0.2" - memory: 256M - -networks: - lti-network: - driver: bridge - -volumes: - dev-postgres-lti-data: diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6ab2d398..a10d6a94 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -78,6 +78,36 @@ func (u *ClosingController) GetOne(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetOverheadByProjectFlockKandang(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") + } + + kandangID := uint(pfkID) + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID), &kandangID) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get overhead by project flock kandang successfully", + Data: result, + }) +} + func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { param := c.Params("projectFlockId") @@ -108,12 +138,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") } - projectFlock, err := u.ClosingService.GetProjectFlockByID(c, uint(projectFlockID)) - if err != nil { - return err - } - - result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID)) + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID), nil) if err != nil { return err } @@ -123,19 +148,60 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Code: fiber.StatusOK, Status: "success", Message: "Get closing penjualan successfully", - Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), + Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), + }) +} + +func (u *ClosingController) GetPenjualanByProjectFlockKandang(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") + } + + kandangID := uint(pfkID) + + result, err := u.ClosingService.GetPenjualan(c, uint(projectFlockID), &kandangID) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get closing penjualan by project flock kandang successfully", + Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result), }) } func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { - param := c.Params("project_flock_id") + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") - projectFlockID, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + projectFlockID, err := strconv.Atoi(projectParam) + if err != nil || projectFlockID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") } - result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID)) + var projectFlockKandangID *uint + if kandangParam != "" { + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + kandangID := uint(pfkID) + projectFlockKandangID = &kandangID + } + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID), projectFlockKandangID) if err != nil { return err } diff --git a/internal/modules/closings/dto/closingKeuangan.dto.go b/internal/modules/closings/dto/closingKeuangan.dto.go index 08bfb5fc..fa99a59d 100644 --- a/internal/modules/closings/dto/closingKeuangan.dto.go +++ b/internal/modules/closings/dto/closingKeuangan.dto.go @@ -79,10 +79,11 @@ type HppGroup struct { } type SummaryHpp struct { - Label string `json:"label"` - Comparison `json:"-"` - EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` - EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` + 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 { @@ -246,11 +247,9 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [ realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) summary := SummaryHpp{ - Label: label, - Comparison: ToComparison( - ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), - ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), - ), + Label: label, + Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), + Realization: ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), } if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 { diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 42d95be2..1a790ad6 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -87,7 +87,7 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { return result } -func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { +func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { return PenjualanRealisasiResponseDTO{ diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 71975da1..4730474a 100644 --- a/internal/modules/closings/dto/closingOverhead.dto.go +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -1,6 +1,8 @@ package dto import ( + "encoding/json" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" ) @@ -69,7 +71,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal return dto } -func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64) OverheadListDTO { +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int, projectFlockKandangCountMap map[uint]int) OverheadListDTO { overheadsByNonstockID := make(map[uint]*OverheadDTO) latestDateByNonstockID := make(map[uint]string) @@ -82,9 +84,20 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex itemName, itemUOM := getItemInfo(budgets[i].Nonstock) overheadsByNonstockID[nonstockID].ItemName = itemName overheadsByNonstockID[nonstockID].UOMName = itemUOM - overheadsByNonstockID[nonstockID].BudgetQuantity = budgets[i].Qty - overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgets[i].Price - overheadsByNonstockID[nonstockID].BudgetTotalAmount = calculateTotal(budgets[i].Qty, budgets[i].Price) + + budgetQty := budgets[i].Qty + budgetPrice := budgets[i].Price + budgetTotal := calculateTotal(budgets[i].Qty, budgets[i].Price) + + // Budget division: per kandang view only + if isPerKandang && totalKandangCount > 0 { + budgetQty = budgetQty / float64(totalKandangCount) + budgetTotal = budgetTotal / float64(totalKandangCount) + } + + overheadsByNonstockID[nonstockID].BudgetQuantity = budgetQty + overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgetPrice + overheadsByNonstockID[nonstockID].BudgetTotalAmount = budgetTotal } for i := range realizations { @@ -97,8 +110,40 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex overheadsByNonstockID[nonstockID] = &OverheadDTO{} } - overheadsByNonstockID[nonstockID].ActualQuantity += realizations[i].Qty - overheadsByNonstockID[nonstockID].ActualTotalAmount += calculateTotal(realizations[i].Qty, realizations[i].Price) + qty := realizations[i].Qty + totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) + + // Farm-level expense division + if realizations[i].ExpenseNonstock.Expense != nil && + realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil { + projectFlockIDs := parseProjectFlockIDsFromJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) + + if len(projectFlockIDs) > 0 { + totalKandangInAllProjects := 0 + for _, pfID := range projectFlockIDs { + if count, exists := projectFlockKandangCountMap[pfID]; exists { + totalKandangInAllProjects += count + } + } + + if totalKandangInAllProjects > 0 { + if isPerKandang { + qty = qty / float64(totalKandangInAllProjects) + totalAmount = totalAmount / float64(totalKandangInAllProjects) + } else { + // Overhead ALL: divide by total kandang then multiply by this project's kandang count + perKandangAmount := totalAmount / float64(totalKandangInAllProjects) + perKandangQty := qty / float64(totalKandangInAllProjects) + + qty = perKandangQty * float64(totalKandangCount) + totalAmount = perKandangAmount * float64(totalKandangCount) + } + } + } + } + + overheadsByNonstockID[nonstockID].ActualQuantity += qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount if overheadsByNonstockID[nonstockID].ItemName == "" { itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) @@ -146,7 +191,26 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex } } -// === Helper Functions === +func parseProjectFlockIDsFromJSON(projectFlockJSON string) []uint { + if projectFlockJSON == "" { + return []uint{} + } + + var projectFlocks []uint + if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { + return []uint{} + } + + return projectFlocks +} + +func countProjectFlocksInJSON(projectFlockJSON string) int { + projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON) + if len(projectFlocks) == 0 { + return 1 + } + return len(projectFlocks) +} func getItemInfo(nonstock *entity.Nonstock) (string, string) { if nonstock != nil && nonstock.Id != 0 { diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index c89e6125..658f1bef 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -37,7 +37,7 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index 79c83c22..1cd4559d 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -23,8 +23,10 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/", m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll) route.Get("/:project_flock_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualan) + route.Get("/:project_flock_id/:project_flock_kandang_id/penjualan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetPenjualanByProjectFlockKandang) route.Get("/:projectFlockId", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSummary) route.Get("/:project_flock_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) + route.Get("/:project_flock_id/:project_flock_kandang_id/overhead", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetOverhead) route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject) route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak) @@ -32,4 +34,5 @@ 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) + } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 245fd24c..f137901d 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "math" "strconv" @@ -32,9 +33,9 @@ import ( type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) - GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) + GetPenjualan(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) - GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) + GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) @@ -46,6 +47,7 @@ type closingService struct { Validate *validator.Validate Repository repository.ClosingRepository ProjectFlockRepo projectflockRepository.ProjectflockRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository MarketingRepo marketingRepository.MarketingRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService @@ -56,12 +58,13 @@ type closingService struct { RecordingRepo recordingRepository.RecordingRepository } -func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, Repository: repo, ProjectFlockRepo: projectFlockRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, MarketingRepo: marketingRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, @@ -129,24 +132,9 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj return projectFlock, nil } -func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) { +func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { - realisasi, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB { - return db. - Preload("MarketingProduct"). - Preload("MarketingProduct.ProductWarehouse"). - Preload("MarketingProduct.ProductWarehouse.Product"). - Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). - Preload("MarketingProduct.ProductWarehouse.Product.Uom"). - Preload("MarketingProduct.ProductWarehouse.Product.Flags"). - Preload("MarketingProduct.ProductWarehouse.Warehouse"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). - Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). - Preload("MarketingProduct.Marketing"). - Preload("MarketingProduct.Marketing.Customer"). - Order("marketing_delivery_products.delivery_date DESC") - }) + realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) if err != nil { return nil, err } @@ -154,16 +142,7 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint) ([]entit return []entity.MarketingDeliveryProduct{}, nil } - filtered := make([]entity.MarketingDeliveryProduct, 0, len(realisasi)) - for _, item := range realisasi { - - if item.UsageQty != 0 || item.TotalWeight != 0 || item.AvgWeight != 0 || - item.UnitPrice != 0 || item.TotalPrice != 0 { - filtered = append(filtered, item) - } - } - - return filtered, nil + return realisasi, nil } func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { @@ -379,35 +358,90 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID return statusProject, statusClosing, nil } -func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) { +func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) { budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err } - realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + realizations, err := s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlockID, projectFlockKandangID) if err != nil { return nil, err } + projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + totalKandangCount := len(projectFlockKandangs) + + // Build kandang count map for farm expense division + projectFlockKandangCountMap := make(map[uint]int) + projectFlockKandangCountMap[projectFlockID] = totalKandangCount + + involvedProjectFlocks := make(map[uint]bool) + for _, realization := range realizations { + if realization.ExpenseNonstock != nil && + realization.ExpenseNonstock.Expense != nil && + realization.ExpenseNonstock.Expense.ProjectFlockId != nil { + var projectFlockIDs []uint + if err := json.Unmarshal([]byte(*realization.ExpenseNonstock.Expense.ProjectFlockId), &projectFlockIDs); err == nil { + for _, pfID := range projectFlockIDs { + if pfID != projectFlockID { + involvedProjectFlocks[pfID] = true + } + } + } + } + } + + for pfID := range involvedProjectFlocks { + if pfKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), pfID); err == nil { + projectFlockKandangCountMap[pfID] = len(pfKandangs) + } + } + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) if err != nil { return nil, err } var totalChickinQty float64 - for _, chickin := range chickins { - totalChickinQty += chickin.UsageQty - } + var totalDepletion float64 - totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) - if err != nil { - s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + if projectFlockKandangID != nil { + for _, chickin := range chickins { + if chickin.ProjectFlockKandangId == *projectFlockKandangID { + totalChickinQty += chickin.UsageQty + } + } + + var depletionResult float64 + err = s.RecordingRepo.DB().WithContext(c.Context()). + Table("recording_depletions"). + Select("COALESCE(SUM(recording_depletions.qty), 0)"). + Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", *projectFlockKandangID). + Scan(&depletionResult).Error + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockKandangID error: %v", err) + } else { + totalDepletion = depletionResult + } + } else { + for _, chickin := range chickins { + totalChickinQty += chickin.UsageQty + } + + totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) + } } totalActualPopulation := totalChickinQty - totalDepletion - result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation) + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap) return &result, nil } diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 8fa0a2c9..b4635b2e 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -98,12 +98,14 @@ func (s dashboardService) buildPerformanceStatistics(ctx context.Context, params endDate := params.PeriodEnd endExclusive := params.PeriodEndExclusive - hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, filter, startDate, endExclusive, endDate, location) + globalStartDate, globalEndDate, globalEndExclusive := currentPeriodDates(location) + + hppCurrent, hppLast, err := s.calculateHppGlobal(ctx, globalStartDate, globalEndExclusive, globalEndDate, location) if err != nil { return nil, err } - sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, filter, endDate, location) + sellingCurrent, sellingLast, err := s.calculateSellingPrice(ctx, globalEndDate, location) if err != nil { return nil, err } @@ -271,15 +273,15 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va weekFeed := weeklyFeedMap[week] actFcr := 0.0 - if weekFeed > 0 { - actFcr = weekEgg / weekFeed + if weekEgg > 0 { + actFcr = weekFeed / weekEgg } cumEgg += weekEgg cumFeed += weekFeed actFcrCum := 0.0 - if cumFeed > 0 { - actFcrCum = cumEgg / cumFeed + if cumEgg > 0 { + actFcrCum = cumFeed / cumEgg } bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{ @@ -357,10 +359,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va }, "fcr": { Series: []dto.DashboardChartSeriesDTO{ - {Id: "act_fcr", Label: "Act. FCR", Unit: "%"}, - {Id: "std_fcr", Label: "STD. FCR", Unit: "%"}, - {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "%"}, - {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "%"}, + {Id: "act_fcr", Label: "Act. FCR", Unit: "kg/kg"}, + {Id: "std_fcr", Label: "STD. FCR", Unit: "kg/kg"}, + {Id: "act_fcr_cum", Label: "Act. FCR Cummulative", Unit: "kg/kg"}, + {Id: "std_fcr_cum", Label: "STD. FCR Cummulative", Unit: "kg/kg"}, }, Dataset: fcrDataset, }, @@ -843,12 +845,12 @@ func percentDelta(current, last float64) float64 { return (current - last) / last } -func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { - totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, filter) +func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) { + totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil) if err != nil { return 0, 0, err } - totalCost, err := s.sumHppCost(ctx, filter, startDate, endExclusive) + totalCost, err := s.sumHppCost(ctx, nil, startDate, endExclusive) if err != nil { return 0, 0, err } @@ -859,11 +861,11 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *valida } lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location) - lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, filter) + lastEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, lastMonthStart, lastMonthEndExclusive, nil) if err != nil { return 0, 0, err } - lastCost, err := s.sumHppCost(ctx, filter, lastMonthStart, lastMonthEndExclusive) + lastCost, err := s.sumHppCost(ctx, nil, lastMonthStart, lastMonthEndExclusive) if err != nil { return 0, 0, err } @@ -876,16 +878,16 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, filter *valida return hppCurrent, hppLast, nil } -func (s dashboardService) calculateSellingPrice(ctx context.Context, filter *validation.DashboardFilter, endDate time.Time, location *time.Location) (float64, float64, error) { +func (s dashboardService) calculateSellingPrice(ctx context.Context, endDate time.Time, location *time.Location) (float64, float64, error) { startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location) currentEndExclusive := endDate.AddDate(0, 0, 1) - currentAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, currentEndExclusive) + currentAvg, err := s.avgSellingPrice(ctx, nil, startPrevMonth, currentEndExclusive) if err != nil { return 0, 0, err } - lastAvg, err := s.avgSellingPrice(ctx, filter, startPrevMonth, endPrevMonthExclusive) + lastAvg, err := s.avgSellingPrice(ctx, nil, startPrevMonth, endPrevMonthExclusive) if err != nil { return 0, 0, err } @@ -935,11 +937,11 @@ func (s dashboardService) fcrValue(ctx context.Context, filter *validation.Dashb } feedUsageGrams := feedUsageToGrams(feedRows) - if feedUsageGrams <= 0 { + if eggWeightGrams <= 0 { return 0, nil } - return eggWeightGrams / feedUsageGrams, nil + return feedUsageGrams / eggWeightGrams, nil } func (s dashboardService) mortalityValue(ctx context.Context, filter *validation.DashboardFilter, startDate, endExclusive time.Time) (float64, error) { @@ -1027,3 +1029,11 @@ func monthRange(t time.Time, location *time.Location) (time.Time, time.Time) { endExclusive := start.AddDate(0, 1, 0) return start, endExclusive } + +func currentPeriodDates(location *time.Location) (time.Time, time.Time, time.Time) { + now := time.Now().In(location) + startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) + endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + endExclusive := endDate.AddDate(0, 0, 1) + return startDate, endDate, endExclusive +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index f1387483..60ec97a7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -15,6 +16,7 @@ type ExpenseRealizationRepository interface { IdExists(ctx context.Context, id uint64) (bool, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) + GetClosingOverhead(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.ExpenseRealization, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) } @@ -55,6 +57,40 @@ func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Conte return realizations, err } +func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.ExpenseRealization, error) { + var realizations []entity.ExpenseRealization + + db := r.DB().WithContext(ctx). + Preload("ExpenseNonstock"). + Preload("ExpenseNonstock.Nonstock"). + Preload("ExpenseNonstock.Nonstock.Uom"). + Preload("ExpenseNonstock.Nonstock.Flags"). + Preload("ExpenseNonstock.Expense"). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + 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") + + if projectFlockKandangID != nil { + db = db.Where(`( + expense_nonstocks.project_flock_kandang_id = ? OR + (expense_nonstocks.kandang_id = (SELECT kandang_id FROM project_flock_kandangs WHERE id = ?) AND + expense_nonstocks.project_flock_kandang_id IS NULL) OR + (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) + )`, *projectFlockKandangID, *projectFlockKandangID, fmt.Sprintf("[%d]", projectFlockID)) + } else { + db = db.Where(`( + project_flock_kandangs.project_flock_id = ? OR + kandangs.id IN (SELECT kandang_id FROM project_flock_kandangs WHERE project_flock_id = ?) OR + (expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb) + )`, projectFlockID, projectFlockID, fmt.Sprintf("[%d]", projectFlockID)) + } + + err := db.Find(&realizations).Error + return realizations, err +} + func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) { var realizations []entity.ExpenseRealization var total int64 diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 04051009..f14988b1 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -14,6 +14,7 @@ import ( type MarketingDeliveryProductRepository interface { repository.BaseRepository[entity.MarketingDeliveryProduct] GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) + GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) @@ -53,6 +54,43 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo return deliveryProducts, nil } +func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + + db := r.DB().WithContext(ctx). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("marketing_delivery_products.delivery_date IS NOT NULL"). + Distinct("marketing_delivery_products.*") + + if projectFlockKandangID != nil { + db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID) + } + + db = db. + Preload("MarketingProduct"). + Preload("MarketingProduct.ProductWarehouse"). + Preload("MarketingProduct.ProductWarehouse.Product"). + Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory"). + Preload("MarketingProduct.ProductWarehouse.Product.Uom"). + Preload("MarketingProduct.ProductWarehouse.Product.Flags"). + Preload("MarketingProduct.ProductWarehouse.Warehouse"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Kandang"). + Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins"). + Preload("MarketingProduct.Marketing"). + Preload("MarketingProduct.Marketing.Customer"). + Order("marketing_delivery_products.delivery_date DESC") + + if err := db.Find(&deliveryProducts).Error; err != nil { + return nil, err + } + + return deliveryProducts, nil +} + func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) { var deliveryProducts []entity.MarketingDeliveryProduct @@ -99,13 +137,14 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock") }). Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). - Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id") + 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 != "" { + 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") } - if filters.ProductId > 0 || filters.Search != "" { + if filters.ProductId > 0 || filters.Search != "" || filters.MarketingType != "" { db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") } @@ -139,6 +178,29 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) } + 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") + + switch filters.MarketingType { + 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{ + string(utils.FlagTelur), string(utils.FlagTelurUtuh), string(utils.FlagTelurPecah), + string(utils.FlagTelurPutih), string(utils.FlagTelurRetak), + }) + case "trading": + db = db.Where("flags.name IN (?)", []string{ + string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia), + string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher), + }) + } + } + if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { if filters.FilterBy == "so_date" { if filters.StartDate != "" { diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 22ff4acf..1d273af1 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -82,6 +82,7 @@ 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)), + MarketingType: ctx.Query("marketing_type", ""), FilterBy: ctx.Query("filter_by", ""), StartDate: ctx.Query("start_date", ""), EndDate: ctx.Query("end_date", ""), diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 90c2fe50..36df7a05 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -1,12 +1,16 @@ package dto import ( + "encoding/json" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" marketingDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -22,7 +26,7 @@ type RepportMarketingItemDTO struct { DoNumber string `json:"do_number"` Sales *userDTO.UserRelationDTO `json:"sales,omitempty"` VehicleNumber string `json:"vehicle_number"` - Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + Product *ProductRelationDTOFixed `json:"product,omitempty"` MarketingType string `json:"marketing_type"` Qty float64 `json:"qty"` AverageWeightKg float64 `json:"average_weight_kg"` @@ -46,6 +50,12 @@ type RepportMarketingResponseDTO struct { 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 @@ -106,7 +116,7 @@ func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerK if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 { mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) - item.Product = &mapped + item.Product = newProductRelationDTOFixedPtr(&mapped) } return item @@ -139,7 +149,7 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct } func getMarketingType(mdp entity.MarketingDeliveryProduct) string { - hasAyam, hasTelur := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) + hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) if hasAyam { return "ayam" @@ -147,12 +157,15 @@ func getMarketingType(mdp entity.MarketingDeliveryProduct) string { if hasTelur { return "telur" } - return "trading" + if hasTrading { + return "trading" + } + return "trading" // default to trading if no flags found } -func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur bool) { +func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) { if len(flags) == 0 { - return false, false + return false, false, false } for _, flag := range flags { @@ -167,13 +180,18 @@ func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur bool) { 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 + return hasAyam, hasTelur, hasTrading } func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool { - hasAyam, hasTelur := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) + hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags) if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing { return hasAyam @@ -259,3 +277,39 @@ func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPr Total: total, } } + +func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { + if original == nil { + return nil + } + fixed := ProductRelationDTOFixed{ + ProductRelationDTO: *original, + ProductPrice: original.ProductPrice, + SellingPrice: original.SellingPrice, + } + return &fixed +} + +func (p ProductRelationDTOFixed) MarshalJSON() ([]byte, error) { + type Alias struct { + Id uint `json:"id"` + Name string `json:"name"` + ProductPrice float64 `json:"product_price"` + SellingPrice *float64 `json:"selling_price"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flags *[]string `json:"flags,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + } + + return json.Marshal(&Alias{ + Id: p.ProductRelationDTO.Id, + Name: p.ProductRelationDTO.Name, + ProductPrice: p.ProductPrice, + SellingPrice: p.SellingPrice, + Uom: p.ProductRelationDTO.Uom, + Flags: p.ProductRelationDTO.Flags, + ProductCategory: p.ProductRelationDTO.ProductCategory, + Suppliers: p.ProductRelationDTO.Suppliers, + }) +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 5b60a31f..5dde8f51 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -23,6 +23,7 @@ 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"` + 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"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`