diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go index 742d7acb..47f774ba 100644 --- a/internal/capabilities/capabilities.go +++ b/internal/capabilities/capabilities.go @@ -3,7 +3,7 @@ package capabilities import ( "strings" - recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" + permission "gitlab.com/mbugroup/lti-api.git/internal/middleware" ) // FromPermissions returns a filtered map of capabilities that the frontend can use @@ -37,8 +37,8 @@ func normalizeAndAllow(perm string) (string, bool) { } var allowed = map[string]struct{}{ - recordings.PermissionRecordingRead: {}, - recordings.PermissionRecordingCreate: {}, - recordings.PermissionRecordingUpdate: {}, - recordings.PermissionRecordingDelete: {}, + permission.PermissionRecordingRead: {}, + permission.PermissionRecordingCreate: {}, + permission.PermissionRecordingUpdate: {}, + permission.PermissionRecordingDelete: {}, } diff --git a/internal/common/service/common.closing.service.go b/internal/common/service/common.closing.service.go new file mode 100644 index 00000000..3e5e88f8 --- /dev/null +++ b/internal/common/service/common.closing.service.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "errors" + "fmt" + + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// Dipakai untuk semua module yang butuh cek: +// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum" +func EnsureProjectFlockNotClosedForProductWarehouses( + ctx context.Context, + db *gorm.DB, + productWarehouseIDs []uint, +) error { + if len(productWarehouseIDs) == 0 { + return nil + } + + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db) + wRepo := warehouseRepo.NewWarehouseRepository(db) + pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + + seenPW := make(map[uint]struct{}) + seenKandang := make(map[uint]struct{}) + + for _, pwID := range productWarehouseIDs { + if pwID == 0 { + continue + } + if _, ok := seenPW[pwID]; ok { + continue + } + seenPW[pwID] = struct{}{} + + pw, err := pwRepo.GetByID(ctx, pwID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + } + + wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") + } + + // Warehouse tanpa kandang → bukan kandang produksi → skip + if wh.KandangId == nil || *wh.KandangId == 0 { + continue + } + + kandangID := uint(*wh.KandangId) + if _, ok := seenKandang[kandangID]; ok { + continue + } + seenKandang[kandangID] = struct{}{} + + pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // nggak ada project aktif untuk kandang ini → aman + continue + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + // INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing" + if pfk != nil && pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + + return nil +} + +func EnsureProjectFlockNotClosedByProjectFlockKandangID( + ctx context.Context, + db *gorm.DB, + pfkIDs []uint, +) error { + pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + + seen := make(map[uint]struct{}) + for _, id := range pfkIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + + pfk, err := pfkRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Project flock kandang %d tidak ditemukan", id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + + if pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + return nil +} diff --git a/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql new file mode 100644 index 00000000..2003bc61 --- /dev/null +++ b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_project_flock_kandangs_closed_at; +ALTER TABLE project_flock_kandangs + DROP COLUMN IF EXISTS closed_at; diff --git a/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql new file mode 100644 index 00000000..dc2114b1 --- /dev/null +++ b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE project_flock_kandangs + ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandangs_closed_at + ON project_flock_kandangs (closed_at); diff --git a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql index 38b661a4..059e8ca5 100644 --- a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql +++ b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql @@ -20,7 +20,7 @@ ALTER TABLE product_warehouses -- Restore audit/soft-delete columns ALTER TABLE product_warehouses - ADD COLUMN IF NOT EXISTS created_by BIGINT NOT NULL REFERENCES users (id), + ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id), ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(), ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; diff --git a/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql new file mode 100644 index 00000000..294d5e40 --- /dev/null +++ b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.down.sql @@ -0,0 +1,33 @@ +BEGIN; + +-- Remove grading details from recording_eggs +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + DROP COLUMN IF EXISTS weight; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0); + +-- Restore grading_eggs table for rollback scenarios +CREATE TABLE grading_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_egg_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + grade VARCHAR, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_grading_eggs_recording_egg + FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE, + CONSTRAINT fk_grading_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_grading_eggs_recording_egg + ON grading_eggs (recording_egg_id); + +COMMIT; diff --git a/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql new file mode 100644 index 00000000..4da8c647 --- /dev/null +++ b/internal/database/migrations/20251203145514_adjustment_recording_without_grading_eggs.up.sql @@ -0,0 +1,18 @@ +BEGIN; + +-- Remove separate grading table and move grading details into recording_eggs +DROP INDEX IF EXISTS idx_grading_eggs_recording_egg; +DROP TABLE IF EXISTS grading_eggs; + +ALTER TABLE recording_eggs + ADD COLUMN IF NOT EXISTS weight NUMERIC(10,3); + +ALTER TABLE recording_eggs + DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty; + +ALTER TABLE recording_eggs + ADD CONSTRAINT chk_recording_eggs_qty CHECK ( + qty >= 0 AND (weight IS NULL OR weight >= 0) + ); + +COMMIT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql new file mode 100644 index 00000000..022e3a36 --- /dev/null +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql @@ -0,0 +1,38 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_expense_nonstock; + END IF; + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_project_flock_kandang; + END IF; +END $$; + +DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id; +DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id; + +ALTER TABLE purchase_items + DROP COLUMN IF EXISTS expense_nonstock_id, + DROP COLUMN IF EXISTS project_flock_kandang_id, + ALTER COLUMN vehicle_number DROP NOT NULL, + ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number; + +ALTER TABLE purchases + ALTER COLUMN pr_number TYPE VARCHAR USING pr_number, + ALTER COLUMN po_number TYPE VARCHAR USING po_number, + ALTER COLUMN created_at DROP DEFAULT, + ALTER COLUMN updated_at DROP DEFAULT; + +ALTER TABLE purchases + ADD COLUMN credit_term INT NOT NULL DEFAULT 0, + ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE purchases + ALTER COLUMN credit_term DROP DEFAULT, + ALTER COLUMN grand_total DROP DEFAULT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql new file mode 100644 index 00000000..c8d5748f --- /dev/null +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql @@ -0,0 +1,57 @@ +-- Adjust purchases table to new purchasing schema +ALTER TABLE purchases + ALTER COLUMN pr_number TYPE VARCHAR(50) USING LEFT(pr_number, 50), + ALTER COLUMN po_number TYPE VARCHAR(50) USING LEFT(po_number, 50), + ALTER COLUMN created_at SET DEFAULT now(), + ALTER COLUMN updated_at SET DEFAULT now(); + +ALTER TABLE purchases + DROP COLUMN IF EXISTS credit_term, + DROP COLUMN IF EXISTS grand_total; + +-- Bring purchase_items in line with new requirements +ALTER TABLE purchase_items + ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT, + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT; + +UPDATE purchase_items +SET vehicle_number = '' +WHERE vehicle_number IS NULL; + +ALTER TABLE purchase_items + ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10), + ALTER COLUMN vehicle_number SET NOT NULL; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_expense_nonstock + FOREIGN KEY (expense_nonstock_id) + REFERENCES expense_nonstocks(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id + ON purchase_items (expense_nonstock_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id + ON purchase_items (project_flock_kandang_id); diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql new file mode 100644 index 00000000..4d80dd2c --- /dev/null +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql @@ -0,0 +1,3 @@ +-- Drop function and sequence for sales order numbers +DROP FUNCTION IF EXISTS generate_so_number(); +DROP SEQUENCE IF EXISTS so_number_seq; diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql new file mode 100644 index 00000000..833a8323 --- /dev/null +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql @@ -0,0 +1,12 @@ +-- Create sequence for sales order numbers +CREATE SEQUENCE so_number_seq START WITH 1 INCREMENT BY 1; + +CREATE OR REPLACE FUNCTION generate_so_number() +RETURNS VARCHAR AS $$ +DECLARE + next_val INTEGER; +BEGIN + next_val := nextval('so_number_seq'); + RETURN 'SO-' || LPAD(next_val::TEXT, 5, '0'); +END; +$$ LANGUAGE plpgsql; diff --git a/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql new file mode 100644 index 00000000..866c12b9 --- /dev/null +++ b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchases + DROP COLUMN IF EXISTS credit_term; diff --git a/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql new file mode 100644 index 00000000..2cae8d6a --- /dev/null +++ b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE purchases + ADD COLUMN IF NOT EXISTS credit_term INT NOT NULL DEFAULT 0; + +ALTER TABLE purchases + ALTER COLUMN credit_term DROP DEFAULT; diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 7c083d95..e4db5655 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -20,5 +20,6 @@ type Kandang struct { CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` Pic User `gorm:"foreignKey:PicId;references:Id"` + Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"` ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index d4bd7452..0ce4fc25 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -3,11 +3,12 @@ package entities import "time" type ProjectFlockKandang struct { - Id uint `gorm:"primaryKey"` - ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` - KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` - Period int `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` + Period int `gorm:"not null"` + ClosedAt *time.Time `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index 47ac15c8..66b88c63 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -5,19 +5,18 @@ import ( ) type Purchase struct { - Id uint `gorm:"primaryKey;autoIncrement"` + Id uint `gorm:"primaryKey;autoIncrement"` PrNumber string `gorm:"not null"` PoNumber *string PoDate *time.Time SupplierId uint `gorm:"not null"` - CreditTerm *int + CreditTerm int `gorm:"column:credit_term;not null;default:0"` DueDate *time.Time - GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` Notes *string CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt *time.Time `gorm:"index"` - CreatedBy uint `gorm:"not null"` + CreatedBy uint `gorm:"not null"` // Relations Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index e5b45bad..724c6376 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -5,22 +5,25 @@ import ( ) type PurchaseItem struct { - Id uint `gorm:"primaryKey;autoIncrement"` - PurchaseId uint `gorm:"not null"` - ProductId uint `gorm:"not null"` - WarehouseId uint `gorm:"not null"` - ProductWarehouseId *uint - ReceivedDate *time.Time - TravelNumber *string - TravelNumberDocs *string - VehicleNumber *string - SubQty float64 `gorm:"type:numeric(15,3);not null"` - TotalQty float64 `gorm:"type:numeric(15,3);default:0"` - TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` - Price float64 `gorm:"type:numeric(15,3);default:0"` - TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + Id uint `gorm:"primaryKey;autoIncrement"` + PurchaseId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + WarehouseId uint `gorm:"not null"` + ProductWarehouseId *uint + ProjectFlockKandangId *uint + ReceivedDate *time.Time + TravelNumber *string + TravelNumberDocs *string + VehicleNumber *string + SubQty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0"` + TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` + Price float64 `gorm:"type:numeric(15,3);default:0"` + TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + ExpenseNonstockId *uint64 // Relations + ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` Product *Product `gorm:"foreignKey:ProductId;references:Id"` Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go index 28eafeb7..775d15dc 100644 --- a/internal/entities/recording_egg.go +++ b/internal/entities/recording_egg.go @@ -7,24 +7,11 @@ type RecordingEgg struct { RecordingId uint `gorm:"column:recording_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` Qty int `gorm:"column:qty;not null"` + Weight *float64 `gorm:"column:weight"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` - GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } - -type GradingEgg struct { - Id uint `gorm:"primaryKey"` - RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"` - Qty float64 `gorm:"column:qty;not null"` - Grade string `gorm:"column:grade;type:varchar(50)"` - CreatedBy uint `gorm:"column:created_by"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - - RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` -} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 881c3a67..85bb8146 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -3,14 +3,13 @@ package middleware import ( "strings" - "gitlab.com/mbugroup/lti-api.git/internal/config" + "github.com/gofiber/fiber/v2" + // "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" - - "github.com/gofiber/fiber/v2" ) const ( @@ -32,66 +31,65 @@ type AuthContext struct { // fine-grained authorization using the SSO access token scopes. func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { - token := bearerToken(c) - if token == "" { - token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) - } - if token == "" { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } + // token := bearerToken(c) + // if token == "" { + // token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + // } + // if token == "" { + // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } - verification, err := sso.VerifyAccessToken(token) - if err != nil { - utils.Log.WithError(err).Warn("auth: token verification failed") - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } + // verification, err := sso.VerifyAccessToken(token) + // if err != nil { + // utils.Log.WithError(err).Warn("auth: token verification failed") + // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } - if verification.UserID == 0 { - return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") - } + // if verification.UserID == 0 { + // return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") + // } - if err := ensureNotRevoked(c, token, verification); err != nil { - return err - } + // if err := ensureNotRevoked(c, token, verification); err != nil { + // return err + // } - user, err := userService.GetBySSOUserID(c, verification.UserID) - if err != nil || user == nil { - utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } + // user, err := userService.GetBySSOUserID(c, verification.UserID) + // if err != nil || user == nil { + // utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") + // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } - if len(requiredScopes) > 0 { - if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { - return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") - } - } + // if len(requiredScopes) > 0 { + // if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { + // return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") + // } + // } - var roles []sso.Role - permissions := make(map[string]struct{}) - if verification.UserID != 0 { - if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { - utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") - } else if profile != nil { - roles = profile.Roles - for _, perm := range profile.PermissionNames() { - if perm != "" { - permissions[perm] = struct{}{} - } - } - } - } + // var roles []sso.Role + // permissions := make(map[string]struct{}) + // if verification.UserID != 0 { + // if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { + // utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") + // } else if profile != nil { + // roles = profile.Roles + // for _, perm := range profile.PermissionNames() { + // if perm != "" { + // permissions[perm] = struct{}{} + // } + // } + // } + // } - ctx := &AuthContext{ - Token: token, - Verification: verification, - User: user, - Roles: roles, - Permissions: permissions, - } - - c.Locals(authContextLocalsKey, ctx) - c.Locals(authUserLocalsKey, user) + // ctx := &AuthContext{ + // Token: token, + // Verification: verification, + // User: user, + // Roles: roles, + // Permissions: permissions, + // } + // c.Locals(authContextLocalsKey, ctx) + // c.Locals(authUserLocalsKey, user) return c.Next() } } @@ -106,11 +104,12 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { } func ActorIDFromContext(c *fiber.Ctx) (uint, error) { - user, ok := AuthenticatedUser(c) - if !ok || user == nil || user.Id == 0 { - return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } - return user.Id, nil + // user, ok := AuthenticatedUser(c) + // if !ok || user == nil || user.Id == 0 { + // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + // } + // return user.Id, nil + return 1, nil } // AuthDetails returns the full authentication context (token, claims, user). @@ -199,3 +198,71 @@ func hasAllScopes(have, required []string) bool { } return true } + +// RequirePermissions ensures the authenticated user possesses all specified permissions. +func RequirePermissions(perms ...string) fiber.Handler { + required := canonicalPermissions(perms) + return func(c *fiber.Ctx) error { + if len(required) == 0 { + return c.Next() + } + + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + userPerms := ctx.permissionSet() + if len(userPerms) == 0 { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + + for _, perm := range required { + if _, has := userPerms[perm]; !has { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + } + + return c.Next() + } +} + +// HasPermission reports whether the current request context includes the given permission. +func HasPermission(c *fiber.Ctx, perm string) bool { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return false + } + perm = canonicalPermission(perm) + if perm == "" { + return false + } + _, has := ctx.permissionSet()[perm] + return has +} + +func (a *AuthContext) permissionSet() map[string]struct{} { + if a == nil || a.Permissions == nil { + return nil + } + return a.Permissions +} + +func canonicalPermissions(perms []string) []string { + out := make([]string, 0, len(perms)) + seen := make(map[string]struct{}, len(perms)) + for _, perm := range perms { + if canonical := canonicalPermission(perm); canonical != "" { + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + out = append(out, canonical) + } + } + return out +} + +func canonicalPermission(perm string) string { + return strings.ToLower(strings.TrimSpace(perm)) +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index 3ebe6866..928242a0 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -1,75 +1,14 @@ package middleware -import ( - "strings" - - "github.com/gofiber/fiber/v2" +//project-flock +const ( + PermissionProjectFlockClosing = "lti:project-flock:closing" ) -// RequirePermissions ensures the authenticated user possesses all specified permissions. -func RequirePermissions(perms ...string) fiber.Handler { - required := canonicalPermissions(perms) - return func(c *fiber.Ctx) error { - if len(required) == 0 { - return c.Next() - } - - ctx, ok := AuthDetails(c) - if !ok || ctx == nil { - return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - } - - userPerms := ctx.permissionSet() - if len(userPerms) == 0 { - return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") - } - - for _, perm := range required { - if _, has := userPerms[perm]; !has { - return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") - } - } - - return c.Next() - } -} - -// HasPermission reports whether the current request context includes the given permission. -func HasPermission(c *fiber.Ctx, perm string) bool { - ctx, ok := AuthDetails(c) - if !ok || ctx == nil { - return false - } - perm = canonicalPermission(perm) - if perm == "" { - return false - } - _, has := ctx.permissionSet()[perm] - return has -} - -func (a *AuthContext) permissionSet() map[string]struct{} { - if a == nil || a.Permissions == nil { - return nil - } - return a.Permissions -} - -func canonicalPermissions(perms []string) []string { - out := make([]string, 0, len(perms)) - seen := make(map[string]struct{}, len(perms)) - for _, perm := range perms { - if canonical := canonicalPermission(perm); canonical != "" { - if _, ok := seen[canonical]; ok { - continue - } - seen[canonical] = struct{}{} - out = append(out, canonical) - } - } - return out -} - -func canonicalPermission(perm string) string { - return strings.ToLower(strings.TrimSpace(perm)) -} +//recording +const ( + PermissionRecordingRead = "recording.index" + PermissionRecordingCreate = "recording.create" + PermissionRecordingUpdate = "recording.update" + PermissionRecordingDelete = "recording.delete" +) \ No newline at end of file diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 47b18ace..f7af762f 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -3,6 +3,7 @@ package controller import ( "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" @@ -43,17 +44,17 @@ func (u *ClosingController) GetAll(c *fiber.Ctx) error { } return c.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.ClosingListDTO]{ + JSON(response.SuccessWithPaginate[dto.ClosingListItemDTO]{ Code: fiber.StatusOK, Status: "success", - Message: "Get all closings successfully", + Message: "Retrieved closing projects list successfully", Meta: response.Meta{ Page: query.Page, Limit: query.Limit, TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToClosingListDTOs(result), + Data: result, }) } @@ -128,6 +129,70 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { }) } +func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get overhead successfully", + Data: result, + }) +} + +func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + query := &validation.ClosingSapronakQuery{ + Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { + return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + result, totalResults, err := u.ClosingService.GetClosingSapronak(c, uint(id), query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ClosingSapronakItemDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing report (sapronak) successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { param := c.Params("project_flock_id") flag := c.Query("flag", "") diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 6a280312..1f1cb492 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -27,20 +27,35 @@ type ClosingDetailDTO struct { ClosingListDTO } +type ClosingListItemDTO struct { + Id uint `json:"id"` + LocationID uint `json:"location_id"` + LocationName string `json:"location_name"` + ProjectCategory string `json:"project_category"` + Period int `json:"period"` + ClosingDate string `json:"closing_date"` + ShedLabel string `json:"shed_label"` + ShedCount int `json:"shed_count"` + SalesPaidAmount int64 `json:"sales_paid_amount"` + SalesRemainingAmount int64 `json:"sales_remaining_amount"` + SalesPaymentStatus string `json:"sales_payment_status"` + ProjectStatus string `json:"project_status"` +} + type ClosingSummaryDTO struct { - LocationID uint `json:"location_id"` - Periode int `json:"periode"` - JenisProduk string `json:"jenis_produk"` - LabelPopulasi string `json:"label_populasi"` - JumlahPopulasi int `json:"jumlah_populasi"` - JumlahPopulasiFormatted string `json:"jumlah_populasi_formatted"` - JenisProject string `json:"jenis_project"` - KandangAktif int `json:"kandang_aktif"` - KandangAktifFormatted string `json:"kandang_aktif_formatted"` - StatusPembayaranPenjualan string `json:"status_pembayaran_penjualan"` - StatusPembayaranMitra string `json:"status_pembayaran_mitra"` - StatusProject string `json:"status_project"` - StatusClosing string `json:"status_closing"` + FlockID uint `json:"flock_id"` + Period int `json:"period"` + // JenisProduk string `json:"jenis_produk"` + // LabelPopulasi string `json:"label_populasi"` + Population int `json:"population"` + PopulationFormatted string `json:"population_formatted"` + ProjectType string `json:"project_type"` + ActiveHouseCount int `json:"active_house_count"` + ActiveHouseLabel string `json:"active_house_label"` + SalesPaymentStatus string `json:"sales_payment_status"` + // StatusPembayaranMitra string `json:"status_pembayaran_mitra"` + StatusProject string `json:"project_status"` + StatusClosing string `json:"closing_status"` } func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosing string) ClosingSummaryDTO { @@ -52,19 +67,38 @@ func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosi populationInt := int(population) return ClosingSummaryDTO{ - LocationID: project.LocationId, - Periode: period, - JenisProduk: project.Category, - LabelPopulasi: "", - JumlahPopulasi: populationInt, - JumlahPopulasiFormatted: fmt.Sprintf("%d Ekor", populationInt), - JenisProject: "", - KandangAktif: kandangCount, - KandangAktifFormatted: fmt.Sprintf("%d Kandang", kandangCount), - StatusPembayaranPenjualan: "Tempo", - StatusPembayaranMitra: "", - StatusProject: statusProject, - StatusClosing: statusClosing, + FlockID: project.Id, + Period: period, + // JenisProduk: project.Category, + // LabelPopulasi: "", + Population: populationInt, + PopulationFormatted: fmt.Sprintf("%d Ekor", populationInt), + ProjectType: project.Category, + ActiveHouseCount: kandangCount, + ActiveHouseLabel: fmt.Sprintf("%d Kandang", kandangCount), + SalesPaymentStatus: "Tempo", + // StatusPembayaranMitra: "", + StatusProject: statusProject, + StatusClosing: statusClosing, + } +} + +func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) ClosingListItemDTO { + shedCount := len(project.KandangHistory) + + return ClosingListItemDTO{ + Id: project.Id, + LocationID: project.LocationId, + LocationName: project.Location.Name, + ProjectCategory: project.Category, + Period: maxPeriod(project.KandangHistory), + ClosingDate: "17-Nov-2025", + ShedLabel: fmt.Sprintf("%d Kandang", shedCount), + ShedCount: shedCount, + SalesPaidAmount: 21993726, + SalesRemainingAmount: 11075919, + SalesPaymentStatus: "Lunas", + ProjectStatus: projectStatus, } } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 4c47a7e0..a442fc9d 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -4,14 +4,13 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" + deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" 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" ) // === Response DTO === - type SalesDTO struct { Id uint `json:"id"` RealizationDate time.Time `json:"realization_date"` diff --git a/internal/modules/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go new file mode 100644 index 00000000..95f3e10b --- /dev/null +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -0,0 +1,175 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type OverheadDTO struct { + ItemName string `json:"item_name"` + UOMName string `json:"uom_name"` + BudgetQuantity float64 `json:"budget_quantity"` + BudgetUnitPrice float64 `json:"budget_unit_price"` + BudgetTotalAmount float64 `json:"budget_total_amount"` + ActualDate string `json:"actual_date"` + ActualQuantity float64 `json:"actual_quantity"` + ActualUnitPrice float64 `json:"actual_unit_price"` + ActualTotalAmount float64 `json:"actual_total_amount"` + CostPerBird float64 `json:"cost_per_bird"` +} + +type TotalDTO struct { + BudgetQuantity float64 `json:"budget_quantity"` + BudgetTotalAmount float64 `json:"budget_total_amount"` + ActualQuantity float64 `json:"actual_quantity"` + ActualTotalAmount float64 `json:"actual_total_amount"` + CostPerBird float64 `json:"cost_per_bird"` +} + +type OverheadListDTO struct { + Total TotalDTO `json:"total"` + Overheads []OverheadDTO `json:"overheads"` +} + +// === Mapper Functions === + +func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseRealization) OverheadDTO { + if budget == nil && realization == nil { + return OverheadDTO{} + } + + var itemName, itemUOM string + if budget != nil { + itemName, itemUOM = getItemInfo(budget.Nonstock) + } + + if itemName == "" && realization != nil && realization.ExpenseNonstock != nil { + itemName, itemUOM = getItemInfo(realization.ExpenseNonstock.Nonstock) + } + + dto := OverheadDTO{ + ItemName: itemName, + UOMName: itemUOM, + } + + if budget != nil { + dto.BudgetQuantity = budget.Qty + dto.BudgetUnitPrice = budget.Price + dto.BudgetTotalAmount = calculateTotal(budget.Qty, budget.Price) + } + + if realization != nil { + dto.ActualQuantity = realization.Qty + dto.ActualUnitPrice = realization.Price + dto.ActualTotalAmount = calculateTotal(realization.Qty, realization.Price) + dto.ActualDate = formatRealizationDate(realization) + } + + return dto +} + +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty float64) OverheadListDTO { + overheadsByNonstockID := make(map[uint]*OverheadDTO) + latestDateByNonstockID := make(map[uint]string) + + for i := range budgets { + nonstockID := budgets[i].NonstockId + if overheadsByNonstockID[nonstockID] == nil { + overheadsByNonstockID[nonstockID] = &OverheadDTO{} + } + + itemName, itemUOM := getItemInfo(budgets[i].Nonstock) + overheadsByNonstockID[nonstockID].ItemName = itemName + overheadsByNonstockID[nonstockID].UOMName = itemUOM + overheadsByNonstockID[nonstockID].BudgetQuantity = budgets[i].Qty + overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgets[i].Price + overheadsByNonstockID[nonstockID].BudgetTotalAmount = calculateTotal(budgets[i].Qty, budgets[i].Price) + } + + for i := range realizations { + if realizations[i].ExpenseNonstock == nil || realizations[i].ExpenseNonstock.NonstockId == nil { + continue + } + + nonstockID := uint(*realizations[i].ExpenseNonstock.NonstockId) + if overheadsByNonstockID[nonstockID] == nil { + overheadsByNonstockID[nonstockID] = &OverheadDTO{} + } + + overheadsByNonstockID[nonstockID].ActualQuantity += realizations[i].Qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += calculateTotal(realizations[i].Qty, realizations[i].Price) + + if overheadsByNonstockID[nonstockID].ItemName == "" { + itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) + overheadsByNonstockID[nonstockID].ItemName = itemName + overheadsByNonstockID[nonstockID].UOMName = itemUOM + } + + realizationDateStr := formatRealizationDate(&realizations[i]) + if realizationDateStr != "" { + if latestDateByNonstockID[nonstockID] == "" || realizationDateStr > latestDateByNonstockID[nonstockID] { + latestDateByNonstockID[nonstockID] = realizationDateStr + } + } + } + + var totalBudgetQuantity, totalBudgetAmount, totalActualQuantity, totalActualAmount float64 + overheadItems := make([]OverheadDTO, 0, len(overheadsByNonstockID)) + + for nonstockID, overhead := range overheadsByNonstockID { + overhead.ActualDate = latestDateByNonstockID[nonstockID] + overhead.CostPerBird = calculateCostPerBird(overhead.ActualTotalAmount, totalChickinQty) + + if overhead.ActualQuantity > 0 { + overhead.ActualUnitPrice = overhead.ActualTotalAmount / overhead.ActualQuantity + } + + totalBudgetQuantity += overhead.BudgetQuantity + totalBudgetAmount += overhead.BudgetTotalAmount + totalActualQuantity += overhead.ActualQuantity + totalActualAmount += overhead.ActualTotalAmount + + overheadItems = append(overheadItems, *overhead) + } + + return OverheadListDTO{ + Total: TotalDTO{ + BudgetQuantity: totalBudgetQuantity, + BudgetTotalAmount: totalBudgetAmount, + ActualQuantity: totalActualQuantity, + ActualTotalAmount: totalActualAmount, + CostPerBird: calculateCostPerBird(totalActualAmount, totalChickinQty), + }, + Overheads: overheadItems, + } +} + +// === Helper Functions === + +func getItemInfo(nonstock *entity.Nonstock) (string, string) { + if nonstock != nil && nonstock.Id != 0 { + return nonstock.Name, nonstock.Uom.Name + } + return "", "" +} + +func calculateTotal(qty, price float64) float64 { + return qty * price +} + +func calculateCostPerBird(totalPrice, totalChickinQty float64) float64 { + if totalChickinQty > 0 { + return totalPrice / totalChickinQty + } + return 0 +} + +func formatRealizationDate(realization *entity.ExpenseRealization) string { + if realization != nil && realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Expense != nil { + if !realization.ExpenseNonstock.Expense.RealizationDate.IsZero() { + return realization.ExpenseNonstock.Expense.RealizationDate.Format("2006-01-02T15:04:05Z07:00") + } + } + return "" +} diff --git a/internal/modules/closings/dto/sapronak.dto.go b/internal/modules/closings/dto/sapronak.dto.go index edb6bc88..c6fe43fa 100644 --- a/internal/modules/closings/dto/sapronak.dto.go +++ b/internal/modules/closings/dto/sapronak.dto.go @@ -86,3 +86,26 @@ type SapronakProjectAggregatedDTO struct { Ovk *SapronakCategoryDTO `json:"ovk,omitempty"` Pakan *SapronakCategoryDTO `json:"pakan,omitempty"` } + +type ClosingSapronakItemDTO struct { + Id uint64 `json:"id"` + Date string `json:"date"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` + ProductSubCategory string `json:"product_sub_category"` + SourceWarehouse string `json:"source_warehouse"` + DestinationWarehouse string `json:"destination_warehouse,omitempty"` + // Destination string `json:"destination,omitempty"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + FormattedQuantity string `json:"formatted_quantity"` + Notes string `json:"notes"` + SortDate time.Time `json:"-"` +} + +type ClosingSapronakDTO struct { + IncomingSapronak []ClosingSapronakItemDTO `json:"incoming_sapronak"` + OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index 9ca91447..00206823 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -9,7 +9,9 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" - rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + rExpenseRealization "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" @@ -22,12 +24,15 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * closingRepo := rClosing.NewClosingRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db) marketingRepo := rMarketings.NewMarketingRepository(db) marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db) + expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db) + chickinRepo := rChickin.NewChickinRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, validate) sapronakService := sClosing.NewSapronakService(closingRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 88c7da41..f9f64a89 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "fmt" + "strings" "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -14,7 +15,8 @@ import ( type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] - ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) + GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) + ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) MapSapronakStartDates(ctx context.Context, pfkIDs []uint) (map[uint]time.Time, error) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) @@ -34,6 +36,202 @@ func NewClosingRepository(db *gorm.DB) ClosingRepository { } } +type SapronakRow struct { + Id uint64 `gorm:"column:id"` + SortDate time.Time `gorm:"column:sort_date"` + DateText string `gorm:"column:date_text"` + ReferenceNumber string `gorm:"column:reference_number"` + TransactionType string `gorm:"column:transaction_type"` + ProductName string `gorm:"column:product_name"` + ProductCategory string `gorm:"column:product_category"` + ProductSubCategory string `gorm:"column:product_sub_category"` + SourceWarehouse string `gorm:"column:source_warehouse"` + DestinationWarehouse string `gorm:"column:destination_warehouse"` + Destination string `gorm:"column:destination"` + Quantity float64 `gorm:"column:quantity"` + Unit string `gorm:"column:unit"` + Notes string `gorm:"column:notes"` +} + +type SapronakQueryParams struct { + Type string + WarehouseIDs []uint + ProjectFlockKandangIDs []uint + Limit int + Offset int +} + +func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { + db := r.DB().WithContext(ctx) + + var ( + unionParts []string + args []any + ) + + switch params.Type { + case validation.SapronakTypeIncoming: + if len(params.WarehouseIDs) == 0 { + return []SapronakRow{}, 0, nil + } + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) + case validation.SapronakTypeOutgoing: + if len(params.WarehouseIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingTransfersSQL) + args = append(args, params.WarehouseIDs) + } + if len(params.ProjectFlockKandangIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) + args = append(args, params.ProjectFlockKandangIDs) + } + if len(unionParts) == 0 { + return []SapronakRow{}, 0, nil + } + default: + return nil, 0, fmt.Errorf("invalid sapronak type: %s", params.Type) + } + + unionSQL := strings.Join(unionParts, " UNION ALL ") + + var totalResults int64 + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) + if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { + return nil, 0, err + } + + dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) + + var rows []SapronakRow + if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { + return nil, 0, err + } + + return rows, totalResults, nil +} + +const ( + sapronakIncomingPurchasesSQL = ` +SELECT + CAST(pi.id AS BIGINT) AS id, + 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, + 'Purchase' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + 'External Supplier' AS source_warehouse, + w.name AS destination_warehouse, + '' AS destination, + pi.total_qty AS quantity, + u.name AS unit, + COALESCE(p.notes, '') AS notes +FROM purchase_items pi +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +WHERE pi.warehouse_id IN ? +` + + sapronakIncomingTransfersSQL = ` +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, + 'Internal Transfer In' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(fw.name, '') AS source_warehouse, + COALESCE(tw.name, '') AS destination_warehouse, + '' AS destination, + std.quantity AS quantity, + u.name AS unit, + '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 +LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id +JOIN products prod ON prod.id = std.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.to_warehouse_id IN ? +` + + 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, + 'Internal Transfer Out' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(fw.name, '') AS source_warehouse, + '' AS destination_warehouse, + COALESCE(tw.name, '') AS destination, + std.quantity AS quantity, + u.name AS unit, + '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 +LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id +JOIN products prod ON prod.id = std.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.from_warehouse_id IN ? +` + + 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, + 'Trading Sales' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + w.name AS source_warehouse, + '' AS destination_warehouse, + 'RETAIL CUSTOMER' AS destination, + mp.qty AS quantity, + u.name AS unit, + m.notes AS notes +FROM marketing_products mp +JOIN marketings m ON m.id = mp.marketing_id +JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id +JOIN products prod ON prod.id = pw.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pw.warehouse_id +WHERE pw.project_flock_kandang_id IN ? +` +) + type SapronakIncomingRow struct { ProductID uint ProductName string @@ -62,7 +260,7 @@ type SapronakDetailRow struct { Price float64 } -func (r *ClosingRepositoryImpl) ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) { +func (r *ClosingRepositoryImpl) ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { db := r.DB(). WithContext(ctx). Preload("ProjectFlock"). diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index eca546a2..b12cd72f 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -13,7 +13,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService formatter := closing.NewSapronakFormatter() ctrl := controller.NewClosingController(s, sapronakSvc, formatter) - route := v1.Group("/closing") + route := v1.Group("/closings") // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) @@ -23,7 +23,9 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/", ctrl.GetAll) route.Get("/:project_flock_id/penjualan", ctrl.GetPenjualan) + route.Get("/:project_flock_id/overhead", ctrl.GetOverhead) route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", ctrl.GetSapronakByKandang) route.Get("/:project_flock_id/perhitungan_sapronak", ctrl.GetSapronakByProject) route.Get("/:projectFlockId", ctrl.GetClosingSummary) + route.Get("/:projectFlockId/sapronak", ctrl.GetClosingSapronak) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 79bbfd24..b1780359 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -3,14 +3,17 @@ package service import ( "context" "errors" + "strconv" 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" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" - marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + marketingRepository "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" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -22,10 +25,12 @@ import ( ) type ClosingService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) + GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) + GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) } type closingService struct { @@ -36,9 +41,12 @@ type closingService struct { MarketingRepo marketingRepository.MarketingRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository + ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository + ChickinRepo chickinRepository.ProjectChickinRepository } -func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, @@ -47,6 +55,9 @@ func NewClosingService(repo repository.ClosingRepository, projectFlockRepo proje MarketingRepo: marketingRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + ExpenseRealizationRepo: expenseRealizationRepo, + ProjectBudgetRepo: projectBudgetRepo, + ChickinRepo: chickinRepo, } } @@ -56,11 +67,12 @@ func (s closingService) withRelations(db *gorm.DB) *gorm.DB { func (s closingService) withClosingRelations(db *gorm.DB) *gorm.DB { return s.withRelations(db). + Preload("Location"). Preload("KandangHistory"). Preload("KandangHistory.Chickins") } -func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { +func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -68,9 +80,9 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity offset := (params.Page - 1) * params.Limit closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + db = s.withClosingRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("flock_name LIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -79,7 +91,19 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity s.Log.Errorf("Failed to get closings: %+v", err) return nil, 0, err } - return closings, total, nil + + result := make([]dto.ClosingListItemDTO, 0, len(closings)) + for _, closing := range closings { + statusProject, _, err := s.getApprovalStatuses(c.Context(), closing.Id) + if err != nil { + s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", closing.Id, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status") + } + + result = append(result, dto.ToClosingListItemDTO(closing, statusProject)) + } + + return result, total, nil } func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { @@ -144,6 +168,147 @@ func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*d return &summary, nil } +func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { + if projectFlockID == 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.ClosingSapronakQuery{} + } + + if params.Page == 0 { + params.Page = 1 + } + if params.Limit == 0 { + params.Limit = 10 + } + + if err := s.Validate.Struct(params); err != nil { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 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") + } + + var projectFlockKandangIDs []uint + if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + } + + offset := (params.Page - 1) * params.Limit + rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{ + Type: params.Type, + WarehouseIDs: warehouseIDs, + ProjectFlockKandangIDs: projectFlockKandangIDs, + Limit: params.Limit, + Offset: offset, + }) + if err != nil { + s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak data") + } + + items := make([]dto.ClosingSapronakItemDTO, 0, len(rows)) + for _, row := range rows { + dateStr := row.DateText + if dateStr == "" && !row.SortDate.IsZero() { + dateStr = row.SortDate.Format("02-Jan-2006") + } + items = append(items, dto.ClosingSapronakItemDTO{ + Id: row.Id, + Date: dateStr, + ReferenceNumber: row.ReferenceNumber, + TransactionType: row.TransactionType, + ProductName: row.ProductName, + ProductCategory: row.ProductCategory, + ProductSubCategory: row.ProductSubCategory, + SourceWarehouse: row.SourceWarehouse, + DestinationWarehouse: row.DestinationWarehouse, + // Destination: row.Destination, + Quantity: row.Quantity, + Unit: row.Unit, + FormattedQuantity: formatQuantity(row.Quantity, row.Unit), + Notes: row.Notes, + SortDate: row.SortDate, + }) + } + + return items, totalResults, nil +} + +func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { + var kandangIDs []uint + db := s.Repository.DB().WithContext(ctx) + + if err := db.Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID). + Pluck("kandang_id", &kandangIDs).Error; err != nil { + return nil, err + } + + if len(kandangIDs) == 0 { + return []uint{}, nil + } + + var warehouses []entity.Warehouse + if err := db.Where("kandang_id IN ?", kandangIDs).Find(&warehouses).Error; err != nil { + return nil, err + } + + unique := make(map[uint]struct{}) + for _, warehouse := range warehouses { + unique[warehouse.Id] = struct{}{} + } + + ids := make([]uint, 0, len(unique)) + for id := range unique { + ids = append(ids, id) + } + + return ids, nil +} + +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { + var ids []uint + err := s.Repository.DB().WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &ids).Error + if err != nil { + return nil, err + } + + return ids, nil +} + +func formatQuantity(qty float64, uom string) string { + qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) + if uom == "" { + return qtyStr + } + return qtyStr + " " + uom +} + func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID uint) (string, string, error) { if s.ApprovalSvc == nil { return "", "Belum Selesai", nil @@ -188,3 +353,29 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID return statusProject, statusClosing, nil } + +func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) { + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + var totalChickinQty float64 + for _, chickin := range chickins { + totalChickinQty += chickin.UsageQty + } + + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty) + + return &result, nil +} diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 31952479..9958a815 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -19,7 +19,7 @@ import ( type SapronakService interface { GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) - GetSapronakReport(ctx *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) + GetSapronakReport(ctx *fiber.Ctx, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) } type sapronakService struct { @@ -36,7 +36,7 @@ func NewSapronakService(repo repository.ClosingRepository, validate *validator.V } } -func (s sapronakService) GetSapronakReport(c *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) { +func (s sapronakService) GetSapronakReport(c *fiber.Ctx, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { if err := s.Validate.Struct(params); err != nil { return nil, err } @@ -47,7 +47,7 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") } - reports, err := s.computeSapronakReports(c.Context(), &validation.SapronakQuery{ + reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ ProjectFlockID: projectFlockID, Status: "all", Flag: flag, @@ -68,7 +68,7 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") } - results, err := s.computeSapronakReports(c.Context(), &validation.SapronakQuery{ + results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ ProjectFlockID: projectFlockID, ProjectFlockKandangID: pfkID, Status: "all", @@ -87,7 +87,7 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") } -func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) { +func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { pfks, err := s.loadProjectFlockKandangs(ctx, params) if err != nil { return nil, err @@ -158,7 +158,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val return results, nil } -func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) { +func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { pfks, err := s.Repository.ListProjectFlockKandangsForSapronak(ctx, params) if err != nil { s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err) diff --git a/internal/modules/closings/services/sapronak_formatter.go b/internal/modules/closings/services/sapronak_formatter.go index 880d2149..036d1e64 100644 --- a/internal/modules/closings/services/sapronak_formatter.go +++ b/internal/modules/closings/services/sapronak_formatter.go @@ -37,22 +37,22 @@ func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO, flag st result := dto.SapronakProjectAggregatedDTO{} if report == nil { - return result + report = &dto.SapronakReportDTO{} } filter := strings.ToUpper(strings.TrimSpace(flag)) byFlag := map[string]**dto.SapronakCategoryDTO{} if filter == "" || filter == "DOC" { - result.Doc = &dto.SapronakCategoryDTO{} + result.Doc = &dto.SapronakCategoryDTO{Rows: make([]dto.SapronakCategoryRowDTO, 0)} byFlag["DOC"] = &result.Doc } if filter == "" || filter == "OVK" { - result.Ovk = &dto.SapronakCategoryDTO{} + result.Ovk = &dto.SapronakCategoryDTO{Rows: make([]dto.SapronakCategoryRowDTO, 0),} byFlag["OVK"] = &result.Ovk } if filter == "" || filter == "PAKAN" { - result.Pakan = &dto.SapronakCategoryDTO{} + result.Pakan = &dto.SapronakCategoryDTO{Rows: make([]dto.SapronakCategoryRowDTO, 0),} byFlag["PAKAN"] = &result.Pakan } diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 7d16d3ee..610e89b8 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { @@ -13,3 +13,14 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` } + +const ( + SapronakTypeIncoming = "incoming" + SapronakTypeOutgoing = "outgoing" +) + +type ClosingSapronakQuery struct { + Type string `query:"type" validate:"required,oneof=incoming outgoing"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` +} diff --git a/internal/modules/closings/validations/sapronak.validation.go b/internal/modules/closings/validations/sapronak.validation.go index 1f2ca54f..7de79399 100644 --- a/internal/modules/closings/validations/sapronak.validation.go +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -1,6 +1,6 @@ package validation -type SapronakQuery struct { +type CountSapronakQuery struct { ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` KandangID uint `query:"kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 2f71a349..6d276b5d 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -11,7 +11,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 9e97a180..844a6409 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -2,9 +2,11 @@ package repository import ( "context" + "errors" "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" "gorm.io/gorm" ) @@ -13,6 +15,8 @@ type ExpenseRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (int, error) GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) + WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB + CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) } type ExpenseRepositoryImpl struct { @@ -49,3 +53,57 @@ func (r *ExpenseRepositoryImpl) GetWithSupplier(ctx context.Context, id uint64) } return &expense, nil } + +func (r *ExpenseRepositoryImpl) WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if pfkID == 0 && kandangID == 0 { + return db + } + q := db.Joins("JOIN expense_nonstocks ON expense_nonstocks.expense_id = expenses.id") + if pfkID > 0 && kandangID > 0 { + return q.Where("expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.kandang_id = ?", pfkID, kandangID) + } + if pfkID > 0 { + return q.Where("expense_nonstocks.project_flock_kandang_id = ?", pfkID) + } + return q.Where("expense_nonstocks.kandang_id = ?", kandangID) + } +} + +func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) { + if pfkID == 0 && kandangID == 0 { + return 0, nil + } + + var ids []uint64 + if err := r.DB().WithContext(ctx). + Table("expenses"). + Scopes(r.WithProjectFlockKandangFilter(pfkID, kandangID)). + Group("expenses.id").Where("expenses.deleted_at IS NULL"). + Pluck("expenses.id", &ids).Error; err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + + var unfinished int64 + for _, id := range ids { + var latest entity.Approval + err := r.DB().WithContext(ctx). + Table("approvals"). + Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowExpense.String(), id). + Order("action_at DESC"). + Limit(1). + First(&latest).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + if isFinished != nil { + if !isFinished(&latest) { + unfinished++ + } + } + } + return unfinished, nil +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 77f075f7..e60324ca 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -12,6 +12,7 @@ type ExpenseRealizationRepository interface { repository.BaseRepository[entity.ExpenseRealization] IdExists(ctx context.Context, id uint64) (bool, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) } type ExpenseRealizationRepositoryImpl struct { @@ -30,11 +31,22 @@ func (r *ExpenseRealizationRepositoryImpl) IdExists(ctx context.Context, id uint func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) { var realization entity.ExpenseRealization - err := r.DB().WithContext(ctx). - Where("expense_nonstock_id = ?", expenseNonstockID). - First(&realization).Error - if err != nil { - return nil, err - } - return &realization, nil + err := r.DB().WithContext(ctx).Where("expense_nonstock_id = ?", expenseNonstockID).First(&realization).Error + return &realization, err +} + +func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) { + var realizations []entity.ExpenseRealization + err := r.DB().WithContext(ctx). + Preload("ExpenseNonstock"). + Preload("ExpenseNonstock.Nonstock"). + Preload("ExpenseNonstock.Nonstock.Uom"). + Preload("ExpenseNonstock.Expense"). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("expenses.category = ?", "BOP"). + Find(&realizations).Error + return realizations, err } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 363c52ff..dbfb00c2 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -11,6 +11,7 @@ import ( 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" + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" @@ -183,12 +184,16 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction) - referenceNumber, err := s.generateReferenceNumber(dbTransaction) + referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } - createdBy := uint64(1) //todo get from auth + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } + createdBy := uint64(actorID) expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, @@ -360,6 +365,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { + return err + } categoryChanged := false var newCategory string if req.Category != nil && *req.Category != currentExpense.Category { @@ -404,6 +412,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if ens.KandangId != nil { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId)) if err != nil { + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { + return err + } if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") } @@ -496,7 +507,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } if *latestApproval.Action != entity.ApprovalActionUpdated { approvalAction := entity.ApprovalActionUpdated @@ -543,7 +557,21 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { ); err != nil { return err } + expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Expense not found for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to get expense for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return err + } if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) @@ -572,6 +600,20 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") } + expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return nil, err + } + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) @@ -655,7 +697,10 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( return nil, err } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -712,7 +757,19 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va ); err != nil { return nil, err } + expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return nil, err + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") @@ -960,11 +1017,14 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided") } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } var results []expenseDto.ExpenseDetailDTO - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) expenseRepoTx := repository.NewExpenseRepository(tx) @@ -1010,6 +1070,21 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") } + if approvalAction == entity.ApprovalActionApproved { + expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return err + } + } if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -1056,17 +1131,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, return results, nil } -func (s *expenseService) generateReferenceNumber(ctx *gorm.DB) (string, error) { - - sequence, err := s.Repository.GetNextSequence(ctx.Statement.Context) - if err != nil { - return "", err - } - refNum := fmt.Sprintf("BOP-LTI-%05d", sequence) - - return refNum, nil -} - func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) { expenseRepoTx := repository.NewExpenseRepository(ctx) @@ -1090,13 +1154,45 @@ func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expense return nil } -// func actorIDFromContext(c *fiber.Ctx) (uint, error) { -// user, ok := authmiddleware.AuthenticatedUser(c) -// if !ok || user == nil || user.Id == 0 { -// return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// return user.Id, nil -// } +func (s *expenseService) ensureProjectFlockNotClosedForExpense( + ctx context.Context, + expense *entity.Expense, +) error { + // Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa + if s.ProjectFlockKandangRepo == nil || expense == nil { + return nil + } -// return user.Id, nil -// } + seen := make(map[uint]struct{}) + + for _, ens := range expense.Nonstocks { + // Field ini pointer, bisa nil + if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 { + continue + } + + pfkID := uint(*ens.ProjectFlockKandangId) + if _, ok := seen[pfkID]; ok { + continue + } + seen[pfkID] = struct{}{} + + pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Project flock %d tidak ditemukan", pfkID), + ) + } + s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + // ❗ RULE: kalau ClosedAt tidak nil → project sudah closing + if pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + + return nil +} diff --git a/internal/modules/expenses/services/number_helper.go b/internal/modules/expenses/services/number_helper.go new file mode 100644 index 00000000..2d1be912 --- /dev/null +++ b/internal/modules/expenses/services/number_helper.go @@ -0,0 +1,17 @@ +package service + +import ( + "context" + "fmt" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" +) + +// GenerateExpenseReferenceNumber builds a new reference number using the expense sequence. +func GenerateExpenseReferenceNumber(ctx context.Context, repo expenseRepo.ExpenseRepository) (string, error) { + sequence, err := repo.GetNextSequence(ctx) + if err != nil { + return "", err + } + return fmt.Sprintf("BOP-LTI-%05d", sequence), nil +} diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index b3e12676..610dc11e 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -9,8 +9,8 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" ) @@ -21,10 +21,11 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate) + adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/route.go b/internal/modules/inventory/adjustments/route.go index 8f58bb4d..57200215 100644 --- a/internal/modules/inventory/adjustments/route.go +++ b/internal/modules/inventory/adjustments/route.go @@ -1,7 +1,7 @@ package adjustments import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/controllers" adjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,7 +13,7 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme ctrl := controller.NewAdjustmentController(s) route := v1.Group("/adjustments") - + route.Use(m.Auth(u)) // Standard CRUD routes following master data pattern route.Get("/", ctrl.AdjustmentHistory) // Get all with pagination and filters route.Post("/", ctrl.Adjustment) // Create adjustment diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index be4ae7a2..da118438 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -1,9 +1,14 @@ package service import ( + "context" "errors" + "fmt" "strings" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" @@ -11,13 +16,10 @@ import ( ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" ) type AdjustmentService interface { @@ -27,22 +29,24 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate) AdjustmentService { +func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } @@ -105,11 +109,15 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") } if !isProductWarehouseExist { - + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) + if err != nil { + return nil, err + } newPW := &entity.ProductWarehouse{ - ProductId: uint(req.ProductID), - WarehouseId: uint(req.WarehouseID), - Quantity: 0, + ProductId: uint(req.ProductID), + WarehouseId: uint(req.WarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, // CreatedBy: 1, // TODO: should Get from auth middleware } @@ -120,6 +128,23 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e s.Log.Infof("Product warehouse created: %+v", newPW.Id) } + pw, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + ctx, + uint(req.ProductID), + uint(req.WarehouseID), + ) + if err != nil { + s.Log.Errorf("Failed to get product warehouse for project flock check: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + } + + if err := common.EnsureProjectFlockNotClosedForProductWarehouses( + ctx, + s.StockLogsRepository.DB(), + []uint{pw.Id}, + ); err != nil { + return nil, err + } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) if err != nil { @@ -170,6 +195,32 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return s.GetOne(c, createdLogId) } +func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { + warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + } + + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + } + s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + } + + return uint(projectFlockKandang.Id), nil +} + func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 641ce531..8b33a852 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -258,7 +258,10 @@ func (r *ProductWarehouseRepositoryImpl) GetByFlagAndWarehouseID(ctx context.Con Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Where("flags.name = ? AND product_warehouses.warehouse_id = ?", flagName, warehouseId). Order("product_warehouses.id DESC"). - Preload("Product").Preload("Warehouse"). + Preload("Product"). + Preload("Product.ProductCategory"). + Preload("Product.Uom"). + Preload("Warehouse"). Find(&productWarehouses).Error if err != nil { return nil, err diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 734f0f03..19a0ded6 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -9,6 +9,8 @@ import ( rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -25,8 +27,10 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index ef273664..3293d21b 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -1,21 +1,26 @@ package service import ( + "context" "errors" "fmt" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + 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" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "strings" - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -35,9 +40,11 @@ type transferService struct { StockLogsRepository rStockLogs.StockLogRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository SupplierRepo rSupplier.SupplierRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -48,6 +55,8 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr StockLogsRepository: stockLogsRepo, ProductWarehouseRepo: productWarehouseRepo, SupplierRepo: supplierRepo, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } func (s transferService) withRelations(db *gorm.DB) *gorm.DB { @@ -111,6 +120,8 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { + // Validasi stok di gudang asal harus exist dan mencukupi + pwIDs := make([]uint, 0, len(req.Products)) // Validasi stok di gudang asal harus exist dan mencukupi for _, product := range req.Products { @@ -126,7 +137,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if sourcePW.Quantity < product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) } + pwIDs = append(pwIDs, sourcePW.Id) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + s.StockTransferRepo.DB(), + pwIDs, + ); err != nil { + return nil, err + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err @@ -301,10 +321,16 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { // Jika belum ada record untuk produk di gudang tujuan, buat baru + ctx := c.Context() + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) + if err != nil { + return err + } destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, // CreatedBy: 1, // TODO: should Get from auth middleware } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { @@ -357,3 +383,29 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } return result, nil } + +func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { + warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + } + + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + } + s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + } + + return uint(projectFlockKandang.Id), nil +} diff --git a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go similarity index 92% rename from internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go rename to internal/modules/marketing/controllers/deliveryorder.controller.go index 292381d0..73904cc3 100644 --- a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -4,9 +4,9 @@ import ( "math" "strconv" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" @@ -23,7 +23,7 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer } func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { - query := &validation.Query{ + query := &validation.DeliveryOrderQuery{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), MarketingId: uint(c.QueryInt("marketing_id", 0)), @@ -76,7 +76,7 @@ func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error { } func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { - req := new(validation.Create) + req := new(validation.DeliveryOrderCreate) if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -97,7 +97,7 @@ func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { } func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) + req := new(validation.DeliveryOrderUpdate) param := c.Params("id") id, err := strconv.Atoi(param) diff --git a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go b/internal/modules/marketing/controllers/salesorder.controller.go similarity index 95% rename from internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go rename to internal/modules/marketing/controllers/salesorder.controller.go index 16d3b5be..416af20f 100644 --- a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go +++ b/internal/modules/marketing/controllers/salesorder.controller.go @@ -3,9 +3,9 @@ package controller import ( "strconv" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" diff --git a/internal/modules/marketing/delivery-orderss/module.go b/internal/modules/marketing/delivery-orderss/module.go deleted file mode 100644 index efe3737d..00000000 --- a/internal/modules/marketing/delivery-orderss/module.go +++ /dev/null @@ -1,38 +0,0 @@ -package delivery_orderss - -import ( - "fmt" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - sDeliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - rMarketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type DeliveryOrdersModule struct{} - -func (DeliveryOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - marketingRepo := rMarketing.NewMarketingRepository(db) - marketingProductRepo := rMarketing.NewMarketingProductRepository(db) - marketingDeliveryProductRepo := rMarketing.NewMarketingDeliveryProductRepository(db) - userRepo := rUser.NewUserRepository(db) - approvalRepo := commonRepo.NewApprovalRepository(db) - approvalSvc := commonSvc.NewApprovalService(approvalRepo) - - // Register workflow steps for MARKETINGS approval - if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) - } - - deliveryOrdersService := sDeliveryOrders.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) - userService := sUser.NewUserService(userRepo, validate) - - DeliveryOrdersRoutes(router, userService, deliveryOrdersService) -} diff --git a/internal/modules/marketing/delivery-orderss/route.go b/internal/modules/marketing/delivery-orderss/route.go deleted file mode 100644 index c83330da..00000000 --- a/internal/modules/marketing/delivery-orderss/route.go +++ /dev/null @@ -1,31 +0,0 @@ -package delivery_orderss - -import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/controllers" - deliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" -) - -func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders.DeliveryOrdersService) { - ctrl := controller.NewDeliveryOrdersController(s) - - v1.Get("/", ctrl.GetAll) - v1.Get("/:id", ctrl.GetOne) - - // Sisanya di group /delivery-orders - route := v1.Group("/delivery-orders") - route.Use(m.Auth(u)) - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Post("/", ctrl.CreateOne) - route.Patch("/:id", ctrl.UpdateOne) - -} diff --git a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go similarity index 94% rename from internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go rename to internal/modules/marketing/dto/deliveryorder.dto.go index 69037499..b2bb70d7 100644 --- a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -24,7 +24,7 @@ type MarketingListDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` - SalesOrder []MarketingProductDTO `json:"sales_order"` + SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -36,13 +36,14 @@ type MarketingDetailDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` - SalesOrder []MarketingProductDTO `json:"sales_order"` + SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LatestApproval approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } + type MarketingDeliveryProductDTO struct { Id uint `json:"id"` MarketingProductId uint `json:"marketing_product_id"` @@ -73,7 +74,7 @@ type DeliveryGroupDTO struct { Deliveries []DeliveryItemDTO `json:"deliveries"` } -type MarketingProductDTO struct { +type DeliveryMarketingProductDTO struct { Id uint `json:"id"` MarketingId uint `json:"marketing_id"` ProductWarehouseId uint `json:"product_warehouse_id"` @@ -95,14 +96,14 @@ func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { } } -func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { +func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO { var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO if e.ProductWarehouse.Id != 0 { mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) productWarehouse = &mapped } - return MarketingProductDTO{ + return DeliveryMarketingProductDTO{ Id: e.Id, MarketingId: e.MarketingId, ProductWarehouseId: e.ProductWarehouseId, @@ -155,11 +156,11 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M latestApproval = mapped } - var salesOrderProducts []MarketingProductDTO + var salesOrderProducts []DeliveryMarketingProductDTO if len(marketing.Products) > 0 { - salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) } } @@ -195,11 +196,11 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity salesPerson = mapped } - var salesOrderProducts []MarketingProductDTO + var salesOrderProducts []DeliveryMarketingProductDTO if len(marketing.Products) > 0 { - salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) } } diff --git a/internal/modules/marketing/sales-orders/dto/sales-orders.dto.go b/internal/modules/marketing/dto/salesorder.dto.go similarity index 100% rename from internal/modules/marketing/sales-orders/dto/sales-orders.dto.go rename to internal/modules/marketing/dto/salesorder.dto.go diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 9bf4f018..586e7961 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -1,13 +1,52 @@ package marketing import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type MarketingModule struct{} func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - RegisterRoutes(router, db, validate) + // Initialize repositories + marketingRepo := repository.NewMarketingRepository(db) + marketingProductRepo := repository.NewMarketingProductRepository(db) + marketingDeliveryProductRepo := repository.NewMarketingDeliveryProductRepository(db) + userRepo := rUser.NewUserRepository(db) + customerRepo := rCustomer.NewCustomerRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + + // Initialize approval service + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + + // Register workflow steps for marketing approval + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) + } + + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + // Initialize services + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + userService := sUser.NewUserService(userRepo, validate) + + // Register routes + RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) } diff --git a/internal/modules/marketing/repositories/salesorder.repository.go b/internal/modules/marketing/repositories/salesorder.repository.go new file mode 100644 index 00000000..51351e55 --- /dev/null +++ b/internal/modules/marketing/repositories/salesorder.repository.go @@ -0,0 +1,57 @@ +package repository + +import ( + "context" + "fmt" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type MarketingRepository interface { + repository.BaseRepository[entity.Marketing] + IdExists(ctx context.Context, id uint) (bool, error) + GetNextSequence(ctx context.Context) (uint, error) + NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) +} + +type MarketingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Marketing] +} + +func NewMarketingRepository(db *gorm.DB) MarketingRepository { + return &MarketingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Marketing](db), + } +} + +func (r *MarketingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Marketing](ctx, r.DB(), id) +} + +func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, error) { + var maxID uint + if err := r.DB().WithContext(ctx).Model(&entity.Marketing{}).Select("COALESCE(MAX(id), 0)").Scan(&maxID).Error; err != nil { + return 0, err + } + return maxID + 1, nil +} + +func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) { + db := tx + if db == nil { + db = r.DB() + } + + var soNumber string + err := db.WithContext(ctx). + Raw("SELECT generate_so_number()"). + Scan(&soNumber).Error + + if err != nil { + return "", fmt.Errorf("failed to generate SO number: %w", err) + } + + return soNumber, nil +} diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go similarity index 100% rename from internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go rename to internal/modules/marketing/repositories/salesorder_delivery_product.repository.go diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go b/internal/modules/marketing/repositories/salesorder_product.repository.go similarity index 100% rename from internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go rename to internal/modules/marketing/repositories/salesorder_product.repository.go diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 1ab03896..75ecc0f6 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -1,27 +1,31 @@ package marketing import ( - "gitlab.com/mbugroup/lti-api.git/internal/modules" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/controllers" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - salesOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders" - deliveryOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss" - // MODULE IMPORTS ) -func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - group := router.Group("/marketing") +func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrdersService service.SalesOrdersService, deliveryOrdersService service.DeliveryOrdersService) { + salesOrdersCtrl := controller.NewSalesOrdersController(salesOrdersService) + deliveryOrdersCtrl := controller.NewDeliveryOrdersController(deliveryOrdersService) - allModules := []modules.Module{ - salesOrderss.SalesOrdersModule{}, - deliveryOrderss.DeliveryOrdersModule{}, - // MODULE REGISTRY - } + route := router.Group("/marketing") + route.Use(m.Auth(userService)) - for _, m := range allModules { - m.RegisterRoutes(group, db, validate) - } + route.Get("/", deliveryOrdersCtrl.GetAll) + route.Get("/:id", deliveryOrdersCtrl.GetOne) + route.Delete("/:id", salesOrdersCtrl.DeleteOne) + + route.Post("/sales-orders", salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", salesOrdersCtrl.Approval) + + route.Get("/delivery-orders", deliveryOrdersCtrl.GetAll) + route.Get("/delivery-orders/:id", deliveryOrdersCtrl.GetOne) + route.Post("/delivery-orders", deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/sales-orders/module.go b/internal/modules/marketing/sales-orders/module.go deleted file mode 100644 index 0d9583d0..00000000 --- a/internal/modules/marketing/sales-orders/module.go +++ /dev/null @@ -1,39 +0,0 @@ -package sales_orders - -import ( - "fmt" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - sSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type SalesOrdersModule struct{} - -func (SalesOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - marketingRepo := rSalesOrders.NewMarketingRepository(db) - userRepo := rUser.NewUserRepository(db) - customerRepo := rCustomer.NewCustomerRepository(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) - - if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) - } - - salesOrdersService := sSalesOrders.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, validate) - userService := sUser.NewUserService(userRepo, validate) - - SalesOrdersRoutes(router, userService, salesOrdersService) -} diff --git a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go b/internal/modules/marketing/sales-orders/repositories/marketings.repository.go deleted file mode 100644 index df8a7c98..00000000 --- a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go +++ /dev/null @@ -1,122 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "strconv" - "strings" - - "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" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -type MarketingRepository interface { - repository.BaseRepository[entity.Marketing] - IdExists(ctx context.Context, id uint) (bool, error) - GetNextSequence(ctx context.Context) (uint, error) - NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) -} - -type MarketingRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.Marketing] -} - -func NewMarketingRepository(db *gorm.DB) MarketingRepository { - return &MarketingRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.Marketing](db), - } -} - -func (r *MarketingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.Marketing](ctx, r.DB(), id) -} - -func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, error) { - var maxID uint - if err := r.DB().WithContext(ctx).Model(&entity.Marketing{}).Select("COALESCE(MAX(id), 0)").Scan(&maxID).Error; err != nil { - return 0, err - } - return maxID + 1, nil -} - -func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) { - return r.generateSequentialNumber(ctx, tx, "so_number", utils.MarketingSoNumberPrefix, utils.MarketingNumberPadding) -} - -func parseNumericSuffix(value, prefix string) (int, bool) { - if !strings.HasPrefix(value, prefix) { - return 0, false - } - suffix := strings.TrimPrefix(value, prefix) - if suffix == "" { - return 0, false - } - trimmed := strings.TrimLeft(suffix, "0") - if trimmed == "" { - trimmed = "0" - } - number, err := strconv.Atoi(trimmed) - if err != nil { - return 0, false - } - return number, true -} - -func (r *MarketingRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, column, value string) (bool, error) { - var count int64 - if err := db.WithContext(ctx). - Model(&entity.Marketing{}). - Where(fmt.Sprintf("%s = ?", column), value). - Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil -} - -func (r *MarketingRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { - - db := tx - if db == nil { - db = r.DB() - } - - var values []string - err := db.WithContext(ctx). - Model(&entity.Marketing{}). - Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). - Select(column). - Order(fmt.Sprintf("%s DESC", column)). - Limit(20). - Clauses(clause.Locking{Strength: "UPDATE"}). - Pluck(column, &values).Error - if err != nil { - return "", err - } - - next := 1 - for _, value := range values { - if number, ok := parseNumericSuffix(value, prefix); ok { - next = number + 1 - break - } - } - - const maxAttempts = 20 - for attempt := 0; attempt < maxAttempts; attempt++ { - candidate := fmt.Sprintf("%s%0*d", prefix, padding, next) - exists, err := r.numberExists(ctx, db, column, candidate) - if err != nil { - return "", err - } - if !exists { - return candidate, nil - } - next++ - } - - return "", fmt.Errorf("unable to generate unique %s", column) - -} diff --git a/internal/modules/marketing/sales-orders/route.go b/internal/modules/marketing/sales-orders/route.go deleted file mode 100644 index f87cea66..00000000 --- a/internal/modules/marketing/sales-orders/route.go +++ /dev/null @@ -1,27 +0,0 @@ -package sales_orders - -import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/controllers" - salesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" -) - -func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesOrdersService) { - ctrl := controller.NewSalesOrdersController(s) - - v1.Delete("/:id", ctrl.DeleteOne) - route := v1.Group("/sales-orders") - route.Use(m.Auth(u)) - - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Post("/", ctrl.CreateOne) - route.Patch("/:id", ctrl.UpdateOne) - - route.Post("/approvals", ctrl.Approval) -} diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/services/deliveryorder.service.go similarity index 93% rename from internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go rename to internal/modules/marketing/services/deliveryorder.service.go index 52ced7d7..793ed716 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -11,9 +11,9 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" - marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" @@ -23,10 +23,10 @@ import ( ) type DeliveryOrdersService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) + GetAll(ctx *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) + CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) + UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) } type deliveryOrdersService struct { @@ -85,7 +85,7 @@ func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketin return &responseDTO, nil } -func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) { +func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -164,7 +164,7 @@ func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDeta return &responseDTO, nil } -func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) { +func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -222,6 +222,14 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + dbTransaction, + []uint{foundMarketingProduct.ProductWarehouseId}, + ); err != nil { + return err + } + deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -285,7 +293,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return s.getMarketingWithDeliveries(c, req.MarketingId) } -func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) { +func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -319,6 +327,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i if foundMarketingProduct == nil { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + dbTransaction, + []uint{foundMarketingProduct.ProductWarehouseId}, + ); err != nil { + return err + } deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) if err != nil { diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/services/salesorder.service.go similarity index 85% rename from internal/modules/marketing/sales-orders/services/sales-orders.service.go rename to internal/modules/marketing/services/salesorder.service.go index 061ffaf7..02cd2e42 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -11,9 +11,11 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -32,24 +34,29 @@ type SalesOrdersService interface { } type salesOrdersService struct { - Log *logrus.Logger - Validate *validator.Validate - MarketingRepo repository.MarketingRepository - CustomerRepo customerRepo.CustomerRepository - ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository - UserRepo userRepo.UserRepository - ApprovalSvc commonSvc.ApprovalService + Log *logrus.Logger + Validate *validator.Validate + MarketingRepo repository.MarketingRepository + CustomerRepo customerRepo.CustomerRepository + ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + UserRepo userRepo.UserRepository + ApprovalSvc commonSvc.ApprovalService + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) SalesOrdersService { +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo warehouseRepo.WarehouseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ - Log: utils.Log, - Validate: validate, - MarketingRepo: marketingRepo, - CustomerRepo: customerRepo, - ProductWarehouseRepo: productWarehouseRepo, - UserRepo: userRepo, - ApprovalSvc: approvalSvc, + Log: utils.Log, + Validate: validate, + MarketingRepo: marketingRepo, + CustomerRepo: customerRepo, + ProductWarehouseRepo: productWarehouseRepo, + UserRepo: userRepo, + ApprovalSvc: approvalSvc, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } @@ -140,10 +147,18 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } if len(req.MarketingProducts) > 0 { + pwIDs := make([]uint, 0, len(req.MarketingProducts)) for _, product := range req.MarketingProducts { + if product.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, product.ProductWarehouseId) + } if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } + + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return err } } @@ -213,6 +228,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } } + if len(req.MarketingProducts) > 0 { + pwIDs := make([]uint, 0, len(req.MarketingProducts)) + for _, item := range req.MarketingProducts { + if item.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, item.ProductWarehouseId) + } + } + + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return nil, err + } + } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -367,7 +394,18 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") } + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, p := range marketing.Products { + if p.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, p.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return err + } + } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) @@ -458,6 +496,27 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e fmt.Sprintf("Marketing %d cannot be approved - current step is %d", id, latestApproval.StepNumber)) } } + marketing, mErr := s.MarketingRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Products") + }) + if mErr != nil { + if errors.Is(mErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("SalesOrders %d not found", id)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order for project validation") + } + + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, p := range marketing.Products { + if p.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, p.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return nil, err + } + } } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { diff --git a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go similarity index 91% rename from internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go rename to internal/modules/marketing/validations/deliveryorder.validation.go index 3317e952..7db2cdd1 100644 --- a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -11,22 +11,22 @@ type DeliveryProduct struct { VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` } -type Create struct { +type DeliveryOrderCreate struct { MarketingId uint `json:"marketing_id" validate:"required,gt=0"` DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"required,min=1,dive"` } -type Update struct { +type DeliveryOrderUpdate struct { DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"omitempty,min=1,dive"` } -type Query struct { +type DeliveryOrderQuery struct { Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` } -type Approve struct { +type DeliveryOrderApprove struct { Action string `json:"action" validate:"required_strict"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` diff --git a/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go b/internal/modules/marketing/validations/salesorder.validation.go similarity index 100% rename from internal/modules/marketing/sales-orders/validations/sales-orders.validation.go rename to internal/modules/marketing/validations/salesorder.validation.go diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index dd187230..b2af526c 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -1,11 +1,11 @@ package dto import ( - "time" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "time" ) // === DTO Structs === @@ -18,13 +18,14 @@ type NonstockRelationDTO struct { } type NonstockListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Flags []string `json:"flags"` - Uom *uomDTO.UomRelationDTO `json:"uom"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Flags []string `json:"flags"` + Uom *uomDTO.UomRelationDTO `json:"uom"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type NonstockDetailDTO struct { @@ -76,6 +77,7 @@ func ToNonstockListDTO(e entity.Nonstock) NonstockListDTO { Name: e.Name, Flags: flags, Uom: uomRef, + Suppliers: toNonstockSupplierDTOs(e.NonstockSuppliers), CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, @@ -95,3 +97,23 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { NonstockListDTO: ToNonstockListDTO(e), } } + +func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { + if len(relations) == 0 { + return nil + } + + result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + for _, relation := range relations { + if relation.Supplier.Id == 0 { + continue + } + result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + } + + if len(result) == 0 { + return nil + } + + return result +} diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index afa90660..a7cfac94 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -24,10 +24,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous func (u *WarehouseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - AreaId: c.QueryInt("area_id", 0), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + AreaId: c.QueryInt("area_id", 0), + ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 4c15b94c..79c41284 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -53,11 +53,28 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.ActiveProjectFlockOnly { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM kandangs k + JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id + JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id + WHERE k.id = warehouses.kandang_id + AND LOWER(latest_approval.step_name) = LOWER(?) + ) + `, "Aktif") + } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 6046defe..1e305520 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -17,8 +17,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` - AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + ActiveProjectFlockOnly bool `query:"active_project_flock"` } diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f6dd554b..f4e91056 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -41,8 +41,8 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index a98dab67..bef062f5 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -11,6 +11,7 @@ import ( type ProjectChickinRepository interface { repository.BaseRepository[entity.ProjectChickin] GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectChickin, error) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) @@ -40,6 +41,16 @@ func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, pr return &chickin, nil } +func (r *ChickinRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + err := r.db.WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = project_chickins.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Order("project_chickins.created_at DESC"). + Find(&chickins).Error + return chickins, err +} + func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { var chickins []entity.ProjectChickin err := r.db.WithContext(ctx). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 660f1e7e..cb816431 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -190,14 +190,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } if category == string(utils.ProjectFlockCategoryLaying) { for _, chickin := range newChikins { - updates := map[string]any{"quantity": gorm.Expr("quantity - ?", chickin.PendingUsageQty)} + updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -218,9 +218,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if latest == nil { if _, err := approvalSvcTx.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, projectFlockKandang.Id, - utils.ProjectFlockKandangStepPengajuan, + utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { @@ -228,12 +228,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } } - } else if latest.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + } else if latest.StepNumber != uint16(utils.ChickinStepPengajuan) { if _, err := approvalSvcTx.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, projectFlockKandang.Id, - utils.ProjectFlockKandangStepPengajuan, + utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { @@ -388,7 +388,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") @@ -396,14 +396,14 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if latestApproval == nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for ProjectFlockKandang %d - chickins must be created first", id)) } - if latestApproval.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + if latestApproval.StepNumber != uint16(utils.ChickinStepPengajuan) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d cannot be approved - current status is not in PENGAJUAN stage", id)) } } - step := utils.ProjectFlockKandangStepPengajuan + step := utils.ChickinStepPengajuan if action == entity.ApprovalActionApproved { - step = utils.ProjectFlockKandangStepDisetujui + step = utils.ChickinStepDisetujui } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -415,7 +415,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, approvableID, step, &action, @@ -498,7 +498,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"quantity": gorm.Expr("quantity + ?", chickin.PendingUsageQty)} + updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -600,7 +600,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if chickin.ProductWarehouseId != targetPW.Id { if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity - ?", quantityToConvert), + "qty": gorm.Expr("qty - ?", quantityToConvert), }, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) @@ -610,7 +610,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti } if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "quantity": gorm.Expr("quantity + ?", quantityToConvert), + "qty": gorm.Expr("qty + ?", quantityToConvert), }, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) diff --git a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go index 4b6e605a..dce7b02b 100644 --- a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -84,3 +84,48 @@ func (u *ProjectFlockKandangController) GetOne(c *fiber.Ctx) error { Data: dto.ToProjectFlockKandangDetailDTOWithAvailableQty(*result, availableQtys, productWarehouses), }) } + +func (u *ProjectFlockKandangController) Closing(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + req := new(validation.Closing) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectFlockKandangService.Closing(c, uint(id), req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Status closing kandang diperbarui", + // Data: dto.ProjectFlockKandangDetailDTO(*result), + Data: result, + }) +} + +func (u *ProjectFlockKandangController) CheckClosing(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProjectFlockKandangService.CheckClosing(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Cek persyaratan closing kandang", + Data: result, + }) +} diff --git a/internal/modules/production/project-flock-kandangs/module.go b/internal/modules/production/project-flock-kandangs/module.go index 160cec5e..00ae03ff 100644 --- a/internal/modules/production/project-flock-kandangs/module.go +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -9,9 +9,10 @@ import ( sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -28,15 +29,17 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - // register workflow steps for project flock kandang approvals + // register workflow steps for chickin approvals if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, validate) + expenseRepo := rExpense.NewExpenseRepository(db) + projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index 7bab770e..3998a324 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -22,5 +22,8 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo route.Get("/", ctrl.GetAll) route.Get("/:id", ctrl.GetOne) - + // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) + // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) + route.Post("/:id/closing", ctrl.Closing) + route.Get("/:id/closing/check", ctrl.CheckClosing) } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 11e8b0d5..883e64b0 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -2,47 +2,84 @@ package service import ( "errors" - - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/utils" + "fmt" + "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + 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" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type ProjectFlockKandangService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) + CheckClosing(ctx *fiber.Ctx, id uint) (*ClosingCheckResult, error) + Closing(ctx *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangClosingDate(c *fiber.Ctx, id uint) (*time.Time, error) } -// Note: map[uint]float64 adalah mapping dari ProductWarehouse ID ke calculated available quantity - type projectFlockKandangService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService + ExpenseRepo expenseRepo.ExpenseRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository PopulationRepo repository.ProjectFlockPopulationRepository + KandangRepo kandangRepo.KandangRepository } -func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, validate *validator.Validate) ProjectFlockKandangService { +type ClosingCheckResult struct { + UnfinishedExpenses int64 `json:"unfinished_expenses"` + StockRemaining []StockRemainingDetail `json:"stock_remaining"` + Expenses []ExpenseSummary `json:"expenses"` +} + +type StockRemainingDetail struct { + FlagName string `json:"flag_name"` + ProductWarehouseId uint `json:"product_warehouse_id"` + ProductId uint `json:"product_id"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` + Uom string `json:"uom"` + Quantity float64 `json:"quantity"` +} + +type ExpenseSummary struct { + Id uint64 `json:"id"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Total float64 `json:"total"` + Status string `json:"status"` + StepName string `json:"step_name"` + Step uint16 `json:"step"` + Reference string `json:"reference_number"` +} + +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { return &projectFlockKandangService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, + ExpenseRepo: expenseRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, PopulationRepo: populationRepo, + KandangRepo: kandangRepo, } } @@ -166,6 +203,309 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project return result, nil } +func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*ClosingCheckResult, error) { + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + + var unfinished int64 + if s.ExpenseRepo != nil && s.ApprovalSvc != nil { + count, err := s.ExpenseRepo.CountUnfinishedByProjectFlockKandang(c.Context(), pfk.Id, pfk.KandangId, func(appr *entity.Approval) bool { + return appr != nil && appr.StepNumber == uint16(utils.ExpenseStepSelesai) && appr.Action != nil && *appr.Action == entity.ApprovalActionApproved + }) + if err != nil { + return nil, err + } + unfinished = count + } + + stockRemain := make([]StockRemainingDetail, 0) + if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { + warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + if werr != nil { + return nil, werr + } + + for _, flagName := range []utils.FlagType{utils.FlagPakan, utils.FlagOVK} { + productWarehouses, pwErr := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), string(flagName), warehouse.Id) + if pwErr != nil { + return nil, pwErr + } + + for _, pw := range productWarehouses { + if pw.Quantity > 0 { + category := "" + if pw.Product.ProductCategory.Id != 0 { + category = pw.Product.ProductCategory.Name + } + uomName := "" + if pw.Product.Uom.Id != 0 { + uomName = pw.Product.Uom.Name + } + stockRemain = append(stockRemain, StockRemainingDetail{ + FlagName: string(flagName), + ProductWarehouseId: pw.Id, + ProductId: pw.ProductId, + ProductName: pw.Product.Name, + ProductCategory: category, + Uom: uomName, + Quantity: pw.Quantity, + }) + } + } + } + } + + expenseSummaries := make([]ExpenseSummary, 0) + if s.ExpenseRepo != nil { + var expenses []entity.Expense + if err := s.ExpenseRepo.DB().WithContext(c.Context()). + Scopes(s.ExpenseRepo.WithProjectFlockKandangFilter(pfk.Id, pfk.KandangId)). + Preload("Nonstocks"). + Find(&expenses).Error; err != nil { + return nil, err + } + + if len(expenses) > 0 && s.ApprovalSvc != nil { + ids := make([]uint, 0, len(expenses)) + for _, e := range expenses { + ids = append(ids, uint(e.Id)) + } + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowExpense, ids, nil) + if err == nil { + for i := range expenses { + if latest, ok := latestMap[uint(expenses[i].Id)]; ok { + expenses[i].LatestApproval = latest + } + } + } + } + + for _, exp := range expenses { + total := 0.0 + for _, ns := range exp.Nonstocks { + total += ns.Qty * ns.Price + } + + status := "Pending" + stepName := "" + stepNum := uint16(0) + if exp.LatestApproval != nil { + stepName = exp.LatestApproval.StepName + stepNum = exp.LatestApproval.StepNumber + if exp.LatestApproval.Action != nil { + status = string(*exp.LatestApproval.Action) + } else if stepName != "" { + status = stepName + } + } + + expenseSummaries = append(expenseSummaries, ExpenseSummary{ + Id: exp.Id, + PoNumber: exp.PoNumber, + Category: exp.Category, + Total: total, + Status: status, + StepName: stepName, + Step: stepNum, + Reference: exp.ReferenceNumber, + }) + } + } + + return &ClosingCheckResult{ + UnfinishedExpenses: unfinished, + StockRemaining: stockRemain, + Expenses: expenseSummaries, + }, nil +} + +// getProjectFlockKandangClosingDate mengembalikan tanggal closing PFK jika sudah di-close. +func (s projectFlockKandangService) GetProjectFlockKandangClosingDate(c *fiber.Ctx, id uint) (*time.Time, error) { + if id == 0 { + return nil, nil + } + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return pfk.ClosedAt, nil +} + +func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + + if s.ApprovalSvc != nil { + latest, aerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if aerr != nil { + return nil, aerr + } + if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock belum berstatus aktif") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) && latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock belum berstatus aktif") + } + } + + action := strings.ToLower(strings.TrimSpace(req.Action)) + now := time.Now() + + switch action { + case "close": + if pfk.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusConflict, "Kandang sudah closed") + } + if s.ExpenseRepo != nil && s.ApprovalSvc != nil { + unfinished, err := s.ExpenseRepo.CountUnfinishedByProjectFlockKandang(c.Context(), pfk.Id, pfk.KandangId, func(appr *entity.Approval) bool { + return appr != nil && appr.StepNumber == uint16(utils.ExpenseStepSelesai) && appr.Action != nil && *appr.Action == entity.ApprovalActionApproved + }) + if err != nil { + return nil, err + } + if unfinished > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Masih ada expense belum selesai untuk kandang ini") + } + } + + if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { + warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + if werr != nil { + return nil, werr + } + + for _, flagName := range []utils.FlagType{utils.FlagPakan, utils.FlagOVK} { + productWarehouses, pwErr := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), string(flagName), warehouse.Id) + if pwErr != nil { + return nil, pwErr + } + + for _, pw := range productWarehouses { + if pw.Quantity > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok %s masih tersedia (product warehouse %d: %.2f)", flagName, pw.Id, pw.Quantity)) + } + } + } + } + + closeTime := now + if req.ClosedDate != nil { + parsed, perr := utils.ParseDateString(strings.TrimSpace(*req.ClosedDate)) + if perr != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "closed_date tidak valid, gunakan format YYYY-MM-DD") + } + closeTime = parsed + } + if err := s.Repository.UpdateClosedAt(c.Context(), id, &closeTime); err != nil { + return nil, err + } + if s.KandangRepo != nil { + if err := s.KandangRepo.UpdateStatusByIDs( + c.Context(), + []uint{pfk.KandangId}, + utils.KandangStatusNonActive, + ); err != nil { + return nil, err + } + } + if s.ApprovalSvc != nil { + closeAction := entity.ApprovalActionApproved + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + id, + utils.ProjectFlockKandangStepClosed, + &closeAction, + actorID, + nil, + ); aerr != nil { + return nil, aerr + } + + // Jika semua kandang dalam project sudah ditutup, set approval project flock ke SELESAI. + pfks, ferr := s.Repository.GetByProjectFlockID(c.Context(), pfk.ProjectFlockId) + if ferr != nil { + return nil, ferr + } + allClosed := true + for _, item := range pfks { + if item.ClosedAt == nil { + allClosed = false + break + } + } + if allClosed { + latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if lerr != nil { + return nil, lerr + } + if latestPF == nil || latestPF.StepNumber != uint16(utils.ProjectFlockStepSelesai) { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + pfk.ProjectFlockId, + utils.ProjectFlockStepSelesai, + &closeAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } + } + } + case "unclose": + if pfk.ClosedAt == nil { + return nil, fiber.NewError(fiber.StatusConflict, "Kandang belum closed") + } + openNewer, err := s.Repository.HasOpenNewerPeriod(c.Context(), pfk.KandangId, pfk.Period, &pfk.Id) + if err != nil { + return nil, err + } + if openNewer { + return nil, fiber.NewError(fiber.StatusBadRequest, "Tidak dapat un-close: ada periode yang sedang berjalan") + } + if err := s.Repository.UpdateClosedAt(c.Context(), id, nil); err != nil { + return nil, err + } + if s.KandangRepo != nil { + if err := s.KandangRepo.UpdateStatusByIDs( + c.Context(), + []uint{pfk.KandangId}, + utils.KandangStatusActive, + ); err != nil { + return nil, err + } + } + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") + } + + return s.Repository.GetByID(c.Context(), id) +} + func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehouse(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang, productWarehouse *entity.ProductWarehouse) (float64, error) { availableQty := productWarehouse.Quantity diff --git a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go index 93e0256a..729d8329 100644 --- a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -22,3 +22,8 @@ type Query struct { SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` StepName string `query:"step_name" validate:"omitempty,max=50"` } + +type Closing struct { + Action string `json:"action" validate:"required,oneof=close unclose"` + ClosedDate *string `json:"closed_date,omitempty"` +} \ No newline at end of file diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 52d53be5..937c9058 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -165,33 +165,6 @@ func (u *ProjectflockController) CreateOne(c *fiber.Ctx) error { }) } -func (u *ProjectflockController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.ProjectflockService.UpdateOne(c, req, uint(id)) - if err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Update projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), - }) -} - func (u *ProjectflockController) DeleteOne(c *fiber.Ctx) error { param := c.Params("id") diff --git a/internal/modules/production/project_flocks/repositories/project_budget.repository.go b/internal/modules/production/project_flocks/repositories/project_budget.repository.go index 943a22b3..720bfc40 100644 --- a/internal/modules/production/project_flocks/repositories/project_budget.repository.go +++ b/internal/modules/production/project_flocks/repositories/project_budget.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,7 @@ import ( type ProjectBudgetRepository interface { repository.BaseRepository[entity.ProjectBudget] + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectBudget, error) } type ProjectBudgetRepositoryImpl struct { @@ -21,3 +24,13 @@ func NewProjectBudgetRepository(db *gorm.DB) ProjectBudgetRepository { db: db, } } + +func (r *ProjectBudgetRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectBudget, error) { + var budgets []entity.ProjectBudget + err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Preload("Nonstock"). + Preload("Nonstock.Uom"). + Find(&budgets).Error + return budgets, err +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 76f23b39..889a95be 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -14,18 +15,20 @@ type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) + UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) + HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository IdExists(ctx context.Context, id uint) (bool, error) - DB() *gorm.DB } type projectFlockKandangRepositoryImpl struct { @@ -75,6 +78,16 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context, offset i return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) { + var records []entity.ProjectFlockKandang + if err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) { var records []entity.ProjectFlockKandang var total int64 @@ -223,32 +236,57 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont } func (r *projectFlockKandangRepositoryImpl) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) { - record := new(entity.ProjectFlockKandang) + latestApprovalSubQuery := r.db. + Table("approvals"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, action_at"). + Where("approvable_type = ?", "PROJECT_FLOCKS"). + Order("approvable_id, action_at DESC") + + var pfkID uint if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Joins(` - INNER JOIN ( - SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at - FROM approvals - WHERE approvable_type = 'PROJECT_FLOCKS' - ORDER BY approvable_id, action_at DESC - ) latest_approval ON latest_approval.approvable_id = project_flocks.id - `). + Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery). Where("project_flock_kandangs.kandang_id = ?", kandangID). + Where("project_flock_kandangs.closed_at IS NULL"). Where("LOWER(latest_approval.step_name) = LOWER(?)", "Aktif"). Order("project_flock_kandangs.id DESC"). - Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). - Preload("ProjectFlock.Area"). - Preload("ProjectFlock.Location"). - Preload("ProjectFlock.CreatedUser"). - Preload("ProjectFlock.Kandangs"). - Preload("ProjectFlock.KandangHistory"). - Preload("Kandang"). - First(record).Error; err != nil { + Limit(1). + Pluck("project_flock_kandangs.id", &pfkID).Error; err != nil { return nil, err } - return record, nil + + if pfkID == 0 { + return nil, gorm.ErrRecordNotFound + } + + return r.GetByID(ctx, pfkID) +} + +func (r *projectFlockKandangRepositoryImpl) UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error { + return r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("id = ?", id). + Update("closed_at", t).Error +} + +func (r *projectFlockKandangRepositoryImpl) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) { + if kandangID == 0 { + return false, nil + } + q := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("kandang_id = ?", kandangID). + Where("period > ?", currentPeriod). + Where("closed_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil } func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { @@ -269,7 +307,8 @@ func (r *projectFlockKandangRepositoryImpl) HasKandangsLinkedToOtherProject(ctx } q := r.db.WithContext(ctx). Table("project_flock_kandangs"). - Where("kandang_id IN ?", kandangIDs) + Where("kandang_id IN ?", kandangIDs). + Where("closed_at IS NULL") if exceptProjectID != nil { q = q.Where("project_flock_id <> ?", *exceptProjectID) } diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 710f5225..1fdefcdf 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -18,7 +18,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) route.Delete("/:id", ctrl.DeleteOne) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 1a7fc6f2..62e1d389 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -34,7 +34,6 @@ type ProjectflockService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) @@ -255,6 +254,16 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } + var location entity.Location + if err := s.Repository.DB().WithContext(c.Context()). + Where("id = ? AND area_id = ?", req.LocationId, req.AreaId). + First(&location).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Lokasi tidak berada pada area yang diminta") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi area-lokasi") + } + canonicalBase := baseName if s.FlockRepo != nil { baseFlock, err := s.ensureFlockByName(c.Context(), actorID, baseName) @@ -348,365 +357,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return s.getOneEntityOnly(c, createBody.Id) } -func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - actorID, err := m.ActorIDFromContext(c) - if err != nil { - return nil, err - } - - existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - if err != nil { - s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - updateBody := make(map[string]any) - hasBodyChanges := false - var relationChecks []commonSvc.RelationCheck - existingBase := pfutils.DeriveBaseName(existing.FlockName) - targetBaseName := existingBase - needFlockNameRegenerate := false - - if req.FlockName != nil { - trimmed := strings.TrimSpace(*req.FlockName) - if trimmed == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") - } - canonicalBase := trimmed - if s.FlockRepo != nil { - flockEntity, err := s.ensureFlockByName(c.Context(), actorID, trimmed) - if err != nil { - return nil, err - } - canonicalBase = flockEntity.Name - } - if !strings.EqualFold(canonicalBase, existingBase) { - needFlockNameRegenerate = true - targetBaseName = canonicalBase - hasBodyChanges = true - } - } - if req.AreaId != nil { - updateBody["area_id"] = *req.AreaId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Area", - ID: req.AreaId, - Exists: s.Repository.AreaExists, - }) - } - if req.Category != nil { - cat := strings.ToUpper(*req.Category) - if !utils.IsValidProjectFlockCategory(cat) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") - } - - updateBody["category"] = cat - } - if req.FcrId != nil { - updateBody["fcr_id"] = *req.FcrId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "FCR", - ID: req.FcrId, - Exists: s.Repository.FcrExists, - }) - } - if req.LocationId != nil { - updateBody["location_id"] = *req.LocationId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Location", - ID: req.LocationId, - Exists: s.Repository.LocationExists, - }) - } - - if len(relationChecks) > 0 { - if err := commonSvc.EnsureRelations(c.Context(), relationChecks...); err != nil { - return nil, err - } - } - - var newKandangIDs []uint - hasKandangChanges := false - if req.KandangIds != nil { - hasKandangChanges = true - if len(req.KandangIds) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") - } - newKandangIDs = uniqueUintSlice(req.KandangIds) - kandangs, err := s.KandangRepo.GetByIDs(c.Context(), newKandangIDs, nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") - } - if len(kandangs) != len(newKandangIDs) { - return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") - } - targetLocationID := existing.LocationId - if req.LocationId != nil && *req.LocationId > 0 { - targetLocationID = *req.LocationId - } - for _, kandang := range kandangs { - if kandang.LocationId != targetLocationID { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak berada pada lokasi yang sama dengan project flock", kandang.Id)) - } - } - if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") - } else if linked { - return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") - } - - } - - hasChanges := hasBodyChanges || hasKandangChanges - if !hasChanges { - return s.getOneEntityOnly(c, id) - } - - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - projectRepo := repository.NewProjectflockRepository(dbTransaction) - - baseForGeneration := targetBaseName - if strings.TrimSpace(baseForGeneration) == "" { - baseForGeneration = existingBase - } - if strings.TrimSpace(baseForGeneration) == "" { - baseForGeneration = strings.TrimSpace(existing.FlockName) - } - - if needFlockNameRegenerate { - newName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, 1, &id) - if err != nil { - return err - } - updateBody["flock_name"] = newName - } - - if len(updateBody) > 0 { - if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { - return err - } - } else { - if _, err := projectRepo.GetByID(c.Context(), id, nil); err != nil { - return err - } - } - - if req.KandangIds != nil { - existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) - for _, k := range existing.Kandangs { - existingIDs[k.Id] = struct{}{} - } - newSet := make(map[uint]struct{}, len(newKandangIDs)) - for _, kid := range newKandangIDs { - newSet[kid] = struct{}{} - } - - var toDetach []uint - for kid := range existingIDs { - if _, ok := newSet[kid]; !ok { - toDetach = append(toDetach, kid) - } - } - - var toAttach []uint - for kid := range newSet { - if _, ok := existingIDs[kid]; !ok { - toAttach = append(toAttach, kid) - } - } - - if len(toDetach) > 0 { - if err := s.detachKandangs(c.Context(), dbTransaction, id, toDetach, true); err != nil { - return err - } - } - - if len(toAttach) > 0 { - currentPeriod, err := projectRepo.GetCurrentProjectPeriod(c.Context(), id) - if err != nil { - return err - } - - periods := make(map[uint]int, len(toAttach)) - if currentPeriod > 0 { - for _, kid := range toAttach { - periods[kid] = currentPeriod - } - } else { - periods, err = projectRepo.GetNextPeriodsForKandangs(c.Context(), toAttach) - if err != nil { - return err - } - } - - if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach, periods); err != nil { - return err - } - } - } - - if hasChanges { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - if approvalSvc != nil { - latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) - if err != nil { - return err - } - shouldRecordUpdate := latestBeforeReset == nil || - latestBeforeReset.StepNumber != uint16(utils.ProjectFlockStepPengajuan) || - latestBeforeReset.Action == nil || - (latestBeforeReset.Action != nil && *latestBeforeReset.Action != entity.ApprovalActionUpdated) - - if shouldRecordUpdate { - action := entity.ApprovalActionUpdated - if _, err := approvalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlock, - id, - utils.ProjectFlockStepPengajuan, - &action, - actorID, - nil, - ); err != nil { - return err - } - } - } - } - - return nil - }) - - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - return nil, fiberErr - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock") - } - - return s.getOneEntityOnly(c, id) -} - -func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - actorID, err := m.ActorIDFromContext(c) - if err != nil { - return nil, err - } - - var action entity.ApprovalAction - switch strings.ToUpper(strings.TrimSpace(req.Action)) { - case string(entity.ApprovalActionRejected): - action = entity.ApprovalActionRejected - case string(entity.ApprovalActionApproved): - action = entity.ApprovalActionApproved - default: - return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") - } - - approvableIDs := uniqueUintSlice(req.ApprovableIds) - if len(approvableIDs) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") - } - - step := utils.ProjectFlockStepPengajuan - if action == entity.ApprovalActionApproved { - step = utils.ProjectFlockStepAktif - } - - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) - projectRepoTx := repository.NewProjectflockRepository(dbTransaction) - - for _, approvableID := range approvableIDs { - if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) - } - return err - } - - if _, err := approvalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlock, - approvableID, - step, - &action, - actorID, - req.Notes, - ); err != nil { - return err - } - - switch action { - case entity.ApprovalActionApproved: - if err := kandangRepoTx.UpdateStatusByProjectFlockID( - c.Context(), - approvableID, - utils.KandangStatusActive, - ); err != nil { - return err - } - case entity.ApprovalActionRejected: - if err := kandangRepoTx.UpdateStatusByProjectFlockID( - c.Context(), - approvableID, - utils.KandangStatusNonActive, - ); err != nil { - return err - } - } - } - - return nil - }) - - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - return nil, fiberErr - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - s.Log.Errorf("Failed to record approval for projectflocks %+v: %+v", approvableIDs, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") - } - - updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) - for _, approvableID := range approvableIDs { - project, err := s.getOneEntityOnly(c, approvableID) - if err != nil { - return nil, err - } - updated = append(updated, *project) - } - - return updated, nil -} - func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -827,6 +477,27 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } +// getProjectFlockClosingDate mengembalikan tanggal closing Project Flock jika sudah mencapai step SELESAI (Approved). +// func (s projectflockService) getProjectFlockClosingDate(ctx context.Context, projectFlockID uint) (*time.Time, error) { +// if projectFlockID == 0 || s.ApprovalSvc == nil { +// return nil, nil +// } + +// latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) +// if err != nil { +// return nil, err +// } +// if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { +// return nil, nil +// } +// if latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { +// return nil, nil +// } + +// t := latest.ActionAt +// return &t, nil +// } + func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil @@ -834,6 +505,133 @@ func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) return s.pivotRepo().ProjectPeriodsByProjectIDs(c.Context(), projectIDs) } +func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + approvableIDs := uniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.ProjectFlockStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.ProjectFlockStepAktif + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) + projectRepoTx := repository.NewProjectflockRepository(dbTransaction) + projectFlockKandangRepoTx := repository.NewProjectFlockKandangRepository(dbTransaction) + + for _, approvableID := range approvableIDs { + if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + approvableID, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + switch action { + case entity.ApprovalActionApproved: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusActive, + ); err != nil { + return err + } + + pfks, err := projectFlockKandangRepoTx.GetByProjectFlockID(c.Context(), approvableID) + if err != nil { + return err + } + for _, pfk := range pfks { + latest, lerr := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, pfk.Id, nil) + if lerr != nil { + return lerr + } + if latest != nil && latest.StepNumber == uint16(utils.ProjectFlockKandangStepDisetujui) { + continue + } + if _, aerr := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + pfk.Id, + utils.ProjectFlockKandangStepDisetujui, + &action, + actorID, + req.Notes, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return aerr + } + } + case entity.ApprovalActionRejected: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusNonActive, + ); err != nil { + return err + } + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to record approval for projectflocks %+v: %+v", approvableIDs, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) + for _, approvableID := range approvableIDs { + project, err := s.getOneEntityOnly(c, approvableID) + if err != nil { + return nil, err + } + updated = append(updated, *project) + } + + return updated, nil +} + func (s projectflockService) GetPeriodSummary(c *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) { if locationID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "location_id is required") diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 00b01456..66045dc1 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -10,15 +10,6 @@ type Create struct { ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } -type Update struct { - FlockName *string `json:"flock_name,omitempty" validate:"omitempty"` - AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` - Category *string `json:"category,omitempty" validate:"omitempty"` - FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` -} - type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index c348a454..c0f1737b 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -146,27 +146,6 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { }) } -func (u *RecordingController) SubmitGrading(c *fiber.Ctx) error { - req := new(validation.SubmitGrading) - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.RecordingService.SubmitGrading(c, req) - if err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Submit grading eggs successfully", - Data: dto.ToRecordingDetailDTO(*result), - }) -} - func (u *RecordingController) Approve(c *fiber.Ctx) error { req := new(validation.Approve) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index f7cc4ee2..51fba8a4 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,7 +1,6 @@ package dto import ( - "math" "strings" "time" @@ -16,22 +15,19 @@ import ( // === DTO Structs === type RecordingRelationDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - RecordDatetime time.Time `json:"record_datetime"` - Day int `json:"day"` - ProjectFlockCategory string `json:"project_flock_category"` - TotalDepletionQty float64 `json:"total_depletion_qty"` - CumDepletionRate float64 `json:"cum_depletion_rate"` - DailyGain float64 `json:"daily_gain"` - AvgDailyGain float64 `json:"avg_daily_gain"` - CumIntake int `json:"cum_intake"` - FcrValue float64 `json:"fcr_value"` - TotalChickQty float64 `json:"total_chick_qty"` - Approval approvalDTO.ApprovalRelationDTO `json:"approval"` - EggGradingStatus *string `json:"egg_grading_status"` - EggGradingPendingQty *int `json:"egg_grading_pending_qty"` - EggGradingCompletedQty *int `json:"egg_grading_completed_qty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + Day int `json:"day"` + ProjectFlockCategory string `json:"project_flock_category"` + TotalDepletionQty float64 `json:"total_depletion_qty"` + CumDepletionRate float64 `json:"cum_depletion_rate"` + DailyGain float64 `json:"daily_gain"` + AvgDailyGain float64 `json:"avg_daily_gain"` + CumIntake int `json:"cum_intake"` + FcrValue float64 `json:"fcr_value"` + TotalChickQty float64 `json:"total_chick_qty"` + Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } type RecordingListDTO struct { @@ -72,8 +68,8 @@ type RecordingEggDTO struct { Id uint `json:"id"` ProductWarehouseId uint `json:"product_warehouse_id"` Qty int `json:"qty"` + Weight *float64 `json:"weight,omitempty"` ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` - Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` } type RecordingProductWarehouseDTO struct { @@ -84,11 +80,6 @@ type RecordingProductWarehouseDTO struct { WarehouseName string `json:"warehouse_name"` } -type RecordingEggGradingDTO struct { - Grade string `json:"grade,omitempty"` - Qty float64 `json:"qty"` -} - // === Mapper Functions === func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { @@ -140,25 +131,20 @@ func ToRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { latestApproval = snapshot } - gradingStatus, gradingPending, gradingCompleted := computeEggGradingStatus(e) - return RecordingRelationDTO{ - Id: e.Id, - ProjectFlockKandangId: e.ProjectFlockKandangId, - RecordDatetime: e.RecordDatetime, - Day: day, - ProjectFlockCategory: projectFlockCategory, - TotalDepletionQty: totalDepletionQty, - CumDepletionRate: cumDepletionRate, - DailyGain: dailyGain, - AvgDailyGain: avgDailyGain, - CumIntake: cumIntake, - FcrValue: fcrValue, - TotalChickQty: totalChickQty, - Approval: latestApproval, - EggGradingStatus: gradingStatus, - EggGradingPendingQty: gradingPending, - EggGradingCompletedQty: gradingCompleted, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + Day: day, + ProjectFlockCategory: projectFlockCategory, + TotalDepletionQty: totalDepletionQty, + CumDepletionRate: cumDepletionRate, + DailyGain: dailyGain, + AvgDailyGain: avgDailyGain, + CumIntake: cumIntake, + FcrValue: fcrValue, + TotalChickQty: totalChickQty, + Approval: latestApproval, } } @@ -253,29 +239,13 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { Id: egg.Id, ProductWarehouseId: egg.ProductWarehouseId, Qty: egg.Qty, + Weight: egg.Weight, ProductWarehouse: mapProductWarehouseDTO(&egg.ProductWarehouse), - Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs), } } return result } -func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradingDTO { - if len(gradings) == 0 { - return nil - } - - result := make([]RecordingEggGradingDTO, len(gradings)) - for i, grading := range gradings { - result[i] = RecordingEggGradingDTO{ - Grade: grading.Grade, - Qty: grading.Qty, - } - } - - return result -} - func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.ProductWarehouseDTO { if pw == nil { return productWarehouseDTO.ProductWarehouseDTO{} @@ -289,61 +259,6 @@ func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.Pro return *mapped } -const goodEggProductWarehouseID uint = 5 - -func computeEggGradingStatus(e entity.Recording) (*string, *int, *int) { - goodEggs := filterGoodEggs(e.Eggs) - if len(goodEggs) == 0 { - return nil, nil, nil - } - - totalEggs := 0 - totalGraded := 0.0 - for _, egg := range goodEggs { - totalEggs += egg.Qty - for _, grading := range egg.GradingEggs { - totalGraded += grading.Qty - } - } - - if totalEggs == 0 { - return nil, nil, nil - } - - pendingFloat := float64(totalEggs) - totalGraded - if pendingFloat < 0 { - pendingFloat = 0 - } - pendingInt := int(math.Round(pendingFloat)) - completedInt := int(math.Round(totalGraded)) - if completedInt < 0 { - completedInt = 0 - } - - if pendingInt > 0 { - status := "GRADING_TELUR" - return &status, &pendingInt, &completedInt - } - - status := "GRADING_SELESAI" - zero := 0 - return &status, &zero, &completedInt -} - -func filterGoodEggs(eggs []entity.RecordingEgg) []entity.RecordingEgg { - if len(eggs) == 0 { - return nil - } - - result := make([]entity.RecordingEgg, 0, len(eggs)) - for _, egg := range eggs { - if egg.ProductWarehouseId == goodEggProductWarehouseID { - result = append(result, egg) - } - } - return result -} - func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { result := approvalDTO.ApprovalRelationDTO{} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 341031e1..a19faa33 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -39,7 +39,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate ProductWarehouseID: "product_warehouse_id", UsageQuantity: "usage_qty", PendingQuantity: "pending_qty", - CreatedAt: "created_at", + CreatedAt: "id", }, }); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "already registered") { diff --git a/internal/modules/production/recordings/permissions.go b/internal/modules/production/recordings/permissions.go deleted file mode 100644 index 00f9bd48..00000000 --- a/internal/modules/production/recordings/permissions.go +++ /dev/null @@ -1,8 +0,0 @@ -package recordings - -const ( - PermissionRecordingRead = "recording.read" - PermissionRecordingCreate = "recording.write" - PermissionRecordingUpdate = "recording.update" - PermissionRecordingDelete = "recording.delete" -) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 5feb8d6b..60457074 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -35,8 +35,6 @@ type RecordingRepository interface { DeleteEggs(tx *gorm.DB, recordingID uint) error ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) - CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error - DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) @@ -76,8 +74,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Eggs"). Preload("Eggs.ProductWarehouse"). Preload("Eggs.ProductWarehouse.Product"). - Preload("Eggs.ProductWarehouse.Warehouse"). - Preload("Eggs.GradingEggs") + Preload("Eggs.ProductWarehouse.Warehouse") } func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { @@ -188,7 +185,6 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( Preload("Recording.ProjectFlockKandang"). Preload("Recording.ProjectFlockKandang.ProjectFlock"). Preload("ProductWarehouse"). - Preload("GradingEggs"). Where("id = ?", id) if err := query.First(&egg).Error; err != nil { @@ -197,17 +193,6 @@ func (r *RecordingRepositoryImpl) GetRecordingEggByID( return &egg, nil } -func (r *RecordingRepositoryImpl) CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error { - if len(gradings) == 0 { - return nil - } - return tx.Create(&gradings).Error -} - -func (r *RecordingRepositoryImpl) DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error { - return tx.Where("recording_egg_id = ?", recordingEggID).Delete(&entity.GradingEgg{}).Error -} - func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { if projectFlockKandangId == 0 { return false, nil diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index c492c39f..83b426db 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -18,7 +18,6 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS route.Get("/", ctrl.GetAll) route.Get("/next-day", ctrl.GetNextDay) route.Post("/", ctrl.CreateOne) - route.Post("/gradings", ctrl.SubmitGrading) route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) route.Post("/approvals", ctrl.Approve) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 82f60433..a83c1128 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -33,7 +33,6 @@ type RecordingService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error - SubmitGrading(ctx *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } @@ -273,7 +272,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } action := entity.ApprovalActionCreated - if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepGradingTelur, action, createdRecording.CreatedBy, nil); err != nil { + if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepPengajuan, action, createdRecording.CreatedBy, nil); err != nil { s.Log.Errorf("Failed to create recording approval for %d: %+v", createdRecording.Id, err) return err } @@ -347,16 +346,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - hasExistingGradings := false - for _, egg := range recordingEntity.Eggs { - if len(egg.GradingEggs) > 0 { - hasExistingGradings = true - break - } - } - - hasEggsAfterUpdate := len(recordingEntity.Eggs) > 0 - if hasBodyChanges { if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear body weights: %+v", err) @@ -441,9 +430,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } - - hasExistingGradings = false - hasEggsAfterUpdate = len(req.Eggs) > 0 } if hasBodyChanges || hasStockChanges || hasDepletionChanges { @@ -459,20 +445,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return fiber.NewError(fiber.StatusBadRequest, "Actor Id tidak valid untuk approval") } - var step approvalutils.ApprovalStep - if isLaying { - if !hasEggsAfterUpdate { - step = utils.RecordingStepGradingTelur - } else if hasEggChanges { - step = utils.RecordingStepGradingTelur - } else if hasExistingGradings { - step = utils.RecordingStepPengajuan - } else { - step = utils.RecordingStepGradingTelur - } - } else { - step = utils.RecordingStepPengajuan - } + step := utils.RecordingStepPengajuan latestApproval := recordingEntity.LatestApproval if latestApproval == nil { @@ -517,109 +490,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return s.GetOne(c, id) } -func (s *recordingService) SubmitGrading(c *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - if len(req.EggsGrading) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "eggs_grading must contain at least one item") - } - - recordingEggID := req.EggsGrading[0].RecordingEggId - for _, grading := range req.EggsGrading[1:] { - if grading.RecordingEggId != recordingEggID { - return nil, fiber.NewError(fiber.StatusBadRequest, "semua grading harus untuk recording egg yang sama") - } - } - - ctx := c.Context() - var recordingID uint - transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, recordingEggID, func(db *gorm.DB) *gorm.DB { - return tx - }) - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Recording egg not found") - } - if err != nil { - s.Log.Errorf("Failed to get recording egg %d: %+v", recordingEggID, err) - return err - } - - var category string - if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) - } - if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { - return fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") - } - - totalGradingQty := 0.0 - for _, grading := range req.EggsGrading { - totalGradingQty += grading.Qty - } - - availableRecorded := float64(recordingEgg.Qty) - if totalGradingQty > availableRecorded { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), - ) - } - - if recordingEgg.ProductWarehouse.Id != 0 { - availableWarehouse := recordingEgg.ProductWarehouse.Quantity - if totalGradingQty > availableWarehouse { - return fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), - ) - } - } - - if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { - s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return err - } - - gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) - createdBy := recordingEgg.CreatedBy - if createdBy == 0 { - createdBy = recordingEgg.Recording.CreatedBy - } - for _, item := range req.EggsGrading { - gradings = append(gradings, entity.GradingEgg{ - RecordingEggId: recordingEgg.Id, - Grade: strings.TrimSpace(item.Grade), - Qty: item.Qty, - CreatedBy: createdBy, - }) - } - - if len(gradings) > 0 { - if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { - s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return err - } - } - - action := entity.ApprovalActionUpdated - if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { - s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) - return err - } - - recordingID = recordingEgg.RecordingId - return nil - }) - if transactionErr != nil { - return nil, transactionErr - } - - return s.GetOne(c, recordingID) -} - func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { if err := s.Validate.Struct(req); err != nil { return nil, err @@ -934,14 +804,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm return fmt.Errorf("getFeedUsageInGrams: %w", err) } - fcrId, err := s.Repository.GetFcrID(tx, recording.ProjectFlockKandangId) - if err != nil { - return fmt.Errorf("getFcrID: %w", err) - } - currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) + prevAvgKg := recordingutil.GramsToKg(prevAvgGrams) currentDepletion := float64(totalDepletionQty) cumDepletionQty := prevCumDepletionQty + currentDepletion @@ -951,9 +817,10 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } recording.TotalDepletionQty = &cumDepletionQty + var remainingChick float64 if totalChick > 0 { totalChickFloat := float64(totalChick) - remainingChick := totalChickFloat - cumDepletionQty + remainingChick = totalChickFloat - cumDepletionQty if remainingChick < 0 { remainingChick = 0 } @@ -978,24 +845,19 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm updates["daily_gain"] = dailyGainKg recording.DailyGain = &dailyGainKg } else { - updates["daily_gain"] = gorm.Expr("NULL") - recording.DailyGain = nil + dailyGainKg := 0.0 + updates["daily_gain"] = dailyGainKg + recording.DailyGain = &dailyGainKg } - if fcrId != 0 && currentAvgKg > 0 && day > 0 { - if fcrWeightKg, ok, err := s.Repository.GetFcrStandardWeightKg(tx, fcrId, currentAvgKg); err != nil { - return fmt.Errorf("getFcrStandardWeightKg: %w", err) - } else if ok { - avgDailyGain := (currentAvgKg - fcrWeightKg) / float64(day) - updates["avg_daily_gain"] = avgDailyGain - recording.AvgDailyGain = &avgDailyGain - } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil - } + if currentAvgKg > 0 && remainingChick > 0 { + avgDailyGain := (currentAvgKg - prevAvgKg) / remainingChick + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain } else { - updates["avg_daily_gain"] = gorm.Expr("NULL") - recording.AvgDailyGain = nil + avgDailyGain := 0.0 + updates["avg_daily_gain"] = avgDailyGain + recording.AvgDailyGain = &avgDailyGain } if usageInGrams > 0 && totalChick > 0 { diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 28ea8a9f..28c38ff5 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -19,8 +19,9 @@ type ( } Egg struct { - ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Qty int `json:"qty" validate:"required,number,min=0"` + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty int `json:"qty" validate:"required,number,min=0"` + Weight *float64 `json:"weight,omitempty" validate:"omitempty,gte=0"` } ) @@ -45,16 +46,6 @@ type Query struct { ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` } -type EggGrading struct { - RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` - Grade string `json:"grade" validate:"required"` - Qty float64 `json:"qty" validate:"required,gte=0"` -} - -type SubmitGrading struct { - EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` -} - type Approve struct { Action string `json:"action" validate:"required_strict"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index 4a29d860..1956729c 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -14,20 +14,18 @@ import ( ) type PurchaseRelationDTO struct { - Id uint `json:"id"` - PrNumber string `json:"pr_number"` - PoNumber *string `json:"po_number"` - PoDate *time.Time `json:"po_date"` - Notes *string `json:"notes"` + Id uint `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + PoDate *time.Time `json:"po_date"` + CreditTerm int `json:"credit_term"` + Notes *string `json:"notes"` } - type PurchaseListDTO struct { PurchaseRelationDTO Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` DueDate *time.Time `json:"due_date"` - GrandTotal float64 `json:"grand_total"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -37,9 +35,7 @@ type PurchaseListDTO struct { type PurchaseDetailDTO struct { PurchaseRelationDTO Supplier *supplierDTO.SupplierRelationDTO `json:"supplier"` - CreditTerm *int `json:"credit_term"` DueDate *time.Time `json:"due_date"` - GrandTotal float64 `json:"grand_total"` Items []PurchaseItemDTO `json:"items"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` @@ -47,7 +43,6 @@ type PurchaseDetailDTO struct { LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } - type PurchaseItemDTO struct { Id uint `json:"id"` ProductID uint `json:"product_id"` @@ -64,16 +59,18 @@ type PurchaseItemDTO struct { TravelNumber *string `json:"travel_number"` TravelDocumentPath *string `json:"travel_document_path"` VehicleNumber *string `json:"vehicle_number"` + TransportPerItem *float64 `json:"transport_per_item,omitempty"` + ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` } - func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO { return PurchaseRelationDTO{ - Id: p.Id, - PrNumber: p.PrNumber, - PoNumber: p.PoNumber, - PoDate: p.PoDate, - Notes: p.Notes, + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + PoDate: p.PoDate, + CreditTerm: p.CreditTerm, + Notes: p.Notes, } } @@ -112,6 +109,20 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { dto.Warehouse = &summary } + if item.ExpenseNonstock != nil { + priceCopy := item.ExpenseNonstock.Price + dto.TransportPerItem = &priceCopy + + if item.ExpenseNonstock.Expense != nil { + exp := item.ExpenseNonstock.Expense + + if exp.Supplier != nil && exp.Supplier.Id != 0 { + supplierSummary := supplierDTO.ToSupplierRelationDTO(*exp.Supplier) + dto.ExpeditionVendor = &supplierSummary + } + } + } + return dto } @@ -145,9 +156,7 @@ func ToPurchaseListDTO(p entity.Purchase) PurchaseListDTO { return PurchaseListDTO{ PurchaseRelationDTO: ToPurchaseRelationDTO(&p), Supplier: supplier, - CreditTerm: p.CreditTerm, DueDate: p.DueDate, - GrandTotal: p.GrandTotal, CreatedUser: createdUser, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, @@ -188,13 +197,11 @@ func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { return PurchaseDetailDTO{ PurchaseRelationDTO: ToPurchaseRelationDTO(&p), Supplier: supplier, - CreditTerm: p.CreditTerm, DueDate: p.DueDate, - GrandTotal: p.GrandTotal, Items: ToPurchaseItemDTOs(p.Items), CreatedUser: createdUser, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, LatestApproval: latestApproval, } -} \ No newline at end of file +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index 56dd5932..ec1b24f7 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -8,15 +8,20 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + expenseService "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -28,13 +33,49 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + nonstockRepo := rNonstock.NewNonstockRepository(db) + expenseRepository := expenseRepo.NewExpenseRepository(db) + expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) + projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) } - expenseBridge := service.NewNoopPurchaseExpenseBridge() + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) + } + expenseServiceInstance := expenseService.NewExpenseService( + expenseRepository, + supplierRepo, + nonstockRepo, + approvalService, + expenseRealizationRepo, + projectFlockKandangRepository, + validate, + ) + expenseBridge := service.NewExpenseBridge( + db, + purchaseRepo, + projectFlockKandangRepository, + expenseServiceInstance, + ) + + fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + _ = fifoService.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKey("PURCHASE_ITEMS"), + Table: "purchase_items", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "id", + }, + OrderBy: []string{"id ASC"}, + }) purchaseService := service.NewPurchaseService( validate, @@ -43,8 +84,10 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo, supplierRepo, productWarehouseRepo, + projectFlockKandangRepository, approvalService, expenseBridge, + fifoService, ) userRepo := rUser.NewUserRepository(db) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 49bb07e9..bcb35e85 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -19,12 +19,12 @@ type PurchaseRepository interface { repository.BaseRepository[entity.Purchase] CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error - UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, grandTotal float64) error + UpdatePricing(ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate) error UpdateReceivingDetails(ctx context.Context, purchaseID uint, updates []PurchaseReceivingUpdate) error DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error - UpdateGrandTotal(ctx context.Context, purchaseID uint, grandTotal float64) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) + BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error } type PurchaseRepositoryImpl struct { @@ -59,6 +59,34 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase * return nil } +func (r *PurchaseRepositoryImpl) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error { + if purchaseID == 0 { + return nil + } + + query := ` +WITH latest_pfk AS ( + SELECT pfk.id, pfk.kandang_id + FROM project_flock_kandangs pfk + JOIN ( + SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at + FROM approvals + WHERE approvable_type = 'PROJECT_FLOCKS' + ORDER BY approvable_id, action_at DESC + ) latest_approval ON latest_approval.approvable_id = pfk.project_flock_id + WHERE LOWER(latest_approval.step_name) = LOWER('Aktif') +) +UPDATE purchase_items pi +SET project_flock_kandang_id = lp.id +FROM warehouses w +JOIN latest_pfk lp ON lp.kandang_id = w.kandang_id +WHERE pi.purchase_id = ? + AND pi.project_flock_kandang_id IS NULL + AND pi.warehouse_id = w.id; +` + return r.DB().WithContext(ctx).Exec(query, purchaseID).Error +} + func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil @@ -99,7 +127,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( ctx context.Context, purchaseID uint, updates []PurchasePricingUpdate, - grandTotal float64, ) error { if len(updates) == 0 { return errors.New("pricing updates cannot be empty") @@ -133,14 +160,6 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( } } - if err := db.Model(&entity.Purchase{}). - Where("id = ?", purchaseID). - Updates(map[string]interface{}{ - "grand_total": grandTotal, - }).Error; err != nil { - return err - } - return nil } @@ -201,20 +220,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( return nil } -func (r *PurchaseRepositoryImpl) UpdateGrandTotal( - ctx context.Context, - purchaseID uint, - grandTotal float64, -) error { - return r.DB().WithContext(ctx). - Model(&entity.Purchase{}). - Where("id = ?", purchaseID). - Updates(map[string]interface{}{ - "grand_total": grandTotal, - "updated_at": gorm.Expr("NOW()"), - }).Error -} - func (r *PurchaseRepositoryImpl) DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error { if len(itemIDs) == 0 { return errors.New("itemIDs cannot be empty") diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 3e857d35..d8356e6a 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -2,42 +2,654 @@ package service import ( "context" + "errors" + "fmt" + "strconv" + "strings" "time" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" + expenseSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" + expenseValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) -// PurchaseExpenseBridge defines hooks that allow purchase flows to stay in sync with expense data once it exists. type PurchaseExpenseBridge interface { - OnItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error - OnItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) error - OnItemsReceived(ctx context.Context, purchaseID uint, updates []ExpenseReceivingPayload) error + OnItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error + OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error } -// ExpenseReceivingPayload captures the minimum data expense integration will need once available. type ExpenseReceivingPayload struct { - PurchaseItemID uint - ProductID uint - WarehouseID uint - ReceivedQty float64 - ReceivedDate *time.Time + PurchaseItemID uint + ProductID uint + WarehouseID uint + SupplierID uint + TransportPerItem *float64 + ReceivedQty float64 + ReceivedDate *time.Time } -// noopPurchaseExpenseBridge is the default implementation until the expense module is ready. -type noopPurchaseExpenseBridge struct{} - -func NewNoopPurchaseExpenseBridge() PurchaseExpenseBridge { - return &noopPurchaseExpenseBridge{} +type groupedItem struct { + item *entity.PurchaseItem + payload ExpenseReceivingPayload + projectFK *uint + kandangID *uint + totalPrice float64 } -func (n *noopPurchaseExpenseBridge) OnItemsCreated(_ context.Context, _ uint, _ []entity.PurchaseItem) error { +func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { + return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) +} + +type expenseBridge struct { + db *gorm.DB + purchaseRepo rPurchase.PurchaseRepository + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + expenseSvc expenseSvc.ExpenseService +} + +func NewExpenseBridge( + db *gorm.DB, + purchaseRepo rPurchase.PurchaseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, + expenseSvc expenseSvc.ExpenseService, +) PurchaseExpenseBridge { + return &expenseBridge{ + db: db, + purchaseRepo: purchaseRepo, + projectFlockKandangRepo: projectFlockKandangRepo, + expenseSvc: expenseSvc, + } +} + +func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []entity.PurchaseItem) error { + if len(items) == 0 { + return nil + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + expenseIDs := make(map[uint64]struct{}) + expenseNonstockIDs := make([]uint64, 0) + for _, item := range items { + if item.ExpenseNonstockId != nil && *item.ExpenseNonstockId != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *item.ExpenseNonstockId) + } + } + if len(expenseNonstockIDs) > 0 { + for _, nsID := range expenseNonstockIDs { + var expenseID uint64 + if err := tx. + Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", nsID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + } + if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { + return err + } + } + + var links []struct { + ItemID uint + ExpenseNonstockID *uint64 + } + if err := tx. + Model(&entity.PurchaseItem{}). + Select("id as item_id, expense_nonstock_id"). + Where("id IN ?", extractIDs(items)). + Scan(&links).Error; err != nil { + return err + } + + for _, link := range links { + if link.ExpenseNonstockID == nil || *link.ExpenseNonstockID == 0 { + continue + } + var expenseID uint64 + if err := tx. + Model(&entity.ExpenseNonstock{}). + Select("expense_id"). + Where("id = ?", *link.ExpenseNonstockID). + Scan(&expenseID).Error; err != nil { + return err + } + if expenseID != 0 { + expenseIDs[expenseID] = struct{}{} + } + if err := tx.Delete(&entity.ExpenseNonstock{}, *link.ExpenseNonstockID).Error; err != nil { + return err + } + } + + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for expenseID := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", expenseID). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error { + if len(expenseIDs) == 0 { + return nil + } + if actorID == 0 { + actorID = 1 + } + svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + action := entity.ApprovalActionUpdated + for id := range expenseIDs { + if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return err + } + } return nil } -func (n *noopPurchaseExpenseBridge) OnItemsDeleted(_ context.Context, _ uint, _ []uint) error { +func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error { + if purchaseID == 0 || len(updates) == 0 { + return nil + } + + ctx := c.Context() + + // Load current links to decide whether to update in place or recreate. + type itemLink struct { + ExpenseNonstockID uint64 + ExpenseID uint64 + SupplierID uint + TransactionDate time.Time + Qty float64 + Price float64 + } + + purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB { + return db. + Preload("Items"). + Preload("Items.Warehouse"). + Preload("Items.Warehouse.Kandang") + }) + if err != nil { + return err + } + + itemLinks := make(map[uint]itemLink) + updatedExpenses := make(map[uint64]struct{}) + if len(updates) > 0 { + ids := make([]uint, 0, len(updates)) + for _, upd := range updates { + if upd.PurchaseItemID != 0 { + ids = append(ids, upd.PurchaseItemID) + } + } + if len(ids) > 0 { + rows := make([]struct { + ItemID uint + ExpenseNonstockID uint64 + ExpenseID uint64 + SupplierID uint + TransactionDate time.Time + Qty float64 + Price float64 + }, 0) + if err := b.db.WithContext(ctx). + Table("purchase_items pi"). + Select("pi.id as item_id, en.id as expense_nonstock_id, en.expense_id, e.supplier_id, e.transaction_date, en.qty, en.price"). + Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id"). + Joins("LEFT JOIN expenses e ON e.id = en.expense_id"). + Where("pi.id IN ?", ids). + Scan(&rows).Error; err != nil { + return err + } + // Build quick lookup per item and per group key for existing expenses. + for _, row := range rows { + itemLinks[row.ItemID] = itemLink{ + ExpenseNonstockID: row.ExpenseNonstockID, + ExpenseID: row.ExpenseID, + SupplierID: row.SupplierID, + TransactionDate: row.TransactionDate, + Qty: row.Qty, + Price: row.Price, + } + } + } + } + + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) + for i := range purchase.Items { + itemMap[purchase.Items[i].Id] = &purchase.Items[i] + } + + groups := make(map[string][]groupedItem) + + for _, payload := range updates { + if payload.ReceivedDate == nil { + return fiber.NewError(fiber.StatusBadRequest, "received_date is required") + } + item := itemMap[payload.PurchaseItemID] + if item == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + } + if payload.ReceivedQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d must be greater than 0", payload.PurchaseItemID)) + } + + receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour) + supplierID := payload.SupplierID + if supplierID == 0 { + supplierID = purchase.SupplierId + } + + // Decide whether to update existing expense_nonstock or create new. + link, hasLink := itemLinks[payload.PurchaseItemID] + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour) + newDate := receivedDate + oldSupplier := link.SupplierID + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + + // If supplier/date unchanged, update nonstock in place. + if oldSupplier == supplierID && oldDate.Equal(newDate) { + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + }).Error; err != nil { + return err + } + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + continue + } + + // Supplier/date changed: if the linked expense has only this nonstock, update it in place. + if link.ExpenseID != 0 { + var cnt int64 + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", link.ExpenseID). + Count(&cnt).Error; err != nil { + return err + } + if cnt == 1 { + if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.Expense{}). + Where("id = ?", link.ExpenseID). + Updates(map[string]interface{}{ + "transaction_date": newDate, + "supplier_id": supplierID, + }).Error; err != nil { + return err + } + updateBody := map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + "kandang_id": uint64(*item.Warehouse.KandangId), + } + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + updatedExpenses[link.ExpenseID] = struct{}{} + continue + } + + // Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it. + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + gItem := groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + } + + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID) + if err != nil { + return err + } + + var createdNonstockID uint64 + if expenseDetail != nil { + noteMap := mapExpenseNotes(expenseDetail) + createdNonstockID = noteMap[payload.PurchaseItemID] + } + + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + updateBody := map[string]interface{}{ + "expense_id": expenseDetail.Id, + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + } + if kandangID != nil { + updateBody["kandang_id"] = uint64(*kandangID) + } + if projectFK != nil { + updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } + + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + + if createdNonstockID != 0 { + if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil { + return err + } + } + + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + continue + } + + // Otherwise create new expense/nonstock in grouping flow. + } + + baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) + key := baseKey + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID) + } + + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + groups[key] = append(groups[key], groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + }) + } + + for key, items := range groups { + if len(items) == 0 { + continue + } + parts := strings.Split(key, ":") + if len(parts) < 3 { + return errors.New("invalid expense grouping key") + } + expenseDate, err := utils.ParseDateString(parts[1]) + if err != nil { + return err + } + + supplierID, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return err + } + + expeditionNonstockID, err := b.findExpeditionNonstockID(ctx, uint(supplierID)) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, items, expenseDate, expeditionNonstockID, purchase.PoNumber, uint(supplierID)) + if err != nil { + return err + } + if err := b.linkExpenseNonstocksToItems(ctx, expenseDetail, items); err != nil { + return err + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + } + + if len(updatedExpenses) > 0 { + if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil { + return err + } + } + return nil } -func (n *noopPurchaseExpenseBridge) OnItemsReceived(_ context.Context, _ uint, _ []ExpenseReceivingPayload) error { +func (b *expenseBridge) findExpeditionNonstockID(ctx context.Context, supplierID uint) (uint64, error) { + var id uint64 + err := b.db.WithContext(ctx). + Table("nonstocks AS ns"). + Select("ns.id"). + Joins("JOIN nonstock_suppliers nss ON nss.nonstock_id = ns.id"). + Joins("JOIN flags f ON f.flagable_id = ns.id AND f.flagable_type = ?", entity.FlagableTypeNonstock). + Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))). + Where("nss.supplier_id = ?", supplierID). + Order("ns.id"). + Limit(1). + Scan(&id).Error + if err != nil { + return 0, err + } + if id == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "supplier id tidak sesuai dengan expedisi") + } + return id, nil +} + +func extractIDs(items []entity.PurchaseItem) []uint { + result := make([]uint, 0, len(items)) + for _, item := range items { + if item.Id != 0 { + result = append(result, item.Id) + } + } + return result +} + +func (b *expenseBridge) createExpenseViaService( + c *fiber.Ctx, + purchase *entity.Purchase, + items []groupedItem, + expenseDate time.Time, + expeditionNonstockID uint64, + poNumber *string, + supplierID uint, +) (*expenseDto.ExpenseDetailDTO, error) { + ctx := c.Context() + if b.expenseSvc == nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "expense service not available") + } + if len(items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "no items to create expense") + } + + kandangID := items[0].kandangID + if kandangID == nil || *kandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + + costItems := make([]expenseValidation.CostItem, 0, len(items)) + for _, gi := range items { + note := fmt.Sprintf("purchase_item:%d", gi.payload.PurchaseItemID) + price := gi.item.Price + if gi.payload.TransportPerItem != nil { + price = *gi.payload.TransportPerItem + } + costItems = append(costItems, expenseValidation.CostItem{ + NonstockID: expeditionNonstockID, + Quantity: gi.payload.ReceivedQty, + Price: price, + Notes: note, + }) + } + + req := &expenseValidation.Create{ + PoNumber: "", + TransactionDate: utils.FormatDate(expenseDate), + Category: "BOP", + SupplierID: uint64(supplierID), + ExpenseNonstocks: []expenseValidation.ExpenseNonstock{{ + KandangID: uint64(*kandangID), + CostItems: costItems, + }}, + } + if poNumber != nil { + req.PoNumber = *poNumber + } + + detail, err := b.expenseSvc.CreateOne(c, req) + if err != nil { + return nil, err + } + + // Mark approvals up to Finance so latest is Manager Finance + action := entity.ApprovalActionApproved + actorID := uint(purchase.CreatedBy) + if actorID == 0 { + actorID = 1 + } + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepManager, &action, actorID, nil); err != nil { + return nil, err + } + if _, err := approvalSvc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(detail.Id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { + return nil, err + } + + return detail, nil +} + +func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail *expenseDto.ExpenseDetailDTO, items []groupedItem) error { + if detail == nil || len(items) == 0 { + return nil + } + + noteToExpenseNonstock := mapExpenseNotes(detail) + + if len(noteToExpenseNonstock) == 0 { + return nil + } + + for _, gi := range items { + expenseNonstockID, ok := noteToExpenseNonstock[gi.payload.PurchaseItemID] + if !ok { + continue + } + if err := b.db.WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("id = ?", gi.payload.PurchaseItemID). + Update("expense_nonstock_id", expenseNonstockID).Error; err != nil { + return err + } + } + return nil } + +func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 { + result := make(map[uint]uint64) + if detail == nil { + return result + } + for _, kandang := range detail.Kandangs { + for _, pengajuan := range kandang.Pengajuans { + note := strings.TrimSpace(pengajuan.Notes) + if note == "" { + continue + } + const prefix = "purchase_item:" + if !strings.HasPrefix(note, prefix) { + continue + } + idStr := strings.TrimPrefix(note, prefix) + var itemID uint + if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil { + continue + } + result[itemID] = pengajuan.Id + } + } + return result +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 60a65960..c4b6effd 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -16,10 +16,12 @@ import ( rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -39,26 +41,28 @@ type PurchaseService interface { } const ( - priceTolerance = 0.0001 + priceTolerance = 0.0001 + purchaseStockableKey = fifo.StockableKey("PURCHASE_ITEMS") ) type purchaseService struct { - Log *logrus.Logger - Validate *validator.Validate - PurchaseRepo rPurchase.PurchaseRepository - ProductRepo rProduct.ProductRepository - WarehouseRepo rWarehouse.WarehouseRepository - SupplierRepo rSupplier.SupplierRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ApprovalSvc commonSvc.ApprovalService - ExpenseBridge PurchaseExpenseBridge - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductRepo rProduct.ProductRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseBridge PurchaseExpenseBridge + FifoSvc commonSvc.FifoService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { PricingUpdates []rPurchase.PurchasePricingUpdate NewItems []*entity.PurchaseItem - GrandTotal float64 } func NewPurchaseService( @@ -68,23 +72,24 @@ func NewPurchaseService( warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, + fifoSvc commonSvc.FifoService, ) PurchaseService { - if expenseBridge == nil { - expenseBridge = NewNoopPurchaseExpenseBridge() - } return &purchaseService{ - Log: utils.Log, - Validate: validate, - PurchaseRepo: purchaseRepo, - ProductRepo: productRepo, - WarehouseRepo: warehouseRepo, - SupplierRepo: supplierRepo, - ProductWarehouseRepo: productWarehouseRepo, - ApprovalSvc: approvalSvc, - ExpenseBridge: expenseBridge, - approvalWorkflow: utils.ApprovalWorkflowPurchase, + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductRepo: productRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ApprovalSvc: approvalSvc, + ExpenseBridge: expenseBridge, + FifoSvc: fifoSvc, + approvalWorkflow: utils.ApprovalWorkflowPurchase, } } func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { @@ -94,6 +99,7 @@ func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("Supplier"). + Preload("CreatedUser"). Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }). @@ -104,7 +110,10 @@ func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Items.Product.Flags"). Preload("Items.Warehouse.Area"). Preload("Items.Warehouse.Location"). - Preload("Items.ProductWarehouse") + Preload("Items.ProductWarehouse"). + Preload("Items.ExpenseNonstock"). + Preload("Items.ExpenseNonstock.Expense"). + Preload("Items.ExpenseNonstock.Expense.Supplier") } func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) { @@ -114,9 +123,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti offset := (params.Page - 1) * params.Limit - createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) + createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) if err != nil { - return nil, 0, err + return nil, 0, utils.BadRequest(err.Error()) } purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { @@ -175,7 +184,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err != nil { s.Log.Errorf("Failed to get purchases: %+v", err) - return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") + return nil, 0, utils.Internal("Failed to get purchases") } for i := range purchases { @@ -188,19 +197,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - s.Log.Errorf("Failed to get purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") - } - - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) - } - return purchase, nil + return s.loadPurchase(c.Context(), id) } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { @@ -215,49 +212,69 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if _, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + return nil, utils.NotFound("Supplier not found") } s.Log.Errorf("Failed to get supplier: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") + return nil, utils.Internal("Failed to get supplier") } type aggregatedItem struct { productId uint warehouseId uint subQty float64 + pfkID *uint } if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + return nil, utils.BadRequest("Items must not be empty") } warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) - getWarehouse := func(id uint) (*entity.Warehouse, error) { + getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) { if warehouse, ok := warehouseCache[id]; ok { - return warehouse, nil + return warehouse, nil, nil } warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return db.Preload("Area").Preload("location") + return db.Preload("Area").Preload("Location") }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + return nil, nil, utils.NotFound(fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return nil, nil, utils.Internal("Failed to get warehouse") + } + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) + } + var pfkID *uint + if s.ProjectFlockKandangRepo != nil { + if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + if pfk.ClosedAt != nil { + return nil, nil, utils.BadRequest("Project sudah closing") + } + idCopy := uint(pfk.Id) + pfkID = &idCopy + } else if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d has no active project flock", id)) + } else if err != nil { + s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) + return nil, nil, utils.Internal("Failed to validate project flock") + } } warehouseCache[id] = warehouse - return warehouse, nil + return warehouse, pfkID, nil } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { - if _, err := getWarehouse(item.WarehouseID); err != nil { + _, pfkID, err := getWarehouse(item.WarehouseID) + if err != nil { return nil, err } @@ -265,10 +282,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase linked, err := s.ProductRepo.IsLinkedToSupplier(c.Context(), item.ProductID, req.SupplierID) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + return nil, utils.Internal("Failed to validate product for supplier") } if !linked { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) + return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) } productSupplierCache[item.ProductID] = true } @@ -286,35 +303,38 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productId: productId, warehouseId: warehouseId, subQty: item.Quantity, + pfkID: pfkID, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 } - creditTermValue := req.CreditTerm - creditTerm := &creditTermValue - dueDateValue := time.Now().UTC().AddDate(0, 0, creditTermValue) - dueDate := &dueDateValue + var dueDate *time.Time + now := time.Now().UTC() + d := now.AddDate(0, 0, req.CreditTerm) + dueDate = &d purchase := &entity.Purchase{ SupplierId: uint(req.SupplierID), - CreditTerm: creditTerm, + CreditTerm: req.CreditTerm, DueDate: dueDate, - GrandTotal: 0, Notes: req.Notes, CreatedBy: uint(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) + emptyVehicle := "" for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ - ProductId: item.productId, - WarehouseId: item.warehouseId, - SubQty: item.subQty, - TotalQty: 0, - TotalUsed: 0, - Price: 0, - TotalPrice: 0, + ProductId: item.productId, + WarehouseId: item.warehouseId, + ProjectFlockKandangId: item.pfkID, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + VehicleNumber: &emptyVehicle, }) } @@ -331,6 +351,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return err } + if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil { + return err + } + actorID := uint(purchase.CreatedBy) if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil { return err @@ -340,13 +364,13 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase }) if transactionErr != nil { s.Log.Errorf("Failed to create purchase: %+v", transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") + return nil, utils.Internal("Failed to create purchase") } created, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load created purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), created); err != nil { @@ -361,6 +385,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid return nil, err } + ctx := c.Context() + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err @@ -370,17 +396,15 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid if err != nil { return nil, err } - - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return nil, err } - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } } var latestStep uint16 @@ -394,7 +418,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") + return nil, utils.BadRequest("Purchase is not ready for staff approval") } hasReceivingData := false @@ -407,8 +431,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid syncReceiving := !isInitialApproval && hasReceivingData - if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty for staff approval") + if action == entity.ApprovalActionApproved && len(req.Items) == 0 { + return nil, utils.BadRequest("Items must not be empty for staff approval") } payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) @@ -418,12 +442,10 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) - grandTotalUpdated := false if len(payload.PricingUpdates) > 0 { - if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates, payload.GrandTotal); err != nil { + if err := purchaseRepoTx.UpdatePricing(c.Context(), purchase.Id, payload.PricingUpdates); err != nil { return err } - grandTotalUpdated = true } if len(payload.NewItems) > 0 { @@ -432,12 +454,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid } } - if !grandTotalUpdated { - if err := purchaseRepoTx.UpdateGrandTotal(c.Context(), purchase.Id, payload.GrandTotal); err != nil { - return err - } - } - if isInitialApproval { if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepStaffPurchase, action, actorID, req.Notes, false); err != nil { return err @@ -464,34 +480,23 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + return nil, utils.NotFound("Purchase item not found") } if isInitialApproval { s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase") + return nil, utils.Internal("Failed to approve purchase") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") + return nil, utils.Internal("Failed to update purchase pricing") } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) } - if len(payload.NewItems) > 0 { - newItems := make([]entity.PurchaseItem, len(payload.NewItems)) - for i, item := range payload.NewItems { - if item == nil { - continue - } - newItems[i] = *item - } - s.notifyExpenseItemsCreated(c.Context(), purchase.Id, newItems) - } - return updated, nil } @@ -510,22 +515,19 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val return nil, err } - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) + purchase, err := s.loadPurchase(c.Context(), id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return nil, err + } + + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(c.Context(), purchase); err != nil { + return nil, err } - s.Log.Errorf("Failed to get purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) - } - if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") + return nil, utils.BadRequest("Purchase must reach staff purchase step before manager approval") } if action == entity.ApprovalActionRejected { @@ -585,7 +587,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val }) if transactionErr != nil { s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order") + return nil, utils.Internal("Failed to generate purchase order") } if generatedNumber != "" { @@ -596,7 +598,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { @@ -611,6 +613,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, err } + ctx := c.Context() + action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err @@ -621,40 +625,37 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, err } - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ") + return nil, err } if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") - } - - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + return nil, utils.BadRequest("Purchase order has not been generated") } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") + return nil, utils.BadRequest("Purchase must be approved by manager before receiving products") + } + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } } - if action == entity.ApprovalActionApproved && len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must not be empty") + return nil, utils.BadRequest("Receiving data must not be empty") } if action == entity.ApprovalActionRejected { - if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { + if err := s.createPurchaseApproval(ctx, nil, purchase.Id, utils.PurchaseStepReceiving, action, actorID, req.Notes, true); err != nil { return nil, err } - updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) + updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } - if err := s.attachLatestApproval(c.Context(), updated); err != nil { + if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } return updated, nil @@ -670,23 +671,30 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation payload validation.ReceivePurchaseItemRequest receivedDate time.Time warehouseID uint + supplierID uint + transportPerItem *float64 overrideWarehouse bool receivedQty float64 } visitedItems := make(map[uint]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) + var earliestReceived *time.Time for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] if !exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } - receivedDate, err := time.Parse("2006-01-02", payload.ReceivedDate) + receivedDate, err := utils.ParseDateString(payload.ReceivedDate) if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) } receivedDate = receivedDate.UTC() + if earliestReceived == nil || receivedDate.Before(*earliestReceived) { + copy := receivedDate + earliestReceived = © + } warehouseID := uint(item.WarehouseId) overrideWarehouse := false @@ -695,7 +703,10 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation overrideWarehouse = true } if warehouseID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + } + if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID { + return nil, utils.BadRequest("Receiving does not allow changing warehouse") } var receivedQty float64 @@ -705,22 +716,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation receivedQty = item.SubQty } if receivedQty < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) } if receivedQty > item.SubQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } if _, dup := visitedItems[payload.PurchaseItemID]; dup { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) } visitedItems[payload.PurchaseItemID] = struct{}{} + supplierID := purchase.SupplierId + if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { + supplierID = *payload.ExpeditionVendorID + } + + var transportPerItem *float64 + if payload.TransportPerItem != nil { + if *payload.TransportPerItem < 0 { + return nil, utils.BadRequest(fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID)) + } + val := *payload.TransportPerItem + transportPerItem = &val + } + prepared = append(prepared, preparedReceiving{ item: item, payload: payload, receivedDate: receivedDate, warehouseID: warehouseID, + supplierID: supplierID, + transportPerItem: transportPerItem, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) @@ -729,7 +756,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation // Require receiving payload to cover all purchase items so that // receiving cannot be submitted partially item-by-item. if len(visitedItems) != len(itemMap) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") + return nil, utils.BadRequest("Receiving data must be provided for all purchase items") } receivingAction := action @@ -737,7 +764,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation approvalSvc := commonSvc.NewApprovalService( commonRepo.NewApprovalRepository(s.PurchaseRepo.DB()), ) - + if approvalSvc != nil { filterStep := func(step approvalutils.ApprovalStep) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { @@ -753,7 +780,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation ) if err != nil { s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + return nil, utils.Internal("Failed to record purchase receiving") } if latestReceiving != nil { @@ -767,6 +794,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) + fifoAdds := make([]struct { + itemID uint + pwID uint + qty float64 + }, 0, len(prepared)) for _, prep := range prepared { item := prep.item @@ -780,21 +812,29 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation var newPWID *uint clearPW := false + // Always ensure PW when qty > 0 so stockable has target. if prep.receivedQty > 0 { pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) if err != nil { return err } newPWID = &pwID - deltas[pwID] += prep.receivedQty - affected[pwID] = struct{}{} - } else { + } else if oldPWID != nil { + newPWID = oldPWID clearPW = true } - if oldPWID != nil { - deltas[*oldPWID] -= item.TotalQty - affected[*oldPWID] = struct{}{} + deltaQty := prep.receivedQty - item.TotalQty + switch { + case deltaQty > 0 && newPWID != nil: + fifoAdds = append(fifoAdds, struct { + itemID uint + pwID uint + qty float64 + }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) + case deltaQty < 0 && newPWID != nil: + deltas[*newPWID] += deltaQty // negative + affected[*newPWID] = struct{}{} } dateCopy := prep.receivedDate @@ -830,27 +870,46 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { - return err + // Update due_date based on earliest received date when receiving approved. + if earliestReceived != nil { + due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) + if err := tx.Model(&entity.Purchase{}). + Where("id = ?", purchase.Id). + Update("due_date", due).Error; err != nil { + return err + } } - if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { - return err + if s.FifoSvc != nil { + for _, adj := range fifoAdds { + if adj.pwID == 0 || adj.qty <= 0 { + continue + } + if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ + StockableKey: purchaseStockableKey, + StockableID: adj.itemID, + ProductWarehouseID: adj.pwID, + Quantity: adj.qty, + Tx: tx, + }); err != nil { + return err + } + } } return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found for receiving") + return nil, utils.NotFound("Purchase item not found for receiving") } s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + return nil, utils.Internal("Failed to record purchase receiving") } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") + return nil, utils.Internal("Failed to load purchase ") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) @@ -860,15 +919,31 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation for _, prep := range prepared { date := prep.receivedDate payload := ExpenseReceivingPayload{ - PurchaseItemID: prep.item.Id, - ProductID: prep.item.ProductId, - WarehouseID: uint(prep.warehouseID), - ReceivedQty: prep.receivedQty, - ReceivedDate: &date, + PurchaseItemID: prep.item.Id, + ProductID: prep.item.ProductId, + WarehouseID: uint(prep.warehouseID), + SupplierID: prep.supplierID, + TransportPerItem: prep.transportPerItem, + ReceivedQty: prep.receivedQty, + ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } - s.notifyExpenseItemsReceived(c.Context(), purchase.Id, receivingPayloads) + if err := s.notifyExpenseItemsReceived(c, purchase.Id, receivingPayloads); err != nil { + s.Log.Errorf("Failed to sync expense for purchase %d: %+v", purchase.Id, err) + if fe, ok := err.(*fiber.Error); ok { + return nil, fe + } + return nil, utils.Internal("Failed to sync expense") + } + + // Create approvals only after expense sync succeeds + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepReceiving, receivingAction, actorID, req.Notes, true); err != nil { + return nil, err + } + if err := s.createPurchaseApproval(c.Context(), nil, purchase.Id, utils.PurchaseStepCompleted, completedAction, actorID, req.Notes, true); err != nil { + return nil, err + } return updated, nil } @@ -882,20 +957,23 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return nil, utils.NotFound("Purchase not found") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return nil, utils.Internal("Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval") + return nil, utils.BadRequest("Purchase cannot delete items before staff purchase approval") } if len(purchase.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") + return nil, utils.BadRequest("Purchase has no items to delete") } requested := make(map[uint]struct{}, len(req.ItemIDs)) @@ -915,11 +993,22 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del } if len(toDelete) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") + return nil, utils.BadRequest("Requested items were not found in this purchase") + } + + toDeleteSet := make(map[uint]struct{}, len(toDelete)) + for _, id := range toDelete { + toDeleteSet[id] = struct{}{} + } + itemsToDelete := make([]entity.PurchaseItem, 0, len(toDelete)) + for _, item := range purchase.Items { + if _, ok := toDeleteSet[item.Id]; ok { + itemsToDelete = append(itemsToDelete, item) + } } if len(purchase.Items)-len(toDelete) <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") + return nil, utils.BadRequest("Purchase must keep at least one item") } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -929,26 +1018,28 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del return err } - if err := repoTx.UpdateGrandTotal(ctx, purchase.Id, remainingTotal); err != nil { - return err - } - return nil }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + return nil, utils.NotFound("Purchase item not found") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") + return nil, utils.Internal("Failed to delete purchase items") } - if len(toDelete) > 0 { - s.notifyExpenseItemsDeleted(ctx, purchase.Id, toDelete) + if len(itemsToDelete) > 0 { + if err := s.notifyExpenseItemsDeleted(ctx, purchase.Id, itemsToDelete); err != nil { + s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", purchase.Id, err) + if fe, ok := err.(*fiber.Error); ok { + return nil, fe + } + return nil, utils.Internal("Failed to sync expense") + } } updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) @@ -959,21 +1050,21 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { if id == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + return utils.BadRequest("Invalid purchase id") } ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return err + } + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return err } - itemIDs := make([]uint, 0, len(purchase.Items)) - for _, item := range purchase.Items { - itemIDs = append(itemIDs, item.Id) + itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items)) + for i, item := range purchase.Items { + itemsToDelete[i] = item } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -990,43 +1081,92 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return utils.NotFound("Purchase not found") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") + return utils.Internal("Failed to delete purchase") } - if len(itemIDs) > 0 { - s.notifyExpenseItemsDeleted(ctx, uint(id), itemIDs) + if len(itemsToDelete) > 0 { + if err := s.notifyExpenseItemsDeleted(ctx, uint(id), itemsToDelete); err != nil { + s.Log.Errorf("Failed to sync expense deletion for purchase %d: %+v", id, err) + if fe, ok := err.(*fiber.Error); ok { + return fe + } + return utils.Internal("Failed to sync expense") + } } return nil } -func (s *purchaseService) notifyExpenseItemsCreated(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { - return +func (s *purchaseService) createPurchaseApproval( + ctx context.Context, + db *gorm.DB, + purchaseID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, + allowDuplicate bool, +) error { + if purchaseID == 0 { + return utils.BadRequest("Purchase is invalid for approval") } - if err := s.ExpenseBridge.OnItemsCreated(ctx, purchaseID, items); err != nil { - s.Log.Warnf("Failed to notify expense bridge for created purchase %d: %+v", purchaseID, err) + if actorID == 0 { + actorID = 1 } + + svc := s.approvalServiceForDB(db) + if svc == nil { + return utils.Internal("Approval service not available") + } + + modifier := func(db *gorm.DB) *gorm.DB { + return db.Where("step_number = ?", uint16(step)) + } + + latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) + if err != nil { + return err + } + + if !allowDuplicate && latest != nil && + latest.Action != nil && + *latest.Action == action { + return nil + } + + actionCopy := action + _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) + return err } -func (s *purchaseService) notifyExpenseItemsReceived(ctx context.Context, purchaseID uint, payloads []ExpenseReceivingPayload) { +func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalService { + if db != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } + if s.ApprovalSvc != nil { + return s.ApprovalSvc + } + if s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil { + return commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) + } + return nil +} + +func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error { if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsReceived(ctx, purchaseID, payloads); err != nil { - s.Log.Warnf("Failed to notify expense bridge for received purchase %d: %+v", purchaseID, err) + return nil } + return s.ExpenseBridge.OnItemsReceived(c, purchaseID, payloads) } -func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, itemIDs []uint) { - if s.ExpenseBridge == nil || purchaseID == 0 || len(itemIDs) == 0 { - return - } - if err := s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, itemIDs); err != nil { - s.Log.Warnf("Failed to notify expense bridge for deleted purchase %d: %+v", purchaseID, err) +func (s *purchaseService) notifyExpenseItemsDeleted(ctx context.Context, purchaseID uint, items []entity.PurchaseItem) error { + if s.ExpenseBridge == nil || purchaseID == 0 || len(items) == 0 { + return nil } + return s.ExpenseBridge.OnItemsDeleted(ctx, purchaseID, items) + } func (s *purchaseService) buildStaffAdjustmentPayload( @@ -1036,7 +1176,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( syncReceiving bool, ) (*staffAdjustmentPayload, error) { if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + return nil, utils.BadRequest("Items must not be empty") } requestItems := make(map[uint]validation.StaffPurchaseApprovalItem, len(req.Items)) @@ -1048,13 +1188,12 @@ func (s *purchaseService) buildStaffAdjustmentPayload( continue } if _, exists := requestItems[item.PurchaseItemID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) } requestItems[item.PurchaseItemID] = item } updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) - var grandTotal float64 existingCombos := make(map[string]struct{}, len(purchase.Items)+len(newPayloads)) for _, item := range purchase.Items { @@ -1067,34 +1206,31 @@ func (s *purchaseService) buildStaffAdjustmentPayload( allowedWarehouses[item.WarehouseId] = struct{}{} } if len(allowedWarehouses) == 0 && len(newPayloads) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") + return nil, utils.BadRequest("No available warehouses for this purchase") } for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Missing pricing data for item %d", item.Id)) } if data.ProductID != 0 && data.ProductID != item.ProductId { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), - ) + return nil, utils.BadRequest(fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id)) } if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) } if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) } if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) } effectiveQty = *data.Qty } @@ -1120,49 +1256,40 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } updates = append(updates, update) - grandTotal += totalPrice delete(requestItems, item.Id) } if len(requestItems) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") + return nil, utils.BadRequest("Found pricing data for items that do not belong to this purchase") } productSupplierCache := make(map[uint]bool) newItems := make([]*entity.PurchaseItem, 0, len(newPayloads)) + emptyVehicle := "" for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items") + return nil, utils.BadRequest("Product and warehouse must be provided for new items") } if payload.Qty == nil || *payload.Qty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) } if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID), - ) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID)) } key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID) if _, exists := existingCombos[key]; exists { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID), - ) + return nil, utils.BadRequest(fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID)) } if _, checked := productSupplierCache[payload.ProductID]; !checked { linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId)) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + return nil, utils.Internal("Failed to validate product for supplier") } if !linked { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId), - ) + return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId)) } productSupplierCache[payload.ProductID] = true } @@ -1175,38 +1302,37 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } newItem := &entity.PurchaseItem{ - PurchaseId: purchase.Id, - ProductId: payload.ProductID, - WarehouseId: payload.WarehouseID, - SubQty: qty, - TotalQty: 0, - TotalUsed: 0, - Price: payload.Price, - TotalPrice: totalPrice, + PurchaseId: purchase.Id, + ProductId: payload.ProductID, + WarehouseId: payload.WarehouseID, + SubQty: qty, + TotalQty: 0, + TotalUsed: 0, + Price: payload.Price, + TotalPrice: totalPrice, + VehicleNumber: &emptyVehicle, } newItems = append(newItems, newItem) existingCombos[key] = struct{}{} - grandTotal += totalPrice } if len(updates) == 0 && len(newItems) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") + return nil, utils.BadRequest("Purchase has no items to process") } return &staffAdjustmentPayload{ PricingUpdates: updates, NewItems: newItems, - GrandTotal: grandTotal, }, nil } // ? helper func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { if quantity <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Quantity for %s must be greater than 0", ref)) } if price <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Price for %s must be greater than 0", ref)) } expectedTotal := price * quantity @@ -1215,10 +1341,10 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref return expectedTotal, nil } if *provided <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must be greater than 0", ref)) } if math.Abs(*provided-expectedTotal) > priceTolerance { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must equal quantity x price", ref)) } return *provided, nil } @@ -1239,36 +1365,6 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity return nil } -func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { - var fromPtr *time.Time - var toPtr *time.Time - const queryDateLayout = "2006-01-02" - - if strings.TrimSpace(fromStr) != "" { - parsed, err := time.Parse(queryDateLayout, fromStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must use format YYYY-MM-DD") - } - fromValue := parsed - fromPtr = &fromValue - } - - if strings.TrimSpace(toStr) != "" { - parsed, err := time.Parse(queryDateLayout, toStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_to must use format YYYY-MM-DD") - } - toValue := parsed.AddDate(0, 0, 1) - toPtr = &toValue - } - - if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, "created_from must be earlier than created_to") - } - - return fromPtr, toPtr, nil -} - func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { value := strings.ToUpper(strings.TrimSpace(raw)) switch value { @@ -1277,7 +1373,7 @@ func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { case string(entity.ApprovalActionRejected): return entity.ApprovalActionRejected, nil default: - return "", fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + return "", utils.BadRequest("action must be APPROVED or REJECTED") } } @@ -1292,63 +1388,58 @@ func (s *purchaseService) rejectAndReload( if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { return nil, err } - - updated, err := s.PurchaseRepo.GetByID(c.Context(), purchaseID, s.withRelations) + return s.loadPurchase(c.Context(), purchaseID) +} +func (s *purchaseService) loadPurchase( + ctx context.Context, + id uint, +) (*entity.Purchase, error) { + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.NotFound("Purchase not found") + } + s.Log.Errorf("Failed to get purchase %d: %+v", id, err) + return nil, utils.Internal("Failed to get purchase") } - if err := s.attachLatestApproval(c.Context(), updated); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } - return updated, nil + + return purchase, nil } -func (s *purchaseService) createPurchaseApproval( +func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { + seen := make(map[uint]struct{}) + ids := make([]uint, 0) + + for _, item := range p.Items { + if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { + continue + } + id := uint(*item.ProjectFlockKandangId) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids +} +func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( ctx context.Context, - db *gorm.DB, - purchaseID uint, - step approvalutils.ApprovalStep, - action entity.ApprovalAction, - actorID uint, - notes *string, - allowDuplicate bool, + purchase *entity.Purchase, ) error { - if purchaseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") - } - if actorID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "ActorId is invalid for approval") - } - - var svc commonSvc.ApprovalService - switch { - case db != nil: - svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) - case s.ApprovalSvc != nil: - svc = s.ApprovalSvc - case s.PurchaseRepo != nil && s.PurchaseRepo.DB() != nil: - svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(s.PurchaseRepo.DB())) - } - if svc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") - } - - modifier := func(db *gorm.DB) *gorm.DB { - return db.Where("step_number = ?", uint16(step)) - } - - latest, err := svc.LatestByTarget(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), modifier) - if err != nil { - return err - } - - if !allowDuplicate && latest != nil && - latest.Action != nil && - *latest.Action == action { + pfkIDs := collectPFKIDsFromPurchase(purchase) + if len(pfkIDs) == 0 { return nil } - actionCopy := action - _, err = svc.CreateApproval(ctx, utils.ApprovalWorkflowPurchase, uint(purchaseID), step, &actionCopy, actorID, notes) - return err + db := s.PurchaseRepo.DB() + if db == nil { + return utils.Internal("DB not available for project flock validation") + } + + return commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, db, pfkIDs) } diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 420b6c63..1637ccaf 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -8,7 +8,8 @@ type PurchaseItemPayload struct { type CreatePurchaseRequest struct { SupplierID uint `json:"supplier_id" validate:"required,gt=0"` - CreditTerm int `json:"credit_term" validate:"required,gte=0"` + CreditTerm int `json:"credit_term" validate:"required,number,gte=0"` + DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"` Notes *string `json:"notes" validate:"omitempty,max=500"` Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` } @@ -38,6 +39,8 @@ type ReceivePurchaseItemRequest struct { PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"` WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` + ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` + TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go new file mode 100644 index 00000000..e4b6088e --- /dev/null +++ b/internal/modules/repports/controllers/repport.controller.go @@ -0,0 +1,98 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type RepportController struct { + RepportService service.RepportService +} + +func NewRepportController(repportService service.RepportService) *RepportController { + return &RepportController{ + RepportService: repportService, + } +} + +func (c *RepportController) GetAll(ctx *fiber.Ctx) error { + query := &validation.Query{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + Search: ctx.Query("search", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetAll(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RepportListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all reports successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + +func (c *RepportController) GetOne(ctx *fiber.Ctx) error { + param := ctx.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := c.RepportService.GetOne(ctx, uint(id)) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get report successfully", + Data: result, + }) +} + +func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { + param := ctx.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := c.RepportService.GetOne(ctx, uint(id)) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get report successfully", + Data: result, + }) +} diff --git a/internal/modules/repports/dto/repport.dto.go b/internal/modules/repports/dto/repport.dto.go new file mode 100644 index 00000000..154c6f47 --- /dev/null +++ b/internal/modules/repports/dto/repport.dto.go @@ -0,0 +1,16 @@ +package dto + +import "time" + +// === DTO Structs === + +type RepportListDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepportDetailDTO struct { + RepportListDTO +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go new file mode 100644 index 00000000..be0ba7a3 --- /dev/null +++ b/internal/modules/repports/module.go @@ -0,0 +1,23 @@ +package repports + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + sRepport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" +) + +type RepportModule struct{} + +func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + // Initialize expense realization repository + expRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) + + // Initialize report service with expense realization repo + repportService := sRepport.NewRepportService(validate, expRealizationRepo) + + RepportRoutes(router, repportService) +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go new file mode 100644 index 00000000..d01fd4b2 --- /dev/null +++ b/internal/modules/repports/route.go @@ -0,0 +1,20 @@ +package repports + +import ( + + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/controllers" + repport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + + "github.com/gofiber/fiber/v2" +) + +func RepportRoutes(v1 fiber.Router, s repport.RepportService) { + ctrl := controller.NewRepportController(s) + + route := v1.Group("/repports") + + route.Get("/", ctrl.GetAll) + route.Get("/:id", ctrl.GetOne) + + route.Get("expense", ctrl.GetExpense) +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go new file mode 100644 index 00000000..82fd5470 --- /dev/null +++ b/internal/modules/repports/services/repport.service.go @@ -0,0 +1,106 @@ +package service + +import ( + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" +) + +type RepportService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.RepportListDTO, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*dto.RepportListDTO, error) + GetExpense(ctx *fiber.Ctx, id uint) (*dto.RepportListDTO, error) +} + +type repportService struct { + Log *logrus.Logger + Validate *validator.Validate + dummyData map[uint]dto.RepportListDTO + ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository +} + +func NewRepportService(validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository) RepportService { + // Initialize with dummy data + now := time.Now() + dummyData := map[uint]dto.RepportListDTO{ + 1: { + Id: 1, + Name: "Sales Report", + CreatedAt: now, + UpdatedAt: now, + }, + 2: { + Id: 2, + Name: "Inventory Report", + CreatedAt: now, + UpdatedAt: now, + }, + 3: { + Id: 3, + Name: "Production Report", + CreatedAt: now, + UpdatedAt: now, + }, + } + + return &repportService{ + Log: utils.Log, + Validate: validate, + dummyData: dummyData, + ExpenseRealizationRepo: expenseRealizationRepo, + } +} + +func (s *repportService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.RepportListDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + // Convert map to slice + var results []dto.RepportListDTO + for _, v := range s.dummyData { + // Apply search filter if provided + if params.Search != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(params.Search)) { + continue + } + results = append(results, v) + } + + // Apply pagination + total := int64(len(results)) + offset := (params.Page - 1) * params.Limit + + if offset >= int(total) { + return []dto.RepportListDTO{}, total, nil + } + + end := offset + params.Limit + if end > int(total) { + end = int(total) + } + + return results[offset:end], total, nil +} + +func (s *repportService) GetOne(c *fiber.Ctx, id uint) (*dto.RepportListDTO, error) { + if data, ok := s.dummyData[id]; ok { + return &data, nil + } + return nil, fiber.NewError(fiber.StatusNotFound, "Report not found") +} + +func (s *repportService) GetExpense(c *fiber.Ctx, id uint) (*dto.RepportListDTO, error) { + if data, ok := s.dummyData[id]; ok { + return &data, nil + } + return nil, fiber.NewError(fiber.StatusNotFound, "Report not found") +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go new file mode 100644 index 00000000..a7ec4a6d --- /dev/null +++ b/internal/modules/repports/validations/repport.validation.go @@ -0,0 +1,7 @@ +package validation + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/route/route.go b/internal/route/route.go index 4d1c1bae..294fc900 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -19,6 +19,7 @@ import ( purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" // MODULE IMPORTS ) @@ -42,6 +43,7 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, + repports.RepportModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 0bb23d53..b09bc187 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -165,17 +165,33 @@ var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ } // ------------------------------------------------------------------- -// Project Flock Kandang Approval +// Chickin Approval +// ------------------------------------------------------------------- +const ( + ApprovalWorkflowChickin approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("CHICKINS") + ChickinStepPengajuan approvalutils.ApprovalStep = 1 + ChickinStepDisetujui approvalutils.ApprovalStep = 2 +) + +var ChickinApprovalSteps = map[approvalutils.ApprovalStep]string{ + ChickinStepPengajuan: "Pengajuan", + ChickinStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Project-Flock kandang Approval // ------------------------------------------------------------------- const ( ApprovalWorkflowProjectFlockKandang approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCK_KANDANGS") ProjectFlockKandangStepPengajuan approvalutils.ApprovalStep = 1 ProjectFlockKandangStepDisetujui approvalutils.ApprovalStep = 2 + ProjectFlockKandangStepClosed approvalutils.ApprovalStep = 3 ) var ProjectFlockKandangApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockKandangStepPengajuan: "Pengajuan", ProjectFlockKandangStepDisetujui: "Disetujui", + ProjectFlockKandangStepClosed: "Selesai", } // ------------------------------------------------------------------- @@ -200,13 +216,11 @@ var TransferToLayingApprovalSteps = map[approvalutils.ApprovalStep]string{ const ( ApprovalWorkflowRecording approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("RECORDINGS") - RecordingStepGradingTelur approvalutils.ApprovalStep = 1 - RecordingStepPengajuan approvalutils.ApprovalStep = 2 - RecordingStepDisetujui approvalutils.ApprovalStep = 3 + RecordingStepPengajuan approvalutils.ApprovalStep = 1 + RecordingStepDisetujui approvalutils.ApprovalStep = 2 ) var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ - RecordingStepGradingTelur: "Grading-Telur", RecordingStepPengajuan: "Pengajuan", RecordingStepDisetujui: "Disetujui", } diff --git a/internal/utils/error.go b/internal/utils/error.go index e409e50c..ead06aeb 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -25,3 +25,16 @@ func ErrorHandler(c *fiber.Ctx, err error) error { func NotFoundHandler(c *fiber.Ctx) error { return response.Error(c, fiber.StatusNotFound, "Endpoint Not Found", nil) } + + +func BadRequest(msg string) error { + return fiber.NewError(fiber.StatusBadRequest, msg) +} + +func NotFound(msg string) error { + return fiber.NewError(fiber.StatusNotFound, msg) +} + +func Internal(msg string) error { + return fiber.NewError(fiber.StatusInternalServerError, msg) +} diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index 8f0fe81f..f10926dc 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -80,6 +80,7 @@ func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity. RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, Qty: item.Qty, + Weight: item.Weight, CreatedBy: createdBy, }) } diff --git a/internal/utils/time.go b/internal/utils/time.go index f57a3bb3..5f34923e 100644 --- a/internal/utils/time.go +++ b/internal/utils/time.go @@ -1,8 +1,9 @@ package utils import ( - "time" "errors" + "strings" + "time" ) // ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time @@ -23,3 +24,35 @@ func ParseDateString(dateStr string) (time.Time, error) { func FormatDate(t time.Time) string { return t.Format("2006-01-02") } + +// ParseDateRangeForQuery parses optional YYYY-MM-DD from/to strings for list filters. +// It returns a start pointer (inclusive) and an end pointer advanced by one day +// so callers can safely use "< end" to achieve an inclusive upper bound. +func ParseDateRangeForQuery(fromStr, toStr string) (*time.Time, *time.Time, error) { + var fromPtr *time.Time + var toPtr *time.Time + + if strings.TrimSpace(fromStr) != "" { + parsed, err := ParseDateString(strings.TrimSpace(fromStr)) + if err != nil { + return nil, nil, errors.New("created_from must use format YYYY-MM-DD") + } + fromValue := parsed + fromPtr = &fromValue + } + + if strings.TrimSpace(toStr) != "" { + parsed, err := ParseDateString(strings.TrimSpace(toStr)) + if err != nil { + return nil, nil, errors.New("created_to must use format YYYY-MM-DD") + } + nextDay := parsed.AddDate(0, 0, 1) + toPtr = &nextDay + } + + if fromPtr != nil && toPtr != nil && fromPtr.After(*toPtr) { + return nil, nil, errors.New("created_from must be earlier than created_to") + } + + return fromPtr, toPtr, nil +} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go index 755e9e95..dd5f7d53 100644 --- a/test/integration/production/recordings/recording_fifo_integration_test.go +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -263,6 +263,7 @@ func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.Pr ProductId: 1, WarehouseId: 1, Quantity: qty, + // CreatedBy: 1, } if err := db.Create(&pw).Error; err != nil { t.Fatalf("create product warehouse: %v", err)