From b6a60d50093313292b8b7199077f7118ed1b232d Mon Sep 17 00:00:00 2001 From: GitLab Deploy Bot Date: Mon, 15 Dec 2025 09:25:50 +0700 Subject: [PATCH 01/20] remove --- .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 0c534172..00000000 --- a/.air.toml +++ /dev/null @@ -1,13 +0,0 @@ -# .air.toml -root = "." -tmp_dir = "tmp" - -[build] -cmd = "go build -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 1e9fdd2b0da1d1541baca8174668a7512d54b9bc Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:41:04 +0000 Subject: [PATCH 02/20] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 127 +++++++++++++++++++++---------------------------- 1 file changed, 54 insertions(+), 73 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 53f28b3e..62acf585 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,90 +1,71 @@ stages: + - build - deploy -deploy-dev: +variables: + DOCKER_BUILDKIT: "1" + DOCKER_DRIVER: overlay2 + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + + IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" + IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" + IMAGE_LATEST_STG_EC2: "${CI_REGISTRY_IMAGE}:staging_latest" + +build:staging: + stage: build + image: docker:27.0.3 + services: + - name: docker:27.0.3-dind + command: ["--mtu=1460"] + rules: + - if: '$CI_COMMIT_BRANCH == "staging"' + before_script: + - docker info + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + script: + - docker build -t "$IMAGE_NAME" -f Dockerfile . + - docker push "$IMAGE_NAME" + - docker tag "$IMAGE_NAME" "$IMAGE_LATEST_STG_EC2" + - docker push "$IMAGE_LATEST_STG_EC2" + +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_COMMIT_BRANCH == "staging"' + needs: + - job: build:staging before_script: - - echo "🧰 Installing dependencies..." - - apk update && apk add --no-cache openssh git curl bash - - # Setup SSH di runner + - apk add --no-cache openssh-client bash ca-certificates - mkdir -p ~/.ssh - - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 700 ~/.ssh + + # SSH_PRIVATE_KEY = multiline private key (bukan File) + - printf "%s\n" "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - sed -i 's/\r$//' ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa + + - head -n 1 ~/.ssh/id_rsa + - tail -n 1 ~/.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 + ssh "$SERVER_USER@$SERVER_IP" + "export CI_REGISTRY_USER='$CI_REGISTRY_USER'; + export CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD'; + export CI_REGISTRY='$CI_REGISTRY'; + set -e; + cd /home/ubuntu/docker/deployment/staging/stg-lti-api; + echo \"\$CI_REGISTRY_PASSWORD\" | docker login -u \"\$CI_REGISTRY_USER\" --password-stdin \"\$CI_REGISTRY\"; + docker compose pull; + docker compose up -d; + docker image prune -f" environment: - name: development \ No newline at end of file + name: staging \ No newline at end of file From 81f4a5e33ed35f886b0d3f29f2bd8dc2512c8a7c Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:50:22 +0000 Subject: [PATCH 03/20] Delete docker-compose.local.yml --- docker-compose.local.yml | 77 ---------------------------------------- 1 file changed, 77 deletions(-) delete mode 100644 docker-compose.local.yml 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 From e738a97e4c4ba8b6dba9c4ec42219799ea388bce Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:50:41 +0000 Subject: [PATCH 04/20] Delete docker-compose.yaml --- docker-compose.yaml | 98 --------------------------------------------- 1 file changed, 98 deletions(-) delete mode 100644 docker-compose.yaml 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: From 30231fabe9ffb77b3f0b582bcea3db7266aa1bae Mon Sep 17 00:00:00 2001 From: kris Date: Thu, 18 Dec 2025 06:51:52 +0000 Subject: [PATCH 05/20] Edit Dockerfile --- Dockerfile | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 87781228..abe12eb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,35 @@ -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 binary dari cmd/api +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api + +# ========================= +# Runtime stage +# ========================= +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata curl \ + && adduser -D -H -u 10001 appuser + +WORKDIR /app + +COPY --from=builder /app/lti-api /app/lti-api + +USER appuser + +# Samakan dengan APP_PORT default kamu (8081) EXPOSE 8081 -CMD ["air", "-c", ".air.toml"] +CMD ["/app/lti-api"] \ No newline at end of file From f8aee4be7bdde9d8b7003fd301ac9096eb62cf33 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Fri, 9 Jan 2026 10:58:11 +0700 Subject: [PATCH 06/20] penyesuaian flow cicid --- .DS_Store | Bin 6148 -> 0 bytes .gitlab-ci.yml | 180 +++++++++++++++++++++++++++++++++++-------------- Dockerfile | 13 ++-- 3 files changed, 137 insertions(+), 56 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4c14efd89e4d913a63e6242a245ab626c5fffe6d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~&2H2%5XZ;c6-vv8$_)-k;RRMIPY_nwLk~!Zl@Q0W$&ywin^nI=Ipx9$A;b&t z3Oq*p0vve|PVk@c?ADIkazhAtB>SJ(GyeFk9j}SVj8DoPqHQ8dkXVOX$gVK1=M>mL zOCCavv@xP%YN?@mw+_5xK_n0f{A&bw?{3nFUef^`Lf8AZEoOB)LoGfH<`H!COH3wk z7oH_{dO>e#j<^G=Xo2@bn(x+L8to5)^ibm-crqyCn4f5V_4gYlRq*lI1bV7|kH9_b4CP1~oBIHH206rWfF0f!B z2L3B3IFE2>^&0c>eA>Ip#|8D|{i_wIpsl98M0S&(2XD}!ON{Forp7zx4MrHOefowS zch6ZBoAiPvSOq!aCIY>W9Q)IY;9mZ0%m|j;wi@$DAQFfK)&%(bkRq{Ws-0E&bRd%} z0Cb3AF+B5Kf@4zEOtrI$8kn)6P#bFWieYRx%2V~rR6DCSoQz&RjP`8whGL|5w4aJQ znapa;BY{Z3C$M2(xB2{^Tz~)fgW_8x5DENO1k5m>=3`7r&(>gaeAcE&dq`~T*IAVi jWO^LS10Tf?ki<}z@&hze?X02%7XJtchL}YHf0e**B}4%m diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 62acf585..60e132fd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,71 +1,149 @@ 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" - DOCKER_DRIVER: overlay2 - DOCKER_HOST: tcp://docker:2375 - DOCKER_TLS_CERTDIR: "" - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST_STG_EC2: "${CI_REGISTRY_IMAGE}:staging_latest" + IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" + DEPLOY_DIR: "/opt/deploy/stg-lti-api" -build:staging: +# ========================= +# BUILD (AUTO) +# ========================= +build_staging: stage: build - image: docker:27.0.3 - services: - - name: docker:27.0.3-dind - command: ["--mtu=1460"] rules: - - if: '$CI_COMMIT_BRANCH == "staging"' - before_script: - - docker info - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - script: - - docker build -t "$IMAGE_NAME" -f Dockerfile . - - docker push "$IMAGE_NAME" - - docker tag "$IMAGE_NAME" "$IMAGE_LATEST_STG_EC2" - - docker push "$IMAGE_LATEST_STG_EC2" + - 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" -deploy:staging: - stage: deploy - image: alpine:3.20 + 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) - migrations diambil dari repo GitLab +# ========================= +migrate_staging: + stage: migrate rules: - - if: '$CI_COMMIT_BRANCH == "staging"' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' needs: - - job: build:staging + - job: build_staging + artifacts: false + script: | + set -e - before_script: - - apk add --no-cache openssh-client bash ca-certificates - - mkdir -p ~/.ssh - - chmod 700 ~/.ssh + # ✅ Load env dari server (.env hanya ada di server) + cd "$DEPLOY_DIR" + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + set -a + . ./.env + set +a - # SSH_PRIVATE_KEY = multiline private key (bukan File) - - printf "%s\n" "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa - - sed -i 's/\r$//' ~/.ssh/id_rsa - - chmod 600 ~/.ssh/id_rsa + # ✅ Generate DATABASE_URL dari DB_* + 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) - - head -n 1 ~/.ssh/id_rsa - - tail -n 1 ~/.ssh/id_rsa + export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" + echo "✅ DATABASE_URL ready" - - eval "$(ssh-agent -s)" - - ssh-add ~/.ssh/id_rsa - - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + # ✅ migrations dari repo + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - script: - - > - ssh "$SERVER_USER@$SERVER_IP" - "export CI_REGISTRY_USER='$CI_REGISTRY_USER'; - export CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD'; - export CI_REGISTRY='$CI_REGISTRY'; - set -e; - cd /home/ubuntu/docker/deployment/staging/stg-lti-api; - echo \"\$CI_REGISTRY_PASSWORD\" | docker login -u \"\$CI_REGISTRY_USER\" --password-stdin \"\$CI_REGISTRY\"; - docker compose pull; - docker compose up -d; - docker image prune -f" + echo "✅ Running migrations via migrate/migrate container" + set +e + docker run --rm \ + -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ + migrate/migrate:v4.15.2 \ + -path=/migrations -database "$DATABASE_URL" up + code=$? + set -e - environment: - name: staging \ No newline at end of file + if [ $code -eq 0 ]; then + echo "✅ Migration applied successfully" + elif [ $code -eq 1 ]; then + echo "✅ No change (already up to date)" + else + echo "❌ Migration failed with exit code $code" + exit $code + fi + +# ========================= +# DEPLOY (AUTO) +# ========================= +deploy_staging: + 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" + + cd "$DEPLOY_DIR" + test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + docker compose pull + docker compose up -d --force-recreate + docker image prune -f + +# ========================= +# 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 docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) + test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + + echo "✅ Pull latest seed image" + docker compose pull seed || true + + echo "🌱 Running seeder..." + docker compose run --rm seed + + echo "✅ Seed completed" diff --git a/Dockerfile b/Dockerfile index abe12eb9..32e0688d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,25 +11,28 @@ RUN go mod download COPY . . -# Build binary dari cmd/api +# 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 \ +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 - -# Samakan dengan APP_PORT default kamu (8081) EXPOSE 8081 -CMD ["/app/lti-api"] \ No newline at end of file +CMD ["/app/lti-api"] From 29933a5df9f0003d7708f7c3b85f9e8579c67b5c Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Fri, 9 Jan 2026 11:17:49 +0700 Subject: [PATCH 07/20] change cicd --- .gitlab-ci.yml | 169 ++++++++++++++++++++++++++++--------------------- 1 file changed, 97 insertions(+), 72 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 60e132fd..65df90e6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,10 +16,13 @@ workflow: 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" + + DEPLOY_DIR: "/opt/deploy/stg-lti-api" + COMPOSE_FILE: "docker-compose.yaml" # ========================= # BUILD (AUTO) @@ -28,23 +31,23 @@ 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" + 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 "✅ Build image: $IMAGE_NAME" + - docker build -t "$IMAGE_NAME" -f Dockerfile . - echo "✅ Push image: $IMAGE_NAME" - docker push "$IMAGE_NAME" + - echo "✅ Push image: $IMAGE_NAME" + - docker push "$IMAGE_NAME" - echo "✅ Tag latest: $IMAGE_LATEST" - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - docker push "$IMAGE_LATEST" + - echo "✅ Tag latest: $IMAGE_LATEST" + - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" + - docker push "$IMAGE_LATEST" # ========================= -# MIGRATE (AUTO) - migrations diambil dari repo GitLab +# MIGRATE (AUTO) - JOIN COMPOSE NETWORK # ========================= migrate_staging: stage: migrate @@ -53,47 +56,76 @@ migrate_staging: needs: - job: build_staging artifacts: false - script: | - set -e + script: + - set -e + - echo "✅ Running migrations (staging) ..." - # ✅ Load env dari server (.env hanya ada di server) - cd "$DEPLOY_DIR" - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - set -a - . ./.env - set +a + # ✅ masuk deploy dir (ada .env + docker-compose) + - 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) - # ✅ Generate DATABASE_URL dari DB_* - 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) + # ✅ load env dari server + - set -a + - . ./.env + - set +a - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL ready" + # ✅ pastikan DB env ada + - 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) - # ✅ migrations dari repo - echo "✅ Checking migrations from repo..." - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + # ✅ generate DATABASE_URL + - 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 via migrate/migrate container" - set +e - docker run --rm \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up - code=$? - set -e + # ✅ pastikan postgres container hidup supaya network exist + - echo "✅ Ensuring postgres & redis running ..." + - docker compose -f "$COMPOSE_FILE" up -d postgres-sso redis-sso || true + # NOTE: ganti postgres-sso/redis-sso sesuai nama service di docker-compose lti kamu: + # kalau lti compose pakai stg-postgres-lti / stg-redis-lti, ganti di line ini. + + # ✅ ambil network name compose (1st network) + - export COMPOSE_NETWORK="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" + - echo "✅ Compose network key: $COMPOSE_NETWORK" + + # ✅ ambil nama network aktual di docker (prefix foldername_) + - export NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK}$" | head -n 1)" + - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK)" && 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" + + # ✅ run migrate (JOIN NETWORK) + - 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 properly + - | + 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 - if [ $code -eq 0 ]; then echo "✅ Migration applied successfully" - elif [ $code -eq 1 ]; then - echo "✅ No change (already up to date)" - else - echo "❌ Migration failed with exit code $code" - exit $code - fi # ========================= # DEPLOY (AUTO) @@ -107,22 +139,21 @@ deploy_staging: artifacts: false - job: build_staging artifacts: false - script: | - set -e + script: + - set -e + - docker info + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - 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) - cd "$DEPLOY_DIR" - test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - docker compose pull - docker compose up -d --force-recreate - docker image prune -f + - docker compose -f "$COMPOSE_FILE" pull + - docker compose -f "$COMPOSE_FILE" up -d --force-recreate + - docker image prune -f # ========================= -# SEED (MANUAL) +# SEED (MANUAL) - OPTIONAL # ========================= seed_staging: stage: seed @@ -133,17 +164,11 @@ seed_staging: artifacts: false when: manual allow_failure: false - script: | - set -e + 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) - cd "$DEPLOY_DIR" - test -f docker-compose.yaml || (echo "❌ docker-compose.yaml not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - echo "✅ Pull latest seed image" - docker compose pull seed || true - - echo "🌱 Running seeder..." - docker compose run --rm seed - - echo "✅ Seed completed" + - docker compose -f "$COMPOSE_FILE" pull seed || true + - docker compose -f "$COMPOSE_FILE" run --rm seed From b7a3882f20ff2e9e3e270bf5fa3c7d4262e09f2a Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 9 Jan 2026 04:19:24 +0000 Subject: [PATCH 08/20] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 171 ++++++++++++++++++++++++------------------------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 65df90e6..637677a4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,7 +21,7 @@ variables: IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - DEPLOY_DIR: "/opt/deploy/stg-lti-api" + DEPLOY_DIR: "/opt/deploy/stg-lti-api" COMPOSE_FILE: "docker-compose.yaml" # ========================= @@ -31,20 +31,22 @@ 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" + script: | + set -e + docker info - - echo "✅ Build image: $IMAGE_NAME" - - docker build -t "$IMAGE_NAME" -f Dockerfile . + echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - echo "✅ Push image: $IMAGE_NAME" - - docker push "$IMAGE_NAME" + 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" - - echo "✅ Tag latest: $IMAGE_LATEST" - - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - - docker push "$IMAGE_LATEST" # ========================= # MIGRATE (AUTO) - JOIN COMPOSE NETWORK @@ -56,76 +58,72 @@ migrate_staging: needs: - job: build_staging artifacts: false - script: - - set -e - - echo "✅ Running migrations (staging) ..." + script: | + set -e + echo "✅ Running migrations (staging) ..." - # ✅ masuk deploy dir (ada .env + docker-compose) - - 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) + 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 + set -a + . ./.env + set +a - # ✅ pastikan DB env ada - - 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 + 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) - # ✅ generate DATABASE_URL - - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - - echo "✅ DATABASE_URL=$DATABASE_URL" + 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 container hidup supaya network exist - - echo "✅ Ensuring postgres & redis running ..." - - docker compose -f "$COMPOSE_FILE" up -d postgres-sso redis-sso || true - # NOTE: ganti postgres-sso/redis-sso sesuai nama service di docker-compose lti kamu: - # kalau lti compose pakai stg-postgres-lti / stg-redis-lti, ganti di line ini. + # ✅ 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 name compose (1st network) - - export COMPOSE_NETWORK="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - - echo "✅ Compose network key: $COMPOSE_NETWORK" + # ✅ 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" - # ✅ ambil nama network aktual di docker (prefix foldername_) - - export NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK}$" | head -n 1)" - - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK)" && exit 1) - - echo "✅ Docker network detected: $NETWORK_NAME" + # ✅ 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) - # ✅ migrations dari repo (CI workspace) - - echo "✅ Checking migrations from repo..." - - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" + echo "✅ Docker network detected: $NETWORK_NAME" - # ✅ run migrate (JOIN NETWORK) - - 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 + # ✅ Migrations dari repo (CI workspace) + echo "✅ Checking migrations from repo..." + ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - - echo "$out" + 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 - # ✅ handle no change properly - - | - if echo "$out" | grep -qi "no change"; then - echo "✅ No change (already up to date)" - exit 0 - fi + echo "$out" - if [ $code -ne 0 ]; then - echo "❌ Migration failed with exit code $code" - exit $code - fi + # ✅ 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" - echo "✅ Migration applied successfully" # ========================= # DEPLOY (AUTO) @@ -139,21 +137,22 @@ deploy_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" + 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) + 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 - - docker compose -f "$COMPOSE_FILE" pull - - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - - docker image prune -f # ========================= -# SEED (MANUAL) - OPTIONAL +# SEED (MANUAL) # ========================= seed_staging: stage: seed @@ -164,11 +163,11 @@ seed_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: | + 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) - - docker compose -f "$COMPOSE_FILE" pull seed || true - - docker compose -f "$COMPOSE_FILE" run --rm seed + docker compose -f "$COMPOSE_FILE" pull seed || true + docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file From 3d76854273ce239602c5ea589d3fa9f36ea0499c Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 9 Jan 2026 04:27:45 +0000 Subject: [PATCH 09/20] 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 637677a4..a46bb3aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -49,7 +49,7 @@ build_staging: # ========================= -# MIGRATE (AUTO) - JOIN COMPOSE NETWORK +# MIGRATE (AUTO) # ========================= migrate_staging: stage: migrate From d33119661afbfa8c4d430cb5ec071878a37bf935 Mon Sep 17 00:00:00 2001 From: kris Date: Fri, 9 Jan 2026 08:37:55 +0000 Subject: [PATCH 10/20] 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..b0e3883e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -126,7 +126,7 @@ migrate_staging: # ========================= -# DEPLOY (AUTO) +# DEPLOY (AUTO) # ========================= deploy_staging: stage: deploy From e2d352721cb803203c4ec22c04bc265e6cc64ae0 Mon Sep 17 00:00:00 2001 From: M1 AIR Date: Mon, 12 Jan 2026 11:15:59 +0700 Subject: [PATCH 11/20] Merge from development --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d6a26e97..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 From 6a166ceb8601856c6cbd073d0920553b707b3fb8 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 00:15:48 +0700 Subject: [PATCH 12/20] [FIX/BE-US-390] dashboard statistic hpp global and avg seling price not refrence to filter --- .../dashboards/services/dashboard.service.go | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 8fa0a2c9..f929cc7b 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 } @@ -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 } @@ -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 +} From 4bd8319e3b2ce563b419a1e562e97ca4d6914174 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 10:16:28 +0700 Subject: [PATCH 13/20] FIX[BE] : fixing typografical error on report marketing --- .../repports/dto/repportMarketing.dto.go | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 90c2fe50..3f133674 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 @@ -259,3 +269,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, + }) +} From f7a392be528e53a49c6cb7658bc9c69ab5b22bbb Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 11:37:23 +0700 Subject: [PATCH 14/20] feat[BE]: add GetPenjualanByProjectFlockKandang endpoint and update related services --- .../controllers/closing.controller.go | 39 +++++++++++++++---- .../closings/dto/closingMarketing.dto.go | 2 +- internal/modules/closings/route.go | 2 + .../closings/services/closing.service.go | 32 ++------------- .../salesorder_delivery_product.repository.go | 38 ++++++++++++++++++ 5 files changed, 77 insertions(+), 36 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6ab2d398..1a472f03 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -108,12 +108,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,7 +118,37 @@ 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), }) } 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/route.go b/internal/modules/closings/route.go index 79c83c22..a9d25758 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -23,6 +23,7 @@ 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/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang) @@ -32,4 +33,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..6c682b9c 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -32,7 +32,7 @@ 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) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) @@ -129,24 +129,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 +139,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) { diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index 04051009..bb1343a2 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 From 3c10866208e2cbf2f1dfca047e39900aba7aaa8b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 13:20:06 +0700 Subject: [PATCH 15/20] feat[BE]: add GetOverheadByProjectFlockKandang endpoint and update related services --- .../controllers/closing.controller.go | 51 +++++++++++++++-- .../closings/dto/closingKeuangan.dto.go | 17 +++--- .../closings/dto/closingOverhead.dto.go | 55 ++++++++++++++++-- internal/modules/closings/module.go | 2 +- internal/modules/closings/route.go | 1 + .../closings/services/closing.service.go | 56 +++++++++++++++---- .../expense_realization.repository.go | 40 +++++++++++++ 7 files changed, 190 insertions(+), 32 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 1a472f03..c4ef4585 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") @@ -153,14 +183,25 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro } 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") // bisa kosong - 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/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 71975da1..42903794 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) OverheadListDTO { overheadsByNonstockID := make(map[uint]*OverheadDTO) latestDateByNonstockID := make(map[uint]string) @@ -82,9 +84,19 @@ 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) + + 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 +109,22 @@ 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) + // Check if this is farm-level expense (multiple project flocks) + qty := realizations[i].Qty + totalAmount := calculateTotal(realizations[i].Qty, realizations[i].Price) + + if realizations[i].ExpenseNonstock.Expense != nil && + realizations[i].ExpenseNonstock.Expense.ProjectFlockId != nil { + projectFlockCount := countProjectFlocksInJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) + if projectFlockCount > 1 { + // Bagi biaya sesuai jumlah project flock + qty = qty / float64(projectFlockCount) + totalAmount = totalAmount / float64(projectFlockCount) + } + } + + overheadsByNonstockID[nonstockID].ActualQuantity += qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += totalAmount if overheadsByNonstockID[nonstockID].ItemName == "" { itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) @@ -148,6 +174,23 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex // === Helper Functions === +func countProjectFlocksInJSON(projectFlockJSON string) int { + if projectFlockJSON == "" { + return 0 + } + + var projectFlocks []int + if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { + return 1 // default to 1 if parsing fails + } + + if len(projectFlocks) == 0 { + return 1 + } + + return len(projectFlocks) +} + func getItemInfo(nonstock *entity.Nonstock) (string, string) { if nonstock != nil && nonstock.Id != 0 { return nonstock.Name, nonstock.Uom.Name 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 a9d25758..1cd4559d 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -26,6 +26,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService 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) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 6c682b9c..5870aa12 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -34,7 +34,7 @@ type ClosingService interface { GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, 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 +46,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 +57,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, @@ -355,35 +357,67 @@ 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 } + // Count total kandang in project flock (for budget division if per kandang) + projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + totalKandangCount := len(projectFlockKandangs) + 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) return &result, nil } diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index f1387483..2c7db649 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,44 @@ 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") + + // Build WHERE clause for project flock filtering + if projectFlockKandangID != nil { + // Per kandang: hanya ambil expense yang specific ke kandang tersebut + // SKIP expense level farm (yang punya multiple project flocks di JSON array) + // IMPORTANT: Untuk kandang_id, pastikan kandang tersebut belong to project_flock_kandang ini + 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) + )`, *projectFlockKandangID, *projectFlockKandangID) + } else { + // All kandang: include expense kandang-specific DAN expense level farm + 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 From b088eebac5f9aa8a3256c3c865b47062fe663cac Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 13:36:08 +0700 Subject: [PATCH 16/20] feat[BE]: enhance GetOverhead functionality with project flock kandang count mapping and update related DTOs --- .../controllers/closing.controller.go | 2 +- .../closings/dto/closingOverhead.dto.go | 57 +++++++++++++------ .../closings/services/closing.service.go | 32 +++++++++-- .../expense_realization.repository.go | 10 +--- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index c4ef4585..a10d6a94 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -184,7 +184,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { projectParam := c.Params("project_flock_id") - kandangParam := c.Params("project_flock_kandang_id") // bisa kosong + kandangParam := c.Params("project_flock_kandang_id") projectFlockID, err := strconv.Atoi(projectParam) if err != nil || projectFlockID <= 0 { diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go index 42903794..4730474a 100644 --- a/internal/modules/closings/dto/closingOverhead.dto.go +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -71,7 +71,7 @@ func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseReal return dto } -func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty, totalActualPopulation float64, isPerKandang bool, totalKandangCount int) 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) @@ -89,6 +89,7 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex 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) @@ -109,17 +110,35 @@ func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.Ex overheadsByNonstockID[nonstockID] = &OverheadDTO{} } - // Check if this is farm-level expense (multiple project flocks) 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 { - projectFlockCount := countProjectFlocksInJSON(*realizations[i].ExpenseNonstock.Expense.ProjectFlockId) - if projectFlockCount > 1 { - // Bagi biaya sesuai jumlah project flock - qty = qty / float64(projectFlockCount) - totalAmount = totalAmount / float64(projectFlockCount) + 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) + } + } } } @@ -172,22 +191,24 @@ 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 { - if projectFlockJSON == "" { - return 0 - } - - var projectFlocks []int - if err := json.Unmarshal([]byte(projectFlockJSON), &projectFlocks); err != nil { - return 1 // default to 1 if parsing fails - } - + projectFlocks := parseProjectFlockIDsFromJSON(projectFlockJSON) if len(projectFlocks) == 0 { return 1 } - return len(projectFlocks) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 5870aa12..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" @@ -368,13 +369,38 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl return nil, err } - // Count total kandang in project flock (for budget division if per kandang) 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 @@ -384,7 +410,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl var totalDepletion float64 if projectFlockKandangID != nil { - for _, chickin := range chickins { if chickin.ProjectFlockKandangId == *projectFlockKandangID { totalChickinQty += chickin.UsageQty @@ -404,7 +429,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl totalDepletion = depletionResult } } else { - for _, chickin := range chickins { totalChickinQty += chickin.UsageQty } @@ -417,7 +441,7 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl totalActualPopulation := totalChickinQty - totalDepletion - result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount) + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty, totalActualPopulation, projectFlockKandangID != nil, totalKandangCount, projectFlockKandangCountMap) return &result, nil } diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 2c7db649..60ec97a7 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -72,18 +72,14 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Where("expenses.realization_date IS NOT NULL") - // Build WHERE clause for project flock filtering if projectFlockKandangID != nil { - // Per kandang: hanya ambil expense yang specific ke kandang tersebut - // SKIP expense level farm (yang punya multiple project flocks di JSON array) - // IMPORTANT: Untuk kandang_id, pastikan kandang tersebut belong to project_flock_kandang ini 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) - )`, *projectFlockKandangID, *projectFlockKandangID) + 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 { - // All kandang: include expense kandang-specific DAN expense level farm 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 From 5d7b613ffcec659aa9daa5e570b467e6f69cd92c Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 15:39:20 +0700 Subject: [PATCH 17/20] [FIX/BE-US-281] changes calculate fcr egg --- .../dashboards/services/dashboard.service.go | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index f929cc7b..b4635b2e 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -273,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{}{ @@ -359,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, }, @@ -937,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) { From 1b5b5bc8476acc9f1e78ca5d7c13da58dc7ba2c6 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 13 Jan 2026 16:10:27 +0700 Subject: [PATCH 18/20] feat[BE]: add MarketingType filter to marketing reports and update related validations --- .../salesorder_delivery_product.repository.go | 30 +++++++++++++++++-- .../controllers/repport.controller.go | 1 + .../repports/dto/repportMarketing.dto.go | 20 +++++++++---- .../validations/repport.validation.go | 1 + 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index bb1343a2..f14988b1 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -137,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") } @@ -177,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 3f133674..36df7a05 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -149,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" @@ -157,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 { @@ -177,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 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"` From 5de81f6315a89febf9602bff6b753af9c24470fa Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 13 Jan 2026 16:18:15 +0700 Subject: [PATCH 19/20] chore: test image versioning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da8394f1..5b502da1 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group ## 📃 License -This project is private. All rights reserved. +> This project is private. All rights reserved. From de6580d11cf6b4b28be589c9d20b45db70f2245b Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Tue, 13 Jan 2026 16:46:49 +0700 Subject: [PATCH 20/20] chore: test image versioning 2 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b502da1..da8394f1 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group ## 📃 License -> This project is private. All rights reserved. +This project is private. All rights reserved.