Compare commits

..

1 Commits

Author SHA1 Message Date
aguhh18 88bdf70994 FIX[BE]: fix wrong get data on transfer stock 2026-01-23 15:40:59 +07:00
207 changed files with 4904 additions and 17193 deletions
+1 -15
View File
@@ -1,29 +1,15 @@
workflow:
rules:
# MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
# Push pipeline hanya untuk env branch
- if: '$CI_COMMIT_BRANCH == "development"'
when: always
- if: '$CI_COMMIT_BRANCH == "staging"'
when: always
- if: '$CI_COMMIT_BRANCH == "production"'
when: always
# Selain itu jangan buat pipeline
- when: never
include:
# khusus MR (notif)
- local: "ci/merge_request.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
# khusus push ke branch env
- local: "ci/development.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"'
- local: "ci/staging.yml"
+5 -6
View File
@@ -4,14 +4,9 @@ stages:
deploy-dev:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
@@ -32,6 +27,7 @@ deploy-dev:
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
@@ -87,5 +83,8 @@ deploy-dev:
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
-48
View File
@@ -1,48 +0,0 @@
stages:
- notify
notify_discord_on_mr_request_main_dev:
stage: notify
image: alpine:3.20
rules:
# hanya MR yang target ke main atau development
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
when: on_success
- when: never
script:
- apk add --no-cache curl jq coreutils
- |
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TITLE="${CI_MERGE_REQUEST_TITLE}"
IID="!${CI_MERGE_REQUEST_IID}"
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
PROJECT_PATH="${CI_PROJECT_PATH}"
USERNAME="${GITLAB_USER_LOGIN}"
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
payload=$(jq -n \
--arg desc "$DESC" \
--arg project "$PROJECT_PATH" \
--arg timeiso "$TIME_ISO" \
--arg mrurl "$MR_URL" \
'{
"username": "Mock-api - Merge Requests",
"embeds": [
{
"description": ($desc + "\n" + $mrurl),
"color": 15105570,
"footer": { "text": $project },
"timestamp": $timeiso
}
]
}')
curl -sS -H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK_URL"
+44 -68
View File
@@ -1,6 +1,6 @@
stages:
- build
- migrate
# - migrate
- deploy
- seed
@@ -8,6 +8,12 @@ default:
tags:
- self-hosted-prod
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
@@ -24,9 +30,7 @@ variables:
build_production:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
script: |
set -e
docker info
@@ -43,72 +47,44 @@ build_production:
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION)
# MIGRATE (PRODUCTION - MANUAL)
# =========================
migrate_production:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
echo "✅ Running migrations (production) ..."
#migrate_production:
# stage: migrate
# rules:
# - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
# when: manual
# allow_failure: false
# needs:
# - job: build_production
# artifacts: false
# script: |
# set -e
# cd /opt/deploy/lti
# test -f .env || (echo "❌ .env not found" && exit 1)
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)
# set -a
# . ./.env
# set +a
set -a
. ./.env
set +a
# Validasi env wajib
# : "${DB_HOST:?DB_HOST not set}"
# : "${DB_PORT:?DB_PORT not set}"
# : "${DB_USER:?DB_USER not set}"
# : "${DB_PASSWORD:?DB_PASSWORD not set}"
# : "${DB_NAME:?DB_NAME not set}"
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
# DB_SSLMODE="${DB_SSLMODE:-require}"
# export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# echo "✅ Running migrations (production)..."
# docker run --rm \
# -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
# migrate/migrate:v4.15.2 \
# -path=/migrations -database "$DATABASE_URL" up
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
@@ -116,10 +92,10 @@ migrate_production:
deploy_production:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs:
# - job: migrate_production
# artifacts: false
- job: build_production
artifacts: false
script: |
@@ -135,6 +111,7 @@ deploy_production:
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
@@ -143,10 +120,9 @@ seed_production:
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
- when: never
script: |
set -e
cd "$DEPLOY_DIR"
cd /opt/deploy/lti
test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
+22 -13
View File
@@ -8,6 +8,12 @@ default:
tags:
- self-hosted-stg
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
@@ -24,9 +30,7 @@ variables:
build_staging:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
script: |
set -e
docker info
@@ -43,15 +47,14 @@ build_staging:
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: build_staging
artifacts: false
@@ -63,10 +66,12 @@ migrate_staging:
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a
. ./.env
set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
@@ -76,17 +81,21 @@ migrate_staging:
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
@@ -102,6 +111,7 @@ migrate_staging:
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
@@ -114,15 +124,14 @@ migrate_staging:
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_staging:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: migrate_staging
artifacts: false
@@ -141,18 +150,18 @@ deploy_staging:
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: manual
- when: never
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: deploy_staging
artifacts: false
when: manual
allow_failure: false
script: |
set -e
@@ -161,4 +170,4 @@ seed_staging:
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed
docker compose -f "$COMPOSE_FILE" run --rm seed%
+1 -1
View File
@@ -14,8 +14,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
-407
View File
@@ -1,407 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
TotalQty float64 `gorm:"column:total_qty"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
}
func main() {
var (
idsRaw string
apply bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
switch route.Lane {
case "USABLE":
desiredQty := adj.UsageQty + adj.PendingQty
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
}
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.UsageQty,
adj.PendingQty,
activeAlloc,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow usable to zero: %w", err)
}
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
case "STOCKABLE":
removeQty := adj.TotalQty
if removeQty <= 0 && adj.StockLogIncrease > 0 {
removeQty = adj.StockLogIncrease
}
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
failed++
continue
}
if activeAlloc > 0 {
fmt.Printf(
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
adj.ID,
activeAlloc,
)
failed++
continue
}
fmt.Printf(
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
adj.ID,
route.FunctionCode,
adj.TotalQty,
removeQty,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).
Table("adjustment_stocks").
Where("id = ?", adj.ID).
Updates(map[string]any{
"total_qty": 0,
"total_used": 0,
}).Error; err != nil {
return fmt.Errorf("set stockable qty to zero: %w", err)
}
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
AsOf: &adj.CreatedAt,
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
Tx: tx,
}
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
return fmt.Errorf("reflow stockable to zero: %w", err)
}
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
return err
}
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
return err
}
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
success++
default:
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
skipped++
}
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
out := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
v, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if v == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(v)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.total_qty, 0) AS total_qty,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.function_code = ?", functionCode).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, entity.FlagableTypeProduct, productID).
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
return count, err
}
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
Error
}
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
Error
}
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
return tx.WithContext(ctx).
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
Error
}
-333
View File
@@ -1,333 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type adjustmentRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
FunctionCode string `gorm:"column:function_code"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type routeResolution struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
}
func main() {
var (
idsRaw string
apply bool
asOfCreatedAt bool
compensateMissingAlloc bool
)
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.BoolVar(&asOfCreatedAt, "as-of-created-at", true, "Use adjustment created_at as reflow AsOf boundary")
flag.BoolVar(&compensateMissingAlloc, "compensate-missing-alloc", true, "When active allocations are missing and usage_qty > 0, temporarily add back usage_qty before reflow")
flag.Parse()
ids, err := parseIDs(idsRaw)
if err != nil {
log.Fatalf("invalid --ids: %v", err)
}
if len(ids) == 0 {
log.Fatal("--ids is required")
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
adjustments, err := loadAdjustments(ctx, db, ids)
if err != nil {
log.Fatalf("failed to load adjustments: %v", err)
}
if len(adjustments) == 0 {
log.Fatal("no adjustments found for provided IDs")
}
sort.Slice(adjustments, func(i, j int) bool {
return adjustments[i].ID < adjustments[j].ID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
success := 0
failed := 0
skipped := 0
for _, adj := range adjustments {
if strings.TrimSpace(adj.FunctionCode) == "" {
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
skipped++
continue
}
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
if err != nil {
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
failed++
continue
}
if route.Lane != "USABLE" {
fmt.Printf("SKIP adj=%d reason=lane=%s (not USABLE)\n", adj.ID, route.Lane)
skipped++
continue
}
desiredQty := adj.UsageQty + adj.PendingQty
desiredQtySource := "usage+pending"
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
desiredQty = adj.StockLogDecrease
desiredQtySource = "stock_log.decrease"
}
if desiredQty <= 0 {
fmt.Printf(
"SKIP adj=%d reason=no usable qty (usage=%.3f pending=%.3f stock_log.decrease=%.3f)\n",
adj.ID,
adj.UsageQty,
adj.PendingQty,
adj.StockLogDecrease,
)
skipped++
continue
}
activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
if err != nil {
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
failed++
continue
}
compensateQty := adj.UsageQty
if compensateQty <= 0 && desiredQtySource == "stock_log.decrease" {
compensateQty = adj.StockLogDecrease
}
shouldCompensate := compensateMissingAlloc && activeAllocationCount == 0 && compensateQty > 0
reflowReq := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: route.FlagGroupCode,
ProductWarehouseID: adj.ProductWarehouseID,
IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()),
}
if asOfCreatedAt {
asOf := adj.CreatedAt
reflowReq.AsOf = &asOf
}
fmt.Printf(
"PLAN adj=%d pw=%d product=%d function=%s group=%s desired=%.3f source=%s active_alloc=%d compensate=%t\n",
adj.ID,
adj.ProductWarehouseID,
adj.ProductID,
route.FunctionCode,
route.FlagGroupCode,
desiredQty,
desiredQtySource,
activeAllocationCount,
shouldCompensate,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if shouldCompensate {
if err := tx.Table("product_warehouses").
Where("id = ?", adj.ProductWarehouseID).
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", compensateQty)).Error; err != nil {
return fmt.Errorf("compensate product_warehouse qty: %w", err)
}
}
reflowReq.Tx = tx
res, err := fifoStockV2Svc.Reflow(ctx, reflowReq)
if err != nil {
return err
}
fmt.Printf(
"DONE adj=%d rollback=%.3f allocate=%.3f pending=%.3f\n",
adj.ID,
res.Rollback.ReleasedQty,
res.Allocate.AllocatedQty,
res.Allocate.PendingQty,
)
return nil
})
if err != nil {
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
failed++
continue
}
success++
}
fmt.Println()
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
if failed > 0 {
os.Exit(1)
}
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func parseIDs(raw string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
seen := map[uint]struct{}{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseUint(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid id %q", part)
}
if value == 0 {
return nil, fmt.Errorf("id must be > 0: %q", part)
}
id := uint(value)
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
var rows []adjustmentRow
err := db.WithContext(ctx).
Table("adjustment_stocks a").
Select(`
a.id,
a.product_warehouse_id,
pw.product_id,
a.function_code,
COALESCE(a.usage_qty, 0) AS usage_qty,
COALESCE(a.pending_qty, 0) AS pending_qty,
COALESCE((
SELECT sl.increase
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_increase,
COALESCE((
SELECT sl.decrease
FROM stock_logs sl
WHERE sl.loggable_type = 'ADJUSTMENT'
AND sl.loggable_id = a.id
ORDER BY sl.id DESC
LIMIT 1
), 0) AS stock_log_decrease,
a.created_at
`).
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
Where("a.id IN ?", ids).
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
var rows []routeResolution
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where("rr.function_code = ?", functionCode).
Where(`
EXISTS (
SELECT 1
FROM flags f
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE f.flagable_type = ?
AND f.flagable_id = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, entity.FlagableTypeProduct, productID).
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
Order("rr.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
}
selected := rows[0]
for _, row := range rows {
if row.Lane != selected.Lane {
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
}
}
selected.FunctionCode = functionCode
return &selected, nil
}
func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
var count int64
err := db.WithContext(ctx).
Table("stock_allocations").
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
Where("status = ?", entity.StockAllocationStatusActive).
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
-648
View File
@@ -1,648 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"sort"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type productWarehouseScopeRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
WarehouseID uint `gorm:"column:warehouse_id"`
ProjectFlockKandangID *uint `gorm:"column:project_flock_kandang_id"`
}
type reflowTarget struct {
ProductWarehouseID uint
ProductID uint
WarehouseID uint
ProjectFlockKandangID *uint
FlagGroupCode string
}
func main() {
var (
projectFlockKandangID uint
apply bool
asOfRaw string
includeShared bool
)
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Project flock kandang ID (required)")
flag.BoolVar(&apply, "apply", false, "Apply reflow. If false, run as dry-run")
flag.StringVar(&asOfRaw, "as-of", "", "Optional AsOf boundary. Format: RFC3339 or YYYY-MM-DD")
flag.BoolVar(&includeShared, "include-shared", true, "Include product warehouses referenced by transactions in this PFK scope (including shared/non-bound product warehouses)")
flag.Parse()
if projectFlockKandangID == 0 {
log.Fatal("--project-flock-kandang-id is required")
}
asOf, err := parseAsOf(asOfRaw)
if err != nil {
log.Fatalf("invalid --as-of: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
exists, err := projectFlockKandangExists(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to check project flock kandang: %v", err)
}
if !exists {
log.Fatalf("project_flock_kandang_id %d not found", projectFlockKandangID)
}
scopedPWs, err := loadScopedProductWarehouses(ctx, db, projectFlockKandangID, includeShared)
if err != nil {
log.Fatalf("failed to load scoped product warehouses: %v", err)
}
if len(scopedPWs) == 0 {
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Scope: project_flock_kandang_id=%d\n", projectFlockKandangID)
fmt.Println("No product warehouse found in scope")
return
}
targets := make([]reflowTarget, 0, len(scopedPWs))
skippedPW := 0
failedResolve := 0
for _, pw := range scopedPWs {
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, db, pw.ProductWarehouseID)
if err != nil {
fmt.Printf("FAIL pw=%d error=resolve flag groups: %v\n", pw.ProductWarehouseID, err)
failedResolve++
continue
}
if len(flagGroups) == 0 {
fmt.Printf("SKIP pw=%d reason=no active fifo v2 route by product flag\n", pw.ProductWarehouseID)
skippedPW++
continue
}
for _, group := range flagGroups {
targets = append(targets, reflowTarget{
ProductWarehouseID: pw.ProductWarehouseID,
ProductID: pw.ProductID,
WarehouseID: pw.WarehouseID,
ProjectFlockKandangID: pw.ProjectFlockKandangID,
FlagGroupCode: group,
})
}
}
sort.Slice(targets, func(i, j int) bool {
if targets[i].ProductWarehouseID == targets[j].ProductWarehouseID {
return targets[i].FlagGroupCode < targets[j].FlagGroupCode
}
return targets[i].ProductWarehouseID < targets[j].ProductWarehouseID
})
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Scope: project_flock_kandang_id=%d include_shared=%t\n", projectFlockKandangID, includeShared)
if asOf != nil {
fmt.Printf("AsOf: %s\n", asOf.UTC().Format(time.RFC3339))
} else {
fmt.Println("AsOf: <nil> (full timeline)")
}
fmt.Printf("Product warehouses in scope: %d\n", len(scopedPWs))
fmt.Printf("Planned reflow targets: %d\n\n", len(targets))
for _, target := range targets {
fmt.Printf(
"PLAN pw=%d product=%d warehouse=%d pw_pfk=%s flag_group=%s\n",
target.ProductWarehouseID,
target.ProductID,
target.WarehouseID,
displayOptionalUint(target.ProjectFlockKandangID),
target.FlagGroupCode,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=0 failed_apply=0\n", len(targets), skippedPW, failedResolve)
if failedResolve > 0 {
os.Exit(1)
}
return
}
successApply := 0
failedApply := 0
for idx, target := range targets {
req := commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: target.FlagGroupCode,
ProductWarehouseID: target.ProductWarehouseID,
AsOf: asOf,
IdempotencyKey: fmt.Sprintf(
"manual-pfk-reflow-%d-%d-%s-%d-%d",
projectFlockKandangID,
target.ProductWarehouseID,
strings.ToUpper(strings.TrimSpace(target.FlagGroupCode)),
time.Now().UnixNano(),
idx,
),
}
res, err := fifoStockV2Svc.Reflow(ctx, req)
if err != nil {
fmt.Printf("FAIL pw=%d flag_group=%s error=%v\n", target.ProductWarehouseID, target.FlagGroupCode, err)
failedApply++
continue
}
fmt.Printf(
"DONE pw=%d flag_group=%s rollback=%.3f allocate=%.3f pending=%.3f processed_usable=%d\n",
target.ProductWarehouseID,
target.FlagGroupCode,
res.Rollback.ReleasedQty,
res.Allocate.AllocatedQty,
res.Allocate.PendingQty,
res.ProcessedUsables,
)
successApply++
}
orphanPopulationRows := int64(0)
syncedPopulationQtyRows := int64(0)
syncedPopulationUsedRows := int64(0)
traceReleasedRows := int64(0)
traceInsertedRows := int64(0)
if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil {
fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
failedApply++
} else {
orphanPopulationRows = rowsOrphan
syncedPopulationQtyRows = rowsQty
syncedPopulationUsedRows = rowsUsed
fmt.Printf(
"SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n",
orphanPopulationRows,
syncedPopulationQtyRows,
syncedPopulationUsedRows,
)
}
if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil {
fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
failedApply++
} else {
traceReleasedRows = released
traceInsertedRows = inserted
fmt.Printf(
"SYNC chickin_trace released=%d inserted=%d\n",
traceReleasedRows,
traceInsertedRows,
)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n",
len(targets),
skippedPW,
failedResolve,
successApply,
failedApply,
orphanPopulationRows,
syncedPopulationQtyRows,
syncedPopulationUsedRows,
traceReleasedRows,
traceInsertedRows,
)
if failedResolve > 0 || failedApply > 0 {
os.Exit(1)
}
}
func parseAsOf(raw string) (*time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.Parse(layout, raw)
if err != nil {
continue
}
if layout == "2006-01-02" {
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
return &endOfDay, nil
}
asOf := parsed.UTC()
return &asOf, nil
}
return nil, fmt.Errorf("unsupported format %q", raw)
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func displayOptionalUint(v *uint) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%d", *v)
}
func projectFlockKandangExists(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (bool, error) {
var count int64
err := db.WithContext(ctx).
Table("project_flock_kandangs").
Where("id = ?", projectFlockKandangID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func loadScopedProductWarehouses(ctx context.Context, db *gorm.DB, projectFlockKandangID uint, includeShared bool) ([]productWarehouseScopeRow, error) {
if !includeShared {
var rows []productWarehouseScopeRow
err := db.WithContext(ctx).
Table("product_warehouses").
Select("id AS product_warehouse_id, product_id, warehouse_id, project_flock_kandang_id").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
query := `
WITH scoped_pw AS (
SELECT pw.id AS product_warehouse_id
FROM product_warehouses pw
WHERE pw.project_flock_kandang_id = ?
UNION
SELECT pc.product_warehouse_id
FROM project_chickins pc
WHERE pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
UNION
SELECT rs.product_warehouse_id
FROM recordings r
JOIN recording_stocks rs ON rs.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT rd.product_warehouse_id
FROM recordings r
JOIN recording_depletions rd ON rd.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT rd.source_product_warehouse_id
FROM recordings r
JOIN recording_depletions rd ON rd.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
AND rd.source_product_warehouse_id IS NOT NULL
UNION
SELECT re.product_warehouse_id
FROM recordings r
JOIN recording_eggs re ON re.recording_id = r.id
WHERE r.project_flock_kandangs_id = ?
AND r.deleted_at IS NULL
UNION
SELECT lts.product_warehouse_id
FROM laying_transfer_sources lts
WHERE lts.source_project_flock_kandang_id = ?
AND lts.deleted_at IS NULL
AND lts.product_warehouse_id IS NOT NULL
UNION
SELECT ltt.product_warehouse_id
FROM laying_transfer_targets ltt
WHERE ltt.target_project_flock_kandang_id = ?
AND ltt.deleted_at IS NULL
AND ltt.product_warehouse_id IS NOT NULL
UNION
SELECT pi.product_warehouse_id
FROM purchase_items pi
WHERE pi.project_flock_kandang_id = ?
AND pi.product_warehouse_id IS NOT NULL
)
SELECT DISTINCT
pw.id AS product_warehouse_id,
pw.product_id,
pw.warehouse_id,
pw.project_flock_kandang_id
FROM scoped_pw s
JOIN product_warehouses pw ON pw.id = s.product_warehouse_id
ORDER BY pw.id ASC
`
var rows []productWarehouseScopeRow
err := db.WithContext(ctx).
Raw(
query,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
projectFlockKandangID,
).
Scan(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]string, error) {
var groups []string
err := db.WithContext(ctx).
Table("fifo_stock_v2_route_rules rr").
Select("DISTINCT rr.flag_group_code").
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
Where("rr.is_active = TRUE").
Where(`
EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = ?
AND f.flagable_type = ?
AND fm.flag_group_code = rr.flag_group_code
)
`, productWarehouseID, entity.FlagableTypeProduct).
Order("rr.flag_group_code ASC").
Scan(&groups).Error
if err != nil {
return nil, err
}
return groups, nil
}
func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) {
if projectFlockKandangID == 0 {
return 0, 0, 0, nil
}
orphanResult := db.WithContext(ctx).Exec(`
UPDATE project_flock_populations pfp
SET deleted_at = NOW(),
updated_at = NOW()
FROM project_chickins pc
WHERE pfp.project_chickin_id = pc.id
AND pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NOT NULL
AND pfp.deleted_at IS NULL
`, projectFlockKandangID)
if orphanResult.Error != nil {
return 0, 0, 0, orphanResult.Error
}
qtyResult := db.WithContext(ctx).Exec(`
UPDATE project_flock_populations p
SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0),
updated_at = NOW()
FROM project_chickins pc
WHERE p.project_chickin_id = pc.id
AND pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
AND p.deleted_at IS NULL
`, projectFlockKandangID)
if qtyResult.Error != nil {
return 0, 0, 0, qtyResult.Error
}
usedResult := db.WithContext(ctx).Exec(`
WITH scoped AS (
SELECT pfp.id, pfp.total_qty
FROM project_flock_populations pfp
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
WHERE pc.project_flock_kandang_id = ?
AND pc.deleted_at IS NULL
AND pfp.deleted_at IS NULL
),
alloc AS (
SELECT sa.stockable_id, SUM(sa.qty) AS used_qty
FROM stock_allocations sa
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'CONSUME'
GROUP BY sa.stockable_id
)
UPDATE project_flock_populations p
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
updated_at = NOW()
FROM scoped s
LEFT JOIN alloc a ON a.stockable_id = s.id
WHERE p.id = s.id
`, projectFlockKandangID)
if usedResult.Error != nil {
return 0, 0, 0, usedResult.Error
}
return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil
}
func resyncChickinTraceByProjectFlockKandang(
ctx context.Context,
db *gorm.DB,
fifoStockV2Svc commonSvc.FifoStockV2Service,
projectFlockKandangID uint,
) (int64, int64, error) {
if projectFlockKandangID == 0 {
return 0, 0, nil
}
var productWarehouseIDs []uint
if err := db.WithContext(ctx).
Table("project_chickins").
Distinct("product_warehouse_id").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where("deleted_at IS NULL").
Order("product_warehouse_id ASC").
Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil {
return 0, 0, err
}
if len(productWarehouseIDs) == 0 {
return 0, 0, nil
}
totalReleased := int64(0)
totalInserted := int64(0)
for _, productWarehouseID := range productWarehouseIDs {
var releasedRows int64
var insertedRows int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID)
if err != nil {
return err
}
if len(flagGroups) == 0 {
return nil
}
flagGroupCode := strings.TrimSpace(flagGroups[0])
if flagGroupCode == "" {
return nil
}
released := tx.WithContext(ctx).
Table("stock_allocations").
Where("product_warehouse_id = ?", productWarehouseID).
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin).
Where("status = ?", entity.StockAllocationStatusActive).
Updates(map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": time.Now(),
"updated_at": time.Now(),
"note": "chickin_trace_reflow_reset",
})
if released.Error != nil {
return released.Error
}
releasedRows = released.RowsAffected
type chickinRow struct {
ID uint `gorm:"column:id"`
UsageQty float64 `gorm:"column:usage_qty"`
ChickIn time.Time `gorm:"column:chick_in_date"`
}
chickins := make([]chickinRow, 0)
if err := tx.WithContext(ctx).
Table("project_chickins").
Select("id, usage_qty, chick_in_date").
Where("product_warehouse_id = ?", productWarehouseID).
Where("deleted_at IS NULL").
Where("usage_qty > 0").
Order("chick_in_date ASC, id ASC").
Scan(&chickins).Error; err != nil {
return err
}
if len(chickins) == 0 {
return nil
}
gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: "STOCKABLE",
AllocationPurpose: entity.StockAllocationPurposeTraceChickin,
IgnoreSourceUsed: true,
ProductWarehouseID: productWarehouseID,
Limit: 50000,
Tx: tx,
})
if err != nil {
return err
}
if len(gatherRows) == 0 {
return nil
}
type lotKey struct {
StockableType string
StockableID uint
}
remainingByLot := make(map[lotKey]float64, len(gatherRows))
for _, row := range gatherRows {
key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID}
remainingByLot[key] = row.AvailableQuantity
}
now := time.Now()
lotIndex := 0
for _, chickinRow := range chickins {
remaining := chickinRow.UsageQty
for remaining > 1e-6 && lotIndex < len(gatherRows) {
lot := gatherRows[lotIndex]
key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID}
available := remainingByLot[key]
if available <= 1e-6 {
lotIndex++
continue
}
portion := math.Min(remaining, available)
if portion <= 1e-6 {
lotIndex++
continue
}
insert := map[string]any{
"product_warehouse_id": productWarehouseID,
"stockable_type": lot.Ref.LegacyTypeKey,
"stockable_id": lot.Ref.ID,
"usable_type": fifo.UsableKeyProjectChickin.String(),
"usable_id": chickinRow.ID,
"qty": portion,
"status": entity.StockAllocationStatusActive,
"allocation_purpose": entity.StockAllocationPurposeTraceChickin,
"engine_version": "v2",
"flag_group_code": flagGroupCode,
"function_code": "CHICKIN_TRACE",
"created_at": now,
"updated_at": now,
}
if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil {
return err
}
insertedRows++
remaining -= portion
remainingByLot[key] = available - portion
}
}
return nil
})
if err != nil {
return totalReleased, totalInserted, err
}
totalReleased += releasedRows
totalInserted += insertedRows
}
return totalReleased, totalInserted, nil
}
-122
View File
@@ -1,122 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"math"
"os"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type mismatchRow struct {
ChickinID uint `gorm:"column:chickin_id"`
ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
UsageQty float64 `gorm:"column:usage_qty"`
TraceQty float64 `gorm:"column:trace_qty"`
}
func main() {
var projectFlockKandangID uint
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope")
flag.Parse()
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to load trace mismatches: %v", err)
}
activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID)
if err != nil {
log.Fatalf("failed to count active consume rows: %v", err)
}
fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID)
fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows))
fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows)
if len(rows) > 0 {
for _, row := range rows {
fmt.Printf(
"MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n",
row.ChickinID,
row.ProjectFlockKandang,
row.ProductWarehouseID,
row.UsageQty,
row.TraceQty,
row.TraceQty-row.UsageQty,
)
}
}
if len(rows) > 0 || activeConsumeRows > 0 {
os.Exit(1)
}
}
func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) {
query := db.WithContext(ctx).
Table("project_chickins pc").
Select(`
pc.id AS chickin_id,
pc.project_flock_kandang_id,
pc.product_warehouse_id,
COALESCE(pc.usage_qty, 0) AS usage_qty,
COALESCE(SUM(sa.qty), 0) AS trace_qty
`).
Joins(`
LEFT JOIN stock_allocations sa
ON sa.usable_type = ?
AND sa.usable_id = pc.id
AND sa.status = 'ACTIVE'
AND sa.allocation_purpose = 'TRACE_CHICKIN'
`, fifo.UsableKeyProjectChickin.String()).
Where("pc.deleted_at IS NULL").
Where("COALESCE(pc.usage_qty,0) > 0").
Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty")
if projectFlockKandangID > 0 {
query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
}
rows := make([]mismatchRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]mismatchRow, 0, len(rows))
for _, row := range rows {
if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 {
out = append(out, row)
}
}
return out, nil
}
func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) {
q := db.WithContext(ctx).
Table("stock_allocations sa").
Joins("JOIN project_chickins pc ON pc.id = sa.usable_id").
Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Where("sa.status = 'ACTIVE'").
Where("sa.allocation_purpose = 'CONSUME'")
if projectFlockKandangID > 0 {
q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
@@ -20,7 +20,7 @@ type HppCostRepository interface {
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
}
@@ -51,8 +51,8 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
@@ -103,11 +103,11 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
@@ -136,14 +136,15 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Where("f.name IN ?", flags).
Scan(&total).Error
if err != nil {
return 0, err
@@ -175,15 +176,15 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(sa.qty * CASE
COALESCE(SUM(pc.usage_qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
@@ -218,57 +219,25 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang
return totals.TotalPieces, totals.TotalWeightKg, nil
}
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
ctx context.Context,
projectFlockKandangIDs []uint,
startDate *time.Time,
endDate *time.Time,
) (float64, float64, error) {
if endDate == nil {
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
if date == nil {
now := time.Now()
endDate = &now
date = &now
}
type subResult struct {
UsableID uint
MdpUsageQty float64
MdpWeight float64
}
subQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
DISTINCT sa.usable_id,
mdp.usage_qty AS mdp_usage_qty,
mdp.total_weight AS mdp_weight
`).
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins(
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *endDate).
Where("mdp.delivery_date <= ?", *startDate)
var totals struct {
TotalPieces float64
TotalWeight float64
}
err := r.db.WithContext(ctx).
Table("(?) AS x", subQuery).
Select(`
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
`).
Table("recordings AS r").
Select("COALESCE(SUM(mdp.usage_qty), 0) AS total_pieces, COALESCE(SUM(mdp.total_weight), 0) AS total_weight").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Joins("JOIN stock_allocations AS sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", fifo.StockableKeyRecordingEgg.String(), fifo.UsableKeyMarketingDelivery.String()).
Joins("JOIN marketing_delivery_products AS mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
@@ -299,9 +268,6 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("lt.deleted_at IS NULL").
Where("ltt.deleted_at IS NULL").
Where("lt.executed_at IS NOT NULL").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id").
Scan(&summary).Error
@@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
var allocations []entity.StockAllocation
q := r.DB().WithContext(ctx).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
if modifier != nil {
q = modifier(q)
@@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
q := baseDB.WithContext(ctx).
Model(&entity.StockAllocation{}).
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
return q.Updates(updates).Error
}
@@ -15,7 +15,7 @@ type ApprovalService interface {
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error)
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
@@ -70,14 +70,9 @@ func (s *approvalService) List(
approvableID *uint,
page, limit int,
search string,
orderByDate string,
) ([]entity.Approval, int64, error) {
module = strings.TrimSpace(strings.ToUpper(module))
search = strings.TrimSpace(search)
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
if orderByDate != "ASC" && orderByDate != "DESC" {
orderByDate = "DESC"
}
if limit <= 0 {
limit = 10
@@ -95,7 +90,7 @@ func (s *approvalService) List(
func(db *gorm.DB) *gorm.DB {
query := db.
Where("approvable_type = ?", module).
Order("action_at " + orderByDate).
Order("action_at DESC").
Preload("ActionUser")
if approvableID != nil {
@@ -20,7 +20,7 @@ import (
)
const (
defaultDocumentPathLimit = 255
defaultDocumentPathLimit = 50
defaultDocumentKeyPrefix = "docs"
maxDocumentNameLength = 50
)
@@ -363,19 +363,13 @@ func (s *documentService) generateObjectKey(ext string) (string, error) {
}
u := uuid.New().String()
keyPrefix := strings.Trim(s.keyPrefix, "/")
key := fmt.Sprintf("%s%s", u, normalizedExt)
if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt)
if s.keyPrefix == "" {
key = fmt.Sprintf("%s%s", u, normalizedExt)
}
if len(key) > s.maxPathLength {
compact := strings.ReplaceAll(u, "-", "")
if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
} else {
key = fmt.Sprintf("%s%s", compact, normalizedExt)
}
key = fmt.Sprintf("%s%s", u, normalizedExt)
}
if len(key) > s.maxPathLength {
+31 -107
View File
@@ -26,7 +26,6 @@ type FifoService interface {
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
}
type fifoService struct {
@@ -112,11 +111,6 @@ type PendingResolution struct {
Quantity float64
}
type PendingResolveRequest struct {
ProductWarehouseID uint
Tx *gorm.DB
}
type StockReplenishResult struct {
AddedQuantity float64
PendingResolved []PendingResolution
@@ -153,7 +147,6 @@ type StockReleaseRequest struct {
Reason *string
Tx *gorm.DB
}
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required")
@@ -233,23 +226,6 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
return result, nil
}
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
var resolved []PendingResolution
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
var err error
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
return err
})
if err != nil {
return nil, err
}
return resolved, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required")
@@ -332,7 +308,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
}
if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
if err != nil {
return err
}
@@ -379,7 +355,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
}
var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
return err
}
usageDelta -= ctxRow.UsageQty
@@ -528,7 +504,6 @@ func (s *fifoService) allocateFromStock(
UsableType: usableKey.String(),
UsableId: usableID,
Qty: portion,
AllocationPurpose: entities.StockAllocationPurposeConsume,
Status: entities.StockAllocationStatusActive,
})
@@ -746,7 +721,6 @@ func (s *fifoService) releaseUsagePortion(
usableKey fifo.UsableKey,
usableID uint,
target float64,
expectedWarehouseID uint,
) (float64, error) {
if target <= 0 {
return 0, nil
@@ -762,18 +736,6 @@ func (s *fifoService) releaseUsagePortion(
if len(allocations) == 0 {
return 0, nil
}
for i := range allocations {
alloc := &allocations[i]
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
continue
}
if err := tx.Model(&entities.StockAllocation{}).
Where("id = ?", alloc.Id).
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
return 0, err
}
alloc.ProductWarehouseId = expectedWarehouseID
}
var (
remaining = target
@@ -870,79 +832,41 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
cfg.Columns.CreatedAt,
)
if cfg.Columns.CreatedAt == cfg.Columns.ID {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt int64 `gorm:"column:created_at"`
}
var rows []struct {
ID uint
Pending float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt),
})
}
} else {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
@@ -1,41 +0,0 @@
package service
import (
"github.com/sirupsen/logrus"
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
"gorm.io/gorm"
)
type FifoStockV2Service = fifoStockV2.Service
type FifoStockV2Lane = fifoStockV2.Lane
type FifoStockV2Ref = fifoStockV2.Ref
type FifoStockV2GatherRequest = fifoStockV2.GatherRequest
type FifoStockV2GatherRow = fifoStockV2.GatherRow
type FifoStockV2AllocateRequest = fifoStockV2.AllocateRequest
type FifoStockV2AllocateResult = fifoStockV2.AllocateResult
type FifoStockV2AllocationDetail = fifoStockV2.AllocationDetail
type FifoStockV2RollbackRequest = fifoStockV2.RollbackRequest
type FifoStockV2RollbackResult = fifoStockV2.RollbackResult
type FifoStockV2ReflowRequest = fifoStockV2.ReflowRequest
type FifoStockV2ReflowResult = fifoStockV2.ReflowResult
type FifoStockV2RecalculateRequest = fifoStockV2.RecalculateRequest
type FifoStockV2RecalculateResult = fifoStockV2.RecalculateResult
type FifoStockV2WarehouseDrift = fifoStockV2.WarehouseDrift
func NewFifoStockV2Service(db *gorm.DB, logger *logrus.Logger) FifoStockV2Service {
return fifoStockV2.NewService(db, logger)
}
+34 -38
View File
@@ -11,10 +11,10 @@ import (
type HppService interface {
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error)
GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error)
GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, totalDepresiasiGrowing float64) (float64, error)
GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error)
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
}
type HppCostResponse struct {
@@ -44,25 +44,17 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
date = &now
}
location, err := time.LoadLocation("Asia/Jakarta")
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, date)
if err != nil {
return nil, err
}
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, date, depresiasiTransfer)
if err != nil {
return nil, err
}
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil {
return nil, err
}
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, date)
}
@@ -109,23 +101,23 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
}
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, date *time.Time, depresiasiTransfer float64) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, date)
if err != nil {
return 0, err
}
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, date)
if err != nil {
return 0, err
}
@@ -135,7 +127,7 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate
return 0, err
}
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, date)
if err != nil {
return 0, err
}
@@ -143,11 +135,11 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
}
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return 0, nil
@@ -163,12 +155,12 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate
return 0, err
}
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, date)
if err != nil {
return 0, err
}
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date)
if err != nil {
return 0, err
}
@@ -185,11 +177,11 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
}
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if endDate == nil {
// now := time.Now()
// endDate = &now
// }
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return 0, nil
@@ -213,7 +205,7 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
return 0, nil
}
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate)
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, date)
if err != nil {
return 0, err
}
@@ -221,18 +213,22 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
}
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date)
if err != nil {
return nil, err
}
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date)
if err != nil {
return nil, err
}
@@ -1,58 +0,0 @@
# RFC Ringkas: FIFO Stock V2
## Tujuan
`fifo_stock_v2` adalah engine FIFO baru berbasis konfigurasi `Flag Group + Jalur` yang berjalan paralel dengan v1 tanpa memutus kompatibilitas `stock_allocations`, HPP, dan closing/reporting existing.
## Prinsip
- V1 tidak dihapus, V2 jalan paralel.
- Semua operasi transactional.
- FIFO sorting deterministic lintas tabel.
- Default over-consume `ALLOW` (pending), exception dapat `BLOCK`.
- Reflow idempotent.
- Recalculate bisa memperbaiki drift `product_warehouses.qty`.
## Komponen
- `fifo_stock_v2_flag_groups`: master grouping flag produk.
- `fifo_stock_v2_flag_members`: pemetaan flag -> group.
- `fifo_stock_v2_traits`: trait sort per `table:date_column` (+ optional join date source).
- `fifo_stock_v2_route_rules`: rule per `flag_group + lane + function + table`.
- `fifo_stock_v2_overconsume_rules`: policy pending/over-consume.
- `fifo_stock_v2_operation_log`: idempotency + audit operasi.
- `fifo_stock_v2_reflow_runs` + checkpoints + shadow allocations: bulk reflow resumable/observable.
## API Service
- `Gather`: union cross-table berdasarkan route rules + trait sorting.
- `Allocate`: alokasi lot FIFO ke usable.
- `Rollback`: batalkan alokasi aktif.
- `Reflow`: rollback penuh lalu allocate ulang (idempotent).
- `Recalculate`: rekonsiliasi qty warehouse dari ledger FIFO.
## Deterministic Sorting
Urutan gather:
1. `sort_at ASC` (dari trait `date_column`)
2. `sort_priority ASC`
3. `source_table ASC`
4. `source_id ASC`
Fallback waktu: `1970-01-01 00:00:00+00` bila tanggal null.
## Compat Strategy
- Tetap menulis ke `stock_allocations` dengan tambahan metadata:
- `engine_version` (`v1`/`v2`)
- `flag_group_code`
- `function_code`
- `idempotency_key`
- Query lama yang bergantung `stockable_type/usable_type` tetap berjalan.
## Migration Strategy
1. Deploy schema + seed v2.
2. Aktifkan shadow-run comparator v1 vs v2.
3. Canary cutover per flag group.
4. Full cutover jika parity aman.
5. Jalankan bulk reflow existing data.
## Acceptance Criteria Singkat
- Parity mismatch terkendali pada aggregate + detail alokasi.
- Tidak ada regression closing/HPP.
- Drift qty warehouse turun signifikan pasca reflow.
- Rollback via feature flag memungkinkan kembali ke v1.
@@ -1,748 +0,0 @@
package fifo_stock_v2
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"gorm.io/gorm"
)
type allocationRow struct {
ID uint `gorm:"column:id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
StockableType string `gorm:"column:stockable_type"`
StockableID uint `gorm:"column:stockable_id"`
UsableType string `gorm:"column:usable_type"`
UsableID uint `gorm:"column:usable_id"`
Qty float64 `gorm:"column:qty"`
Status string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
}
type usableQtySnapshot struct {
Usage float64 `gorm:"column:usage_qty"`
Pending float64 `gorm:"column:pending_qty"`
}
func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) {
if err := s.validateAllocateRequest(req); err != nil {
return nil, err
}
result := &AllocateResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"flag_group_code": req.FlagGroupCode,
"product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"need_qty": req.NeedQty,
"as_of": req.AsOf,
"allow_over_consume": req.AllowOverConsume,
})
logRow, reused, err := s.beginOperation(
tx,
OperationAllocate,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent allocate has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
allocated, allocErr := s.allocateInternal(ctx, tx, req)
if allocErr != nil {
err = allocErr
return allocErr
}
*result = *allocated
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) {
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey)
if err != nil {
return nil, err
}
allowOverConsume := usableRule.AllowPendingDefault
if req.AllowOverConsume != nil {
allowOverConsume = *req.AllowOverConsume
} else {
allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume)
if err != nil {
return nil, err
}
}
gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneStockable,
ProductWarehouseID: req.ProductWarehouseID,
AsOf: req.AsOf,
Limit: s.defaultGatherLimit,
})
if err != nil {
return nil, err
}
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode)
if err != nil {
return nil, err
}
now := time.Now()
remaining := req.NeedQty
result := &AllocateResult{Details: make([]AllocationDetail, 0)}
for _, lot := range gatherRows {
if remaining <= 0 {
break
}
if lot.AvailableQuantity <= 0 {
continue
}
portion := math.Min(remaining, lot.AvailableQuantity)
if nearlyZero(portion) {
continue
}
allocationInsert := map[string]any{
"product_warehouse_id": req.ProductWarehouseID,
"stockable_type": lot.Ref.LegacyTypeKey,
"stockable_id": lot.Ref.ID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"qty": portion,
"status": activeAllocationStatus(),
"allocation_purpose": defaultAllocationPurpose(),
"created_at": now,
"updated_at": now,
"engine_version": "v2",
"flag_group_code": req.FlagGroupCode,
"function_code": req.Usable.FunctionCode,
}
if strings.TrimSpace(req.IdempotencyKey) != "" {
allocationInsert["idempotency_key"] = req.IdempotencyKey
}
if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil {
return nil, err
}
rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey]
if !ok {
return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey)
}
if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil {
return nil, err
}
result.Details = append(result.Details, AllocationDetail{
StockableType: lot.Ref.LegacyTypeKey,
StockableID: lot.Ref.ID,
Qty: portion,
SortAt: lot.SortAt,
})
remaining -= portion
result.AllocatedQty += portion
}
if remaining > 0 {
if !allowOverConsume {
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
}
result.PendingQty = remaining
}
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
return nil, err
}
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
if err := s.validateRollbackRequest(req); err != nil {
return nil, err
}
result := &RollbackResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req)
if err != nil {
return err
}
if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"product_warehouse_id": req.ProductWarehouseID,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"release_qty": req.ReleaseQty,
"reason": req.Reason,
"flag_group_code": flagGroupCode,
})
logRow, reused, beginErr := s.beginOperation(
tx,
OperationRollback,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
flagGroupCode,
req.Usable.LegacyTypeKey,
req.Usable.ID,
)
if beginErr != nil {
return beginErr
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent rollback has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
*result = *rolled
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) rollbackInternal(
ctx context.Context,
tx *gorm.DB,
req RollbackRequest,
flagGroupCode string,
) (*RollbackResult, error) {
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey)
if err != nil {
return nil, err
}
allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
if err != nil {
return nil, err
}
if len(allocations) == 0 {
if req.ReleaseQty == nil {
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
return nil, err
}
}
return &RollbackResult{}, nil
}
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode)
if err != nil {
return nil, err
}
target := 0.0
for _, alloc := range allocations {
target += alloc.Qty
}
if req.ReleaseQty != nil {
if *req.ReleaseQty < 0 {
return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
}
target = *req.ReleaseQty
}
if nearlyZero(target) {
return &RollbackResult{}, nil
}
result := &RollbackResult{Details: make([]AllocationDetail, 0)}
now := time.Now()
remaining := target
for _, alloc := range allocations {
if remaining <= 0 {
break
}
portion := math.Min(remaining, alloc.Qty)
if nearlyZero(portion) {
continue
}
if nearlyZero(alloc.Qty - portion) {
updates := map[string]any{
"status": releasedAllocationStatus(),
"released_at": now,
"updated_at": now,
}
if strings.TrimSpace(req.Reason) != "" {
updates["note"] = req.Reason
}
if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil {
return nil, err
}
} else {
if err := tx.Table("stock_allocations").
Where("id = ?", alloc.ID).
Updates(map[string]any{
"qty": alloc.Qty - portion,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
}
stockableRule, ok := stockableRuleMap[alloc.StockableType]
if !ok {
return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType)
}
if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil {
return nil, err
}
result.ReleasedQty += portion
remaining -= portion
result.Details = append(result.Details, AllocationDetail{
StockableType: alloc.StockableType,
StockableID: alloc.StockableID,
Qty: portion,
SortAt: alloc.CreatedAt,
})
}
if req.ReleaseQty != nil && remaining > 1e-6 {
return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty)
}
if req.ReleaseQty == nil {
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
return nil, err
}
} else {
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil {
return nil, err
}
}
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
}
result := &ReflowResult{}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.ensureStockAllocationColumns(tx); err != nil {
return err
}
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
return err
}
hash := requestHash(map[string]any{
"flag_group_code": req.FlagGroupCode,
"product_warehouse_id": req.ProductWarehouseID,
"as_of": req.AsOf,
})
logRow, reused, err := s.beginOperation(
tx,
OperationReflow,
req.IdempotencyKey,
hash,
req.ProductWarehouseID,
req.FlagGroupCode,
"",
0,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent reflow has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
FlagGroupCode: req.FlagGroupCode,
Lane: LaneUsable,
ProductWarehouseID: req.ProductWarehouseID,
Limit: s.defaultGatherLimit,
})
if gatherErr != nil {
err = gatherErr
return gatherErr
}
result.ProcessedUsables = len(usableRows)
for _, usableRow := range usableRows {
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
ProductWarehouseID: req.ProductWarehouseID,
Usable: usableRow.Ref,
ReleaseQty: nil,
Reason: "reflow reset",
}, req.FlagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
if len(rollbackRes.Details) > 0 {
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
}
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
if desiredQty < minDesired {
desiredQty = minDesired
}
if desiredQty <= 0 {
continue
}
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: req.ProductWarehouseID,
Usable: usableRow.Ref,
NeedQty: desiredQty,
AsOf: nil,
})
if allocateErr != nil {
err = allocateErr
return allocateErr
}
result.Allocate.AllocatedQty += allocateRes.AllocatedQty
result.Allocate.PendingQty += allocateRes.PendingQty
if len(allocateRes.Details) > 0 {
result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...)
}
}
expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil)
if calcErr != nil {
err = calcErr
return calcErr
}
actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID)
if loadErr != nil {
err = loadErr
return loadErr
}
drift := expectedQty - actualQty
if math.Abs(drift) >= 1e-6 {
if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil {
err = adjustErr
return adjustErr
}
}
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
err = finishErr
return finishErr
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) gatherAllRows(
ctx context.Context,
tx *gorm.DB,
req GatherRequest,
) ([]GatherRow, error) {
limit := req.Limit
if limit <= 0 {
limit = s.defaultGatherLimit
}
if limit <= 0 {
limit = 1000
}
req.Limit = limit
out := make([]GatherRow, 0, limit)
var cursorSortAt *time.Time
cursorSourceTable := ""
var cursorSourceID uint
for {
req.AfterSortAt = cursorSortAt
req.AfterSourceTable = cursorSourceTable
req.AfterSourceID = cursorSourceID
rows, err := s.gatherRows(ctx, tx, req)
if err != nil {
return nil, err
}
if len(rows) == 0 {
break
}
out = append(out, rows...)
if len(rows) < limit {
break
}
last := rows[len(rows)-1]
lastSortAt := last.SortAt
cursorSortAt = &lastSortAt
cursorSourceTable = last.SourceTable
cursorSourceID = last.SourceID
}
return out, nil
}
func (s *fifoStockV2Service) loadActiveAllocations(
tx *gorm.DB,
usableType string,
usableID uint,
productWarehouseID uint,
) ([]allocationRow, error) {
query := tx.Table("stock_allocations").
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at").
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose())
if productWarehouseID > 0 {
query = query.Where("product_warehouse_id = ?", productWarehouseID)
}
query = query.Order("created_at DESC, id DESC")
var rows []allocationRow
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) {
rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable)
if err != nil {
return nil, err
}
m := make(map[string]routeRule, len(rules))
for _, rule := range rules {
m[rule.LegacyTypeKey] = rule
}
return m, nil
}
func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error {
if nearlyZero(delta) || sourceID == 0 {
return nil
}
if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" {
return nil
}
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol)
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Update(usedCol, gorm.Expr(expr, delta)).Error
}
func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error {
if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) {
return nil
}
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
updates := map[string]any{}
if !nearlyZero(usageDelta) {
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol)
updates[usageCol] = gorm.Expr(expr, usageDelta)
}
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol)
updates[pendingCol] = gorm.Expr(expr, pendingDelta)
}
if len(updates) == 0 {
return nil
}
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Updates(updates).Error
}
func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error {
if sourceID == 0 {
return nil
}
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
updates := map[string]any{usageCol: 0}
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
updates[pendingCol] = 0
}
return tx.Table(sourceTable).
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
Updates(updates).Error
}
func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) {
type row struct {
FlagGroupCode string `gorm:"column:flag_group_code"`
}
var latest row
err := tx.WithContext(ctx).
Table("stock_allocations").
Select("flag_group_code").
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
Where("engine_version = 'v2'").
Where("allocation_purpose = ?", defaultAllocationPurpose()).
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
Order("id DESC").
Limit(1).
Take(&latest).Error
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
return latest.FlagGroupCode, nil
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
var rules []routeRule
err = tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(LaneUsable)).
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
Find(&rules).Error
if err != nil {
return "", err
}
if len(rules) == 0 {
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
}
if len(rules) > 1 {
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
}
return rules[0].FlagGroupCode, nil
}
func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest)
}
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
}
if req.NeedQty < 0 {
return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest)
}
return nil
}
func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error {
if req.ProductWarehouseID == 0 {
return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest)
}
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
}
if req.ReleaseQty != nil && *req.ReleaseQty < 0 {
return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
}
return nil
}
@@ -1,170 +0,0 @@
package fifo_stock_v2
import (
"context"
"fmt"
"strings"
"gorm.io/gorm"
)
type routeRule struct {
ID uint `gorm:"column:id"`
FlagGroupCode string `gorm:"column:flag_group_code"`
Lane string `gorm:"column:lane"`
FunctionCode string `gorm:"column:function_code"`
SourceTable string `gorm:"column:source_table"`
SourceIDColumn string `gorm:"column:source_id_column"`
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
QuantityCol string `gorm:"column:quantity_col"`
UsedQuantityCol *string `gorm:"column:used_quantity_col"`
PendingQuantityCol *string `gorm:"column:pending_quantity_col"`
ScopeSQL *string `gorm:"column:scope_sql"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
}
type traitRule struct {
ID uint `gorm:"column:id"`
SourceTable string `gorm:"column:source_table"`
Lane string `gorm:"column:lane"`
DateTable *string `gorm:"column:date_table"`
DateJoinLeftCol *string `gorm:"column:date_join_left_col"`
DateJoinRightCol *string `gorm:"column:date_join_right_col"`
DateColumn string `gorm:"column:date_column"`
FallbackDateColumn *string `gorm:"column:fallback_date_column"`
SortPriority int `gorm:"column:sort_priority"`
IDColumn string `gorm:"column:id_column"`
}
func (s *fifoStockV2Service) loadRouteRules(ctx context.Context, tx *gorm.DB, flagGroupCode string, lane Lane) ([]routeRule, error) {
var rules []routeRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("flag_group_code = ?", flagGroupCode).
Where("lane = ?", string(lane)).
Order("id ASC").
Find(&rules).Error
if err != nil {
return nil, err
}
for _, rule := range rules {
if err := validateRouteRule(rule); err != nil {
return nil, err
}
}
return rules, nil
}
func (s *fifoStockV2Service) loadRouteRuleByLegacyType(
ctx context.Context,
tx *gorm.DB,
lane Lane,
flagGroupCode string,
legacyTypeKey string,
) (*routeRule, error) {
var rule routeRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_route_rules").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("flag_group_code = ?", flagGroupCode).
Where("legacy_type_key = ?", legacyTypeKey).
Order("id ASC").
Limit(1).
Take(&rule).Error
if err != nil {
return nil, err
}
if err := validateRouteRule(rule); err != nil {
return nil, err
}
return &rule, nil
}
func (s *fifoStockV2Service) loadTraitMap(
ctx context.Context,
tx *gorm.DB,
lane Lane,
sourceTables []string,
) (map[string]traitRule, error) {
if len(sourceTables) == 0 {
return map[string]traitRule{}, nil
}
var traits []traitRule
err := tx.WithContext(ctx).
Table("fifo_stock_v2_traits").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("source_table IN ?", sourceTables).
Find(&traits).Error
if err != nil {
return nil, err
}
out := make(map[string]traitRule, len(traits))
for _, tr := range traits {
if err := validateTraitRule(tr); err != nil {
return nil, err
}
out[tr.SourceTable] = tr
}
return out, nil
}
func validateRouteRule(rule routeRule) error {
fields := []string{rule.SourceTable, rule.SourceIDColumn, rule.ProductWarehouseCol, rule.QuantityCol}
for _, value := range fields {
if _, err := mustSafeIdentifier(value); err != nil {
return err
}
}
if rule.UsedQuantityCol != nil {
if _, err := mustSafeIdentifier(*rule.UsedQuantityCol); err != nil {
return err
}
}
if rule.PendingQuantityCol != nil {
if _, err := mustSafeIdentifier(*rule.PendingQuantityCol); err != nil {
return err
}
}
if strings.TrimSpace(rule.LegacyTypeKey) == "" {
return fmt.Errorf("route rule has empty legacy type key")
}
return nil
}
func validateTraitRule(rule traitRule) error {
if _, err := mustSafeIdentifier(rule.SourceTable); err != nil {
return err
}
if _, err := mustSafeIdentifier(rule.DateColumn); err != nil {
return err
}
if _, err := mustSafeIdentifier(rule.IDColumn); err != nil {
return err
}
if rule.DateTable != nil {
if _, err := mustSafeIdentifier(*rule.DateTable); err != nil {
return err
}
if rule.DateJoinLeftCol == nil || rule.DateJoinRightCol == nil {
return fmt.Errorf("trait %s requires date join columns", rule.SourceTable)
}
if _, err := mustSafeIdentifier(*rule.DateJoinLeftCol); err != nil {
return err
}
if _, err := mustSafeIdentifier(*rule.DateJoinRightCol); err != nil {
return err
}
}
if rule.FallbackDateColumn != nil {
if _, err := mustSafeIdentifier(*rule.FallbackDateColumn); err != nil {
return err
}
}
return nil
}
@@ -1,8 +0,0 @@
package fifo_stock_v2
import "errors"
var (
ErrInvalidRequest = errors.New("invalid fifo stock v2 request")
ErrInsufficientStock = errors.New("insufficient stock")
)
@@ -1,293 +0,0 @@
package fifo_stock_v2
import (
"context"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type gatherSQLRow struct {
SourceTable string `gorm:"column:source_table"`
LegacyTypeKey string `gorm:"column:legacy_type_key"`
FunctionCode string `gorm:"column:function_code"`
SourceID uint `gorm:"column:source_id"`
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
SortAt time.Time `gorm:"column:sort_at"`
SortPriority int `gorm:"column:sort_priority"`
Quantity float64 `gorm:"column:quantity"`
UsedQuantity float64 `gorm:"column:used_quantity"`
PendingQuantity float64 `gorm:"column:pending_quantity"`
AvailableQuantity float64 `gorm:"column:available_quantity"`
}
func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) {
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
return nil, fmt.Errorf("%w: flag group and product warehouse are required", ErrInvalidRequest)
}
if req.Lane != LaneStockable && req.Lane != LaneUsable {
return nil, fmt.Errorf("%w: unsupported lane %q", ErrInvalidRequest, req.Lane)
}
var out []GatherRow
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
rows, err := s.gatherRows(ctx, tx, req)
if err != nil {
return err
}
out = rows
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) {
req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose)
rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []GatherRow{}, nil
}
tables := make([]string, 0, len(rules))
for _, rule := range rules {
tables = append(tables, rule.SourceTable)
}
traits, err := s.loadTraitMap(ctx, tx, req.Lane, tables)
if err != nil {
return nil, err
}
subqueries := make([]string, 0, len(rules))
args := make([]any, 0, len(rules)*10)
for _, rule := range rules {
trait, ok := traits[rule.SourceTable]
if !ok {
return nil, fmt.Errorf("missing trait for table %s lane %s", rule.SourceTable, req.Lane)
}
subSQL, subArgs, err := s.buildGatherSubquery(rule, trait, req)
if err != nil {
return nil, err
}
subqueries = append(subqueries, subSQL)
args = append(args, subArgs...)
}
if len(subqueries) == 0 {
return []GatherRow{}, nil
}
limit := req.Limit
if limit <= 0 {
limit = s.defaultGatherLimit
}
if limit <= 0 {
limit = 1000
}
query := "SELECT * FROM (" + strings.Join(subqueries, " UNION ALL ") + ") AS g"
if req.AfterSortAt != nil {
query += `
WHERE
(g.sort_at > ?)
OR (g.sort_at = ? AND g.source_table > ?)
OR (g.sort_at = ? AND g.source_table = ? AND g.source_id > ?)
`
args = append(args,
*req.AfterSortAt,
*req.AfterSortAt, req.AfterSourceTable,
*req.AfterSortAt, req.AfterSourceTable, req.AfterSourceID,
)
}
query += " ORDER BY g.sort_at ASC, g.sort_priority ASC, g.source_table ASC, g.source_id ASC LIMIT ?"
args = append(args, limit)
var rows []gatherSQLRow
if err := tx.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]GatherRow, 0, len(rows))
for _, row := range rows {
out = append(out, GatherRow{
Ref: Ref{
Table: row.SourceTable,
ID: row.SourceID,
LegacyTypeKey: row.LegacyTypeKey,
FunctionCode: row.FunctionCode,
},
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: row.ProductWarehouseID,
SortAt: row.SortAt,
SortPriority: row.SortPriority,
Quantity: row.Quantity,
UsedQuantity: row.UsedQuantity,
PendingQuantity: row.PendingQuantity,
AvailableQuantity: row.AvailableQuantity,
SourceTable: row.SourceTable,
SourceID: row.SourceID,
})
}
return out, nil
}
func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule, req GatherRequest) (string, []any, error) {
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
productWarehouseCol, _ := mustSafeIdentifier(rule.ProductWarehouseCol)
quantityCol, _ := mustSafeIdentifier(rule.QuantityCol)
baseQtyExpr := fmt.Sprintf("COALESCE(src.%s,0)::numeric", quantityCol)
usedExpr := "0::numeric"
pendingExpr := "0::numeric"
availableExpr := baseQtyExpr
extraArgs := make([]any, 0, 2)
whereExtraArgs := make([]any, 0, 1)
if req.Lane == LaneStockable {
if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
} else {
// NOTE:
// usedExpr is referenced twice in the generated SELECT:
// 1) as used_quantity
// 2) inside available_quantity = base - usedExpr
// plus once in stockable WHERE clause via availableExpr > 0.
// We split the args because the WHERE placeholder order appears
// after product/flag filter placeholders in the final SQL.
usedExpr = fmt.Sprintf(
"(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)",
sourceIDCol,
activeAllocationStatus(),
)
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
}
availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
} else {
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
pendingExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", pendingCol)
}
availableExpr = baseQtyExpr
}
sortExpr, joinClause, err := buildSortExpr(trait)
if err != nil {
return "", nil, err
}
functionCodeExpr := "?::text"
functionCodeArgs := []any{rule.FunctionCode}
if rule.SourceTable == "adjustment_stocks" {
functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)"
}
whereParts := []string{
fmt.Sprintf("src.%s = ?", productWarehouseCol),
fmt.Sprintf(`EXISTS (
SELECT 1
FROM product_warehouses pw
JOIN flags f ON f.flagable_type = ? AND f.flagable_id = pw.product_id
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
WHERE pw.id = src.%s AND fm.flag_group_code = ?
)`, productWarehouseCol),
}
if req.Lane == LaneStockable {
whereParts = append(whereParts, fmt.Sprintf("%s > 0", availableExpr))
}
if req.AsOf != nil {
whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr))
}
if req.From != nil {
whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr))
}
if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" {
whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
}
subquery := fmt.Sprintf(`
SELECT
?::text AS source_table,
?::text AS legacy_type_key,
%s AS function_code,
src.%s AS source_id,
src.%s AS product_warehouse_id,
%s AS sort_at,
?::int AS sort_priority,
%s AS quantity,
%s AS used_quantity,
%s AS pending_quantity,
%s AS available_quantity
FROM %s src
%s
WHERE %s
`, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND "))
args := []any{
rule.SourceTable,
rule.LegacyTypeKey,
}
args = append(args, functionCodeArgs...)
args = append(args, trait.SortPriority)
args = append(args, extraArgs...)
args = append(args,
req.ProductWarehouseID,
entity.FlagableTypeProduct,
req.FlagGroupCode,
)
args = append(args, whereExtraArgs...)
if req.AsOf != nil {
args = append(args, *req.AsOf)
}
if req.From != nil {
args = append(args, *req.From)
}
return subquery, args, nil
}
func buildSortExpr(trait traitRule) (string, string, error) {
dateCol, _ := mustSafeIdentifier(trait.DateColumn)
idCol, _ := mustSafeIdentifier(trait.IDColumn)
_ = idCol
joinClause := ""
sortBase := fmt.Sprintf("src.%s", dateCol)
if trait.DateTable != nil && strings.TrimSpace(*trait.DateTable) != "" {
dateTable, _ := mustSafeIdentifier(*trait.DateTable)
if trait.DateJoinLeftCol == nil || trait.DateJoinRightCol == nil {
return "", "", fmt.Errorf("trait %s requires date join columns", trait.SourceTable)
}
leftCol, _ := mustSafeIdentifier(*trait.DateJoinLeftCol)
rightCol, _ := mustSafeIdentifier(*trait.DateJoinRightCol)
joinClause = fmt.Sprintf("LEFT JOIN %s dt ON src.%s = dt.%s", dateTable, leftCol, rightCol)
sortBase = fmt.Sprintf("dt.%s", dateCol)
}
if trait.FallbackDateColumn != nil && strings.TrimSpace(*trait.FallbackDateColumn) != "" {
fallbackCol, _ := mustSafeIdentifier(*trait.FallbackDateColumn)
sortBase = fmt.Sprintf("COALESCE(%s, src.%s)", sortBase, fallbackCol)
}
sortExpr := fmt.Sprintf("COALESCE(%s, '1970-01-01 00:00:00+00'::timestamptz)", sortBase)
return sortExpr, joinClause, nil
}
@@ -1,131 +0,0 @@
package fifo_stock_v2
import (
"context"
"errors"
"math"
"sort"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
func ReleasePopulationConsumptionByUsable(
ctx context.Context,
tx *gorm.DB,
usableType string,
usableID uint,
) error {
if tx == nil {
return errors.New("transaction is required")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil)
if err != nil {
return err
}
for _, allocation := range allocations {
if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 {
continue
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", allocation.StockableId).
Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil {
return err
}
}
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil)
}
func AllocatePopulationConsumption(
ctx context.Context,
tx *gorm.DB,
populations []entity.ProjectFlockPopulation,
productWarehouseID uint,
usableType string,
usableID uint,
consumeQty float64,
) error {
if consumeQty <= 0 {
return nil
}
if tx == nil {
return errors.New("transaction is required")
}
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid")
}
if usableType == "" || usableID == 0 {
return errors.New("usable type and id are required")
}
if len(populations) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan")
}
if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil {
return err
}
sort.Slice(populations, func(i, j int) bool {
if populations[i].CreatedAt.Equal(populations[j].CreatedAt) {
return populations[i].Id < populations[j].Id
}
return populations[i].CreatedAt.Before(populations[j].CreatedAt)
})
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
remaining := consumeQty
for _, pop := range populations {
available := pop.TotalQty - pop.TotalUsedQty
if available <= 0 {
continue
}
portion := math.Min(available, remaining)
if portion <= 0 {
continue
}
allocation := &entity.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
StockableId: pop.Id,
UsableType: usableType,
UsableId: usableID,
Qty: portion,
Status: entity.StockAllocationStatusActive,
AllocationPurpose: entity.StockAllocationPurposeConsume,
}
if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil {
return err
}
if err := tx.WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}).
Where("id = ?", pop.Id).
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
return err
}
remaining -= portion
if remaining <= 1e-6 {
break
}
}
if remaining > 1e-6 {
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
}
return nil
}
@@ -1,177 +0,0 @@
package fifo_stock_v2
import (
"context"
"encoding/json"
"fmt"
"math"
"time"
"gorm.io/gorm"
)
func (s *fifoStockV2Service) Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) {
result := &RecalculateResult{Drifts: make([]WarehouseDrift, 0)}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
hash := requestHash(map[string]any{
"product_warehouse_ids": req.ProductWarehouseIDs,
"flag_group_codes": req.FlagGroupCodes,
"as_of": req.AsOf,
"fix_drift": req.FixDrift,
})
logRow, reused, err := s.beginOperation(
tx,
OperationRecalculate,
req.IdempotencyKey,
hash,
0,
"RECALCULATE",
"",
0,
)
if err != nil {
return err
}
if reused {
if len(logRow.ResultPayload) == 0 {
return fmt.Errorf("idempotent recalculate has empty payload")
}
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
return err
}
return nil
}
if logRow != nil {
defer func() {
if err != nil {
s.failOperation(tx, logRow, err)
}
}()
}
warehouseIDs, err := s.resolveRecalculateWarehouseIDs(ctx, tx, req.ProductWarehouseIDs)
if err != nil {
return err
}
groupCodes, err := s.resolveRecalculateGroupCodes(ctx, tx, req.FlagGroupCodes)
if err != nil {
return err
}
for _, warehouseID := range warehouseIDs {
expected := 0.0
for _, flagGroup := range groupCodes {
available, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, warehouseID, flagGroup, req.AsOf)
if calcErr != nil {
return calcErr
}
expected += available
}
actual, actualErr := s.loadWarehouseQty(ctx, tx, warehouseID)
if actualErr != nil {
return actualErr
}
delta := expected - actual
result.Checked++
if math.Abs(delta) < 1e-6 {
continue
}
drift := WarehouseDrift{
ProductWarehouseID: warehouseID,
ExpectedQty: expected,
ActualQty: actual,
Delta: delta,
}
result.Drifts = append(result.Drifts, drift)
if req.FixDrift {
if err := s.adjustProductWarehouseQty(tx, warehouseID, delta); err != nil {
return err
}
result.Fixed++
}
}
if err := s.finishOperation(tx, logRow, result); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoStockV2Service) resolveRecalculateWarehouseIDs(ctx context.Context, tx *gorm.DB, provided []uint) ([]uint, error) {
if len(provided) > 0 {
return provided, nil
}
var ids []uint
err := tx.WithContext(ctx).Table("product_warehouses").Select("id").Order("id ASC").Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *fifoStockV2Service) resolveRecalculateGroupCodes(ctx context.Context, tx *gorm.DB, provided []string) ([]string, error) {
if len(provided) > 0 {
return provided, nil
}
var groups []string
err := tx.WithContext(ctx).
Table("fifo_stock_v2_flag_groups").
Select("code").
Where("is_active = TRUE").
Order("priority ASC, code ASC").
Scan(&groups).Error
if err != nil {
return nil, err
}
return groups, nil
}
func (s *fifoStockV2Service) calculateWarehouseAvailableForGroup(
ctx context.Context,
tx *gorm.DB,
warehouseID uint,
flagGroupCode string,
asOf *time.Time,
) (float64, error) {
rows, err := s.gatherRows(ctx, tx, GatherRequest{
FlagGroupCode: flagGroupCode,
Lane: LaneStockable,
ProductWarehouseID: warehouseID,
AsOf: asOf,
Limit: 50000,
})
if err != nil {
return 0, err
}
total := 0.0
for _, row := range rows {
total += row.AvailableQuantity
}
return total, nil
}
func (s *fifoStockV2Service) loadWarehouseQty(ctx context.Context, tx *gorm.DB, warehouseID uint) (float64, error) {
type row struct {
Qty float64 `gorm:"column:qty"`
}
var out row
err := tx.WithContext(ctx).
Table("product_warehouses").
Select("COALESCE(qty,0) AS qty").
Where("id = ?", warehouseID).
Take(&out).Error
if err != nil {
return 0, err
}
return out.Qty, nil
}
@@ -1,100 +0,0 @@
package fifo_stock_v2
import "strings"
func normalizeScopeSQL(scopeSQL string) string {
scopeSQL = strings.TrimSpace(scopeSQL)
if scopeSQL == "" {
return scopeSQL
}
var out strings.Builder
out.Grow(len(scopeSQL) + 16)
inSingleQuote := false
inDoubleQuote := false
for i := 0; i < len(scopeSQL); {
ch := scopeSQL[i]
if inSingleQuote {
out.WriteByte(ch)
i++
if ch == '\'' {
if i < len(scopeSQL) && scopeSQL[i] == '\'' {
out.WriteByte(scopeSQL[i])
i++
} else {
inSingleQuote = false
}
}
continue
}
if inDoubleQuote {
out.WriteByte(ch)
i++
if ch == '"' {
inDoubleQuote = false
}
continue
}
if ch == '\'' {
inSingleQuote = true
out.WriteByte(ch)
i++
continue
}
if ch == '"' {
inDoubleQuote = true
out.WriteByte(ch)
i++
continue
}
if isIdentifierStart(ch) {
start := i
i++
for i < len(scopeSQL) && isIdentifierPart(scopeSQL[i]) {
i++
}
token := scopeSQL[start:i]
if strings.EqualFold(token, "deleted_at") && !hasAliasQualifier(scopeSQL, start) {
out.WriteString("src.deleted_at")
} else {
out.WriteString(token)
}
continue
}
out.WriteByte(ch)
i++
}
return out.String()
}
func hasAliasQualifier(scopeSQL string, tokenStart int) bool {
for i := tokenStart - 1; i >= 0; i-- {
switch scopeSQL[i] {
case ' ', '\t', '\n', '\r':
continue
case '.':
return true
default:
return false
}
}
return false
}
func isIdentifierStart(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
}
func isIdentifierPart(ch byte) bool {
return isIdentifierStart(ch) || (ch >= '0' && ch <= '9')
}
@@ -1,277 +0,0 @@
package fifo_stock_v2
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"math"
"regexp"
"strings"
"github.com/sirupsen/logrus"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
var identifierPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
type fifoStockV2Service struct {
db *gorm.DB
logger *logrus.Logger
defaultGatherLimit int
}
func NewService(db *gorm.DB, logger *logrus.Logger) Service {
if logger == nil {
logger = logrus.StandardLogger()
}
return &fifoStockV2Service{
db: db,
logger: logger,
defaultGatherLimit: 1000,
}
}
func (s *fifoStockV2Service) withTransaction(
ctx context.Context,
tx *gorm.DB,
fn func(*gorm.DB) error,
) error {
if tx != nil {
return fn(tx.WithContext(ctx))
}
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return fn(inner)
})
}
func isSafeIdentifier(v string) bool {
return identifierPattern.MatchString(strings.TrimSpace(v))
}
func mustSafeIdentifier(v string) (string, error) {
v = strings.TrimSpace(v)
if !isSafeIdentifier(v) {
return "", fmt.Errorf("unsafe identifier: %s", v)
}
return v, nil
}
func requestHash(v any) string {
payload, _ := json.Marshal(v)
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func shardLockKey(flagGroupCode string, productWarehouseID uint) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(strings.TrimSpace(strings.ToUpper(flagGroupCode))))
_, _ = h.Write([]byte("|"))
_, _ = h.Write([]byte(fmt.Sprintf("%d", productWarehouseID)))
return int64(h.Sum64())
}
func (s *fifoStockV2Service) lockShard(tx *gorm.DB, flagGroupCode string, productWarehouseID uint) error {
if strings.TrimSpace(flagGroupCode) == "" || productWarehouseID == 0 {
return fmt.Errorf("lock shard requires flag group and product warehouse")
}
return tx.Exec("SELECT pg_advisory_xact_lock(?)", shardLockKey(flagGroupCode, productWarehouseID)).Error
}
type operationLogRow struct {
ID uint `gorm:"column:id"`
Status string `gorm:"column:status"`
RequestHash string `gorm:"column:request_hash"`
ResultPayload json.RawMessage `gorm:"column:result_payload"`
}
func (s *fifoStockV2Service) beginOperation(
tx *gorm.DB,
op Operation,
idempotencyKey string,
requestHashValue string,
productWarehouseID uint,
flagGroupCode string,
usableType string,
usableID uint,
) (*operationLogRow, bool, error) {
if strings.TrimSpace(idempotencyKey) == "" {
return nil, false, nil
}
inserted := operationLogRow{}
insertSQL := `
INSERT INTO fifo_stock_v2_operation_log
(idempotency_key, operation, product_warehouse_id, flag_group_code, usable_type, usable_id, request_hash, status, created_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, 0), ?, 'RUNNING', NOW())
ON CONFLICT (idempotency_key, operation) DO NOTHING
RETURNING id, status, request_hash
`
if err := tx.Raw(insertSQL,
idempotencyKey,
string(op),
productWarehouseID,
flagGroupCode,
usableType,
usableID,
requestHashValue,
).Scan(&inserted).Error; err != nil {
return nil, false, err
}
if inserted.ID != 0 {
return &inserted, false, nil
}
existing := operationLogRow{}
if err := tx.Table("fifo_stock_v2_operation_log").
Select("id, status, request_hash, result_payload").
Where("idempotency_key = ? AND operation = ?", idempotencyKey, string(op)).
Take(&existing).Error; err != nil {
return nil, false, err
}
if existing.RequestHash != requestHashValue {
return nil, false, fmt.Errorf("idempotency key %s reused with different payload", idempotencyKey)
}
switch strings.ToUpper(existing.Status) {
case "DONE":
return &existing, true, nil
case "RUNNING":
return nil, false, fmt.Errorf("operation %s with idempotency key %s is still running", op, idempotencyKey)
case "FAILED":
if err := tx.Table("fifo_stock_v2_operation_log").
Where("id = ?", existing.ID).
Updates(map[string]any{
"status": "RUNNING",
"error_text": nil,
"finished_at": nil,
}).Error; err != nil {
return nil, false, err
}
existing.Status = "RUNNING"
return &existing, false, nil
default:
return nil, false, fmt.Errorf("unknown operation status: %s", existing.Status)
}
}
func (s *fifoStockV2Service) finishOperation(tx *gorm.DB, logRow *operationLogRow, payload any) error {
if logRow == nil || logRow.ID == 0 {
return nil
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
return tx.Table("fifo_stock_v2_operation_log").
Where("id = ?", logRow.ID).
Updates(map[string]any{
"status": "DONE",
"result_payload": encoded,
"finished_at": gorm.Expr("NOW()"),
}).Error
}
func (s *fifoStockV2Service) failOperation(tx *gorm.DB, logRow *operationLogRow, failure error) {
if logRow == nil || logRow.ID == 0 || failure == nil {
return
}
_ = tx.Table("fifo_stock_v2_operation_log").
Where("id = ?", logRow.ID).
Updates(map[string]any{
"status": "FAILED",
"error_text": failure.Error(),
"finished_at": gorm.Expr("NOW()"),
}).Error
}
func (s *fifoStockV2Service) resolveOverConsume(
tx *gorm.DB,
flagGroupCode string,
functionCode string,
lane Lane,
defaultValue bool,
) (bool, error) {
type row struct {
Allow bool `gorm:"column:allow_overconsume"`
}
selected := row{}
err := tx.Table("fifo_stock_v2_overconsume_rules").
Select("allow_overconsume").
Where("is_active = TRUE").
Where("lane = ?", string(lane)).
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
Where("(function_code IS NULL OR function_code = ?)", functionCode).
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
Order("priority ASC, id ASC").
Limit(1).
Take(&selected).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return defaultValue, nil
}
return false, err
}
return selected.Allow, nil
}
func (s *fifoStockV2Service) adjustProductWarehouseQty(tx *gorm.DB, productWarehouseID uint, delta float64) error {
if productWarehouseID == 0 || delta == 0 {
return nil
}
return tx.Table("product_warehouses").
Where("id = ?", productWarehouseID).
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error
}
func nearlyZero(v float64) bool {
return math.Abs(v) < 1e-6
}
func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error {
checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"}
for _, col := range checkCols {
var count int64
err := tx.Raw(`
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'stock_allocations' AND column_name = ?
`, col).Scan(&count).Error
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("stock_allocations.%s does not exist, run fifo_stock_v2 migration first", col)
}
}
return nil
}
func activeAllocationStatus() string {
return entity.StockAllocationStatusActive
}
func releasedAllocationStatus() string {
return entity.StockAllocationStatusReleased
}
func defaultAllocationPurpose() string {
return entity.StockAllocationPurposeConsume
}
func normalizeAllocationPurpose(purpose string) string {
purpose = strings.TrimSpace(strings.ToUpper(purpose))
if purpose == "" {
return defaultAllocationPurpose()
}
return purpose
}
@@ -1,143 +0,0 @@
package fifo_stock_v2
import (
"context"
"time"
"gorm.io/gorm"
)
type Lane string
const (
LaneStockable Lane = "STOCKABLE"
LaneUsable Lane = "USABLE"
)
type Operation string
const (
OperationAllocate Operation = "ALLOCATE"
OperationRollback Operation = "ROLLBACK"
OperationReflow Operation = "REFLOW"
OperationRecalculate Operation = "RECALCULATE"
)
type Ref struct {
Table string
ID uint
LegacyTypeKey string
FunctionCode string
}
type GatherRequest struct {
FlagGroupCode string
Lane Lane
AllocationPurpose string
IgnoreSourceUsed bool
ProductWarehouseID uint
From *time.Time
AsOf *time.Time
Limit int
AfterSortAt *time.Time
AfterSourceTable string
AfterSourceID uint
ForUpdate bool
Tx *gorm.DB
}
type GatherRow struct {
Ref Ref
FlagGroupCode string
ProductWarehouseID uint
SortAt time.Time
SortPriority int
Quantity float64
UsedQuantity float64
PendingQuantity float64
AvailableQuantity float64
SourceTable string
SourceID uint
}
type AllocateRequest struct {
FlagGroupCode string
ProductWarehouseID uint
Usable Ref
NeedQty float64
AllowOverConsume *bool
IdempotencyKey string
AsOf *time.Time
Tx *gorm.DB
}
type AllocationDetail struct {
StockableType string
StockableID uint
Qty float64
SortAt time.Time
}
type AllocateResult struct {
AllocatedQty float64
PendingQty float64
Details []AllocationDetail
}
type RollbackRequest struct {
ProductWarehouseID uint
Usable Ref
ReleaseQty *float64
Reason string
IdempotencyKey string
Tx *gorm.DB
}
type RollbackResult struct {
ReleasedQty float64
Details []AllocationDetail
}
type ReflowRequest struct {
FlagGroupCode string
ProductWarehouseID uint
AsOf *time.Time
IdempotencyKey string
Tx *gorm.DB
}
type ReflowResult struct {
ProcessedUsables int
Rollback RollbackResult
Allocate AllocateResult
}
type RecalculateRequest struct {
ProductWarehouseIDs []uint
FlagGroupCodes []string
AsOf *time.Time
FixDrift bool
IdempotencyKey string
Tx *gorm.DB
}
type WarehouseDrift struct {
ProductWarehouseID uint
ExpectedQty float64
ActualQty float64
Delta float64
}
type RecalculateResult struct {
Checked int
Fixed int
Drifts []WarehouseDrift
}
type Service interface {
Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error)
Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error)
Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error)
Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error)
Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error)
}
+53 -81
View File
@@ -22,61 +22,58 @@ type SSOClientConfig struct {
}
var (
IsProd bool
AppHost string
Version string
LogLevel string
AppPort int
DBHost string
DBUser string
DBPassword string
DBName string
DBPort int
DBSSLMode string
DBSSLRootCert string
DBSSLCert string
DBSSLKey string
JWTSecret string
JWTAccessExp int
JWTRefreshExp int
JWTResetPasswordExp int
JWTVerifyEmailExp int
RedisURL string
CORSAllowOrigins []string
CORSAllowMethods []string
CORSAllowHeaders []string
CORSExposeHeaders []string
CORSAllowCredentials bool
CORSMaxAge int
SSOIssuer string
SSOJWKSURL string
SSOAllowedAudiences []string
SSOAuthorizeURL string
SSOTokenURL string
SSOGetMeURL string
SSOPortalURL string
SSOClients map[string]SSOClientConfig
SSOAccessCookieName string
SSORefreshCookieName string
SSOCookieDomain string
SSOCookieSecure bool
SSOCookieSameSite string
SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration
SSOUserSyncNonceTTL time.Duration
SSOUserSyncMaxBodyBytes int
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
S3ForcePathStyle bool
S3PublicBaseURL string
S3EnvPrefix string
S3DocumentKeyPrefix string
TransferToLayingGrowingMaxWeek int
IsProd bool
AppHost string
Version string
LogLevel string
AppPort int
DBHost string
DBUser string
DBPassword string
DBName string
DBPort int
DBSSLMode string
DBSSLRootCert string
DBSSLCert string
DBSSLKey string
JWTSecret string
JWTAccessExp int
JWTRefreshExp int
JWTResetPasswordExp int
JWTVerifyEmailExp int
RedisURL string
CORSAllowOrigins []string
CORSAllowMethods []string
CORSAllowHeaders []string
CORSExposeHeaders []string
CORSAllowCredentials bool
CORSMaxAge int
SSOIssuer string
SSOJWKSURL string
SSOAllowedAudiences []string
SSOAuthorizeURL string
SSOTokenURL string
SSOGetMeURL string
SSOPortalURL string
SSOClients map[string]SSOClientConfig
SSOAccessCookieName string
SSORefreshCookieName string
SSOCookieDomain string
SSOCookieSecure bool
SSOCookieSameSite string
SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration
SSOUserSyncNonceTTL time.Duration
SSOUserSyncMaxBodyBytes int
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
S3ForcePathStyle bool
S3PublicBaseURL string
S3DocumentKeyPrefix string
)
func init() {
@@ -118,11 +115,6 @@ func init() {
// Redis
RedisURL = viper.GetString("REDIS_URL")
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
if TransferToLayingGrowingMaxWeek <= 0 {
TransferToLayingGrowingMaxWeek = 19
}
// Object storage
S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT"))
S3Region = strings.TrimSpace(viper.GetString("S3_REGION"))
@@ -131,12 +123,7 @@ func init() {
S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY"))
S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE")
S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/")
S3EnvPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_ENV_PREFIX")), "/"), "local")
docPrefix := strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/")
if docPrefix == "" {
docPrefix = "docs"
}
S3DocumentKeyPrefix = joinPath(S3EnvPrefix, docPrefix)
S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs")
// SSO integration
SSOIssuer = viper.GetString("SSO_ISSUER")
@@ -151,10 +138,6 @@ func init() {
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax")
SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES")
if SSOAccessTokenMaxBytes <= 0 {
SSOAccessTokenMaxBytes = 4096
}
SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist")
if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 {
SSOPKCETTL = time.Duration(ttl) * time.Second
@@ -259,17 +242,6 @@ func defaultString(v, def string) string {
return v
}
func joinPath(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(part, "/")
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, "/")
}
func ensureProdConfig() {
if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") {
panic("SSO_AUTHORIZE_URL must be https in production")
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE payments
ALTER COLUMN bank_id SET NOT NULL;
COMMIT;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE payments
ALTER COLUMN bank_id DROP NOT NULL;
COMMIT;
@@ -1,3 +0,0 @@
ALTER TABLE adjustment_stocks ADD COLUMN stock_log_id INTEGER;
CREATE INDEX idx_adjustment_stocks_stock_log_id ON adjustment_stocks (stock_log_id);
@@ -1 +0,0 @@
ALTER TABLE adjustment_stocks DROP COLUMN IF EXISTS stock_log_id;
@@ -1,56 +0,0 @@
BEGIN;
DO $$
DECLARE
t text;
seq_name text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'daily_checklist_activity_task_assignments',
'daily_checklist_activity_tasks',
'daily_checklist_phases',
'daily_checklist_tasks',
'daily_checklists',
'employee_kandangs',
'employees',
'phase_activities',
'phases'
]
LOOP
-- Sequence name convention
seq_name := format('public.%I_id_seq', t);
-- 1) Drop default nextval (bigserial behavior)
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id DROP DEFAULT',
t
);
-- 2) Add IDENTITY back (BY DEFAULT is safer for rollback)
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY',
t
);
-- 3) Detach & optionally drop sequence (safe)
IF EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S'
AND relname = t || '_id_seq'
) THEN
EXECUTE format(
'ALTER SEQUENCE %s OWNED BY NONE',
seq_name
);
-- Optional: drop sequence (comment if you want to keep it)
EXECUTE format(
'DROP SEQUENCE IF EXISTS %s',
seq_name
);
END IF;
END LOOP;
END $$;
COMMIT;
@@ -1,59 +0,0 @@
BEGIN;
DO $$
DECLARE
t text;
seq_name text;
max_id bigint;
BEGIN
FOREACH t IN ARRAY ARRAY[
'daily_checklist_activity_task_assignments',
'daily_checklist_activity_tasks',
'daily_checklist_phases',
'daily_checklist_tasks',
'daily_checklists',
'employee_kandangs',
'employees',
'phase_activities',
'phases'
]
LOOP
-- Sequence name convention: public.<table>_id_seq
seq_name := format('public.%I_id_seq', t);
-- Drop IDENTITY only if the column is identity (safe to re-run)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = t
AND column_name = 'id'
AND is_identity = 'YES'
) THEN
EXECUTE format('ALTER TABLE public.%I ALTER COLUMN id DROP IDENTITY', t);
END IF;
-- Ensure sequence exists
EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %s', seq_name);
-- Set default like bigserial
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id SET DEFAULT nextval(''%s'')',
t, seq_name
);
-- Own the sequence by the column
EXECUTE format(
'ALTER SEQUENCE %s OWNED BY public.%I.id',
seq_name, t
);
-- Sync sequence to MAX(id) + 1 to avoid duplicate key
EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM public.%I', t) INTO max_id;
EXECUTE format('SELECT setval(''%s'', $1, false)', seq_name)
USING (max_id + 1);
END LOOP;
END $$;
COMMIT;
@@ -1,2 +0,0 @@
ALTER TABLE stock_logs
DROP COLUMN stock;
@@ -1,18 +0,0 @@
ALTER TABLE stock_logs
ADD COLUMN stock NUMERIC(15, 3) NOT NULL DEFAULT 0;
WITH calc AS (
SELECT
id,
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
OVER (
PARTITION BY product_warehouse_id
ORDER BY id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_stock
FROM stock_logs
)
UPDATE stock_logs t
SET stock = c.running_stock
FROM calc c
WHERE t.id = c.id;
@@ -1,7 +0,0 @@
BEGIN;
-- Migration: revert documents.path length
ALTER TABLE documents
ALTER COLUMN path TYPE VARCHAR(50);
COMMIT;
@@ -1,7 +0,0 @@
BEGIN;
-- Migration: extend documents.path length for environment prefixes
ALTER TABLE documents
ALTER COLUMN path TYPE VARCHAR(255);
COMMIT;
@@ -1,2 +0,0 @@
-- Drop transfer laying sequence
DROP SEQUENCE IF EXISTS transfer_laying_seq;
@@ -1,33 +0,0 @@
-- Create sequence for transfer laying movement number
CREATE SEQUENCE IF NOT EXISTS transfer_laying_seq START
WITH
1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE;
-- Set sequence starting value based on existing data (if any)
-- This prevents duplicate movement numbers if there's already data
DO $$ DECLARE max_existing INTEGER;
BEGIN
-- Check if table exists and has data
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE
table_schema = 'public'
AND table_name = 'transfer_to_layings'
) THEN
-- Get max ID from existing records
SELECT COALESCE(MAX(id), 0) INTO max_existing
FROM transfer_to_layings;
-- Set sequence to start after the highest existing ID
IF max_existing > 0 THEN PERFORM setval (
'transfer_laying_seq',
max_existing
);
END IF;
END IF;
END $$;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP COLUMN adj_number;
COMMIT;
@@ -1,10 +0,0 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN adj_number VARCHAR(255);
UPDATE adjustment_stocks
SET adj_number = CONCAT('ADJ-', LPAD(id::text, 5, '0'))
WHERE adj_number IS NULL;
COMMIT;
@@ -1,8 +0,0 @@
-- Remove columns from marketing_products
ALTER TABLE marketing_products
DROP COLUMN IF EXISTS week,
DROP COLUMN IF EXISTS weight_per_convertion,
DROP COLUMN IF EXISTS convertion_unit;
-- Remove column from marketings
ALTER TABLE marketings DROP COLUMN IF EXISTS marketing_type;
@@ -1,9 +0,0 @@
-- Add marketing_type to marketings table
ALTER TABLE marketings
ADD COLUMN IF NOT EXISTS marketing_type VARCHAR(50);
-- Add convertion fields to marketing_products table
ALTER TABLE marketing_products
ADD COLUMN IF NOT EXISTS convertion_unit VARCHAR(20),
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS week INTEGER;
@@ -1,47 +0,0 @@
BEGIN;
DO $$
DECLARE
fallback_fcr_id BIGINT;
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
ALTER TABLE project_flocks
ADD COLUMN fcr_id BIGINT;
END IF;
SELECT id INTO fallback_fcr_id
FROM fcrs
ORDER BY id ASC
LIMIT 1;
IF fallback_fcr_id IS NOT NULL THEN
UPDATE project_flocks
SET fcr_id = fallback_fcr_id
WHERE fcr_id IS NULL;
ALTER TABLE project_flocks
ALTER COLUMN fcr_id SET NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
ADD CONSTRAINT project_flocks_fcr_id_fkey
FOREIGN KEY (fcr_id) REFERENCES fcrs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END $$;
COMMIT;
@@ -1,26 +0,0 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
DROP COLUMN fcr_id;
END IF;
END $$;
COMMIT;
@@ -1,12 +0,0 @@
BEGIN;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS total_used_qty;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS usage_qty;
COMMIT;
@@ -1,15 +0,0 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
UPDATE recording_depletions
SET pending_qty = 0
WHERE pending_qty IS NULL OR pending_qty <> 0;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depletions_pending_zero
CHECK (pending_qty = 0);
COMMIT;
@@ -1,37 +0,0 @@
BEGIN;
DELETE FROM fifo_stock_v2_overconsume_rules
WHERE reason IN (
'fifo_v2_default_allow',
'fifo_v2_exception_ayam_depletion_block',
'fifo_v2_exception_marketing_block',
'fifo_v2_exception_transfer_block',
'fifo_v2_exception_adjustment_block',
'fifo_v2_exception_transfer_laying_block'
);
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_traits
WHERE source_table IN (
'purchase_items',
'stock_transfer_details',
'laying_transfer_targets',
'laying_transfer_sources',
'adjustment_stocks',
'recording_stocks',
'recording_depletions',
'recording_eggs',
'marketing_delivery_products',
'project_chickins',
'project_flock_populations'
);
DELETE FROM fifo_stock_v2_flag_members
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
DELETE FROM fifo_stock_v2_flag_groups
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
COMMIT;
@@ -1,250 +0,0 @@
BEGIN;
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
VALUES
('AYAM', 'AYAM', 10),
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
('PAKAN', 'PAKAN', 30),
('OVK', 'OVK', 40),
('TELUR', 'TELUR', 50),
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
ON CONFLICT (code) DO UPDATE
SET
name = EXCLUDED.name,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
VALUES
('DOC', 'AYAM', 10),
('PULLET', 'AYAM', 20),
('LAYER', 'AYAM', 30),
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
('PAKAN', 'PAKAN', 10),
('PRE-STARTER', 'PAKAN', 20),
('STARTER', 'PAKAN', 30),
('FINISHER', 'PAKAN', 40),
('OVK', 'OVK', 10),
('OBAT', 'OVK', 20),
('VITAMIN', 'OVK', 30),
('KIMIA', 'OVK', 40),
('TELUR', 'TELUR', 10),
('TELUR-UTUH', 'TELUR_GRADE', 10),
('TELUR-PUTIH', 'TELUR_GRADE', 20),
('TELUR-RETAK', 'TELUR_GRADE', 30),
('TELUR-PECAH', 'TELUR_GRADE', 40),
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
('TELUR-JUMBO', 'TELUR_GRADE', 60)
ON CONFLICT (flag_name) DO UPDATE
SET
flag_group_code = EXCLUDED.flag_group_code,
priority = EXCLUDED.priority,
updated_at = NOW();
INSERT INTO fifo_stock_v2_traits(
source_table,
lane,
date_table,
date_join_left_col,
date_join_right_col,
date_column,
fallback_date_column,
sort_priority,
id_column
)
VALUES
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id')
ON CONFLICT (source_table, lane) DO UPDATE
SET
date_table = EXCLUDED.date_table,
date_join_left_col = EXCLUDED.date_join_left_col,
date_join_right_col = EXCLUDED.date_join_right_col,
date_column = EXCLUDED.date_column,
fallback_date_column = EXCLUDED.fallback_date_column,
sort_priority = EXCLUDED.sort_priority,
id_column = EXCLUDED.id_column,
is_active = TRUE;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
-- AYAM STOCKABLE
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
-- AYAM USABLE
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
-- AFKIR/CULLING/MATI STOCKABLE
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
-- AFKIR/CULLING/MATI USABLE
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- PAKAN STOCKABLE
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- PAKAN USABLE
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- OVK STOCKABLE
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
-- OVK USABLE
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR STOCKABLE
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR USABLE
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
-- TELUR_GRADE STOCKABLE
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
-- TELUR_GRADE USABLE
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
is_active = EXCLUDED.is_active,
updated_at = NOW();
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code IS NULL
AND lane = 'USABLE'
AND reason = 'fifo_v2_default_allow'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code = 'AYAM'
AND function_code = 'RECORDING_DEPLETION_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_ayam_depletion_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'MARKETING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_marketing_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'STOCK_TRANSFER_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'ADJUSTMENT_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_adjustment_block'
);
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
WHERE NOT EXISTS (
SELECT 1 FROM fifo_stock_v2_overconsume_rules
WHERE flag_group_code IS NULL
AND function_code = 'TRANSFER_TO_LAYING_OUT'
AND lane = 'USABLE'
AND reason = 'fifo_v2_exception_transfer_laying_block'
);
COMMIT;
@@ -1,12 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS idx_adjustment_stocks_function_code;
DROP INDEX IF EXISTS idx_adjustment_stocks_transaction_type;
ALTER TABLE adjustment_stocks
DROP COLUMN IF EXISTS grand_total,
DROP COLUMN IF EXISTS price,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS transaction_type;
COMMIT;
@@ -1,23 +0,0 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(100) NOT NULL DEFAULT 'LEGACY',
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64),
ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
UPDATE adjustment_stocks
SET function_code = CASE
WHEN COALESCE(total_qty, 0) > 0 THEN 'ADJUSTMENT_IN'
WHEN COALESCE(usage_qty, 0) > 0 THEN 'ADJUSTMENT_OUT'
ELSE 'ADJUSTMENT_IN'
END
WHERE function_code IS NULL OR function_code = '';
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_transaction_type
ON adjustment_stocks(transaction_type);
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_function_code
ON adjustment_stocks(function_code);
COMMIT;
@@ -1,13 +0,0 @@
BEGIN;
-- Restore CHICKIN route if rollback is required.
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
UPDATE fifo_stock_v2_route_rules
SET is_active = TRUE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -1,151 +0,0 @@
BEGIN;
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
-- not physical stock consumption.
UPDATE fifo_stock_v2_route_rules
SET is_active = FALSE,
updated_at = NOW()
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins'
AND is_active = TRUE;
-- Release existing active allocations created by PROJECT_CHICKIN
-- and return warehouse qty back.
WITH released AS (
UPDATE stock_allocations
SET status = 'RELEASED',
released_at = COALESCE(released_at, NOW()),
updated_at = NOW(),
note = CASE
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
ELSE note || '; fifo_v2_chickin_conversion_release'
END
WHERE usable_type = 'PROJECT_CHICKIN'
AND status = 'ACTIVE'
RETURNING product_warehouse_id, qty
),
pw_delta AS (
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
FROM released
GROUP BY product_warehouse_id
)
UPDATE product_warehouses pw
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
FROM pw_delta d
WHERE pw.id = d.product_warehouse_id;
-- Resync stockable total_used columns from remaining ACTIVE allocations.
-- purchase_items (PURCHASE_ITEMS)
UPDATE purchase_items pi
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'PURCHASE_ITEMS'
GROUP BY stockable_id
) a
WHERE pi.id = a.stockable_id;
UPDATE purchase_items pi
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'PURCHASE_ITEMS'
AND sa.stockable_id = pi.id
);
-- stock_transfer_details (STOCK_TRANSFER_IN)
UPDATE stock_transfer_details std
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'STOCK_TRANSFER_IN'
GROUP BY stockable_id
) a
WHERE std.id = a.stockable_id;
UPDATE stock_transfer_details std
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
AND sa.stockable_id = std.id
);
-- adjustment_stocks (ADJUSTMENT_IN)
UPDATE adjustment_stocks ast
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'ADJUSTMENT_IN'
GROUP BY stockable_id
) a
WHERE ast.id = a.stockable_id;
UPDATE adjustment_stocks ast
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'ADJUSTMENT_IN'
AND sa.stockable_id = ast.id
);
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
UPDATE laying_transfer_targets ltt
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'TRANSFERTOLAYING_IN'
GROUP BY stockable_id
) a
WHERE ltt.id = a.stockable_id;
UPDATE laying_transfer_targets ltt
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
AND sa.stockable_id = ltt.id
);
-- recording_eggs (RECORDING_EGG)
UPDATE recording_eggs re
SET total_used = COALESCE(a.used, 0)
FROM (
SELECT stockable_id, SUM(qty) AS used
FROM stock_allocations
WHERE status = 'ACTIVE'
AND stockable_type = 'RECORDING_EGG'
GROUP BY stockable_id
) a
WHERE re.id = a.stockable_id;
UPDATE recording_eggs re
SET total_used = 0
WHERE NOT EXISTS (
SELECT 1
FROM stock_allocations sa
WHERE sa.status = 'ACTIVE'
AND sa.stockable_type = 'RECORDING_EGG'
AND sa.stockable_id = re.id
);
COMMIT;
@@ -1,13 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS idx_stock_allocations_purpose_stockable_active;
DROP INDEX IF EXISTS idx_stock_allocations_purpose_usable_active;
DROP INDEX IF EXISTS idx_stock_allocations_purpose_status;
ALTER TABLE stock_allocations
DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check;
ALTER TABLE stock_allocations
DROP COLUMN IF EXISTS allocation_purpose;
COMMIT;
@@ -1,33 +0,0 @@
BEGIN;
ALTER TABLE stock_allocations
ADD COLUMN IF NOT EXISTS allocation_purpose VARCHAR(32);
UPDATE stock_allocations
SET allocation_purpose = 'CONSUME'
WHERE allocation_purpose IS NULL
OR BTRIM(allocation_purpose) = '';
ALTER TABLE stock_allocations
ALTER COLUMN allocation_purpose SET DEFAULT 'CONSUME',
ALTER COLUMN allocation_purpose SET NOT NULL;
ALTER TABLE stock_allocations
DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check;
ALTER TABLE stock_allocations
ADD CONSTRAINT stock_allocations_allocation_purpose_check
CHECK (allocation_purpose IN ('CONSUME', 'TRACE_CHICKIN'));
CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_status
ON stock_allocations (allocation_purpose, status);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_usable_active
ON stock_allocations (allocation_purpose, usable_type, usable_id)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_stockable_active
ON stock_allocations (allocation_purpose, stockable_type, stockable_id)
WHERE status = 'ACTIVE';
COMMIT;
@@ -1,24 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS idx_stock_allocations_idempotency;
DROP INDEX IF EXISTS idx_stock_allocations_flag_group;
DROP INDEX IF EXISTS idx_stock_allocations_engine_version;
ALTER TABLE stock_allocations
DROP COLUMN IF EXISTS idempotency_key,
DROP COLUMN IF EXISTS reflow_run_id,
DROP COLUMN IF EXISTS function_code,
DROP COLUMN IF EXISTS flag_group_code,
DROP COLUMN IF EXISTS engine_version;
DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints;
DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs;
DROP TABLE IF EXISTS fifo_stock_v2_operation_log;
DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules;
DROP TABLE IF EXISTS fifo_stock_v2_route_rules;
DROP TABLE IF EXISTS fifo_stock_v2_traits;
DROP TABLE IF EXISTS fifo_stock_v2_flag_members;
DROP TABLE IF EXISTS fifo_stock_v2_flag_groups;
COMMIT;
@@ -1,151 +0,0 @@
BEGIN;
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups (
code VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) NOT NULL,
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members (
flag_name VARCHAR(64) PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
priority INT NOT NULL DEFAULT 100,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits (
id BIGSERIAL PRIMARY KEY,
source_table VARCHAR(64) NOT NULL,
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
date_table VARCHAR(64) NULL,
date_join_left_col VARCHAR(64) NULL,
date_join_right_col VARCHAR(64) NULL,
date_column VARCHAR(64) NOT NULL,
fallback_date_column VARCHAR(64) NULL,
sort_priority INT NOT NULL DEFAULT 100,
id_column VARCHAR(64) NOT NULL DEFAULT 'id',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (source_table, lane)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
function_code VARCHAR(64) NOT NULL,
source_table VARCHAR(64) NOT NULL,
source_id_column VARCHAR(64) NOT NULL DEFAULT 'id',
product_warehouse_col VARCHAR(64) NOT NULL,
quantity_col VARCHAR(64) NOT NULL,
used_quantity_col VARCHAR(64) NULL,
pending_quantity_col VARCHAR(64) NULL,
scope_sql TEXT NULL,
legacy_type_key VARCHAR(100) NOT NULL,
allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (flag_group_code, lane, function_code, source_table)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules (
id BIGSERIAL PRIMARY KEY,
flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code),
function_code VARCHAR(64) NULL,
lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')),
allow_overconsume BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 100,
reason TEXT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log (
id BIGSERIAL PRIMARY KEY,
idempotency_key VARCHAR(128) NOT NULL,
operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')),
product_warehouse_id BIGINT NOT NULL,
flag_group_code VARCHAR(64) NOT NULL,
usable_type VARCHAR(100) NULL,
usable_id BIGINT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')),
result_payload JSONB NULL,
error_text TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
UNIQUE (idempotency_key, operation)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs (
id BIGSERIAL PRIMARY KEY,
mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')),
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')),
as_of TIMESTAMPTZ NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ NULL,
total_shards INT NOT NULL DEFAULT 0,
processed_shards INT NOT NULL DEFAULT 0,
processed_rows BIGINT NOT NULL DEFAULT 0,
mismatch_rows BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NULL,
note TEXT NULL
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
flag_group_code VARCHAR(64) NOT NULL,
product_warehouse_id BIGINT NOT NULL,
last_sort_at TIMESTAMPTZ NULL,
last_source_table VARCHAR(64) NULL,
last_source_id BIGINT NULL,
status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (run_id, flag_group_code, product_warehouse_id)
);
CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
product_warehouse_id BIGINT NOT NULL,
stockable_type VARCHAR(100) NOT NULL,
stockable_id BIGINT NOT NULL,
usable_type VARCHAR(100) NOT NULL,
usable_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
sort_at TIMESTAMPTZ NULL,
source_table VARCHAR(64) NULL,
source_id BIGINT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable
ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id);
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable
ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id);
ALTER TABLE stock_allocations
ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1',
ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL,
ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL,
ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL;
CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version
ON stock_allocations(engine_version);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group
ON stock_allocations(flag_group_code);
CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency
ON stock_allocations(idempotency_key);
COMMIT;
@@ -1,15 +0,0 @@
BEGIN;
DROP INDEX IF EXISTS idx_laying_transfers_executed_by;
DROP INDEX IF EXISTS idx_laying_transfers_executed_at;
DROP INDEX IF EXISTS idx_laying_transfers_effective_move_date;
ALTER TABLE laying_transfers
DROP CONSTRAINT IF EXISTS fk_laying_transfers_executed_by;
ALTER TABLE laying_transfers
DROP COLUMN IF EXISTS executed_by,
DROP COLUMN IF EXISTS executed_at,
DROP COLUMN IF EXISTS effective_move_date;
COMMIT;
@@ -1,50 +0,0 @@
BEGIN;
ALTER TABLE laying_transfers
ADD COLUMN IF NOT EXISTS effective_move_date DATE,
ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS executed_by BIGINT;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users')
AND NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_laying_transfers_executed_by'
) THEN
ALTER TABLE laying_transfers
ADD CONSTRAINT fk_laying_transfers_executed_by
FOREIGN KEY (executed_by)
REFERENCES users(id)
ON DELETE SET NULL
ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_laying_transfers_effective_move_date
ON laying_transfers(effective_move_date);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_at
ON laying_transfers(executed_at);
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_by
ON laying_transfers(executed_by);
-- Backfill historical approved transfers. Before deferred execution,
-- approved transfers were executed immediately during approval.
UPDATE laying_transfers lt
SET
effective_move_date = COALESCE(lt.effective_move_date, lt.transfer_date),
executed_at = COALESCE(lt.executed_at, lt.updated_at),
executed_by = COALESCE(lt.executed_by, lt.created_by)
WHERE (
SELECT a.action
FROM approvals a
WHERE a.approvable_type = 'TRANSFER_TO_LAYINGS'
AND a.approvable_id = lt.id
ORDER BY a.id DESC
LIMIT 1
) = 'APPROVED';
COMMIT;
@@ -1,9 +0,0 @@
BEGIN;
DELETE FROM fifo_stock_v2_route_rules
WHERE flag_group_code = 'AYAM'
AND lane = 'USABLE'
AND function_code = 'CHICKIN_OUT'
AND source_table = 'project_chickins';
COMMIT;
@@ -1,34 +0,0 @@
BEGIN;
INSERT INTO fifo_stock_v2_route_rules(
flag_group_code,
lane,
function_code,
source_table,
source_id_column,
product_warehouse_col,
quantity_col,
used_quantity_col,
pending_quantity_col,
scope_sql,
legacy_type_key,
allow_pending_default,
is_active
)
VALUES
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
SET
source_id_column = EXCLUDED.source_id_column,
product_warehouse_col = EXCLUDED.product_warehouse_col,
quantity_col = EXCLUDED.quantity_col,
used_quantity_col = EXCLUDED.used_quantity_col,
pending_quantity_col = EXCLUDED.pending_quantity_col,
scope_sql = EXCLUDED.scope_sql,
legacy_type_key = EXCLUDED.legacy_type_key,
allow_pending_default = EXCLUDED.allow_pending_default,
updated_at = NOW(),
-- Keep existing is_active (do not override disable migration if it was intentional).
is_active = fifo_stock_v2_route_rules.is_active;
COMMIT;
+5 -25
View File
@@ -74,7 +74,7 @@ func seedUsers(tx *gorm.DB) (map[string]uint, error) {
}
func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor", "Butir"}
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"}
result := make(map[string]uint, len(names))
for _, name := range names {
@@ -235,7 +235,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Utuh",
Brand: "-",
Sku: "4",
Uom: "Butir",
Uom: "Gram",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurUtuh},
@@ -245,7 +245,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Pecah",
Brand: "-",
Sku: "5",
Uom: "Butir",
Uom: "Gram",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPecah},
@@ -255,7 +255,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Putih",
Brand: "-",
Sku: "6",
Uom: "Butir",
Uom: "Gram",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPutih},
@@ -265,32 +265,12 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Retak",
Brand: "-",
Sku: "7",
Uom: "Butir",
Uom: "Gram",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelurRetak},
IsVisible: false,
},
{
Name: "Telur Papacal",
Brand: "-",
Sku: "8",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
{
Name: "Telur Jumbo",
Brand: "-",
Sku: "9",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
}
for _, seed := range seeds {
+2 -6
View File
@@ -4,19 +4,15 @@ import "time"
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
StockLogId uint `gorm:"column:stock_log_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
TotalQty float64 `gorm:"column:total_qty;default:0"`
TotalUsed float64 `gorm:"column:total_used;default:0"`
UsageQty float64 `gorm:"column:usage_qty;default:0"`
PendingQty float64 `gorm:"column:pending_qty;default:0"`
Price float64 `gorm:"column:price;type:numeric(15,3);default:0"`
GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
}
+1 -1
View File
@@ -7,7 +7,7 @@ type Document struct {
DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"`
DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"`
Type string `gorm:"size:50;not null"`
Path string `gorm:"size:255;not null"`
Path string `gorm:"size:50;not null"`
Name string `gorm:"size:50;not null"`
Ext string `gorm:"size:50;not null"`
Size float64 `gorm:"type:numeric(15,3);not null"`
+6 -10
View File
@@ -12,20 +12,16 @@ type LayingTransfer struct {
FromProjectFlockId uint `gorm:"not null"`
ToProjectFlockId uint `gorm:"not null"`
TransferDate time.Time `gorm:"type:date;not null"`
EffectiveMoveDate *time.Time `gorm:"type:date"`
ExecutedAt *time.Time `gorm:"type:timestamptz"`
ExecutedBy *uint `gorm:"index"`
Notes string `gorm:"type:text"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
-1
View File
@@ -14,7 +14,6 @@ type Marketing struct {
SoDate time.Time `gorm:"type:date;not null"`
SalesPersonId uint `gorm:"not null"`
Notes string `gorm:"type:text"`
MarketingType string `gorm:"type:varchar(50)"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
+8 -11
View File
@@ -1,17 +1,14 @@
package entities
type MarketingProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingId uint `gorm:"not null"`
ProductWarehouseId uint `gorm:"not null"`
Qty float64 `gorm:"type:numeric(15,3);not null"`
ConvertionUnit *string `gorm:"type:varchar(20)"`
WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
Week *int `gorm:"type:integer"`
UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
Id uint `gorm:"primaryKey;autoIncrement"`
MarketingId uint `gorm:"not null"`
ProductWarehouseId uint `gorm:"not null"`
Qty float64 `gorm:"type:numeric(15,3);not null"`
UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
+2
View File
@@ -11,6 +11,7 @@ type ProjectFlock struct {
FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"`
ProductionStandardId uint `gorm:"column:production_standard_id"`
LocationId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"`
@@ -19,6 +20,7 @@ type ProjectFlock struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
-1
View File
@@ -21,7 +21,6 @@ type PurchaseItem struct {
Price float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
ExpenseNonstockId *uint64
HasChickin bool `gorm:"-" json:"-"`
// Relations
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
-2
View File
@@ -12,9 +12,7 @@ type Recording struct {
RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
Day *int `gorm:"column:day"`
TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
TotalDepletionCumQty *float64 `gorm:"-"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DepletionRate *float64 `gorm:"-"`
CumIntake *int `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"`
TotalChickQty *float64 `gorm:"column:total_chick_qty"`
-1
View File
@@ -6,7 +6,6 @@ type RecordingDepletion struct {
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
+1 -5
View File
@@ -10,9 +10,6 @@ const (
StockAllocationStatusPending = "PENDING"
StockAllocationStatusActive = "ACTIVE"
StockAllocationStatusReleased = "RELEASED"
StockAllocationPurposeConsume = "CONSUME"
StockAllocationPurposeTraceChickin = "TRACE_CHICKIN"
)
// StockAllocation links a usable record (consumption) with an incoming stock record.
@@ -25,8 +22,7 @@ type StockAllocation struct {
UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"`
UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"`
Qty float64 `gorm:"type:numeric(15,3);not null"`
AllocationPurpose string `gorm:"size:32;not null;default:CONSUME;index:stock_allocations_purpose_status,priority:1"`
Status string `gorm:"size:20;not null;default:ACTIVE;index:stock_allocations_purpose_status,priority:2"`
Status string `gorm:"size:20;not null;default:ACTIVE"`
Note *string `gorm:"type:text"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
-1
View File
@@ -9,7 +9,6 @@ type StockLog struct {
Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"`
Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"`
Stock float64 `gorm:"column:stock;type:numeric(15,3);not null;default:0"`
LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"`
LoggableId uint `gorm:"column:loggable_id;not null"`
+1 -1
View File
@@ -6,7 +6,7 @@ import "time"
type StockTransferDelivery struct {
Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64
SupplierId *uint64
SupplierId uint64
VehiclePlate string
DriverName string
DocumentNumber string
+8 -26
View File
@@ -7,8 +7,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
@@ -24,10 +24,6 @@ type AuthContext struct {
User *entity.User
Roles []sso.Role
Permissions map[string]struct{}
UserAreaIDs []uint
UserLocationIDs []uint
UserAllArea bool
UserAllLocation bool
}
// Auth validates the incoming request against the central SSO access token and
@@ -71,19 +67,15 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
var roles []sso.Role
permissions := make(map[string]struct{})
var profile *sso.UserProfile
if verification.UserID != 0 {
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else {
profile = p
}
}
if profile != nil {
roles = profile.Roles
for _, perm := range profile.PermissionNames() {
if perm != "" {
permissions[perm] = struct{}{}
} else if profile != nil {
roles = profile.Roles
for _, perm := range profile.PermissionNames() {
if perm != "" {
permissions[perm] = struct{}{}
}
}
}
}
@@ -94,16 +86,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
User: user,
Roles: roles,
Permissions: permissions,
UserAreaIDs: nil,
UserLocationIDs: nil,
UserAllArea: false,
UserAllLocation: false,
}
if profile != nil {
ctx.UserAreaIDs = profile.AreaIDs
ctx.UserLocationIDs = profile.LocationIDs
ctx.UserAllArea = profile.AllArea
ctx.UserAllLocation = profile.AllLocation
}
c.Locals(authContextLocalsKey, ctx)
-636
View File
@@ -1,636 +0,0 @@
package middleware
import (
"errors"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
type ScopeFilter struct {
IDs []uint
Restrict bool
}
type roleScope struct {
allArea bool
allLocation bool
areaIDs []uint
locationIDs []uint
hasAnyScopes bool
}
func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
scope, err := collectRoleScope(c)
if err != nil || !scope.hasAnyScopes {
return ScopeFilter{}, err
}
if scope.allArea || scope.allLocation {
return ScopeFilter{}, nil
}
allowed := uniqueUint(scope.areaIDs)
if len(scope.locationIDs) > 0 {
derived, err := areaIDsByLocationIDs(db, scope.locationIDs)
if err != nil {
return ScopeFilter{}, err
}
allowed = uniqueUint(append(allowed, derived...))
}
if len(allowed) == 0 {
return ScopeFilter{Restrict: true}, nil
}
return ScopeFilter{IDs: allowed, Restrict: true}, nil
}
func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
scope, err := collectRoleScope(c)
if err != nil || !scope.hasAnyScopes {
return ScopeFilter{}, err
}
if scope.allLocation || scope.allArea {
return ScopeFilter{}, nil
}
areaIDs := uniqueUint(scope.areaIDs)
locationIDs := uniqueUint(scope.locationIDs)
switch {
case len(locationIDs) > 0 && len(areaIDs) > 0:
filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs)
if err != nil {
return ScopeFilter{}, err
}
locationIDs = filtered
case len(locationIDs) == 0 && len(areaIDs) > 0:
derived, err := locationIDsByAreaIDs(db, areaIDs)
if err != nil {
return ScopeFilter{}, err
}
locationIDs = derived
}
locationIDs = uniqueUint(locationIDs)
if len(locationIDs) == 0 {
return ScopeFilter{Restrict: true}, nil
}
return ScopeFilter{IDs: locationIDs, Restrict: true}, nil
}
func ResolveLocationAreaScopes(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, ScopeFilter, error) {
locationScope, err := ResolveLocationScope(c, db)
if err != nil {
return ScopeFilter{}, ScopeFilter{}, err
}
areaScope, err := ResolveAreaScope(c, db)
if err != nil {
return ScopeFilter{}, ScopeFilter{}, err
}
return locationScope, areaScope, nil
}
func collectRoleScope(c *fiber.Ctx) (roleScope, error) {
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return roleScope{}, nil
}
userAreaIDs := uniqueUint(ctx.UserAreaIDs)
userLocationIDs := uniqueUint(ctx.UserLocationIDs)
userScope := roleScope{
allArea: ctx.UserAllArea,
allLocation: ctx.UserAllLocation,
areaIDs: userAreaIDs,
locationIDs: userLocationIDs,
hasAnyScopes: ctx.UserAllArea || ctx.UserAllLocation || len(userAreaIDs) > 0 || len(userLocationIDs) > 0,
}
if userScope.hasAnyScopes {
return userScope, nil
}
return roleScope{}, nil
}
func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(locationIDs) == 0 {
return nil, nil
}
var areaIDs []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("id IN ?", locationIDs).
Distinct("area_id").
Pluck("area_id", &areaIDs).Error; err != nil {
return nil, err
}
return areaIDs, nil
}
func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(areaIDs) == 0 {
return nil, nil
}
var locationIDs []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("area_id IN ?", areaIDs).
Distinct("id").
Pluck("id", &locationIDs).Error; err != nil {
return nil, err
}
return locationIDs, nil
}
func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(locationIDs) == 0 || len(areaIDs) == 0 {
return nil, nil
}
var filtered []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("id IN ?", locationIDs).
Where("area_id IN ?", areaIDs).
Distinct("id").
Pluck("id", &filtered).Error; err != nil {
return nil, err
}
return filtered, nil
}
func uniqueUint(ids []uint) []uint {
if len(ids) == 0 {
return nil
}
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB {
if db == nil || !scope.Restrict {
return db
}
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
return db.Where(column+" IN ?", scope.IDs)
}
func ApplyLocationScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
scope, err := ResolveLocationScope(c, scopeDB)
if err != nil {
return db, err
}
return ApplyScopeFilter(db, scope, column), nil
}
func ApplyAreaScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
scope, err := ResolveAreaScope(c, scopeDB)
if err != nil {
return db, err
}
return ApplyScopeFilter(db, scope, column), nil
}
func ApplyLocationAreaScope(c *fiber.Ctx, db *gorm.DB, locationColumn, areaColumn string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
if locationColumn != "" {
locationScope, err := ResolveLocationScope(c, scopeDB)
if err != nil {
return db, err
}
db = ApplyScopeFilter(db, locationScope, locationColumn)
}
if areaColumn != "" {
areaScope, err := ResolveAreaScope(c, scopeDB)
if err != nil {
return db, err
}
db = ApplyScopeFilter(db, areaScope, areaColumn)
}
return db, nil
}
func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error {
if warehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Warehouse{}).
Where("id = ?", warehouseID),
scope,
"warehouses.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
return nil
}
func EnsureAreaAccess(c *fiber.Ctx, db *gorm.DB, areaID uint) error {
if areaID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid area id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveAreaScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Area not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Area{}).
Where("id = ?", areaID),
scope,
"areas.id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Area not found")
}
return nil
}
func EnsureLocationAccess(c *fiber.Ctx, db *gorm.DB, locationID uint) error {
if locationID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Location not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Location{}).
Where("id = ?", locationID),
scope,
"locations.id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Location not found")
}
return nil
}
func EnsureKandangAccess(c *fiber.Ctx, db *gorm.DB, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Kandang{}).
Where("id = ?", kandangID),
scope,
"kandangs.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func EnsureProductWarehouseAccess(c *fiber.Ctx, db *gorm.DB, productWarehouseID uint) error {
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product warehouse id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("product_warehouses pw").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.id = ?", productWarehouseID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
return nil
}
func EnsureStockLogAccess(c *fiber.Ctx, db *gorm.DB, stockLogID uint) error {
if stockLogID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid stock log id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("stock_logs sl").
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("sl.id = ?", stockLogID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
}
return nil
}
func EnsureMarketingAccess(c *fiber.Ctx, db *gorm.DB, marketingID uint) error {
if marketingID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid marketing id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("marketings m").
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("m.id = ?", marketingID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
return nil
}
func EnsureRecordingAccess(c *fiber.Ctx, db *gorm.DB, recordingID uint) error {
if recordingID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid recording id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("recordings r").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("r.id = ?", recordingID)
q = ApplyScopeFilter(q, scope, "pf.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
return nil
}
func EnsureUniformityAccess(c *fiber.Ctx, db *gorm.DB, uniformityID uint) error {
if uniformityID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid uniformity id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("project_flock_kandang_uniformity u").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("u.id = ?", uniformityID)
q = ApplyScopeFilter(q, scope, "pf.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
return nil
}
func EnsureLayingTransferAccess(c *fiber.Ctx, db *gorm.DB, transferID uint) error {
if transferID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid transfer id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("laying_transfers lt").
Joins("JOIN project_flocks pf_from ON pf_from.id = lt.from_project_flock_id").
Joins("JOIN project_flocks pf_to ON pf_to.id = lt.to_project_flock_id").
Where("lt.id = ?", transferID).
Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs)
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil
}
func EnsureProjectFlockAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.ProjectFlock{}).
Where("id = ?", projectFlockID),
scope,
"project_flocks.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
return nil
}
func EnsureProjectFlockKandangAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock kandang id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("project_flock_kandangs").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangID)
if projectFlockID > 0 {
q = q.Where("project_flock_kandangs.project_flock_id = ?", projectFlockID)
}
q = ApplyScopeFilter(q, scope, "project_flocks.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil
}
@@ -44,15 +44,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10)
search := strings.TrimSpace(c.Query("search", ""))
orderByDate := strings.TrimSpace(c.Query("order_by_date", ""))
if orderByDate == "" {
orderByDate = "DESC"
} else {
orderByDate = strings.ToUpper(orderByDate)
if orderByDate != "ASC" && orderByDate != "DESC" {
return fiber.NewError(fiber.StatusBadRequest, "order_by_date must be either ASC or DESC")
}
}
query := &validation.Query{
ModuleName: moduleName,
@@ -61,7 +52,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
Page: page,
Limit: limit,
Search: search,
OrderByDate: orderByDate,
}
records, totalResults, err := u.ApprovalService.List(
@@ -71,7 +61,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
query.Page,
query.Limit,
query.Search,
query.OrderByDate,
)
if err != nil {
return err
@@ -7,5 +7,4 @@ type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
OrderByDate string `query:"order_by_date" validate:"omitempty,oneof=ASC DESC"`
}
@@ -347,12 +347,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil {
return err
}
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags)
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag)
return c.Status(fiber.StatusOK).
JSON(response.Success{
@@ -377,12 +377,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
}
result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil {
return err
}
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags)
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag)
return c.Status(fiber.StatusOK).
JSON(response.Success{
@@ -1,12 +1,8 @@
package dto
import (
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === CLOSING KEUANGAN CODES ===
// Closing HPP Codes
type ClosingHPPCode string
const (
@@ -18,30 +14,36 @@ const (
HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI"
)
// Closing Profit Loss Codes
type ClosingProfitLossCode string
const (
PLCodeSales ClosingProfitLossCode = "SALES"
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
PLCodeSales ClosingProfitLossCode = "SALES"
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI"
)
// === NEW CLOSING KEUANGAN DTO ===
// FinancialMetrics represents financial metrics with per unit and total amounts
type FinancialMetrics struct {
RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"`
}
// HPPItem represents an item in HPP section
type HPPItem struct {
ID uint `json:"id"`
Category string `json:"category"`
Code string `json:"code"`
Category string `json:"category"` // "purchase" or "overhead"
Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI"
Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"`
Realization FinancialMetrics `json:"realization"`
}
// HPPSummary represents summary for HPP section
type HPPSummary struct {
Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"`
@@ -50,41 +52,52 @@ type HPPSummary struct {
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
}
// HPPSection represents HPP data section
type HPPSection struct {
Items []HPPItem `json:"items"`
Items []HPPItem `json:"items"`
Summary HPPSummary `json:"summary"`
}
// ProfitLossItem represents an item in Profit & Loss section
type ProfitLossItem struct {
Code string `json:"code"`
Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI"
Label string `json:"label"`
Type string `json:"type"`
Type string `json:"type"` // "income", "purchase", "overhead"
RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"`
}
// ProfitLossSummary represents summary for Profit & Loss section
type ProfitLossSummary struct {
GrossProfit FinancialMetrics `json:"gross_profit"`
SubTotal FinancialMetrics `json:"sub_total"`
NetProfit FinancialMetrics `json:"net_profit"`
}
// ProfitLossSection represents Profit & Loss data section
type ProfitLossSection struct {
Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"`
Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"`
}
// ClosingKeuanganData represents the main data structure
type ClosingKeuanganData struct {
HPP HPPSection `json:"hpp"`
HPP HPPSection `json:"hpp"`
ProfitLoss ProfitLossSection `json:"profit_loss"`
}
type MetricsCalculator struct {
TotalPopulation float64
ActualPopulation float64
TotalWeightProduced float64
// ClosingKeuanganResponse represents the full API response
type ClosingKeuanganResponse struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Data ClosingKeuanganData `json:"data"`
}
// === MAPPER FUNCTIONS ===
// ToFinancialMetrics creates FinancialMetrics from values
func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
return FinancialMetrics{
RpPerBird: rpPerBird,
@@ -93,6 +106,7 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
}
}
// ToHPPItem creates HPP item
func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem {
return HPPItem{
ID: id,
@@ -104,6 +118,7 @@ func ToHPPItem(id uint, category, code, label string, budgeting, realization Fin
}
}
// ToHPPSummary creates HPP summary
func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary {
return HPPSummary{
Label: label,
@@ -114,6 +129,7 @@ func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudg
}
}
// ToHPPSection creates HPP section
func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
return HPPSection{
Items: items,
@@ -121,6 +137,7 @@ func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
}
}
// ToProfitLossItem creates Profit & Loss item
func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem {
return ProfitLossItem{
Code: code,
@@ -132,6 +149,7 @@ func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount f
}
}
// ToProfitLossSummary creates Profit & Loss summary
func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary {
return ProfitLossSummary{
GrossProfit: grossProfit,
@@ -140,6 +158,7 @@ func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) Prof
}
}
// ToProfitLossSection creates Profit & Loss section
func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection {
return ProfitLossSection{
Items: items,
@@ -147,6 +166,7 @@ func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) Prof
}
}
// ToClosingKeuanganData creates complete closing keuangan data
func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData {
return ClosingKeuanganData{
HPP: hpp,
@@ -154,72 +174,12 @@ func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) Closing
}
}
func (mc *MetricsCalculator) CalculateMetrics(amount float64) (rpPerBird, rpPerKg float64) {
if mc.ActualPopulation > 0 {
rpPerBird = amount / mc.ActualPopulation
// ToSuccessClosingKeuanganResponse creates success response
func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse {
return ClosingKeuanganResponse{
Code: 200,
Status: "success",
Message: "Get closing keuangan successfully",
Data: data,
}
if mc.TotalWeightProduced > 0 {
rpPerKg = amount / mc.TotalWeightProduced
}
return
}
func (mc *MetricsCalculator) CalculateProfitLossMetrics(amount float64) (rpPerBird, rpPerKg float64) {
if mc.TotalPopulation > 0 {
rpPerBird = amount / mc.TotalPopulation
}
if mc.TotalWeightProduced > 0 {
rpPerKg = amount / mc.TotalWeightProduced
}
return
}
type ProductFilter struct {
ProjectFlockCategory string
}
func (pf *ProductFilter) IsEggProduct(product entity.Product) bool {
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
if flagName == string(utils.FlagTelur) ||
flagName == string(utils.FlagTelurUtuh) ||
flagName == string(utils.FlagTelurPecah) ||
flagName == string(utils.FlagTelurPutih) ||
flagName == string(utils.FlagTelurRetak) {
return true
}
}
return false
}
func (pf *ProductFilter) IsChickenProduct(product entity.Product) bool {
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
if flagName == string(utils.FlagAyamAfkir) ||
flagName == string(utils.FlagAyamCulling) ||
flagName == string(utils.FlagAyamMati) {
return true
}
}
return false
}
func (pf *ProductFilter) ShouldIncludeProduct(product entity.Product) bool {
if pf.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
return pf.IsEggProduct(product)
}
return pf.IsChickenProduct(product) || (!pf.IsEggProduct(product) && !pf.IsChickenProduct(product))
}
func (pf *ProductFilter) FilterDeliveryProducts(deliveries []entity.MarketingDeliveryProduct) []entity.MarketingDeliveryProduct {
filtered := make([]entity.MarketingDeliveryProduct, 0)
for _, delivery := range deliveries {
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue
}
if pf.ShouldIncludeProduct(delivery.MarketingProduct.ProductWarehouse.Product) {
filtered = append(filtered, delivery)
}
}
return filtered
}
@@ -8,7 +8,6 @@ import (
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === Response DTO ===
@@ -45,17 +44,7 @@ type PenjualanRealisasiResponseDTO struct {
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
}
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate)
var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -101,44 +90,17 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
}
}
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
}
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
Qty: e.UsageQty,
}
}
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
count := len(e)
if count == 0 {
return SummaryDTO{
TotalSalesPrice: 0,
TotalActualPrice: 0,
AvgSalesPrice: 0,
AvgActualPrice: 0,
}
}
for _, item := range e {
totalSalesPrice += item.MarketingProduct.TotalPrice
totalActualPrice += item.TotalPrice
sumSales += item.MarketingProduct.UnitPrice
sumActual += item.UnitPrice
}
return SummaryDTO{
@@ -164,30 +126,11 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua
}
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) (int, int) {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0, 0
}
for _, flag := range productFlags {
if flag == string(utils.FlagOVK) ||
flag == string(utils.FlagPakan) ||
flag == string(utils.FlagPreStarter) ||
flag == string(utils.FlagStarter) ||
flag == string(utils.FlagFinisher) ||
flag == string(utils.FlagObat) ||
flag == string(utils.FlagVitamin) ||
flag == string(utils.FlagKimia) ||
flag == string(utils.FlagEkspedisi) ||
flag == string(utils.FlagTelur) ||
flag == string(utils.FlagTelurUtuh) ||
flag == string(utils.FlagTelurPecah) ||
flag == string(utils.FlagTelurPutih) ||
flag == string(utils.FlagTelurRetak) {
return 0, 0
}
}
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
if chickin.ChickInDate.Before(earliestChickinDate) {
@@ -202,12 +145,8 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
if ageInDays <= 0 {
ageInWeeks = 0
} else {
if category == string(utils.ProjectFlockCategoryLaying) {
ageInDays = ageInDays + 119
ageInWeeks = ((ageInDays - 1) / 7) + 1
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
return ageInDays, ageInWeeks
@@ -1,7 +1,6 @@
package dto
import (
"sort"
"strings"
"time"
)
@@ -65,7 +64,7 @@ type SapronakCategoryRowDTO struct {
QtyOut float64 `json:"qty_out"`
QtyUsed float64 `json:"qty_used"`
Description string `json:"description"`
ProductCategory []string `json:"product_category"`
ProductCategory string `json:"product_category"`
UnitPrice float64 `json:"unit_price"`
TotalAmount float64 `json:"total_amount"`
Notes string `json:"notes"`
@@ -128,7 +127,7 @@ type UomSummaryDTO struct {
// === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 {
@@ -136,10 +135,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
}
rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags)
return ToSapronakProjectAggregatedFromReport(&rep, flag)
}
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{}
if report == nil {
@@ -176,53 +175,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
return t.Format("02-Jan-2006")
}
flagOrder := map[string]int{
"DOC": 0,
"PAKAN": 0,
"OVK": 0,
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) []string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return []string{}
}
return []string{fallback}
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
for _, f := range rawFlags {
flagName := strings.ToUpper(strings.TrimSpace(f))
if flagName == "" {
continue
}
if _, ok := seen[flagName]; ok {
continue
}
seen[flagName] = struct{}{}
ordered = append(ordered, flagName)
}
sort.SliceStable(ordered, func(i, j int) bool {
li := ordered[i]
lj := ordered[j]
ri, iok := flagOrder[li]
rj, jok := flagOrder[lj]
if iok != jok {
if iok {
return true
}
return false
}
if iok && jok && ri != rj {
return ri < rj
}
return li < lj
})
return ordered
}
for _, group := range report.Groups {
flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey]
@@ -244,17 +196,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
for idx, item := range group.Items {
refKey := strings.TrimSpace(item.NoReferensi)
productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + refKey)
if refKey == "" {
productKey = strings.ToUpper(flagKey + "|" + item.ProductName + "|" + formatDate(item.Tanggal))
}
productKey := strings.ToUpper(flagKey + "|" + item.ProductName)
baseRow := SapronakCategoryRowDTO{
ID: idx + 1,
Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi,
Description: item.ProductName,
ProductCategory: buildFlagList(item.ProductID, flagKey),
ProductCategory: item.ProductName,
UnitPrice: item.Harga,
Notes: "-",
}
@@ -264,9 +212,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
switch strings.ToLower(item.JenisTransaksi) {
case "pembelian", "adjustment masuk", "mutasi masuk":
row.QtyIn += item.QtyMasuk
if item.Tanggal != nil {
row.Date = formatDate(item.Tanggal)
}
if row.UnitPrice == 0 {
if item.QtyMasuk > 0 && item.Nilai > 0 {
row.UnitPrice = item.Nilai / item.QtyMasuk
@@ -282,14 +227,14 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
row.Notes = "TRANSFER STOCK"
}
}
case "pemakaian":
case "pemakaian", "adjustment keluar":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
case "adjustment keluar", "mutasi keluar", "penjualan":
case "mutasi keluar", "penjualan":
price := row.UnitPrice
if price == 0 {
price = item.Harga
+2 -3
View File
@@ -25,6 +25,7 @@ type ClosingModule struct{}
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
closingRepo := rClosing.NewClosingRepository(db)
closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db)
userRepo := rUser.NewUserRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
@@ -39,11 +40,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
purchaseRepo := rPurchase.NewPurchaseRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
hppCostRepo := commonRepo.NewHppCostRepository(db)
hppService := commonSvc.NewHppService(hppCostRepo)
closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate)
closingKeuanganService := sClosing.NewClosingKeuanganService(projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo, hppService, hppCostRepo)
closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo)
sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
@@ -24,6 +24,7 @@ type ClosingRepository interface {
SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error)
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error)
@@ -31,11 +32,9 @@ type ClosingRepository interface {
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
}
@@ -101,12 +100,12 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
if len(params.WarehouseIDs) == 0 {
return []SapronakRow{}, 0, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -173,12 +172,12 @@ func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params S
if len(params.WarehouseIDs) == 0 {
return []SapronakSummaryRow{}, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -355,10 +354,9 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("f.name IN ?", flagNames).
Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price").
Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price").
Scan(&agg).Error
if err != nil {
return 0, 0, 0, err
@@ -392,6 +390,22 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla
return agg.TotalQty, nil
}
func (r *ClosingRepositoryImpl) GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) {
if fcrID == 0 {
return []entity.FcrStandard{}, nil
}
var standards []entity.FcrStandard
if err := r.DB().WithContext(ctx).
Where("fcr_id = ?", fcrID).
Order("weight ASC").
Find(&standards).Error; err != nil {
return nil, err
}
return standards, nil
}
func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) {
db := r.DB().WithContext(ctx)
@@ -439,7 +453,7 @@ SELECT
COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number,
'Pembelian' AS transaction_type,
'Purchase' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -488,7 +502,7 @@ SELECT
st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number,
'Mutasi' AS transaction_type,
'Internal Transfer In' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -522,7 +536,7 @@ SELECT
std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
st.reason AS notes
'Stock Refill' AS notes
FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -532,63 +546,13 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.to_warehouse_id IN ?
`
sapronakIncomingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'-' AS source_warehouse,
COALESCE(w.name, '') AS destination_warehouse,
'' AS destination,
COALESCE(ast.total_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.total_qty, 0) <> 0
`
sapronakOutgoingTransfersSQL = `
SELECT
CAST(st.id AS BIGINT) AS id,
st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number,
'Mutasi' AS transaction_type,
'Internal Transfer Out' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -622,7 +586,7 @@ SELECT
std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
st.reason AS notes
'Transfer to other unit' AS notes
FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -632,70 +596,13 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.from_warehouse_id IN ?
`
sapronakOutgoingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
COALESCE(w.name, '') AS source_warehouse,
'-' AS destination_warehouse,
'' AS destination,
COALESCE(ast.usage_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.usage_qty, 0) <> 0
AND EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
)
`
sapronakOutgoingMarketingsSQL = `
SELECT
CAST(mp.id AS BIGINT) AS id,
m.so_date AS sort_date,
TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text,
m.so_number AS reference_number,
'Penjualan' AS transaction_type,
'Trading Sales' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
@@ -743,7 +650,7 @@ WHERE pw.project_flock_kandang_id IN ?
FROM flags f
WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET')
)
`
)
@@ -801,23 +708,6 @@ var (
sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet)
)
func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB {
subquery := r.DB().
Table("flags").
Select("DISTINCT ON (flagable_id) flagable_id, name").
Where("flagable_type = ?", entity.FlagableTypeProduct).
Where("name IN ?", sapronakFlagsAll).
Order(fmt.Sprintf(
"flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END",
utils.FlagDOC,
utils.FlagPullet,
utils.FlagPakan,
utils.FlagOVK,
))
return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery)
}
func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow {
m := make(map[uint][]SapronakDetailRow)
for _, row := range rows {
@@ -854,12 +744,11 @@ func (r *ClosingRepositoryImpl) usageQuery(
COALESCE(p.product_price, 0) AS default_price
`)
db = applyJoins(db, joins...)
db = db.
return db.
Joins("JOIN product_warehouses pw ON "+pwJoinCond).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where(where, args...)
db = r.joinSapronakProductFlag(db, "p")
return db
}
func (r *ClosingRepositoryImpl) fetchSapronakUsage(
@@ -889,11 +778,11 @@ func (r *ClosingRepositoryImpl) detailQuery(
) *gorm.DB {
db := r.withCtx(ctx).
Table(table).
Joins("JOIN product_warehouses pw ON " + pwJoinCond).
Joins("JOIN products p ON p.id = pw.product_id")
Joins("JOIN product_warehouses pw ON "+pwJoinCond).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct)
db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
return db.Select(selectSQL).Where(where, args...)
}
@@ -983,86 +872,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
)
}
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
p_resolve.id AS product_id,
p_resolve.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at,
pc.chick_in_date,
r.record_datetime
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
CONCAT('CHICKIN-', pc.id),
CAST(r.id AS TEXT),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p_resolve.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?)
OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?)
`,
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID,
)
query = r.joinSapronakProductFlag(query, "p_resolve").
Group(`
p_resolve.id, p_resolve.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p_resolve.product_price
`)
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB {
db := r.withCtx(ctx).
return r.withCtx(ctx).
Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL")
return r.joinSapronakProductFlag(db, "p")
}
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) {
@@ -1129,14 +948,14 @@ func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID ui
COALESCE(sl.increase,0) AS increase,
COALESCE(sl.decrease,0) AS decrease,
COALESCE(p.product_price,0) AS price,
` + movementSelect + `
`+movementSelect+`
`).
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
if err := db.
Where("sl.loggable_type = ?", logType).
@@ -1179,76 +998,12 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
}
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB().
Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL").
Order("pi.product_warehouse_id, pi.received_date ASC")
incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("COALESCE(ast.total_qty, 0) > 0")
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery)
rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false)
if err != nil {
return nil, nil, err
}
outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast_in ON ast_in.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Joins("LEFT JOIN (?) pfp_po ON pfp_po.product_warehouse_id = pfp.product_warehouse_id", poByWarehouse).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
}
return incoming, outgoing, nil
in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) })
return in, out, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
@@ -1269,10 +1024,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("w.kandang_id = ?", kandangID).
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil {
return nil, nil, err
@@ -1297,10 +1052,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("w.kandang_id = ?", kandangID).
Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll)
incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p")
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil {
return nil, nil, err
@@ -1328,13 +1083,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
@@ -1360,13 +1114,12 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil {
return nil, nil, err
@@ -1378,7 +1131,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
return incoming, outgoing, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
@@ -1395,110 +1148,14 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
query = r.joinSapronakProductFlag(query, "p")
sales, err := scanAndGroupDetails(query)
if err != nil {
return nil, err
}
nonFifoQuery := r.withCtx(ctx).
Table("marketing_delivery_products AS mdp").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(mdp.delivery_date, mdp.created_at) AS date,
COALESCE(m.so_number, '') AS reference,
0 AS qty_in,
COALESCE(mdp.usage_qty, 0) AS qty_out,
COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
`).
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
entity.StockAllocationPurposeConsume,
).
Where("mdp.usage_qty > 0").
Where("sa.id IS NULL").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p")
nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err != nil {
return nil, err
}
for pid, rows := range nonFifoSales {
sales[pid] = append(sales[pid], rows...)
}
return sales, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.created_at
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group(`
pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at,
po.po_number, st.movement_number, lt.transfer_number, ast.id,
pi.price, p.product_price
`)
query = r.joinSapronakProductFlag(query, "p")
return scanAndGroupDetails(query)
}
@@ -0,0 +1,365 @@
package repository
import (
"context"
"fmt"
"sort"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
// ClosingKeuanganRepository handles database operations for closing keuangan
type ClosingKeuanganRepository interface {
repository.BaseRepository[interface{}]
// All Product Usage
GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error)
// Depletion per kandang
GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// Weight produced from uniformity per kandang
GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// DB returns the underlying GORM DB instance
DB() *gorm.DB
}
type ClosingKeuanganRepositoryImpl struct {
*repository.BaseRepositoryImpl[interface{}]
}
func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository {
return &ClosingKeuanganRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db),
}
}
// Result Rows
type ProductUsageRow struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
FlagNames string `gorm:"column:flag_names"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
TotalPengeluaran float64 `gorm:"column:total_pengeluaran"`
}
// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang
// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments
// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all
func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) {
if projectFlockKandangID == 0 {
return []ProductUsageRow{}, nil
}
type SubQueryResult struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
}
type AggregatedResult struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
PriceCount int `gorm:"-"` // For calculating average price
}
type FlagResult struct {
ProductID uint `gorm:"column:product_id"`
FlagNames string `gorm:"column:flag_names"`
}
var allResults []SubQueryResult
// Subquery 1: Recordings
var recordingsResults []SubQueryResult
err := r.DB().WithContext(ctx).
Table("recordings r").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(CASE "+
"WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+
"WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+
"WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+
"WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+
"WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+
"ELSE 0 END), 0) as total_qty, "+
"COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price").
Joins("JOIN recording_stocks rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'").
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'").
Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'").
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'").
Where("r.project_flock_kandangs_id = ?", projectFlockKandangID).
Where("r.deleted_at IS NULL").
Group("pw.product_id, p.name").
Scan(&recordingsResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get recordings product usage: %w", err)
}
fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID)
allResults = append(allResults, recordingsResults...)
// Subquery 2: Chickins
var chickinsResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("project_chickins pc").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Where("pc.usage_qty > 0").
Group("pw.product_id, p.name").
Scan(&chickinsResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get chickins product usage: %w", err)
}
fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID)
allResults = append(allResults, chickinsResults...)
// Subquery 3: Marketing Delivery
var marketingResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("marketing_delivery_products mdp").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&marketingResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get marketing product usage: %w", err)
}
fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID)
allResults = append(allResults, marketingResults...)
// Subquery 4: Laying Transfer Sources
var layingTransferResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("laying_transfer_sources lts").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&layingTransferResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err)
}
fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID)
allResults = append(allResults, layingTransferResults...)
// Subquery 5: Stock Transfer Details
var stockTransferResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("stock_transfer_details std").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(std.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&stockTransferResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err)
}
fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID)
allResults = append(allResults, stockTransferResults...)
// Subquery 6: Adjustment Stocks
var adjustmentResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("adjustment_stocks ads").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("ads.usage_qty > 0").
Group("pw.product_id, p.name").
Scan(&adjustmentResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get adjustment product usage: %w", err)
}
fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID)
allResults = append(allResults, adjustmentResults...)
fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults))
// Aggregate results by product_id
aggregatedMap := make(map[uint]*AggregatedResult)
for _, result := range allResults {
key := result.ProductID
if existing, exists := aggregatedMap[key]; exists {
existing.TotalQty += result.TotalQty
existing.Price += result.Price
existing.PriceCount++
} else {
aggregatedMap[key] = &AggregatedResult{
ProductID: result.ProductID,
ProductName: result.ProductName,
TotalQty: result.TotalQty,
Price: result.Price,
PriceCount: 1,
}
}
}
fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap))
// Get flags for all products
productIDs := make([]uint, 0, len(aggregatedMap))
for id := range aggregatedMap {
productIDs = append(productIDs, id)
}
var flagResults []FlagResult
if len(productIDs) > 0 {
err = r.DB().WithContext(ctx).
Table("products p").
Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names").
Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id").
Where("p.id IN ?", productIDs).
Group("p.id").
Scan(&flagResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get product flags: %w", err)
}
}
fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults))
// Build flag map
flagMap := make(map[uint]string)
for _, flag := range flagResults {
flagMap[flag.ProductID] = flag.FlagNames
}
// Combine results and calculate average price
results := make([]ProductUsageRow, 0, len(aggregatedMap))
for _, agg := range aggregatedMap {
avgPrice := float64(0)
if agg.PriceCount > 0 {
avgPrice = agg.Price / float64(agg.PriceCount)
}
flagNames := flagMap[agg.ProductID]
// Apply flag filters if provided
if len(flagFilters) > 0 {
// Check if any of the flagFilters exist in flagNames
matched := false
for _, filter := range flagFilters {
if containsIgnoreCase(flagNames, filter) {
matched = true
break
}
}
if !matched {
continue // Skip this product if no flag matches
}
}
results = append(results, ProductUsageRow{
ProductID: agg.ProductID,
ProductName: agg.ProductName,
FlagNames: flagNames,
TotalQty: agg.TotalQty,
Price: avgPrice,
TotalPengeluaran: agg.TotalQty * avgPrice,
})
}
fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results))
for i, r := range results {
fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n",
i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran)
}
// Sort by product name
sort.Slice(results, func(i, j int) bool {
return results[i].ProductName < results[j].ProductName
})
fmt.Printf("[REPO] Final sorted results: %d items\n", len(results))
return results, nil
}
// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang
func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
var result float64
err := r.DB().WithContext(ctx).
Table("recording_depletions").
Select("COALESCE(SUM(recording_depletions.qty), 0)").
Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangID).
Scan(&result).Error
return result, err
}
// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang
// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000
func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
if projectFlockKandangID == 0 {
return 0, nil
}
var uniformity struct {
MeanUp float64
ChickQtyOfWeight float64
}
err := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity").
Select("mean_up, chick_qty_of_weight").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id DESC").
Limit(1).
Scan(&uniformity).Error
if err != nil {
return 0, err
}
// Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000
totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000
return totalWeight, nil
}
// containsIgnoreCase checks if a string contains a substring (case-insensitive)
func containsIgnoreCase(str, substr string) bool {
return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr))
}
@@ -12,7 +12,6 @@ import (
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
@@ -99,11 +98,6 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
return nil, 0, err
}
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
statusFilter := ""
if params.ProjectStatus != nil {
@@ -117,12 +111,6 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withClosingRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id")
}
if params.LocationID != nil {
db = db.Where("location_id = ?", *params.LocationID)
}
@@ -162,10 +150,6 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
}
func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
@@ -177,13 +161,6 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj
}
func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
}
realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID)
if err != nil {
@@ -197,8 +174,8 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectF
}
func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if kandangID != nil {
@@ -282,7 +259,7 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
statusProject := "Belum Selesai"
var approvalDate string
if s.ApprovalSvc != nil {
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "", "")
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "")
if err != nil {
s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data")
@@ -344,8 +321,8 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
}
func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, 0, err
if projectFlockID == 0 {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if params == nil {
@@ -367,7 +344,15 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -451,7 +436,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -494,16 +479,13 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
return items, nil
}
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) {
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) {
var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx)
query := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID)
if kandangID != nil && *kandangID > 0 {
query = query.Where("id = ?", *kandangID)
}
if err := query.Pluck("kandang_id", &kandangIDs).Error; err != nil {
if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID).
Pluck("kandang_id", &kandangIDs).Error; err != nil {
return nil, err
}
@@ -555,7 +537,7 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
return "", "Belum Selesai", nil
}
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "", "")
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "")
if err != nil {
return "", "", err
}
@@ -598,14 +580,6 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
}
func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
}
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
@@ -694,12 +668,8 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
}
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID)
@@ -773,10 +743,6 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week")
}
if !isGrowing && currentWeek != 0 {
currentWeek = currentWeek + 17
}
targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing)
if err != nil {
s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err)
@@ -836,7 +802,15 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
finalPopulation := population - claimCulling
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID)
var standards []entity.FcrStandard
if project.FcrId > 0 {
standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId)
if err != nil {
s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
}
}
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data")
@@ -856,7 +830,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
// FeedUsedPerHead: feedUsedPerHead,
}
chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagLayer)}
chickenFlagNames := []string{string(utils.FlagPullet)}
chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames)
if err != nil {
s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err)
@@ -885,7 +859,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0
}
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age)
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards)
if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording
}
@@ -935,7 +909,7 @@ chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenS
eggDepletion = 0
}
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age)
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards)
if fcrActFromRecording != nil {
eggPerf.FcrAct = *fcrActFromRecording
}
@@ -993,10 +967,10 @@ chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenS
performance.EggMass = eggMass
}
}
performance.DeffFcr = performance.FcrStd - performance.FcrAct
if productionStandardDetail != nil {
if productionStandardDetail.StandardFCR != nil {
performance.FcrStd = *productionStandardDetail.StandardFCR
performance.DeffFcr = performance.FcrStd - performance.FcrAct
}
if !isGrowing {
if productionStandardDetail.TargetHenDayProduction != nil {
@@ -1023,24 +997,38 @@ chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenS
return &result, nil
}
func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) (float64, error) {
penjualan, err := s.MarketingDeliveryProductRepo.GetClosingPenjualanForAgeChickDataProduction(ctx, projectFlockID, projectFlockKandangID)
func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) {
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB {
return db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins")
})
if err != nil {
return 0, err
}
acumulateAgeQty := 0.0
totalQty := 0.0
for _, v := range penjualan {
sale := dto.ToSalesAgeDTO(v)
acumulateAgeQty += float64(sale.Age) * sale.Qty
totalQty += sale.Qty
}
if totalQty > 0 {
averageAge := acumulateAgeQty / totalQty
return averageAge, nil
var (
totalQty float64
totalAgeWeeks float64
)
for _, product := range deliveryProducts {
if product.UsageQty == 0 {
continue
}
projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang
ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate)
totalAgeWeeks += float64(ageWeeks) * product.UsageQty
totalQty += product.UsageQty
}
return 0, err
if totalQty == 0 {
return 0, nil
}
return totalAgeWeeks / totalQty, nil
}
func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) {
@@ -1083,8 +1071,8 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc
return week, nil
}
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := 0.0, 0.0
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := closestFcrValues(standards, averageWeight)
fcrAct := 0.0
if totalWeight > 0 {
@@ -1116,3 +1104,21 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul
AwgAct: awg,
}
}
func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) {
if len(standards) == 0 || averageWeight <= 0 {
return 0, 0
}
closest := standards[0]
minDiff := math.Abs(closest.Weight - averageWeight)
for _, std := range standards[1:] {
diff := math.Abs(std.Weight - averageWeight)
if diff < minDiff {
minDiff = diff
closest = std
}
}
return closest.Mortality, closest.FcrNumber
}
@@ -2,19 +2,20 @@ package service
import (
"errors"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
@@ -24,29 +25,9 @@ type ClosingKeuanganService interface {
GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error)
}
// CostData holds all cost-related information
type CostData struct {
FeedCost float64
OvkCost float64
ChickenCost float64
ExpeditionCost float64
BudgetOperational float64
RealizationOperational float64
}
// ProductionData holds all production and sales related information
type ProductionData struct {
TotalPopulationIn float64
TotalDepletion float64
TotalWeightProduced float64
TotalEggWeightKg float64
TotalWeightSold float64
TotalBirdSold float64
TotalSalesAmount float64
}
type closingKeuanganService struct {
Log *logrus.Logger
ClosingKeuanganRepo repository.ClosingKeuanganRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository
ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
@@ -54,11 +35,10 @@ type closingKeuanganService struct {
ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository
ChickinRepo chickinRepository.ProjectChickinRepository
RecordingRepo recordingRepository.RecordingRepository
HppSvc commonSvc.HppService
HppRepo commonRepo.HppCostRepository
}
func NewClosingKeuanganService(
closingKeuanganRepo repository.ClosingKeuanganRepository,
projectFlockRepo projectflockRepository.ProjectflockRepository,
projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository,
marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository,
@@ -66,11 +46,10 @@ func NewClosingKeuanganService(
projectBudgetRepo projectflockRepository.ProjectBudgetRepository,
chickinRepo chickinRepository.ProjectChickinRepository,
recordingRepo recordingRepository.RecordingRepository,
hppSvc commonSvc.HppService,
hppRepo commonRepo.HppCostRepository,
) ClosingKeuanganService {
return &closingKeuanganService{
Log: utils.Log,
ClosingKeuanganRepo: closingKeuanganRepo,
ProjectFlockRepo: projectFlockRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
@@ -78,8 +57,6 @@ func NewClosingKeuanganService(
ProjectBudgetRepo: projectBudgetRepo,
ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo,
HppSvc: hppSvc,
HppRepo: hppRepo,
}
}
@@ -96,12 +73,30 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Preload Nonstock.Flags manually
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
// Get all kandang for this project flock
kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs")
}
return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs)
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
}
func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) {
@@ -112,11 +107,12 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, err
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
// Validate and fetch project flock kandang
kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found")
}
if projectFlockKandang.ProjectFlockId != projectFlockID {
if kandang.ProjectFlockId != projectFlockID {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock")
}
@@ -125,253 +121,417 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
projectFlockKandangs := []entity.ProjectFlockKandang{*projectFlockKandang}
return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs)
}
func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang) (*dto.ClosingKeuanganData, error) {
var projectFlockKandangIDs []uint
for _, projectFlockKandang := range projectFlockKandangs {
projectFlockKandangIDs = append(projectFlockKandangIDs, projectFlockKandang.Id)
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
isPerKandang := len(projectFlockKandangs) == 1
// Preload Nonstock.Flags manually
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
kandangs := []entity.ProjectFlockKandang{*kandang}
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
}
func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) {
// Define flag filters using constants
pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)}
ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}
ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}
allFilters := append(pakanFilters, ovkFilters...)
allFilters = append(allFilters, ayamFilters...)
var allProductUsageRows []repository.ProductUsageRow
// Get ALL product usage
for _, kandang := range kandangs {
rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters)
if err == nil {
allProductUsageRows = append(allProductUsageRows, rows...)
}
}
// Classify into categories based on flag priority
var pakanProductUsageRows []repository.ProductUsageRow
var ovkProductUsageRows []repository.ProductUsageRow
var ayamProductUsageRows []repository.ProductUsageRow
for _, row := range allProductUsageRows {
// Parse flag names from comma-separated string
flagNames := strings.Split(row.FlagNames, ",")
hasPakanFlag := false
hasOvkFlag := false
hasAyamFlag := false
for _, flag := range flagNames {
flag = strings.TrimSpace(flag)
if containsItem(pakanFilters, flag) {
hasPakanFlag = true
}
if containsItem(ovkFilters, flag) {
hasOvkFlag = true
}
if containsItem(ayamFilters, flag) {
hasAyamFlag = true
}
}
// Priority: PAKAN > OVK > AYAM
if hasPakanFlag {
pakanProductUsageRows = append(pakanProductUsageRows, row)
} else if hasOvkFlag {
ovkProductUsageRows = append(ovkProductUsageRows, row)
} else if hasAyamFlag {
ayamProductUsageRows = append(ayamProductUsageRows, row)
} else {
continue
}
}
// Calculate total price for each category
var totalPakanPrice, totalOvkPrice, totalAyamPrice float64
for _, row := range pakanProductUsageRows {
totalPakanPrice += row.TotalPengeluaran
}
for _, row := range ovkProductUsageRows {
totalOvkPrice += row.TotalPengeluaran
}
for _, row := range ayamProductUsageRows {
totalAyamPrice += row.TotalPengeluaran
}
// Determine if this is per-kandang or per-project-flock scope
isPerKandang := len(kandangs) == 1
var projectFlockKandangID *uint
if isPerKandang {
kandangID := projectFlockKandangs[0].Id
kandangID := kandangs[0].Id
projectFlockKandangID = &kandangID
}
costs, err := s.calculateCosts(c, projectFlock, projectFlockKandangs, projectFlockKandangIDs, projectFlockKandangID)
if err != nil {
return nil, err
}
productionData, err := s.calculateProductionData(c, projectFlock, projectFlockKandangIDs, projectFlockKandangID)
if err != nil {
return nil, err
}
hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData)
profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData)
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
}
func (s closingKeuanganService) calculateCosts(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*CostData, error) {
costs := &CostData{}
var err error
costs.FeedCost, err = s.HppRepo.GetFeedUsageCost(c.Context(), projectFlockKandangIDs, nil)
if err != nil {
costs.FeedCost = 0
}
costs.OvkCost, err = s.HppRepo.GetOvkUsageCost(c.Context(), projectFlockKandangIDs, nil)
if err != nil {
costs.OvkCost = 0
}
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
for _, projectFlockKandang := range projectFlockKandangs {
depresiasiCost, err := s.HppSvc.GetDepresiasiTransfer(projectFlockKandang.Id, nil)
if err == nil {
costs.ChickenCost += depresiasiCost
}
pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id)
if err == nil {
costs.ChickenCost += pulletCost
}
}
// Fetch realizations
var realizations []entity.ExpenseRealization
if isPerKandang && projectFlockKandangID != nil {
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID)
} else {
for _, projectFlockKandang := range projectFlockKandangs {
pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id)
if err == nil {
costs.ChickenCost += pulletCost
}
}
}
costs.ExpeditionCost, err = s.HppRepo.GetExpedisionCost(c.Context(), projectFlockKandangIDs)
if err != nil {
costs.ExpeditionCost = 0
}
if budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlock.Id); err == nil {
totalBudget := 0.0
for _, budget := range budgets {
totalBudget += budget.Price * budget.Qty
}
if projectFlockKandangID != nil {
allKandangs, errKandang := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
if errKandang == nil && len(allKandangs) > 0 {
costs.BudgetOperational = totalBudget / float64(len(allKandangs))
}
} else {
costs.BudgetOperational = totalBudget
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch budgets for project_flock_id=%d: %+v", projectFlock.Id, err)
}
if realizations, err := s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID); err == nil {
for _, realization := range realizations {
amount := realization.Price * realization.Qty
isEkspedisi := realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Nonstock != nil &&
containsFlag(realization.ExpenseNonstock.Nonstock.Flags, "EKSPEDISI")
if !isEkspedisi {
costs.RealizationOperational += amount
}
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch realizations for project_flock_id=%d: %+v", projectFlock.Id, err)
}
return costs, nil
}
func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*ProductionData, error) {
data := &ProductionData{}
var err error
data.TotalPopulationIn, err = s.HppRepo.GetTotalPopulation(c.Context(), projectFlockKandangIDs)
if err != nil {
data.TotalPopulationIn = 0
}
if projectFlockKandangID != nil {
data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil)
}
if err != nil {
data.TotalDepletion = 0
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
}
if projectFlockKandangID != nil {
data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
data.TotalWeightProduced = 0
}
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
_, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil)
if err != nil {
data.TotalEggWeightKg = 0
}
}
var deliveryProducts []entity.MarketingDeliveryProduct
if projectFlockKandangID != nil {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category)
} else {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, nil, projectFlock.Category)
}
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB {
db = db.Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product")
return db
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products")
}
// Filter by kandang if scope is per-kandang (manual filtering after fetch)
if isPerKandang && projectFlockKandangID != nil {
filteredProducts := make([]entity.MarketingDeliveryProduct, 0)
for _, dp := range deliveryProducts {
pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId
if pfKandangID != nil && *pfKandangID == *projectFlockKandangID {
filteredProducts = append(filteredProducts, dp)
}
}
deliveryProducts = filteredProducts
}
// Fetch chickins
var chickins []entity.ProjectChickin
if isPerKandang && projectFlockKandangID != nil {
chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins")
}
// Get total depletion
var totalDepletion float64
if isPerKandang && projectFlockKandangID != nil {
totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
totalDepletion = 0
}
totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id)
if err != nil {
}
// Try to get actual weight from uniformity data
var totalWeightFromUniformity float64
if isPerKandang && projectFlockKandangID != nil {
totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
} else if totalWeightFromUniformity > 0 {
totalWeightProduced = totalWeightFromUniformity
}
// Fetch egg data only for Laying category
var totalEggWeightKg float64
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// TODO: Replace with actual method to get egg weight from RecordingRepo
// totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id)
// For now, set to 0 as placeholder
totalEggWeightKg = 0
} else {
totalEggWeightKg = 0
}
// Build new DTO structure
// Calculate totals
var totalPopulation float64
for _, chickin := range chickins {
totalPopulation += chickin.UsageQty
}
// Calculate actual population (total population - depletion)
actualPopulation := totalPopulation - totalDepletion
// Calculate budget totals by category
calculateBudgetByFlag := func(flags []string) float64 {
var total float64
for _, budget := range budgets {
if budget.Nonstock != nil {
for _, nonstockFlag := range budget.Nonstock.Flags {
flagName := strings.ToUpper(nonstockFlag.Name)
for _, targetFlag := range flags {
if flagName == strings.ToUpper(targetFlag) {
total += budget.Price * budget.Qty
break
}
}
}
}
}
return total
}
// Budget per category
budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"})
budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"})
budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"})
budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"})
// Operational budget = total budget - pakan - ovk - ayam - ekspedisi
totalBudgetAmount := 0.0
for _, budget := range budgets {
totalBudgetAmount += budget.Price * budget.Qty
}
budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi
// Calculate realization totals
var totalRealizationAmount float64
var totalEkspedisiRealization float64
for _, realization := range realizations {
amount := realization.Price * realization.Qty
totalRealizationAmount += amount
// Check if this is ekspedisi (need to check nonstock flags)
if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil {
for _, flag := range realization.ExpenseNonstock.Nonstock.Flags {
if flag.Name == "EKSPEDISI" {
totalEkspedisiRealization += amount
break
}
}
}
}
totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization
// Filter delivery products based on category
var filteredDeliveryProducts []entity.MarketingDeliveryProduct
for _, delivery := range deliveryProducts {
// Get product from delivery
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue
}
data.TotalWeightSold += delivery.TotalWeight
data.TotalBirdSold += delivery.UsageQty
data.TotalSalesAmount += delivery.TotalPrice
product := delivery.MarketingProduct.ProductWarehouse.Product
isEggProduct := false
isChickenProduct := false
// Check product flags
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
// Egg product flags
if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" ||
flagName == "TELURPUTIH" || flagName == "TELURRETAK" {
isEggProduct = true
}
// Chicken product flags
if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" {
isChickenProduct = true
}
}
// Filter based on project flock category
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// Laying: only egg products
if isEggProduct {
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
} else {
// Growing/Contract Growing: only chicken products
if isChickenProduct || (!isEggProduct && !isChickenProduct) {
// Include if chicken product or if no specific flags (default to chicken)
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
}
}
return data, nil
}
func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection {
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok {
actualPopulation = lastPopulation
}
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
weightForCalculation := totalWeightProduced
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
weightForCalculation = totalEggWeightKg
// Calculate total weight sold and sales amount from filtered products
var totalWeightSold float64
var totalSalesAmount float64
for _, delivery := range filteredDeliveryProducts {
totalWeightSold += delivery.TotalWeight
totalSalesAmount += delivery.TotalPrice
}
// Calculate metrics - always use kg ayam for rp_per_kg
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
rpPerBird = amount / actualPopulation // Use actual population
}
if weightForCalculation > 0 {
rpPerKg = amount / weightForCalculation
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
return
}
createHPPItem := func(id uint, category, code, label string, budgetAmount, realizationAmount float64) dto.HPPItem {
budgetRpPerBird, budgetRpPerKg := calculateMetrics(budgetAmount)
realizationRpPerBird, realizationRpPerKg := calculateMetrics(realizationAmount)
return dto.ToHPPItem(
id,
category,
code,
label,
dto.ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount),
dto.ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
)
// Calculate metrics for profit loss (use total population and total weight produced)
calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if totalPopulation > 0 {
rpPerBird = amount / totalPopulation
}
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
return
}
// Build HPP Items using constants
hppItems := []dto.HPPItem{}
hppItems = append(hppItems, createHPPItem(1, "purchase", string(dto.HPPCodePakan), "Pembelian Pakan", costs.FeedCost, costs.FeedCost))
hppItems = append(hppItems, createHPPItem(2, "purchase", string(dto.HPPCodeOVK), "Pembelian OVK", costs.OvkCost, costs.OvkCost))
// PAKAN item
pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan)
pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice)
hppItems = append(hppItems, dto.ToHPPItem(
1,
"purchase",
string(dto.HPPCodePakan),
"Pembelian Pakan",
dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan),
dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice),
))
// OVK item
ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk)
ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice)
hppItems = append(hppItems, dto.ToHPPItem(
2,
"purchase",
string(dto.HPPCodeOVK),
"Pembelian OVK",
dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk),
dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice),
))
// DOC/DEPRESIASI item
docCode := string(dto.HPPCodeDOC)
docLabel := "Pembelian DOC"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
docCode = string(dto.HPPCodeDepresiasi)
docLabel = "Depresiasi"
}
hppItems = append(hppItems, createHPPItem(3, "purchase", docCode, docLabel, costs.ChickenCost, costs.ChickenCost))
hppItems = append(hppItems, createHPPItem(4, "overhead", string(dto.HPPCodeOverhead), "Pengeluaran Overhead", costs.BudgetOperational, costs.RealizationOperational))
hppItems = append(hppItems, createHPPItem(5, "overhead", string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", costs.ExpeditionCost, costs.ExpeditionCost))
docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam)
docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice)
hppItems = append(hppItems, dto.ToHPPItem(
3,
"purchase",
docCode,
docLabel,
dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam),
dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice),
))
totalBudgetHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.BudgetOperational + costs.ExpeditionCost
totalRealizationHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.RealizationOperational + costs.ExpeditionCost
// OVERHEAD item
overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational)
overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization)
hppItems = append(hppItems, dto.ToHPPItem(
4,
"overhead",
string(dto.HPPCodeOverhead),
"Pengeluaran Overhead",
dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational),
dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization),
))
// EKSPEDISI item
ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi)
ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization)
hppItems = append(hppItems, dto.ToHPPItem(
5,
"overhead",
string(dto.HPPCodeEkspedisi),
"Beban Ekspedisi",
dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi),
dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization),
))
// HPP Summary
totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi
totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization
hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp)
hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp)
var eggBudgeting, eggRealization *dto.FinancialMetrics
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
accumulateEggMetrics := func(metrics **dto.FinancialMetrics, amount, rpPerKg float64) {
if *metrics == nil {
*metrics = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: rpPerKg,
Amount: amount,
}
} else {
(*metrics).Amount += amount
if totalEggWeightKg > 0 {
(*metrics).RpPerKg = (*metrics).Amount / totalEggWeightKg
}
}
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 {
eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg
eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg
eggBudgeting = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: eggBudgetRpPerKg,
Amount: totalBudgetHpp,
}
for _, projectFlockKandang := range projectFlockKandangs {
hppResponse, err := s.HppSvc.CalculateHppCost(projectFlockKandang.Id, nil)
if err == nil {
accumulateEggMetrics(&eggBudgeting, hppResponse.Estimation.Total, hppResponse.Estimation.HargaKg)
accumulateEggMetrics(&eggRealization, hppResponse.Real.Total, hppResponse.Real.HargaKg)
}
eggRealization = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: eggRealizationRpPerKg,
Amount: totalRealizationHpp,
}
}
@@ -383,82 +543,15 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
eggRealization,
)
return dto.ToHPPSection(hppItems, hppSummary)
}
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection {
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
totalSalesAmount := production.TotalSalesAmount
totalWeightSold := production.TotalWeightSold
totalBirdSold := production.TotalBirdSold
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
// Fungsi untuk sales: LAYING = populasi aktual, GROWING = ekor terjual
calculateSalesMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if isLaying {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} else {
if totalBirdSold > 0 {
rpPerBird = amount / totalBirdSold
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
}
return
}
// Fungsi untuk cost: per ekor = populasi aktual, per kg = LAYING telur produksi / GROWING ayam produksi
calculateCostMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if isLaying {
if totalEggWeightKg > 0 {
rpPerKg = amount / totalEggWeightKg
}
} else {
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
}
return
}
// Fungsi untuk overhead/ekspedisi: LAYING = populasi aktual, GROWING = ekor terjual
calculateOverheadMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if isLaying {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} else {
if totalBirdSold > 0 {
rpPerBird = amount / totalBirdSold
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
}
return
}
hppSection := dto.ToHPPSection(hppItems, hppSummary)
// Build Profit Loss Items using constants
plItems := []dto.ProfitLossItem{}
salesRpPerBird, salesRpPerKg := calculateSalesMetrics(totalSalesAmount)
// SALES item
salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount)
salesLabel := "Penjualan Ayam"
if isLaying {
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
salesLabel = "Penjualan Telur"
}
plItems = append(plItems, dto.ToProfitLossItem(
@@ -470,100 +563,76 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
totalSalesAmount,
))
totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost
sapronakRpPerBird := 0.0
sapronakRpPerKg := 0.0
for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} {
rpPerBird, rpPerKg := calculateCostMetrics(amount)
sapronakRpPerBird += rpPerBird
sapronakRpPerKg += rpPerKg
}
// SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK
totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice
sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird
sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg
sapronakLabel := "Pengeluaran Sapronak"
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeSapronak),
"Pengeluaran Sapronak",
sapronakLabel,
"purchase",
sapronakRpPerBird,
sapronakRpPerKg,
totalSapronakAmount,
))
overheadRpPerBird, overheadRpPerKg := calculateOverheadMetrics(costs.RealizationOperational)
// OVERHEAD item
overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization)
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeOverhead),
"Overhead",
"overhead",
overheadRpPerBird,
overheadRpPerKg,
costs.RealizationOperational,
totalOperationalRealization,
))
ekspedisiRpPerBird, ekspedisiRpPerKg := calculateOverheadMetrics(costs.ExpeditionCost)
// EKSPEDISI item
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeEkspedisi),
"Ekspedisi",
"overhead",
ekspedisiRpPerBird,
ekspedisiRpPerKg,
costs.ExpeditionCost,
ekspedisiRealizationRpPerBird,
ekspedisiRealizationRpPerKg,
totalEkspedisiRealization,
))
costOfGoodsSold := costs.ChickenCost + costs.FeedCost + costs.OvkCost
// Profit Loss Summary
// Gross Profit = Sales - (DOC + PAKAN + OVK) only
// Gross Profit should NOT include overhead and ekspedisi
costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice
costOfGoodsSoldRpPerBird := sapronakRpPerBird
costOfGoodsSoldRpPerKg := sapronakRpPerKg
grossProfit := totalSalesAmount - costOfGoodsSold
grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird
grossProfitRpPerKg := salesRpPerKg - costOfGoodsSoldRpPerKg
totalOperatingExpenses := costs.RealizationOperational + costs.ExpeditionCost
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRpPerBird
totalOperatingExpensesRpPerKg := overheadRpPerKg + ekspedisiRpPerKg
// Operating Expenses (Overhead + Ekspedisi)
totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird
// Net Profit = Gross Profit - Operating Expenses
netProfit := grossProfit - totalOperatingExpenses
netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird
netProfitRpPerKg := grossProfitRpPerKg - totalOperatingExpensesRpPerKg
plSummary := dto.ToProfitLossSummary(
dto.ToFinancialMetrics(grossProfitRpPerBird, grossProfitRpPerKg, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, totalOperatingExpensesRpPerKg, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, netProfitRpPerKg, netProfit),
dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit),
)
return dto.ToProfitLossSection(plItems, plSummary)
profitLossSection := dto.ToProfitLossSection(plItems, plSummary)
// Build complete response
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
}
func (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) {
if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 {
return 0, false
}
total := 0.0
recordedCount := 0
for _, kandang := range projectFlockKandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
continue
}
recordedCount++
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
if recordedCount != len(projectFlockKandangs) {
return 0, false
}
return total, true
}
func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags {
if flag.Name == name {
// containsItem checks if a string exists in a slice
func containsItem(slice []string, item string) bool {
for _, s := range slice {
if strings.EqualFold(s, item) {
return true
}
}
@@ -18,8 +18,8 @@ import (
)
type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error)
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error)
}
type sapronakService struct {
@@ -42,9 +42,9 @@ func NewSapronakService(
}
}
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) {
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) {
if projectFlockID == 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
}
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID,
@@ -52,27 +52,19 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint,
Flag: flag,
})
if err != nil {
return nil, nil, err
return nil, err
}
if len(reports) <= 1 {
flags, err := s.collectProductFlags(c.Context(), reports)
if err != nil {
return nil, nil, err
}
return reports, flags, nil
return reports, nil
}
combined := s.combineSapronakReports(reports, projectFlockID)
flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined})
if err != nil {
return nil, nil, err
}
return []dto.SapronakReportDTO{combined}, flags, nil
return []dto.SapronakReportDTO{combined}, nil
}
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) {
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) {
if projectFlockID == 0 || pfkID == 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
}
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
@@ -82,20 +74,16 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint,
Flag: flag,
})
if err != nil {
return nil, nil, err
return nil, err
}
for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res})
if err != nil {
return nil, nil, err
}
return &res, flags, nil
return &res, nil
}
}
return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
}
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
@@ -148,52 +136,6 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
return results, nil
}
func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) {
productIDs := make(map[uint]struct{})
for _, report := range reports {
for _, group := range report.Groups {
for _, item := range group.Items {
if item.ProductID > 0 {
productIDs[item.ProductID] = struct{}{}
}
}
}
}
if len(productIDs) == 0 {
return map[uint][]string{}, nil
}
ids := make([]uint, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids)
if err != nil {
return nil, err
}
result := make(map[uint][]string, len(products))
for _, product := range products {
if len(product.Flags) == 0 {
continue
}
flags := make([]string, 0, len(product.Flags))
for _, flag := range product.Flags {
name := strings.TrimSpace(flag.Name)
if name == "" {
continue
}
flags = append(flags, strings.ToUpper(name))
}
if len(flags) > 0 {
result[product.Id] = flags
}
}
return result, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock").
@@ -405,14 +347,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil {
return nil, nil, 0, 0, err
}
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
if len(usageAllocatedDetails) > 0 {
usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{}
}
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
@@ -421,7 +355,7 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil {
return nil, nil, 0, 0, err
}
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id)
salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
}
@@ -628,12 +562,13 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if existing.ProductName == "" {
existing.ProductName = d.ProductName
}
// Adjustment keluar should reduce stock without inflating usage-based HPP.
remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar
if remaining < 0 {
remaining = 0
existing.UsageQty += d.QtyKeluar
existing.UsageValue += d.Nilai
if existing.IncomingQty >= existing.UsageQty {
existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
}
existing.RemainingQty = remaining
itemMap[productID] = existing
}
}
@@ -12,7 +12,7 @@ import (
)
type ConstantRepository interface {
GetConstants() (map[string]interface{}, error)
GetConstants() map[string]interface{}
}
type ConstantRepositoryImpl struct {
@@ -25,7 +25,7 @@ func NewConstantRepository(db *gorm.DB) ConstantRepository {
}
}
func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error) {
func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
flagList := make([]string, 0)
for f := range utils.AllFlagTypes() {
flagList = append(flagList, string(f))
@@ -75,8 +75,6 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
})
}
adjustmentSubtypesByType := utils.AdjustmentTransactionSubtypesByTypeForFrontend()
return map[string]interface{}{
"flags": flagList,
"warehouse_types": []string{
@@ -96,9 +94,6 @@ func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error)
"BISNIS",
"INDIVIDUAL",
},
"adjustment": map[string]interface{}{
"transaction_subtypes": adjustmentSubtypesByType,
},
"approval_workflows": approvalWorkflows,
}, nil
}
}
@@ -22,5 +22,5 @@ func NewConstantService(repo repository.ConstantRepository, validate *validator.
}
func (s constantService) GetAll(c *fiber.Ctx) (map[string]interface{}, error) {
return s.Repository.GetConstants()
return s.Repository.GetConstants(), nil
}
@@ -2,7 +2,6 @@ package controller
import (
"math"
"mime/multipart"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto"
@@ -363,9 +362,6 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
}
req.Documents = form.File["documents"]
if err := validateDailyChecklistDocumentSizes(req.Documents); err != nil {
return err
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
@@ -385,16 +381,6 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
})
}
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files {
if file != nil && file.Size > maxDailyChecklistDocumentBytes {
return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB")
}
}
return nil
}
func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("idDailyChecklist")
@@ -3,14 +3,13 @@ package service
import (
"errors"
"math"
"regexp"
"sort"
"strconv"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
@@ -135,87 +134,6 @@ func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang")
}
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
if checklistID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return nil
}
func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id = ?", kandangID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error {
if taskID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid task id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("t.id = ?", taskID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Task not found")
}
return nil
}
func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -225,15 +143,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if scopeErr != nil {
return nil, 0, scopeErr
}
Joins("JOIN kandangs k ON k.id = dc.kandang_id")
if params.DateFrom != "" {
dateFrom, err := time.Parse("2006-01-02", params.DateFrom)
@@ -260,9 +170,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
}
if params.Search != "" {
re := regexp.MustCompile("[^a-zA-Z0-9]")
like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte(""))
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like))
like := "%" + params.Search + "%"
db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like)
}
countDB := db.Session(&gorm.Session{})
@@ -385,9 +294,6 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
}
func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) {
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -493,9 +399,6 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureKandangAccess(c, req.KandangId); err != nil {
return nil, err
}
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
@@ -528,9 +431,6 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
deletedIDs := make([]uint, 0)
if req.DeletedDocumentIDs != nil {
@@ -556,7 +456,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
updateBody["reject_reason"] = *req.RejectReason
}
actorID, err := m.ActorIDFromContext(c)
actorID, err := middleware.ActorIDFromContext(c)
if err != nil {
return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
}
@@ -602,9 +502,6 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
}
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -619,9 +516,6 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
if err := s.Validate.Struct(req); err != nil {
return err
}
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -703,9 +597,6 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
}
func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -743,9 +634,6 @@ func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entit
if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
}
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
return nil, err
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -770,9 +658,6 @@ func (s dailyChecklistService) GetChecklistPhaseIDs(c *fiber.Ctx, checklistID ui
if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
}
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
return nil, err
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -802,9 +687,6 @@ func (s dailyChecklistService) UpdateAssignment(c *fiber.Ctx, req *validation.Up
if err := s.Validate.Struct(req); err != nil {
return err
}
if err := s.ensureTaskAccess(c, req.TaskID); err != nil {
return err
}
task := new(entity.DailyChecklistActivityTask)
if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil {
@@ -926,9 +808,6 @@ func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validatio
if err := s.Validate.Struct(req); err != nil {
return err
}
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
employeeIDs, err := parseIDs(req.EmployeeIDs)
if err != nil {
@@ -1021,16 +900,8 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
Joins("JOIN daily_checklists d ON d.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas ar ON ar.id = loc.area_id").
Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "ar.id")
if scopeErr != nil {
return nil, scopeErr
}
if params.Category != "" {
db = db.Where("d.category = ?", params.Category)
}
@@ -1075,15 +946,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
return nil, 0, err
}
locationScope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
areaScope, err := m.ResolveAreaScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
buildBase := func() *gorm.DB {
@@ -1100,9 +962,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year).
Where("dc.status = ?", "APPROVED")
db = m.ApplyScopeFilter(db, locationScope, "loc.id")
db = m.ApplyScopeFilter(db, areaScope, "a.id")
if params.AreaID != nil {
db = db.Where("a.id = ?", *params.AreaID)
}
@@ -29,7 +29,7 @@ type Query struct {
}
type AssignPhases struct {
PhaseIDs string `json:"phase_ids" validate:"omitempty"`
PhaseIDs string `json:"phase_ids" validate:"required"`
}
type AssignTask struct {
@@ -6,7 +6,6 @@ import (
"strings"
"time"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -82,20 +81,6 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid include")
}
scope, err := m.ResolveLocationScope(c, u.DashboardService.DB())
if err != nil {
return err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
lokasiIds = []uint{}
} else if len(lokasiIds) > 0 {
lokasiIds = intersectUint(lokasiIds, scope.IDs)
} else {
lokasiIds = scope.IDs
}
}
analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview)))
metric := strings.ToLower(strings.TrimSpace(c.Query("metric", "")))
@@ -191,23 +176,6 @@ func defaultUintSlice(values []uint) []uint {
return values
}
func intersectUint(a, b []uint) []uint {
if len(a) == 0 || len(b) == 0 {
return nil
}
set := make(map[uint]struct{}, len(b))
for _, id := range b {
set[id] = struct{}{}
}
out := make([]uint, 0, len(a))
for _, id := range a {
if _, ok := set[id]; ok {
out = append(out, id)
}
}
return out
}
func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) {
now := time.Now().In(location)
startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location)
+2 -5
View File
@@ -5,8 +5,6 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services"
@@ -18,12 +16,11 @@ type DashboardModule struct{}
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dashboardRepo := rDashboard.NewDashboardRepository(db)
hppCostRepo := commonRepo.NewHppCostRepository(db)
userRepo := rUser.NewUserRepository(db)
hppSvc := commonService.NewHppService(hppCostRepo)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
DashboardRoutes(router, userService, dashboardService)
}
@@ -21,7 +21,6 @@ type DashboardRepository interface {
SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error)
SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error)
SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error)
ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error)
GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error)
GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error)
GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error)
@@ -107,23 +107,16 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recordings AS r").
Select(fmt.Sprintf(`%s AS week,
Select(`((r.day - 1) / 7 + 1) AS week,
COALESCE(AVG(r.hen_day), 0) AS hen_day,
COALESCE(AVG(r.egg_weight), 0) AS egg_weight,
COALESCE(AVG(r.feed_intake), 0) AS feed_intake,
COALESCE(AVG(r.fcr_value), 0) AS fcr_value,
COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`, weekExpr)).
COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0")
@@ -195,19 +188,92 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week
return nil, nil
}
standardIDs := r.standardIDSubquery(filters)
if standardIDs == nil {
return nil, nil
filterClause := ""
filterArgs := make([]interface{}, 0)
if filters != nil {
if len(filters.FlockIds) > 0 {
filterClause += " AND pf.id IN ?"
filterArgs = append(filterArgs, filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
filterClause += " AND k.id IN ?"
filterArgs = append(filterArgs, filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
filterClause += " AND k.location_id IN ?"
filterArgs = append(filterArgs, filters.LokasiIds)
}
}
var rows []StandardWeeklyFcrMetric
db := r.DB().WithContext(ctx).
Table("production_standard_details AS psd").
Select("psd.week AS week, COALESCE(AVG(psd.standard_fcr), 0) AS std_fcr").
Where("psd.week IN ?", weeks).
Where("psd.production_standard_id IN (?)", standardIDs)
query := fmt.Sprintf(`
WITH src AS (
SELECT DISTINCT pf.production_standard_id, pf.fcr_id
FROM project_flocks pf
JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0
%s
),
actual AS (
SELECT u.week AS week,
pf.fcr_id AS fcr_id,
AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight
FROM project_flock_kandang_uniformity u
JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0
%s
GROUP BY u.week, pf.fcr_id
),
target AS (
SELECT sgd.week AS week,
src.fcr_id AS fcr_id,
AVG(sgd.target_mean_bw) AS target_mean_bw
FROM standard_growth_details sgd
JOIN src ON src.production_standard_id = sgd.production_standard_id
WHERE sgd.week IN ?
GROUP BY sgd.week, src.fcr_id
),
weights AS (
SELECT COALESCE(a.week, t.week) AS week,
COALESCE(a.fcr_id, t.fcr_id) AS fcr_id,
COALESCE(
CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END,
CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END
) AS weight
FROM actual a
FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id
)
SELECT w.week AS week,
COALESCE(AVG(
COALESCE(
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
AND fs.weight >= w.weight
ORDER BY fs.weight ASC
LIMIT 1),
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
ORDER BY fs.weight DESC
LIMIT 1)
)
), 0) AS std_fcr
FROM weights w
GROUP BY w.week
ORDER BY w.week ASC
`, filterClause, filterClause)
if err := db.Group("psd.week").Order("psd.week ASC").Scan(&rows).Error; err != nil {
args := make([]interface{}, 0, len(filterArgs)*2+2)
args = append(args, filterArgs...)
args = append(args, weeks)
args = append(args, filterArgs...)
args = append(args, weeks)
var rows []StandardWeeklyFcrMetric
if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err
}
@@ -243,27 +309,6 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context,
return grams / 1000, nil
}
func (r *DashboardRepositoryImpl) ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error) {
var ids []uint
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select("DISTINCT r.project_flock_kandangs_id").
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
db = applyDashboardFilters(db, filters)
if err := db.Scan(&ids).Error; err != nil {
return nil, err
}
return ids, nil
}
func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) {
var rows []FeedUsageByUom
@@ -444,6 +489,30 @@ func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.Dashboa
return db
}
func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB {
db := r.DB().
Table("project_flocks AS pf").
Select("DISTINCT pf.production_standard_id, pf.fcr_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("pf.production_standard_id > 0").
Where("pf.fcr_id > 0")
if filters != nil {
if len(filters.FlockIds) > 0 {
db = db.Where("pf.id IN ?", filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
db = db.Where("k.id IN ?", filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
db = db.Where("k.location_id IN ?", filters.LokasiIds)
}
}
return db
}
func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) {
seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType)
if err != nil {
@@ -482,23 +551,18 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
}
var rows []ComparisonWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recordings AS r").
Select(fmt.Sprintf(`%s AS week,
Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week,
%s AS series_id,
COALESCE(AVG(%s), 0) AS value`, weekExpr, seriesExpr, metricExpr)).
COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0")
db = applyDashboardFilters(db, filters)
@@ -545,19 +609,13 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) {
var rows []EggQualityWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select(fmt.Sprintf(`
%s AS week,
Select(`
((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty,
COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty,
COALESCE(SUM(re.qty), 0) AS total_qty`, weekExpr),
COALESCE(SUM(re.qty), 0) AS total_qty`,
utils.FlagTelurUtuh,
utils.FlagTelurPutih,
utils.FlagTelurRetak,
@@ -566,7 +624,6 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
@@ -587,21 +644,14 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) {
var rows []WeeklyEggWeightMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select(fmt.Sprintf(`
%s AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)).
Select(`
((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`).
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0")
@@ -618,22 +668,15 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) {
var rows []WeeklyFeedUsageMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx).
Table("recording_stocks AS rs").
Select(fmt.Sprintf(`
%s AS week,
Select(`
((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty,
LOWER(uoms.name) AS uom_name`, weekExpr)).
LOWER(uoms.name) AS uom_name`).
Joins("JOIN recordings AS r ON r.id = rs.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN uoms ON uoms.id = p.uom_id").
@@ -10,7 +10,6 @@ import (
"strings"
"time"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -18,34 +17,26 @@ import (
"github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type DashboardService interface {
GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error)
DB() *gorm.DB
}
type dashboardService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DashboardRepository
HppSvc commonService.HppService
}
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService {
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService {
return &dashboardService{
Log: utils.Log,
Validate: validate,
Repository: repo,
HppSvc: hppSvc,
}
}
func (s dashboardService) DB() *gorm.DB {
return s.Repository.DB()
}
func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return dto.DashboardPerformanceOverviewDTO{}, 0, err
@@ -601,13 +592,13 @@ func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.Compar
count++
}
if count == 0 {
continue
}
if result[week] == nil {
result[week] = map[uint]float64{}
}
if count == 0 {
result[week][series.Id] = 0
continue
}
result[week][series.Id] = sum / count
}
}
@@ -855,21 +846,6 @@ func percentDelta(current, last float64) float64 {
}
func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) {
if s.HppSvc != nil {
currentHpp, err := s.hppGlobalForPeriod(ctx, startDate, endExclusive)
if err != nil {
return 0, 0, err
}
lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location)
lastHpp, err := s.hppGlobalForPeriod(ctx, lastMonthStart, lastMonthEndExclusive)
if err != nil {
return 0, 0, err
}
return currentHpp, lastHpp, nil
}
totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil)
if err != nil {
return 0, 0, err
@@ -902,37 +878,6 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, end
return hppCurrent, hppLast, nil
}
func (s dashboardService) hppGlobalForPeriod(ctx context.Context, startDate, endExclusive time.Time) (float64, error) {
kandangIDs, err := s.Repository.ListProjectFlockKandangIDsByEggProduction(ctx, startDate, endExclusive, nil)
if err != nil {
return 0, err
}
if len(kandangIDs) == 0 {
return 0, nil
}
endOfPeriod := endExclusive.Add(-time.Nanosecond)
totalCost := 0.0
totalWeightKg := 0.0
for _, kandangID := range kandangIDs {
hppCost, err := s.HppSvc.CalculateHppCost(kandangID, &endOfPeriod)
if err != nil {
return 0, err
}
if hppCost == nil {
continue
}
totalCost += hppCost.Estimation.Total
totalWeightKg += hppCost.Estimation.Kg
}
if totalWeightKg <= 0 {
return 0, nil
}
return totalCost / totalWeightKg, nil
}
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)
@@ -328,7 +328,6 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
}
kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r)
} else {
directRealisasi = append(directRealisasi, r)
}
}

Some files were not shown because too many files have changed in this diff Show More