Merge branch 'feat/BE/Sprint-8' into 'development'

Feat[BE]: Partial Merge

See merge request mbugroup/lti-api!112
This commit is contained in:
Hafizh A. Y.
2025-12-29 14:39:58 +00:00
124 changed files with 7391 additions and 711 deletions
@@ -84,8 +84,9 @@ func (r *approvalRepositoryImpl) LatestByTargets(
result := make(map[uint]entity.Approval, len(approvableIDs)) result := make(map[uint]entity.Approval, len(approvableIDs))
q := r.DB().WithContext(ctx). q := r.DB().WithContext(ctx).
Select("DISTINCT ON (approvable_id) *").
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs). Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
Order("action_at DESC") Order("approvable_id, action_at DESC")
if modifier != nil { if modifier != nil {
q = modifier(q) q = modifier(q)
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -9,45 +10,59 @@ import (
// Exists reports whether a record with the given ID exists for type T. // Exists reports whether a record with the given ID exists for type T.
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) { func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
var count int64 var marker int
if err := db.WithContext(ctx). err := db.WithContext(ctx).
Model(new(T)). Model(new(T)).
Select("1").
Where("id = ?", id). Where("id = ?", id).
Count(&count).Error; err != nil { Limit(1).
Take(&marker).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err return false, err
} }
return count > 0, nil return true, nil
} }
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) { func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
var count int64
q := db.WithContext(ctx). q := db.WithContext(ctx).
Model(new(T)). Model(new(T)).
Select("1").
Where("name = ?", name). Where("name = ?", name).
Where("deleted_at IS NULL") Where("deleted_at IS NULL")
if excludeID != nil { if excludeID != nil {
q = q.Where("id <> ?", *excludeID) q = q.Where("id <> ?", *excludeID)
} }
if err := q.Count(&count).Error; err != nil { var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err return false, err
} }
return count > 0, nil return true, nil
} }
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
if field == "" { if field == "" {
return false, fmt.Errorf("field is required") return false, fmt.Errorf("field is required")
} }
var count int64
q := db.WithContext(ctx). q := db.WithContext(ctx).
Model(new(T)). Model(new(T)).
Select("1").
Where(fmt.Sprintf("%s = ?", field), value). Where(fmt.Sprintf("%s = ?", field), value).
Where("deleted_at IS NULL") Where("deleted_at IS NULL")
if excludeID != nil { if excludeID != nil {
q = q.Where("id <> ?", *excludeID) q = q.Where("id <> ?", *excludeID)
} }
if err := q.Count(&count).Error; err != nil { var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err return false, err
} }
return count > 0, nil return true, nil
} }
+19 -6
View File
@@ -505,12 +505,25 @@ func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWa
var lots []stockLot var lots []stockLot
for key, cfg := range configs { for key, cfg := range configs {
selectStmt := fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at", usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity), var selectStmt string
cfg.Columns.CreatedAt, if usesNumericTime {
)
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
)
} else {
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
cfg.Columns.CreatedAt,
)
}
var rows []struct { var rows []struct {
ID uint ID uint
@@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id
-- Relasi ke product_warehouses -- Relasi ke product_warehouses
ALTER TABLE project_chickins ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE; ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE;
-- Relasi ke users -- Relasi ke users
ALTER TABLE project_chickins ALTER TABLE project_chickins
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_payments_bank_id;
DROP INDEX IF EXISTS payments_party_polymorphic;
DROP TABLE IF EXISTS payments;
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
payment_code VARCHAR(50) NOT NULL,
reference_number VARCHAR(100) NULL,
transaction_type VARCHAR(50),
party_type VARCHAR(50) NOT NULL,
party_id BIGINT NOT NULL,
payment_date TIMESTAMPTZ NOT NULL,
payment_method VARCHAR(20) NOT NULL,
bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE,
direction VARCHAR(5) NOT NULL,
nominal NUMERIC(15, 3) NOT NULL,
notes TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
-- Indexes
CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id);
CREATE INDEX idx_payments_bank_id ON payments (bank_id);
@@ -0,0 +1,18 @@
DO $$
DECLARE
r record;
trigger_name text;
BEGIN
FOR r IN
SELECT table_schema, table_name
FROM information_schema.columns
WHERE column_name = 'deleted_at'
AND table_schema = 'public'
GROUP BY table_schema, table_name
LOOP
trigger_name := format('trg_soft_delete_fk_%s', r.table_name);
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name);
END LOOP;
END $$;
DROP FUNCTION IF EXISTS soft_delete_handle_fk();
@@ -0,0 +1,126 @@
CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$
DECLARE
fk record;
child_column text;
parent_column text;
parent_value text;
child_has_deleted_at boolean;
ref_exists boolean;
sql text;
BEGIN
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
FOR fk IN
SELECT conrelid::regclass AS child_table,
conkey AS child_cols,
confkey AS parent_cols,
confdeltype
FROM pg_constraint
WHERE contype = 'f'
AND confrelid = TG_RELID
LOOP
IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1
OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN
RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table;
CONTINUE;
END IF;
SELECT attname INTO child_column
FROM pg_attribute
WHERE attrelid = fk.child_table
AND attnum = fk.child_cols[1]
AND NOT attisdropped;
SELECT attname INTO parent_column
FROM pg_attribute
WHERE attrelid = TG_RELID
AND attnum = fk.parent_cols[1]
AND NOT attisdropped;
EXECUTE format('SELECT ($1).%I', parent_column)
INTO parent_value
USING OLD;
SELECT EXISTS (
SELECT 1
FROM pg_attribute
WHERE attrelid = fk.child_table
AND attname = 'deleted_at'
AND NOT attisdropped
) INTO child_has_deleted_at;
IF fk.confdeltype IN ('r', 'a') THEN
sql := format(
'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)',
fk.child_table,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql INTO ref_exists USING parent_value;
IF ref_exists THEN
RAISE EXCEPTION 'Cannot soft delete %, still referenced by %',
TG_TABLE_NAME, fk.child_table;
END IF;
ELSIF fk.confdeltype = 'n' THEN
sql := format(
'UPDATE %s SET %I = NULL WHERE %I = $1 %s',
fk.child_table,
child_column,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql USING parent_value;
ELSIF fk.confdeltype = 'c' THEN
IF child_has_deleted_at THEN
sql := format(
'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL',
fk.child_table,
child_column
);
EXECUTE sql USING parent_value;
ELSE
sql := format(
'DELETE FROM %s WHERE %I = $1',
fk.child_table,
child_column
);
EXECUTE sql USING parent_value;
END IF;
ELSIF fk.confdeltype = 'd' THEN
sql := format(
'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s',
fk.child_table,
child_column,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql USING parent_value;
END IF;
END LOOP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
DECLARE
r record;
trigger_name text;
BEGIN
FOR r IN
SELECT table_schema, table_name
FROM information_schema.columns
WHERE column_name = 'deleted_at'
AND table_schema = 'public'
GROUP BY table_schema, table_name
LOOP
trigger_name := format('trg_soft_delete_fk_%s', r.table_name);
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name);
EXECUTE format(
'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()',
trigger_name,
r.table_schema,
r.table_name
);
END LOOP;
END $$;
@@ -0,0 +1 @@
DROP SEQUENCE IF EXISTS payments_code_seq;
@@ -0,0 +1 @@
CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1;
@@ -0,0 +1,3 @@
-- Rollback: restore document columns to expenses table
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON;
@@ -0,0 +1,3 @@
-- Delete document columns from expenses table since we now use Document service with polymorphic relations
ALTER TABLE expenses DROP COLUMN IF EXISTS document_path;
ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path;
@@ -0,0 +1,28 @@
-- ============================================
-- Rollback: Remove FIFO fields and restore qty column
-- ============================================
-- STEP 1: Drop indexes
DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup;
DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty;
DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty;
DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at;
-- STEP 2: Drop constraints
ALTER TABLE marketing_delivery_products
DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg;
-- STEP 3: Restore qty column from usage_qty data
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL;
-- Migrate data back from usage_qty to qty
UPDATE marketing_delivery_products
SET qty = usage_qty
WHERE qty = 0;
-- STEP 4: Drop FIFO columns
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS created_at;
@@ -0,0 +1,58 @@
-- ============================================
-- Add FIFO fields to marketing_delivery_products
-- This migration adds fields needed for FIFO stock management
-- and removes the old qty field in favor of FIFO-based allocation
-- ============================================
-- STEP 0: Drop orphan indexes from previous migration
DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at;
-- STEP 1: Add created_at column (required for FIFO ordering)
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
-- STEP 2: Add FIFO tracking fields
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0;
-- STEP 3: Migrate data from old qty to usage_qty for existing records
-- This preserves existing quantity data as allocated quantity
UPDATE marketing_delivery_products
SET
usage_qty = COALESCE(qty, 0),
pending_qty = 0
WHERE usage_qty = 0;
-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty)
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS qty;
-- STEP 5: Make FIFO fields NOT NULL
ALTER TABLE marketing_delivery_products
ALTER COLUMN usage_qty SET NOT NULL,
ALTER COLUMN pending_qty SET NOT NULL,
ALTER COLUMN created_at SET NOT NULL;
-- STEP 6: Add constraints to ensure non-negative values
ALTER TABLE marketing_delivery_products
ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK (
usage_qty >= 0 AND
pending_qty >= 0
);
-- STEP 7: Create indexes for FIFO operations
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at
ON marketing_delivery_products(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty
ON marketing_delivery_products(usage_qty)
WHERE usage_qty > 0;
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty
ON marketing_delivery_products(pending_qty)
WHERE pending_qty > 0;
-- Composite index for FIFO lookups
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup
ON marketing_delivery_products(marketing_product_id, created_at DESC);
@@ -0,0 +1,7 @@
-- Remove foreign key constraint
ALTER TABLE marketing_delivery_products
DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse;
-- Drop product_warehouse_id column
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS product_warehouse_id;
@@ -0,0 +1,19 @@
-- Add product_warehouse_id column to marketing_delivery_products
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0;
-- Fill product_warehouse_id from marketing_products
UPDATE marketing_delivery_products mdp
SET product_warehouse_id = mp.product_warehouse_id
FROM marketing_products mp
WHERE mdp.marketing_product_id = mp.id
AND mdp.product_warehouse_id = 0;
-- Set NOT NULL constraint
ALTER TABLE marketing_delivery_products
ALTER COLUMN product_warehouse_id SET NOT NULL;
-- Add foreign key constraint
ALTER TABLE marketing_delivery_products
ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id);
@@ -0,0 +1,10 @@
-- Drop indexes
DROP INDEX IF EXISTS idx_standard_growth_details_standard_week;
DROP INDEX IF EXISTS idx_production_standard_details_standard_week;
DROP INDEX IF EXISTS idx_production_standards_project_category;
DROP INDEX IF EXISTS idx_production_standards_deleted_at;
-- Drop tables (in reverse order due to foreign keys)
DROP TABLE IF EXISTS standard_growth_details;
DROP TABLE IF EXISTS production_standard_details;
DROP TABLE IF EXISTS production_standards;
@@ -0,0 +1,96 @@
-- Create production_standards table
CREATE TABLE IF NOT EXISTS production_standards (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT
);
-- Create index for deleted_at (soft delete)
CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at);
-- Tambahkan Foreign Key ke users
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
ALTER TABLE production_standards
ADD CONSTRAINT fk_production_standards_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- Index
CREATE INDEX idx_production_standards_created_by ON production_standards(created_by);
-- Create production_standard_details table
CREATE TABLE IF NOT EXISTS production_standard_details (
id BIGSERIAL PRIMARY KEY,
production_standard_id BIGINT NOT NULL,
week INT NOT NULL,
target_hen_day_production NUMERIC(15, 3),
target_hen_house_production NUMERIC(15, 3),
target_egg_weight NUMERIC(15, 3),
target_egg_mass NUMERIC(15, 3),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tambahkan Foreign Key ke production_standards
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE production_standard_details
ADD CONSTRAINT fk_production_standard_details_standard
FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE;
END IF;
END $$;
-- Create unique constraint for standard_id + week
CREATE UNIQUE INDEX idx_production_standard_details_standard_week
ON production_standard_details(production_standard_id, week);
-- Create standard_growth_details table
CREATE TABLE IF NOT EXISTS standard_growth_details (
id BIGSERIAL PRIMARY KEY,
production_standard_id BIGINT NOT NULL,
target_mean_bw NUMERIC(15, 3),
max_depletion NUMERIC(15, 3),
min_uniformity NUMERIC(15, 3) NOT NULL,
week INT NOT NULL,
feed_intake NUMERIC(15, 3),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by BIGINT
);
-- Tambahkan Foreign Key ke production_standards
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE standard_growth_details
ADD CONSTRAINT fk_standard_growth_details_standard
FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE;
END IF;
END $$;
-- Tambahkan Foreign Key ke users
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
ALTER TABLE standard_growth_details
ADD CONSTRAINT fk_standard_growth_details_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- Create unique constraint for standard_id + week
CREATE UNIQUE INDEX idx_standard_growth_details_standard_week
ON standard_growth_details(production_standard_id, week);
-- Index
CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by);
-- Create index for project_category
CREATE INDEX idx_production_standards_project_category ON production_standards(project_category);
@@ -0,0 +1,24 @@
-- Rollback: Update expense and expense_nonstocks tables
-- Drop indexes
DROP INDEX IF EXISTS idx_expenses_project_flock_id;
DROP INDEX IF EXISTS idx_expenses_location_id;
-- Drop Foreign Key constraint
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_expenses_location_id'
) THEN
ALTER TABLE expenses
DROP CONSTRAINT fk_expenses_location_id;
END IF;
END $$;
-- Drop columns from expenses table
ALTER TABLE expenses
DROP COLUMN IF EXISTS project_flock_id;
ALTER TABLE expenses
DROP COLUMN IF EXISTS location_id;
@@ -0,0 +1,29 @@
-- Migration: Update expense and expense_nonstocks tables
-- Add location_id column to expenses table
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1;
-- Add project_flock_id column to expenses table (JSON type)
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL;
-- Add Foreign Key constraint to locations table
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN
ALTER TABLE expenses
ADD CONSTRAINT fk_expenses_location_id
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
-- Create index for location_id
CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id);
-- Create index for project_flock_id
CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text));
-- Ensure kandang_id is nullable in expense_nonstocks table
ALTER TABLE expense_nonstocks
ALTER COLUMN kandang_id DROP NOT NULL;
@@ -0,0 +1,42 @@
-- ===============================================================
-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS
-- ===============================================================
-- Drop indexes
DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw;
DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw;
-- Drop foreign keys
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_source_pw'
) THEN
EXECUTE 'ALTER TABLE stock_transfer_details
DROP CONSTRAINT fk_stock_transfer_details_source_pw';
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_dest_pw'
) THEN
EXECUTE 'ALTER TABLE stock_transfer_details
DROP CONSTRAINT fk_stock_transfer_details_dest_pw';
END IF;
END $$;
-- Drop FIFO columns
ALTER TABLE stock_transfer_details
DROP COLUMN IF EXISTS total_used,
DROP COLUMN IF EXISTS total_qty,
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS dest_product_warehouse_id,
DROP COLUMN IF EXISTS source_product_warehouse_id;
-- Restore original columns (in case rollback)
ALTER TABLE stock_transfer_details
ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3);
@@ -0,0 +1,83 @@
-- ===============================================================
-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS
-- Enable transfer module to work with FIFO stock system
--
-- Notes:
-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty)
-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy)
-- - New FIFO fields track actual allocation instead of requested quantity
-- ===============================================================
-- Add FIFO tracking fields
ALTER TABLE stock_transfer_details
ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0;
-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used)
ALTER TABLE stock_transfer_details
DROP COLUMN IF EXISTS quantity,
DROP COLUMN IF EXISTS before_quantity,
DROP COLUMN IF EXISTS after_quantity;
-- Add foreign keys for product warehouse references
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
-- Source warehouse foreign key
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_source_pw'
) THEN
EXECUTE
'ALTER TABLE stock_transfer_details
ADD CONSTRAINT fk_stock_transfer_details_source_pw
FOREIGN KEY (source_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
-- Destination warehouse foreign key
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_dest_pw'
) THEN
EXECUTE
'ALTER TABLE stock_transfer_details
ADD CONSTRAINT fk_stock_transfer_details_dest_pw
FOREIGN KEY (dest_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
END $$;
-- Add indexes for FIFO operations
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw
ON stock_transfer_details (source_product_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw
ON stock_transfer_details (dest_product_warehouse_id);
-- Add comments for documentation
COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS
'Source product warehouse ID - referensi warehouse asal (FIFO usable)';
COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS
'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)';
COMMENT ON COLUMN stock_transfer_details.usage_qty IS
'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field';
COMMENT ON COLUMN stock_transfer_details.pending_qty IS
'Quantity waiting for stock availability (FIFO usable tracking)';
COMMENT ON COLUMN stock_transfer_details.total_qty IS
'Total lot quantity available at destination warehouse (FIFO stockable tracking)';
COMMENT ON COLUMN stock_transfer_details.total_used IS
'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)';
@@ -0,0 +1,16 @@
-- Rollback: Drop adjustment_stocks table
BEGIN;
DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse;
DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log;
DROP TABLE IF EXISTS adjustment_stocks;
COMMIT;
@@ -0,0 +1,40 @@
-- Migration: Create adjustment_stocks table for FIFO tracking
-- This table tracks FIFO allocation for stock adjustments (both increase and decrease)
BEGIN;
CREATE TABLE IF NOT EXISTS adjustment_stocks (
id BIGSERIAL PRIMARY KEY,
stock_log_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
-- FIFO fields for Adjustment INCREASE (Stockable)
-- Tracks stock added to warehouse via adjustment
total_qty NUMERIC(15, 3) DEFAULT 0,
total_used NUMERIC(15, 3) DEFAULT 0,
-- FIFO fields for Adjustment DECREASE (Usable)
-- Tracks stock consumed from warehouse via adjustment
usage_qty NUMERIC(15, 3) DEFAULT 0,
pending_qty NUMERIC(15, 3) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Foreign keys
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_stock_log
FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- Indexes
CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id);
CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id);
COMMIT;
+29
View File
@@ -0,0 +1,29 @@
package entities
import "time"
// AdjustmentStock tracks FIFO allocation for stock adjustments
// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse
// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
StockLogId uint `gorm:"column:stock_log_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
// === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) ===
// Tracks stock added to warehouse via adjustment INCREASE
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available
TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot
// === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) ===
// Tracks stock consumed from warehouse via adjustment DECREASE
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock)
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
// Relations
StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
}
+9 -7
View File
@@ -1,7 +1,6 @@
package entities package entities
import ( import (
"database/sql"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -13,8 +12,8 @@ type Expense struct {
SupplierId uint64 `gorm:""` SupplierId uint64 `gorm:""`
Category string `gorm:"type:varchar(50);not null"` Category string `gorm:"type:varchar(50);not null"`
PoNumber string `gorm:"type:varchar(50)"` PoNumber string `gorm:"type:varchar(50)"`
DocumentPath sql.NullString `gorm:"type:json"` LocationId uint64 `gorm:"not null"`
RealizationDocumentPath sql.NullString `gorm:"type:json;column:realization_document_path"` ProjectFlockId *string `gorm:"type:json"`
RealizationDate time.Time `gorm:"type:date;column:realization_date"` RealizationDate time.Time `gorm:"type:date;column:realization_date"`
TransactionDate time.Time `gorm:"type:date;not null"` TransactionDate time.Time `gorm:"type:date;not null"`
Notes string `gorm:"type:text;column:notes"` Notes string `gorm:"type:text;column:notes"`
@@ -23,8 +22,11 @@ type Expense struct {
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"` Supplier *Supplier `gorm:"foreignKey:SupplierId;references:Id"`
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` Location *Location `gorm:"foreignKey:LocationId;references:Id"`
Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"` CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"` Nonstocks []ExpenseNonstock `gorm:"foreignKey:ExpenseId;references:Id"`
Documents []Document `gorm:"foreignKey:DocumentableId;references:Id"`
RealizationDocuments []Document `gorm:"foreignKey:DocumentableId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"latest_approval,omitempty"`
} }
+30
View File
@@ -0,0 +1,30 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Initial struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ReferenceNumber string `gorm:"type:varchar(100);not null"`
TransactionType string `gorm:"type:varchar(50);not null"`
InitialBalanceType string `gorm:"type:varchar(20);not null"`
PartyType string `gorm:"type:varchar(50);not null;index:initials_party_polymorphic,priority:1"`
PartyId uint `gorm:"not null;index:initials_party_polymorphic,priority:2"`
BankId *uint `gorm:"index"`
Direction string `gorm:"type:varchar(5);not null"`
Nominal float64 `gorm:"type:numeric(15,3);not null"`
Notes string `gorm:"type:text;not null"`
CreatedBy uint `gorm:"index" json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Bank Bank `gorm:"foreignKey:BankId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Customer *Customer `gorm:"foreignKey:PartyId;references:Id"`
Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
@@ -5,15 +5,20 @@ import (
) )
type MarketingDeliveryProduct struct { type MarketingDeliveryProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
MarketingProductId uint `gorm:"uniqueIndex;not null"` MarketingProductId uint `gorm:"uniqueIndex;not null"`
Qty float64 `gorm:"type:numeric(15,3)"` ProductWarehouseId uint `gorm:"not null"`
UnitPrice float64 `gorm:"type:numeric(15,3)"` UnitPrice float64 `gorm:"type:numeric(15,3)"`
TotalWeight float64 `gorm:"type:numeric(15,3)"` TotalWeight float64 `gorm:"type:numeric(15,3)"`
AvgWeight float64 `gorm:"type:numeric(15,3)"` AvgWeight float64 `gorm:"type:numeric(15,3)"`
TotalPrice float64 `gorm:"type:numeric(15,3)"` TotalPrice float64 `gorm:"type:numeric(15,3)"`
DeliveryDate *time.Time `gorm:"type:timestamptz"` DeliveryDate *time.Time `gorm:"type:timestamptz"`
VehicleNumber string `gorm:"type:varchar(50)"` VehicleNumber string `gorm:"type:varchar(50)"`
// FIFO Fields
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
PendingQty float64 `gorm:"type:numeric(15,3);default:0;not null"`
CreatedAt *time.Time `gorm:"type:timestamptz;not null"`
MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"` MarketingProduct MarketingProduct `gorm:"foreignKey:MarketingProductId;references:Id"`
} }
+32
View File
@@ -0,0 +1,32 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Payment struct {
Id uint `gorm:"primaryKey;autoIncrement"`
PaymentCode string `gorm:"type:varchar(50);not null"`
ReferenceNumber *string `gorm:"type:varchar(100)"`
TransactionType string `gorm:"type:varchar(50)"`
PartyType string `gorm:"type:varchar(50);not null;index:payments_party_polymorphic,priority:1"`
PartyId uint `gorm:"not null;index:payments_party_polymorphic,priority:2"`
PaymentDate time.Time `gorm:"not null"`
PaymentMethod string `gorm:"type:varchar(20);not null"`
BankId *uint `gorm:"not null;index:idx_payments_bank_id"`
Direction string `gorm:"type:varchar(5);not null"`
Nominal float64 `gorm:"type:numeric(15,3);not null"`
Notes string `gorm:"type:text;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedBy uint `gorm:"index" json:"-"`
BankWarehouse Bank `gorm:"foreignKey:BankId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
Customer *Customer `gorm:"foreignKey:PartyId;references:Id"`
Supplier *Supplier `gorm:"foreignKey:PartyId;references:Id"`
LatestApproval *Approval `gorm:"-" json:"-"`
}
+19
View File
@@ -0,0 +1,19 @@
package entities
import (
"time"
)
type ProductionStandard struct {
Id uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);uniqueIndex;not null"`
ProjectCategory string `gorm:"type:varchar(20);not null"`
CreatedAt time.Time `gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"type:timestamptz;not null"`
DeletedAt *time.Time `gorm:"type:timestamptz"`
CreatedBy uint `gorm:"not null"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
ProductionStandardDetails []ProductionStandardDetail `gorm:"foreignKey:ProductionStandardId;references:Id"`
StandardGrowthDetails []StandardGrowthDetail `gorm:"foreignKey:ProductionStandardId;references:Id"`
}
@@ -0,0 +1,19 @@
package entities
import (
"time"
)
type ProductionStandardDetail struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ProductionStandardId uint `gorm:"not null"`
Week int `gorm:"not null"`
TargetHenDayProduction *float64 `gorm:"type:numeric(15,3)"`
TargetHenHouseProduction *float64 `gorm:"type:numeric(15,3)"`
TargetEggWeight *float64 `gorm:"type:numeric(15,3)"`
TargetEggMass *float64 `gorm:"type:numeric(15,3)"`
CreatedAt time.Time `gorm:"type:timestamptz;not null"`
UpdatedAt time.Time `gorm:"type:timestamptz;not null"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
}
@@ -0,0 +1,19 @@
package entities
import (
"time"
)
type StandardGrowthDetail struct {
Id uint `gorm:"primaryKey;autoIncrement"`
ProductionStandardId uint `gorm:"not null"`
TargetMeanBw *float64 `gorm:"type:numeric(15,3)"`
MaxDepletion *float64 `gorm:"type:numeric(15,3)"`
MinUniformity float64 `gorm:"type:numeric(15,3);not null"`
Week int `gorm:"not null"`
FeedIntake *float64 `gorm:"type:numeric(15,3)"`
CreatedAt time.Time `gorm:"type:timestamptz;not null"`
CreatedBy uint `gorm:"not null"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
}
+1
View File
@@ -20,4 +20,5 @@ type StockTransfer struct {
Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"`
Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"`
CreatedUser *User `gorm:"foreignKey:CreatedBy"` CreatedUser *User `gorm:"foreignKey:CreatedBy"`
Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
} }
-10
View File
@@ -2,16 +2,6 @@ package entities
import "time" import "time"
const (
LogTypeAdjustment = "ADJUSTMENT"
LogTypeTransfer = "TRANSFER"
)
const (
TransactionTypeIncrease = "INCREASE"
TransactionTypeDecrease = "DECREASE"
)
type StockLog struct { type StockLog struct {
Id uint `gorm:"primaryKey;column:id"` Id uint `gorm:"primaryKey;column:id"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null;index"`
+17 -16
View File
@@ -4,20 +4,21 @@ import "time"
// DETAIL EKSPEDISI // DETAIL EKSPEDISI
type StockTransferDelivery struct { type StockTransferDelivery struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64 StockTransferId uint64
SupplierId uint64 SupplierId uint64
VehiclePlate string VehiclePlate string
DriverName string DriverName string
DocumentNumber string DocumentNumber string
DocumentPath string DocumentPath string
ShippingCostItem float64 ShippingCostItem float64
ShippingCostTotal float64 ShippingCostTotal float64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"` DeletedAt *time.Time `gorm:"index"`
// Relations // Relations
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
Supplier *Supplier `gorm:"foreignKey:SupplierId"` Supplier *Supplier `gorm:"foreignKey:SupplierId"`
Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"`
Documents []Document `gorm:"foreignKey:DocumentableId;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
} }
+24 -8
View File
@@ -7,12 +7,28 @@ type StockTransferDetail struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64 StockTransferId uint64
ProductId uint64 ProductId uint64
Quantity float64
CreatedAt time.Time // === FIFO FIELDS - SOURCE WAREHOUSE (Usable) ===
UpdatedAt time.Time // Tracking stock yang DIAMBIL dari source warehouse
DeletedAt *time.Time `gorm:"index"` SourceProductWarehouseID *uint64 `gorm:"column:source_product_warehouse_id"`
// Relations UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual yang berhasil diambil
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` PendingQty float64 `gorm:"column:pending_qty;default:0"` // Yang pending (nunggu stock)
Product *Product `gorm:"foreignKey:ProductId"`
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` // === FIFO FIELDS - DESTINATION WAREHOUSE (Stockable) ===
// Tracking stock yang DITAMBAHKAN ke destination warehouse
DestProductWarehouseID *uint64 `gorm:"column:dest_product_warehouse_id"`
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot yang tersedia
TotalUsed float64 `gorm:"column:total_used;default:0"` // Yang sudah dipakai dari lot ini
// === METADATA ===
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `gorm:"index"`
// === RELATIONS ===
StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"`
Product *Product `gorm:"foreignKey:ProductId"`
SourceProductWarehouse *ProductWarehouse `gorm:"foreignKey:SourceProductWarehouseID"`
DestProductWarehouse *ProductWarehouse `gorm:"foreignKey:DestProductWarehouseID"`
DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"`
} }
+18
View File
@@ -0,0 +1,18 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Transaction struct {
Id uint `gorm:"primaryKey"`
Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"`
CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
}
+10
View File
@@ -77,6 +77,7 @@ const (
P_DeliveryGetAll = "lti.marketing.delivery_order.list" P_DeliveryGetAll = "lti.marketing.delivery_order.list"
P_DeliveryGetOne = "lti.marketing.delivery_order.detail" P_DeliveryGetOne = "lti.marketing.delivery_order.detail"
P_DeliveryUpdateOne = "lti.marketing.delivery_order.update" P_DeliveryUpdateOne = "lti.marketing.delivery_order.update"
P_DeliveryCreateOne = "lti.marketing.delivery_order.Create"
P_SalesOrderDelete = "lti.marketing.sales_order.delete" P_SalesOrderDelete = "lti.marketing.sales_order.delete"
P_SalesOrderApproval = "lti.marketing.sales_order.approve" P_SalesOrderApproval = "lti.marketing.sales_order.approve"
P_SalesOrderCreateOne = "lti.marketing.sales_order.create" P_SalesOrderCreateOne = "lti.marketing.sales_order.create"
@@ -192,6 +193,15 @@ const (
P_PurchaseApprovalManager = "lti.Purchase.approve.manager" P_PurchaseApprovalManager = "lti.Purchase.approve.manager"
) )
const (
P_FinanceGetAll = "lti.finance.list"
P_FinanceGetOne = "lti.finance.detail"
P_FinanceCreateOne = "lti.finance.create"
P_FinanceUpdateOne = "lti.finance.update"
P_FinanceDeleteOne = "lti.finance.delete"
P_FinanceApproval = "lti.finance.approve"
)
const ( const (
P_UserGetAll = "lti.users.list" P_UserGetAll = "lti.users.list"
P_UserGetOne = "lti.users.detail" P_UserGetOne = "lti.users.detail"
@@ -35,6 +35,7 @@ const (
type CalculationContext struct { type CalculationContext struct {
TotalPopulation float64 TotalPopulation float64
TotalWeightProduced float64 TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64 TotalDepletion float64
TotalWeightSold float64 TotalWeightSold float64
ActualPopulation float64 ActualPopulation float64
@@ -48,6 +49,7 @@ type ClosingKeuanganInput struct {
DeliveryProducts []entities.MarketingDeliveryProduct DeliveryProducts []entities.MarketingDeliveryProduct
Chickins []entities.ProjectChickin Chickins []entities.ProjectChickin
TotalWeightProduced float64 TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64 TotalDepletion float64
} }
@@ -77,8 +79,10 @@ type HppGroup struct {
} }
type SummaryHpp struct { type SummaryHpp struct {
Label string `json:"label"` Label string `json:"label"`
Comparison Comparison `json:"-"`
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"`
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
} }
type HppPurchasesSection struct { type HppPurchasesSection struct {
@@ -231,7 +235,7 @@ func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entiti
// === HPP SUMMARY === // === HPP SUMMARY ===
func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) SummaryHpp { func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp {
purchaseTotal := sumPurchaseTotal(purchaseItems) purchaseTotal := sumPurchaseTotal(purchaseItems)
budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true })
totalBudget := purchaseTotal + budgetTotal totalBudget := purchaseTotal + budgetTotal
@@ -241,16 +245,34 @@ func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets [
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced) budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced)
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced) realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced)
return SummaryHpp{ summary := SummaryHpp{
Label: label, Label: label,
Comparison: ToComparison( Comparison: ToComparison(
ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget), ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget),
ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization), ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization),
), ),
} }
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 {
budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg)
realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg)
summary.EggBudgeting = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: budgetEggRpPerKg,
Amount: totalBudget,
}
summary.EggRealization = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: realizationEggRpPerKg,
Amount: totalRealization,
}
}
return summary
} }
func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppPurchasesSection { func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection {
hppGroups := []HppGroup{ hppGroups := []HppGroup{
{ {
GroupName: HPPGroupPengeluaran, GroupName: HPPGroupPengeluaran,
@@ -259,7 +281,7 @@ func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []enti
ToHppBahanBakuGroup(budgets, realizations, ctx), ToHppBahanBakuGroup(budgets, realizations, ctx),
} }
summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, ctx) summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx)
return HppPurchasesSection{ return HppPurchasesSection{
Hpp: hppGroups, Hpp: hppGroups,
@@ -322,11 +344,9 @@ func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.M
func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem { func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
purchaseAmount := sumPurchaseTotal(purchases) purchaseAmount := sumPurchaseTotal(purchases)
bopAmount := getOperationalExpenses(realizations)
totalCost := purchaseAmount + bopAmount
return []PLItem{ return []PLItem{
createPLItemWithMetrics(PLItemTypeSapronak, totalCost, ctx), createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx),
} }
} }
@@ -414,12 +434,13 @@ func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse {
ctx := CalculationContext{ ctx := CalculationContext{
TotalPopulation: totalPopulation, TotalPopulation: totalPopulation,
TotalWeightProduced: input.TotalWeightProduced, TotalWeightProduced: input.TotalWeightProduced,
TotalEggWeightKg: input.TotalEggWeightKg,
TotalDepletion: input.TotalDepletion, TotalDepletion: input.TotalDepletion,
TotalWeightSold: totalWeightSold, TotalWeightSold: totalWeightSold,
ActualPopulation: totalPopulation - input.TotalDepletion, ActualPopulation: totalPopulation - input.TotalDepletion,
} }
hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, ctx) hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx)
penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx) penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx)
pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx) pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx)
overheadItems := ToOverheadItems(input.Realizations, ctx) overheadItems := ToOverheadItems(input.Realizations, ctx)
@@ -64,7 +64,7 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
DoNumber: doNumber, DoNumber: doNumber,
Product: product, Product: product,
Customer: customer, Customer: customer,
Qty: e.Qty, Qty: e.UsageQty, // Show allocated quantity from FIFO
Weight: e.TotalWeight, Weight: e.TotalWeight,
AvgWeight: e.AvgWeight, AvgWeight: e.AvgWeight,
Price: e.UnitPrice, Price: e.UnitPrice,
@@ -31,6 +31,8 @@ type ClosingRepository interface {
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
} }
type ClosingRepositoryImpl struct { type ClosingRepositoryImpl struct {
@@ -783,7 +785,7 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
} }
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -792,7 +794,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
} }
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -804,3 +806,150 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
}) })
return in, out, nil return in, out, nil
} }
type ActualUsageCostRow struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
FlagName string `gorm:"column:flag_name"`
TotalQty float64 `gorm:"column:total_qty"`
TotalPrice float64 `gorm:"column:total_price"`
AveragePrice float64 `gorm:"column:average_price"`
}
func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) {
if projectFlockID == 0 {
return []ActualUsageCostRow{}, nil
}
db := r.DB().WithContext(ctx)
// Get all project flock kandang IDs for this project flock
var pfkIDs []uint
err := db.Table("project_flock_kandangs").
Where("project_flock_id = ?", projectFlockID).
Pluck("id", &pfkIDs).Error
if err != nil {
return nil, err
}
if len(pfkIDs) == 0 {
return []ActualUsageCostRow{}, nil
}
var rows []ActualUsageCostRow
// Part 1: Get usage from recording_stocks (PAKAN, OVK, Vitamin, Obat, Kimia, dll)
purchaseStockableKey := "PURCHASE_ITEMS"
transferStockableKey := "STOCK_TRANSFER_DETAILS"
recordingQuery := db.
Table("recordings AS r").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(f.name, tf.name) AS flag_name,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
ELSE 0
END
), 0) AS total_qty,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) AS total_price,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
ELSE 0
END
), 0) AS qty_divisor,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) / NULLIF(COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.quantity, 0)
ELSE 0
END
), 0), 0) AS average_price`,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey).
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?",
"recording_stocks", entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey).
Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id IN ?", pfkIDs).
Where("r.deleted_at IS NULL").
Group("pw.product_id, p.name, COALESCE(f.name, tf.name)")
if err := recordingQuery.Scan(&rows).Error; err != nil {
return nil, err
}
// Part 2: Get usage from project_chickins (DOC, Pullet)
chickinQuery := db.
Table("project_chickins AS pc").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag_name,
COALESCE(SUM(pc.usage_qty), 0) AS total_qty,
COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price,
COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price
`).
Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("pc.project_flock_kandang_id IN ?", pfkIDs).
Where("pc.usage_qty > 0").
Group("pw.product_id, p.name, f.name")
var chickinRows []ActualUsageCostRow
if err := chickinQuery.Scan(&chickinRows).Error; err != nil {
return nil, err
}
// Merge results
rows = append(rows, chickinRows...)
return rows, nil
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 {
return []entity.Product{}, nil
}
var products []entity.Product
err := r.DB().WithContext(ctx).
Preload("Flags").
Where("id IN ?", productIDs).
Find(&products).Error
if err != nil {
return nil, err
}
return products, nil
}
@@ -426,11 +426,15 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
} }
purchaseItems, err := s.PurchaseRepo.GetItemsByProjectFlockID(c.Context(), projectFlockID) // Get actual usage cost instead of purchase items
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch purchase items") if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost")
} }
// Convert actual usage rows to pseudo purchase items
purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows)
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
@@ -455,6 +459,11 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err) s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err)
} }
totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err)
}
totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID) totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err) s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err)
@@ -468,6 +477,7 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
DeliveryProducts: deliveryProducts, DeliveryProducts: deliveryProducts,
Chickins: chickins, Chickins: chickins,
TotalWeightProduced: totalWeightProduced, TotalWeightProduced: totalWeightProduced,
TotalEggWeightKg: totalEggWeightKg,
TotalDepletion: totalDepletion, TotalDepletion: totalDepletion,
} }
@@ -476,8 +486,6 @@ func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*
return &report, nil return &report, nil
} }
// GetExpeditionHPP menghitung HPP ekspedisi per vendor untuk sebuah project flock.
// Jika projectFlockKandangID tidak nil, maka hanya data untuk kandang tersebut yang dihitung.
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockID == 0 { if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
@@ -712,13 +720,13 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo
) )
for _, product := range deliveryProducts { for _, product := range deliveryProducts {
if product.Qty == 0 { if product.UsageQty == 0 {
continue continue
} }
projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang
ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate) ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate)
totalAgeWeeks += float64(ageWeeks) * product.Qty totalAgeWeeks += float64(ageWeeks) * product.UsageQty
totalQty += product.Qty totalQty += product.UsageQty
} }
if totalQty == 0 { if totalQty == 0 {
@@ -778,5 +786,54 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl
} }
return closest.Mortality, closest.FcrNumber return closest.Mortality, closest.FcrNumber
}
func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem {
if len(actualUsageRows) == 0 {
return []entity.PurchaseItem{}
}
// Collect all product IDs
productIDs := make([]uint, len(actualUsageRows))
for i, row := range actualUsageRows {
productIDs[i] = row.ProductID
}
// Fetch products with flags from repository
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs)
if err != nil {
s.Log.Warnf("Failed to fetch products for actual usage: %v", err)
products = []entity.Product{}
}
// Create product map
productMap := make(map[uint]*entity.Product)
for i := range products {
productMap[products[i].Id] = &products[i]
}
// Convert to pseudo purchase items
purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows))
for _, row := range actualUsageRows {
product := productMap[row.ProductID]
// Skip if product not found
if product == nil {
s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID)
continue
}
purchaseItem := entity.PurchaseItem{
Id: 0, // Pseudo item, no ID
ProductId: row.ProductID,
TotalQty: row.TotalQty,
TotalPrice: row.TotalPrice,
Price: row.AveragePrice,
Product: product,
}
purchaseItems = append(purchaseItems, purchaseItem)
}
return purchaseItems
} }
@@ -90,6 +90,12 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
} }
req.SupplierID = supplierID req.SupplierID = supplierID
locationID, err := strconv.ParseUint(c.FormValue("location_id"), 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = locationID
form, err := c.MultipartForm() form, err := c.MultipartForm()
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
@@ -106,17 +112,7 @@ func (u *ExpenseController) CreateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
} }
if singleExpenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Field KandangID is required")
}
req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock} req.ExpenseNonstocks = []validation.ExpenseNonstock{singleExpenseNonstock}
} else {
for i, expenseNonstock := range req.ExpenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
} }
} else { } else {
return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required") return fiber.NewError(fiber.StatusBadRequest, "Field expense_nonstocks is required")
@@ -171,6 +167,15 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
req.SupplierID = &supplierID req.SupplierID = &supplierID
} }
locationIDVal := c.FormValue("location_id")
if locationIDVal != "" {
locationID, err := strconv.ParseUint(locationIDVal, 10, 64)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id format")
}
req.LocationID = &locationID
}
expenseNonstocksJSON := c.FormValue("expense_nonstocks") expenseNonstocksJSON := c.FormValue("expense_nonstocks")
if expenseNonstocksJSON != "" { if expenseNonstocksJSON != "" {
var expenseNonstocks []validation.ExpenseNonstock var expenseNonstocks []validation.ExpenseNonstock
@@ -178,12 +183,6 @@ func (u *ExpenseController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid expense_nonstocks JSON: %v", err))
} }
for i, expenseNonstock := range expenseNonstocks {
if expenseNonstock.KandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Field KandangID is required for expense_nonstocks[%d]", i))
}
}
req.ExpenseNonstocks = &expenseNonstocks req.ExpenseNonstocks = &expenseNonstocks
} }
+30 -10
View File
@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"encoding/json"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -41,8 +40,8 @@ type ExpenseListDTO struct {
type ExpenseDetailDTO struct { type ExpenseDetailDTO struct {
ExpenseBaseDTO ExpenseBaseDTO
Documents []DocumentDTO `json:"documents,omitempty"` Documents []DocumentDTO `json:"documents"`
RealizationDocs []DocumentDTO `json:"realization_docs,omitempty"` RealizationDocs []DocumentDTO `json:"realization_docs"`
Kandangs []KandangGroupDTO `json:"kandangs,omitempty"` Kandangs []KandangGroupDTO `json:"kandangs,omitempty"`
TotalPengajuan float64 `json:"total_pengajuan"` TotalPengajuan float64 `json:"total_pengajuan"`
TotalRealisasi float64 `json:"total_realisasi"` TotalRealisasi float64 `json:"total_realisasi"`
@@ -77,7 +76,6 @@ type ExpenseRealizationDTO struct {
type KandangGroupDTO struct { type KandangGroupDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
KandangId uint64 `json:"kandang_id"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"` Pengajuans []ExpenseNonstockDTO `json:"pengajuans,omitempty"`
Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"` Realisasi []ExpenseRealizationDTO `json:"realisasi,omitempty"`
@@ -179,12 +177,18 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO {
var pengajuans []ExpenseNonstockDTO var pengajuans []ExpenseNonstockDTO
var realisasi []ExpenseRealizationDTO var realisasi []ExpenseRealizationDTO
if e.DocumentPath.Valid && e.DocumentPath.String != "" { for _, doc := range e.Documents {
json.Unmarshal([]byte(e.DocumentPath.String), &documents) documents = append(documents, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
})
} }
if e.RealizationDocumentPath.Valid && e.RealizationDocumentPath.String != "" { for _, doc := range e.RealizationDocuments {
json.Unmarshal([]byte(e.RealizationDocumentPath.String), &realizationDocs) realizationDocs = append(realizationDocs, DocumentDTO{
ID: uint64(doc.Id),
Path: doc.Path,
})
} }
if len(e.Nonstocks) > 0 { if len(e.Nonstocks) > 0 {
@@ -264,6 +268,8 @@ func ToExpenseNonstockDTO(ns entity.ExpenseNonstock) ExpenseNonstockDTO {
func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO { func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseRealizationDTO, nonstocks []entity.ExpenseNonstock) []KandangGroupDTO {
kandangMap := make(map[uint64]*KandangGroupDTO) kandangMap := make(map[uint64]*KandangGroupDTO)
var directPengajuans []ExpenseNonstockDTO
var directRealisasi []ExpenseRealizationDTO
for _, p := range pengajuans { for _, p := range pengajuans {
var kandangId uint64 var kandangId uint64
@@ -280,16 +286,19 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
} }
if kandangId > 0 { if kandangId > 0 {
if kandangMap[kandangId] == nil { if kandangMap[kandangId] == nil {
kandangMap[kandangId] = &KandangGroupDTO{ kandangMap[kandangId] = &KandangGroupDTO{
Id: kandangId, Id: kandangId,
KandangId: kandangId,
Name: kandangName, Name: kandangName,
Pengajuans: []ExpenseNonstockDTO{}, Pengajuans: []ExpenseNonstockDTO{},
Realisasi: []ExpenseRealizationDTO{}, Realisasi: []ExpenseRealizationDTO{},
} }
} }
kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p) kandangMap[kandangId].Pengajuans = append(kandangMap[kandangId].Pengajuans, p)
} else {
directPengajuans = append(directPengajuans, p)
} }
} }
@@ -309,13 +318,24 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
if kandangMap[kandangId] == nil { if kandangMap[kandangId] == nil {
kandangMap[kandangId] = &KandangGroupDTO{ kandangMap[kandangId] = &KandangGroupDTO{
Id: kandangId, Id: kandangId,
KandangId: kandangId,
Name: kandangName, Name: kandangName,
Pengajuans: []ExpenseNonstockDTO{}, Pengajuans: []ExpenseNonstockDTO{},
Realisasi: []ExpenseRealizationDTO{}, Realisasi: []ExpenseRealizationDTO{},
} }
} }
kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r)
} else {
}
}
// If there are direct expenses (without kandang), add them as a special entry with id=0
if len(directPengajuans) > 0 || len(directRealisasi) > 0 {
kandangMap[0] = &KandangGroupDTO{
Id: 0,
Name: "",
Pengajuans: directPengajuans,
Realisasi: directRealisasi,
} }
} }
+7 -1
View File
@@ -1,6 +1,7 @@
package expenses package expenses
import ( import (
"context"
"fmt" "fmt"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -31,15 +32,20 @@ func (ExpenseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
realizationRepo := rExpense.NewExpenseRealizationRepository(db) realizationRepo := rExpense.NewExpenseRealizationRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(fmt.Sprintf("failed to create document service: %v", err))
}
// Register workflow steps for EXPENSES approval // Register workflow steps for EXPENSES approval
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register expense approval workflow: %v", err)) panic(fmt.Sprintf("failed to register expense approval workflow: %v", err))
} }
expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, validate) expenseService := sExpense.NewExpenseService(expenseRepo, supplierRepo, nonstockRepo, approvalSvc, realizationRepo, projectFlockKandangRepo, documentSvc, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
ExpenseRoutes(router, userService, expenseService) ExpenseRoutes(router, userService, expenseService)
@@ -2,11 +2,9 @@ package service
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"mime/multipart"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
@@ -49,9 +47,10 @@ type expenseService struct {
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
RealizationRepository repository.ExpenseRealizationRepository RealizationRepository repository.ExpenseRealizationRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService
} }
func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) ExpenseService { func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService {
return &expenseService{ return &expenseService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -61,6 +60,7 @@ func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierR
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
RealizationRepository: realizationRepo, RealizationRepository: realizationRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
} }
} }
@@ -72,7 +72,13 @@ func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Nonstocks.Realization"). Preload("Nonstocks.Realization").
Preload("Nonstocks.ProjectFlockKandang.Kandang.Location"). Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
Preload("Nonstocks.Kandang"). Preload("Nonstocks.Kandang").
Preload("Nonstocks.Kandang.Location") Preload("Nonstocks.Kandang.Location").
Preload("Documents", func(db *gorm.DB) *gorm.DB {
return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense))
}).
Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB {
return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization))
})
} }
func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) { func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
@@ -139,11 +145,8 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
supplierID := uint(req.SupplierID) supplierID := uint(req.SupplierID)
supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) {
return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
}
if err := commonSvc.EnsureRelations(c.Context(), if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc}, commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists},
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -194,11 +197,47 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
} }
createdBy := uint64(actorID) createdBy := uint64(actorID)
hasKandang := false
for _, ens := range req.ExpenseNonstocks {
if ens.KandangID != nil {
hasKandang = true
break
}
}
var projectFlockIdJSON *string
if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) {
projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction)
activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
}
if len(activeProjectFlocks) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location")
}
projectFlockIDs := make([]uint64, len(activeProjectFlocks))
for i, pf := range activeProjectFlocks {
projectFlockIDs[i] = uint64(pf.Id)
}
projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
}
jsonStr := string(projectFlockIdsJSON)
projectFlockIdJSON = &jsonStr
}
expense = &entity.Expense{ expense = &entity.Expense{
ReferenceNumber: referenceNumber, ReferenceNumber: referenceNumber,
PoNumber: req.PoNumber, PoNumber: req.PoNumber,
Category: req.Category, Category: req.Category,
SupplierId: req.SupplierID, SupplierId: req.SupplierID,
LocationId: req.LocationID,
ProjectFlockId: projectFlockIdJSON,
TransactionDate: expenseDate, TransactionDate: expenseDate,
CreatedBy: createdBy, CreatedBy: createdBy,
} }
@@ -211,35 +250,36 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
for _, expenseNonstock := range req.ExpenseNonstocks { for _, expenseNonstock := range req.ExpenseNonstocks {
isAttachingToKandang := (expenseNonstock.KandangID != nil)
var projectFlockKandangId *uint64 var projectFlockKandangId *uint64
var kandangId *uint64
if req.Category == string(utils.ExpenseCategoryBOP) { if isAttachingToKandang {
kandangId = expenseNonstock.KandangID
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) if req.Category == string(utils.ExpenseCategoryBOP) {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
} }
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
} }
id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id } else {
kandangId = nil
projectFlockKandangId = nil
} }
for _, costItem := range expenseNonstock.CostItems { for _, costItem := range expenseNonstock.CostItems {
nonstockId := costItem.NonstockID nonstockId := costItem.NonstockID
var kandangId *uint64 newExpenseNonstock := &entity.ExpenseNonstock{
if req.Category == string(utils.ExpenseCategoryNonBOP) {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if req.Category == string(utils.ExpenseCategoryBOP) {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expense.Id, ExpenseId: &expense.Id,
ProjectFlockKandangId: projectFlockKandangId, ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId, KandangId: kandangId,
@@ -249,7 +289,7 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
Notes: costItem.Notes, Notes: costItem.Notes,
} }
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
} }
} }
@@ -269,9 +309,23 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, uint(expense.Id), req.Documents, false); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpense),
Index: &idx,
})
}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpense),
DocumentableID: expense.Id,
CreatedBy: &createdByUint,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
} }
} }
@@ -342,6 +396,11 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
updateBody["supplier_id"] = *req.SupplierID updateBody["supplier_id"] = *req.SupplierID
} }
if req.LocationID != nil {
locationID := uint(*req.LocationID)
updateBody["location_id"] = locationID
}
if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 { if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 {
responseDTO, err := s.GetOne(c, id) responseDTO, err := s.GetOne(c, id)
@@ -456,18 +515,26 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
for _, expenseNonstock := range *req.ExpenseNonstocks { for _, expenseNonstock := range *req.ExpenseNonstocks {
var projectFlockKandangId *uint64 var projectFlockKandangId *uint64
var kandangId *uint64
if updatedExpense.Category == string(utils.ExpenseCategoryBOP) { // Check if attaching to kandang
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx) if expenseNonstock.KandangID != nil {
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(expenseNonstock.KandangID)) kandangId = expenseNonstock.KandangID
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") // BOP with kandang: Get active project flock kandang
projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
} }
return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang") id := uint64(projectFlockKandang.Id)
projectFlockKandangId = &id
} }
id := uint64(projectFlockKandang.Id) // NON-BOP: projectFlockKandangId stays nil
projectFlockKandangId = &id
} }
for _, costItem := range expenseNonstock.CostItems { for _, costItem := range expenseNonstock.CostItems {
@@ -479,18 +546,8 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return err return err
} }
var kandangId *uint64
if updatedExpense.Category == string(utils.ExpenseCategoryNonBOP) {
id := uint64(expenseNonstock.KandangID)
kandangId = &id
} else if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
if projectFlockKandangId != nil {
kandangId = &expenseNonstock.KandangID
}
}
expenseId := uint64(id) expenseId := uint64(id)
expenseNonstock := &entity.ExpenseNonstock{ newExpenseNonstock := &entity.ExpenseNonstock{
ExpenseId: &expenseId, ExpenseId: &expenseId,
ProjectFlockKandangId: projectFlockKandangId, ProjectFlockKandangId: projectFlockKandangId,
KandangId: kandangId, KandangId: kandangId,
@@ -500,7 +557,7 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
Notes: costItem.Notes, Notes: costItem.Notes,
} }
if err := expenseNonstockRepoTx.CreateOne(c.Context(), expenseNonstock, nil); err != nil { if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
} }
} }
@@ -527,9 +584,23 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
} }
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, id, req.Documents, false); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpense),
Index: &idx,
})
}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpense),
DocumentableID: uint64(id),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
} }
} }
@@ -658,9 +729,24 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpenseRealization),
Index: &idx,
})
}
actorID := uint(1) // TODO: replace with authenticated user id
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
DocumentableID: uint64(expenseID),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
} }
} }
@@ -833,9 +919,24 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
} }
} }
if len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
if err := s.processDocuments(c, expenseRepoTx, expenseID, req.Documents, true); err != nil { documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
return err for idx, file := range req.Documents {
documentFiles = append(documentFiles, commonSvc.DocumentFile{
File: file,
Type: string(utils.DocumentTypeExpenseRealization),
Index: &idx,
})
}
actorID := uint(1) // TODO: replace with authenticated user id
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeExpenseRealization),
DocumentableID: uint64(expenseID),
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
} }
} }
@@ -870,79 +971,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
return responseDTO, nil return responseDTO, nil
} }
func (s *expenseService) processDocuments(ctx *fiber.Ctx, expenseRepoTx repository.ExpenseRepository, expenseID uint, documents []*multipart.FileHeader, isRealization bool) error {
if len(documents) == 0 {
return nil
}
var existingDocuments []expenseDto.DocumentDTO
var fieldName string
if isRealization {
fieldName = "realization_document_path"
} else {
fieldName = "document_path"
}
expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document processing")
}
} else {
var documentField sql.NullString
if isRealization {
documentField = expense.RealizationDocumentPath
} else {
documentField = expense.DocumentPath
}
if documentField.Valid && documentField.String != "" {
if err := json.Unmarshal([]byte(documentField.String), &existingDocuments); err != nil {
existingDocuments = []expenseDto.DocumentDTO{}
}
}
}
var startID uint64 = 1
if len(existingDocuments) > 0 {
maxID := uint64(0)
for _, doc := range existingDocuments {
if doc.ID > maxID {
maxID = doc.ID
}
}
startID = maxID + 1
}
for i, doc := range documents {
documentPath := doc.Filename
document := expenseDto.DocumentDTO{
ID: startID + uint64(i),
Path: documentPath,
}
existingDocuments = append(existingDocuments, document)
}
documentJSON, err := json.Marshal(existingDocuments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{
fieldName: string(documentJSON),
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
return nil
}
func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error { func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error {
if err := commonSvc.EnsureRelations(ctx.Context(), if err := commonSvc.EnsureRelations(ctx.Context(),
@@ -951,62 +979,40 @@ func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, document
return err return err
} }
if err := s.Repository.DB().WithContext(ctx.Context()).Transaction(func(tx *gorm.DB) error { if s.DocumentSvc == nil {
expenseRepoTx := repository.NewExpenseRepository(tx) return fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
}
expense, err := expenseRepoTx.GetByID(ctx.Context(), expenseID, nil) // Verify document exists and belongs to the expense
if err != nil { var documentableType string
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense for document deletion") if isRealization {
documentableType = string(utils.DocumentableTypeExpenseRealization)
} else {
documentableType = string(utils.DocumentableTypeExpense)
}
documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID))
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents")
}
documentFound := false
var documentIDsToDelete []uint
for _, doc := range documents {
if uint64(doc.Id) == documentID {
documentFound = true
documentIDsToDelete = append(documentIDsToDelete, doc.Id)
break
} }
}
var existingDocuments []expenseDto.DocumentDTO if !documentFound {
var fieldName string return fiber.NewError(fiber.StatusNotFound, "Document not found")
}
if isRealization { // Delete document from database and storage
fieldName = "realization_document_path" if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil {
if expense.RealizationDocumentPath.Valid && expense.RealizationDocumentPath.String != "" { return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document")
if err := json.Unmarshal([]byte(expense.RealizationDocumentPath.String), &existingDocuments); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing realization documents")
}
}
} else {
fieldName = "document_path"
if expense.DocumentPath.Valid && expense.DocumentPath.String != "" {
if err := json.Unmarshal([]byte(expense.DocumentPath.String), &existingDocuments); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to parse existing documents")
}
}
}
var updatedDocuments []expenseDto.DocumentDTO
documentFound := false
for _, doc := range existingDocuments {
if doc.ID == documentID {
documentFound = true
continue
}
updatedDocuments = append(updatedDocuments, doc)
}
if !documentFound {
return fiber.NewError(fiber.StatusNotFound, "Document not found")
}
documentJSON, err := json.Marshal(updatedDocuments)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
if err := expenseRepoTx.PatchOne(ctx.Context(), expenseID, map[string]interface{}{
fieldName: string(documentJSON),
}, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to save documents")
}
return nil
}); err != nil {
return err
} }
return nil return nil
@@ -9,12 +9,13 @@ type Create struct {
TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"` TransactionDate string `form:"transaction_date" json:"transaction_date" validate:"required,datetime=2006-01-02"`
Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"` Category string `form:"category" json:"category" validate:"required,oneof=BOP NON-BOP"`
SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"` SupplierID uint64 `form:"supplier_id" json:"supplier_id" validate:"required,gt=0"`
LocationID uint64 `form:"location_id" json:"location_id" validate:"required,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"` ExpenseNonstocks []ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"required,min=1,dive"`
} }
type ExpenseNonstock struct { type ExpenseNonstock struct {
KandangID uint64 `form:"kandang_id" json:"kandang_id" validate:"required,gt=0"` KandangID *uint64 `form:"kandang_id" json:"kandang_id" validate:"omitempty"`
CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"` CostItems []CostItem `form:"cost_items" json:"cost_items" validate:"required,min=1,dive"`
} }
@@ -22,13 +23,14 @@ type CostItem struct {
NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"` NonstockID uint64 `form:"nonstock_id" json:"nonstock_id" validate:"required,gt=0"`
Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"` Quantity float64 `form:"quantity" json:"quantity" validate:"required,gt=0"`
Price float64 `form:"price" json:"price" validate:"required,gt=0"` Price float64 `form:"price" json:"price" validate:"required,gt=0"`
Notes string `form:"notes" json:"notes" validate:"required,max=500"` Notes string `form:"notes" json:"notes" validate:"omitempty,max=500"`
} }
type Update struct { type Update struct {
TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"` TransactionDate *string `form:"transaction_date" json:"transaction_date" validate:"omitempty,datetime=2006-01-02"`
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"`
LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"`
} }
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type InitialController struct {
InitialService service.InitialService
}
func NewInitialController(initialService service.InitialService) *InitialController {
return &InitialController{
InitialService: initialService,
}
}
func (u *InitialController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.InitialService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
func (u *InitialController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InitialService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
func (u *InitialController) 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.InitialService.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 initial successfully",
Data: dto.ToInitialListDTO(*result),
})
}
@@ -0,0 +1,163 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type InitialRelationDTO struct {
Id uint `json:"id"`
ReferenceNumber string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
InitialBalanceType string `json:"initial_balance_type"`
InitialBalanceTypeLabel string `json:"initial_balance_type_label"`
Party Party `json:"party"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
Direction string `json:"direction"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
}
type InitialListDTO struct {
InitialRelationDTO
CreatedBy uint `json:"created_by"`
CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type InitialDetailDTO struct {
InitialListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToInitialRelationDTO(e entity.Payment) InitialRelationDTO {
reference := ""
if e.ReferenceNumber != nil {
reference = *e.ReferenceNumber
}
initialBalanceType := initialBalanceTypeFromPayment(e)
return InitialRelationDTO{
Id: e.Id,
ReferenceNumber: reference,
TransactionType: transactionTypeLabel(e.TransactionType),
InitialBalanceType: initialBalanceType,
InitialBalanceTypeLabel: initialBalanceLabel(initialBalanceType),
Party: partyFromInitial(e),
Bank: bankFromInitial(e),
Direction: e.Direction,
Nominal: e.Nominal,
Notes: e.Notes,
}
}
func ToInitialListDTO(e entity.Payment) InitialListDTO {
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return InitialListDTO{
InitialRelationDTO: ToInitialRelationDTO(e),
CreatedBy: e.CreatedBy,
CreatedByUser: userFromInitial(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToInitialListDTOs(e []entity.Payment) []InitialListDTO {
result := make([]InitialListDTO, len(e))
for i, r := range e {
result[i] = ToInitialListDTO(r)
}
return result
}
func ToInitialDetailDTO(e entity.Payment) InitialDetailDTO {
return InitialDetailDTO{
InitialListDTO: ToInitialListDTO(e),
}
}
func partyFromInitial(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromInitial(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func transactionTypeLabel(transactionType string) string {
if strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal)) {
return "Saldo Awal"
}
return transactionType
}
func initialBalanceLabel(balanceType string) string {
switch strings.ToUpper(strings.TrimSpace(balanceType)) {
case "NEGATIVE":
return "Saldo Awal Negatif"
case "POSITIVE":
return "Saldo Awal Positif"
default:
return balanceType
}
}
func initialBalanceTypeFromPayment(e entity.Payment) string {
if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
}
@@ -0,0 +1,36 @@
package initials
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories"
sInitial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type InitialModule struct{}
func (InitialModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
initialRepo := rInitial.NewInitialRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register initial approval workflow: %v", err))
}
initialService := sInitial.NewInitialService(initialRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
InitialRoutes(router, userService, initialService)
}
@@ -0,0 +1,51 @@
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"
)
type InitialRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
CustomerExists(ctx context.Context, customerId uint) (bool, error)
SupplierExists(ctx context.Context, supplierId uint) (bool, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type InitialRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewInitialRepository(db *gorm.DB) InitialRepository {
return &InitialRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *InitialRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *InitialRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) {
return repository.Exists[entity.Customer](ctx, r.db, customerId)
}
func (r *InitialRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) {
return repository.Exists[entity.Supplier](ctx, r.db, supplierId)
}
func (r *InitialRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package initials
import (
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/controllers"
initial "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func InitialRoutes(v1 fiber.Router, u user.UserService, s initial.InitialService) {
ctrl := controller.NewInitialController(s)
route := v1.Group("/initial-balances")
// route.Use(m.Auth(u))
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
}
@@ -0,0 +1,336 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type InitialService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type initialService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.InitialRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewInitialService(
repo repository.InitialRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) InitialService {
return &initialService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowInitial,
}
}
func (s initialService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
initial, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if err != nil {
s.Log.Errorf("Failed get initial by id: %+v", err)
return nil, err
}
if !isInitialTransaction(initial.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for initial %d: %+v", id, err)
} else {
initial.LatestApproval = approval
}
}
return initial, nil
}
func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
party, err := normalizePartyType(req.PartyType)
if err != nil {
return nil, err
}
if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
balanceType, err := normalizeInitialBalanceType(req.InitialBalanceType)
if err != nil {
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generateInitialCode(c.Context())
if err != nil {
return nil, err
}
reference := req.ReferenceNumber
createBody := &entity.Payment{
PaymentCode: code,
ReferenceNumber: &reference,
TransactionType: string(utils.TransactionTypeSaldoAwal),
PartyType: party,
PartyId: req.PartyId,
PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: directionForInitialType(balanceType),
Nominal: signedNominal(balanceType, req.Nominal),
Notes: req.Note,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
initialRepoTx := repository.NewInitialRepository(dbTransaction)
if err := initialRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.InitialStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create initial: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.ReferenceNumber != nil {
updateBody["reference_number"] = *req.ReferenceNumber
}
if req.Note != nil {
updateBody["notes"] = *req.Note
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil
requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil
var existing *entity.Payment
if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
if err != nil {
s.Log.Errorf("Failed get initial by id: %+v", err)
return nil, err
}
if !isInitialTransaction(current.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
existing = current
}
if req.PartyType != nil || req.PartyId != nil {
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType)
if err != nil {
return nil, err
}
partyType = normalized
updateBody["party_type"] = partyType
}
if req.PartyId != nil {
partyId = *req.PartyId
updateBody["party_id"] = partyId
}
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil {
return nil, err
}
}
if req.InitialBalanceType != nil || req.Nominal != nil {
balanceType := balanceTypeFromPayment(existing)
if req.InitialBalanceType != nil {
normalized, err := normalizeInitialBalanceType(*req.InitialBalanceType)
if err != nil {
return nil, err
}
balanceType = normalized
}
nominal := math.Abs(existing.Nominal)
if req.Nominal != nil {
nominal = *req.Nominal
}
updateBody["direction"] = directionForInitialType(balanceType)
updateBody["nominal"] = signedNominal(balanceType, nominal)
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
s.Log.Errorf("Failed to update initial: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func isInitialTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeSaldoAwal))
}
func balanceTypeFromPayment(payment *entity.Payment) string {
if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
}
func normalizePartyType(partyType string) (string, error) {
party := strings.ToUpper(strings.TrimSpace(partyType))
if !utils.IsValidPaymentParty(party) {
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
return party, nil
}
func normalizeInitialBalanceType(balanceType string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(balanceType))
switch normalized {
case "NEGATIVE", "POSITIVE":
return normalized, nil
default:
return "", utils.BadRequest("`initial_balance_type` must be `NEGATIVE` or `POSITIVE`")
}
}
func directionForInitialType(balanceType string) string {
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
}
func signedNominal(balanceType string, nominal float64) float64 {
normalized := math.Abs(nominal)
if strings.EqualFold(balanceType, "NEGATIVE") {
return -normalized
}
return normalized
}
func (s initialService) generateInitialCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("INIT-%05d", sequence), nil
}
func (s initialService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s initialService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists},
)
case utils.PaymentPartySupplier:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists},
)
default:
return utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
@@ -0,0 +1,27 @@
package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"`
InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Note string `json:"note" validate:"required_strict,max=500"`
}
type Update struct {
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty" validate:"omitempty,max=100"`
InitialBalanceType *string `json:"initial_balance_type,omitempty" validate:"omitempty,oneof=NEGATIVE POSITIVE"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Note *string `json:"note,omitempty" validate:"omitempty,max=500"`
}
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"`
}
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type InjectionController struct {
InjectionService service.InjectionService
}
func NewInjectionController(injectionService service.InjectionService) *InjectionController {
return &InjectionController{
InjectionService: injectionService,
}
}
func (u *InjectionController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.InjectionService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get injection successfully",
Data: dto.ToInjectionListDTO(*result),
})
}
func (u *InjectionController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.InjectionService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Balance injection created successfully",
Data: dto.ToInjectionListDTO(*result),
})
}
func (u *InjectionController) 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.InjectionService.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 injection successfully",
Data: dto.ToInjectionListDTO(*result),
})
}
@@ -0,0 +1,102 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type InjectionRelationDTO struct {
Id uint `json:"id"`
TransactionType string `json:"transaction_type"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
AdjustmentDate string `json:"adjustment_date"`
Direction string `json:"direction"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
}
type InjectionListDTO struct {
InjectionRelationDTO
CreatedBy uint `json:"created_by"`
CreatedByUser userDTO.UserRelationDTO `json:"created_by_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type InjectionDetailDTO struct {
InjectionListDTO
}
// === Mapper Functions ===
func ToInjectionRelationDTO(e entity.Payment) InjectionRelationDTO {
return InjectionRelationDTO{
Id: e.Id,
TransactionType: transactionTypeLabel(e.TransactionType),
Bank: bankFromInjection(e),
AdjustmentDate: utils.FormatDate(e.PaymentDate),
Direction: e.Direction,
Nominal: e.Nominal,
Notes: e.Notes,
}
}
func ToInjectionListDTO(e entity.Payment) InjectionListDTO {
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return InjectionListDTO{
InjectionRelationDTO: ToInjectionRelationDTO(e),
CreatedBy: e.CreatedBy,
CreatedByUser: userFromInjection(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToInjectionListDTOs(e []entity.Payment) []InjectionListDTO {
result := make([]InjectionListDTO, len(e))
for i, r := range e {
result[i] = ToInjectionListDTO(r)
}
return result
}
func ToInjectionDetailDTO(e entity.Payment) InjectionDetailDTO {
return InjectionDetailDTO{
InjectionListDTO: ToInjectionListDTO(e),
}
}
func bankFromInjection(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromInjection(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func transactionTypeLabel(transactionType string) string {
if strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) {
return "Injection"
}
return transactionType
}
@@ -0,0 +1,36 @@
package injections
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories"
sInjection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type InjectionModule struct{}
func (InjectionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
injectionRepo := rInjection.NewInjectionRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register injection approval workflow: %v", err))
}
injectionService := sInjection.NewInjectionService(injectionRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
InjectionRoutes(router, userService, injectionService)
}
@@ -0,0 +1,41 @@
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"
)
type InjectionRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type InjectionRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewInjectionRepository(db *gorm.DB) InjectionRepository {
return &InjectionRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *InjectionRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *InjectionRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package injections
import (
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/controllers"
injection "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func InjectionRoutes(v1 fiber.Router, u user.UserService, s injection.InjectionService) {
ctrl := controller.NewInjectionController(s)
route := v1.Group("/injections")
// route.Use(m.Auth(u))
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
}
@@ -0,0 +1,230 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type InjectionService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type injectionService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.InjectionRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewInjectionService(
repo repository.InjectionRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) InjectionService {
return &injectionService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowInjection,
}
}
func (s injectionService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse")
}
func (s injectionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
injection, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
if err != nil {
s.Log.Errorf("Failed get injection by id: %+v", err)
return nil, err
}
if !isInjectionTransaction(injection.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for injection %d: %+v", id, err)
} else {
injection.LatestApproval = approval
}
}
return injection, nil
}
func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
adjustmentDate, err := utils.ParseDateString(req.AdjustmentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generateInjectionCode(c.Context())
if err != nil {
return nil, err
}
createBody := &entity.Payment{
PaymentCode: code,
TransactionType: string(utils.TransactionTypeInjection),
PartyType: string(utils.PaymentPartyCustomer),
PartyId: 0,
PaymentDate: adjustmentDate,
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: "IN",
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
injectionRepoTx := repository.NewInjectionRepository(dbTransaction)
if err := injectionRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.InjectionStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create injection: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
requiresVerification := req.BankId != nil || req.AdjustmentDate != nil || req.Nominal != nil || req.Notes != nil
if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
if err != nil {
s.Log.Errorf("Failed get injection by id: %+v", err)
return nil, err
}
if !isInjectionTransaction(current.TransactionType) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
if req.AdjustmentDate != nil {
parsedDate, err := utils.ParseDateString(*req.AdjustmentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
updateBody["payment_date"] = parsedDate
}
if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal
}
if req.Notes != nil {
updateBody["notes"] = *req.Notes
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Injection not found")
}
s.Log.Errorf("Failed to update injection: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func isInjectionTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection))
}
func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("INJ-%05d", sequence), nil
}
func (s injectionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s injectionService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
@@ -0,0 +1,21 @@
package validation
type Create struct {
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
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"`
}
+13
View File
@@ -0,0 +1,13 @@
package finance
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type FinanceModule struct{}
func (FinanceModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
RegisterRoutes(router, db, validate)
}
@@ -0,0 +1,92 @@
package controller
import (
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type PaymentController struct {
PaymentService service.PaymentService
}
func NewPaymentController(paymentService service.PaymentService) *PaymentController {
return &PaymentController{
PaymentService: paymentService,
}
}
func (u *PaymentController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.PaymentService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get payment successfully",
Data: dto.ToPaymentListDTO(*result),
})
}
func (u *PaymentController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.PaymentService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create payment successfully",
Data: dto.ToPaymentListDTO(*result),
})
}
func (u *PaymentController) 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.PaymentService.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 payment successfully",
Data: dto.ToPaymentListDTO(*result),
})
}
@@ -0,0 +1,189 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type PaymentRelationDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number,omitempty"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"`
}
type PaymentListDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type PaymentDetailDTO struct {
PaymentListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToPaymentRelationDTO(e entity.Payment) PaymentRelationDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
return PaymentRelationDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
}
}
func ToPaymentListDTO(e entity.Payment) PaymentListDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return PaymentListDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToPaymentListDTOs(e []entity.Payment) []PaymentListDTO {
result := make([]PaymentListDTO, len(e))
for i, r := range e {
result[i] = ToPaymentListDTO(r)
}
return result
}
func ToPaymentDetailDTO(e entity.Payment) PaymentDetailDTO {
return PaymentDetailDTO{
PaymentListDTO: ToPaymentListDTO(e),
}
}
func partyFromPayment(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func paymentCodeFromPayment(e entity.Payment) string {
if e.PaymentCode != "" {
return e.PaymentCode
}
if e.ReferenceNumber != nil {
return *e.ReferenceNumber
}
return ""
}
func transactionTypeFromPayment(e entity.Payment) string {
if e.TransactionType != "" {
return e.TransactionType
}
return e.Direction
}
func paymentAmounts(direction string, nominal float64) (float64, float64) {
switch strings.ToUpper(direction) {
case "IN":
return 0, nominal
case "OUT":
return nominal, 0
default:
return 0, 0
}
}
@@ -0,0 +1,36 @@
package payments
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gorm.io/gorm"
rPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories"
sPayment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type PaymentModule struct{}
func (PaymentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
paymentRepo := rPayment.NewPaymentRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register payment approval workflow: %v", err))
}
paymentService := sPayment.NewPaymentService(paymentRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
PaymentRoutes(router, userService, paymentService)
}
@@ -0,0 +1,62 @@
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"
)
type PaymentRepository interface {
repository.BaseRepository[entity.Payment]
BankExists(ctx context.Context, bankId uint) (bool, error)
CustomerExists(ctx context.Context, customerId uint) (bool, error)
SupplierExists(ctx context.Context, supplierId uint) (bool, error)
SupplierCategory(ctx context.Context, supplierId uint) (string, error)
NextPaymentSequence(ctx context.Context) (int64, error)
}
type PaymentRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
db *gorm.DB
}
func NewPaymentRepository(db *gorm.DB) PaymentRepository {
return &PaymentRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
db: db,
}
}
func (r *PaymentRepositoryImpl) BankExists(ctx context.Context, bankId uint) (bool, error) {
return repository.Exists[entity.Bank](ctx, r.db, bankId)
}
func (r *PaymentRepositoryImpl) CustomerExists(ctx context.Context, customerId uint) (bool, error) {
return repository.Exists[entity.Customer](ctx, r.db, customerId)
}
func (r *PaymentRepositoryImpl) SupplierExists(ctx context.Context, supplierId uint) (bool, error) {
return repository.Exists[entity.Supplier](ctx, r.db, supplierId)
}
func (r *PaymentRepositoryImpl) SupplierCategory(ctx context.Context, supplierId uint) (string, error) {
var supplier entity.Supplier
if err := r.db.WithContext(ctx).
Select("id", "category").
First(&supplier, supplierId).Error; err != nil {
return "", err
}
return supplier.Category, nil
}
func (r *PaymentRepositoryImpl) NextPaymentSequence(ctx context.Context) (int64, error) {
var next int64
if err := r.db.WithContext(ctx).
Raw("SELECT nextval('payments_code_seq')").
Scan(&next).Error; err != nil {
return 0, err
}
return next, nil
}
@@ -0,0 +1,21 @@
package payments
import (
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/controllers"
payment "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func PaymentRoutes(v1 fiber.Router, u user.UserService, s payment.PaymentService) {
ctrl := controller.NewPaymentController(s)
route := v1.Group("/payments")
// route.Use(m.Auth(u))
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
}
@@ -0,0 +1,362 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type PaymentService interface {
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Payment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error)
}
type paymentService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.PaymentRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflow approvalutils.ApprovalWorkflowKey
}
func NewPaymentService(
repo repository.PaymentRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) PaymentService {
return &paymentService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflow: utils.ApprovalWorkflowPayment,
}
}
func (s paymentService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s paymentService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
payment, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
if err != nil {
s.Log.Errorf("Failed get payment by id: %+v", err)
return nil, err
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approval for payment %d: %+v", id, err)
} else {
payment.LatestApproval = approval
}
}
return payment, nil
}
func (s *paymentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
//! CHECK PARTY TYPE
party, err := normalizePartyType(req.PartyType)
if err != nil {
return nil, err
}
//! CHECK EXISTS
if err := s.ensurePartyExists(c.Context(), party, req.PartyId); err != nil {
return nil, err
}
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
//? NORMALIZE
paymentDate, err := utils.ParseDateString(req.PaymentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
method, err := normalizePaymentMethod(req.PaymentMethod)
if err != nil {
return nil, err
}
transactionType, err := s.resolveTransactionType(c.Context(), party, req.PartyId)
if err != nil {
return nil, err
}
//? GET CREATED BY
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
code, err := s.generatePaymentCode(c.Context(), party)
if err != nil {
return nil, err
}
createBody := &entity.Payment{
PaymentCode: code,
ReferenceNumber: req.ReferenceNumber,
TransactionType: transactionType,
PartyType: party,
PartyId: req.PartyId,
PaymentDate: paymentDate,
PaymentMethod: method,
BankId: req.BankId,
Direction: directionForParty(party),
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
paymentRepoTx := repository.NewPaymentRepository(dbTransaction)
if err := paymentRepoTx.CreateOne(c.Context(), createBody, nil); err != nil {
return err
}
if s.ApprovalSvc != nil {
action := entity.ApprovalActionCreated
approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
_, err := approvalSvcTx.CreateApproval(
c.Context(),
s.approvalWorkflow,
createBody.Id,
utils.PaymentStepPengajuan,
&action,
actorID,
nil,
)
if err != nil {
return err
}
}
return nil
})
if err != nil {
s.Log.Errorf("Failed to create payment: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s paymentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.PaymentDate != nil {
parsedDate, err := utils.ParseDateString(*req.PaymentDate)
if err != nil {
return nil, utils.BadRequest(err.Error())
}
updateBody["payment_date"] = parsedDate
}
if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal
}
if req.ReferenceNumber != nil {
updateBody["reference_number"] = *req.ReferenceNumber
}
if req.PaymentMethod != nil {
method, err := normalizePaymentMethod(*req.PaymentMethod)
if err != nil {
return nil, err
}
updateBody["payment_method"] = method
}
if req.BankId != nil {
if err := s.ensureBankExists(c.Context(), req.BankId); err != nil {
return nil, err
}
updateBody["bank_id"] = *req.BankId
}
if req.Notes != nil {
updateBody["notes"] = *req.Notes
}
if req.PartyType != nil || req.PartyId != nil {
existing, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
if err != nil {
s.Log.Errorf("Failed get payment by id: %+v", err)
return nil, err
}
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType)
if err != nil {
return nil, err
}
partyType = normalized
updateBody["party_type"] = partyType
updateBody["direction"] = directionForParty(partyType)
}
if req.PartyId != nil {
partyId = *req.PartyId
updateBody["party_id"] = partyId
}
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil {
return nil, err
}
transactionType, err := s.resolveTransactionType(c.Context(), partyType, partyId)
if err != nil {
return nil, err
}
updateBody["transaction_type"] = transactionType
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Payment not found")
}
s.Log.Errorf("Failed to update payment: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func normalizePartyType(partyType string) (string, error) {
party := strings.ToUpper(strings.TrimSpace(partyType))
if !utils.IsValidPaymentParty(party) {
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
return party, nil
}
func normalizePaymentMethod(method string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(method))
if !utils.IsValidPaymentMethod(normalized) {
return "", utils.BadRequest("Invalid payment_method")
}
return normalized, nil
}
func directionForParty(partyType string) string {
if utils.PaymentParty(partyType) == utils.PaymentPartyCustomer {
return "IN"
}
return "OUT"
}
func (s paymentService) resolveTransactionType(ctx context.Context, partyType string, partyId uint) (string, error) {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return string(utils.TransactionTypePenjualan), nil
case utils.PaymentPartySupplier:
category, err := s.getSupplierCategory(ctx, partyId)
if err != nil {
return "", err
}
if isSupplierCategoryBiaya(category) {
return string(utils.TransactionTypeBiaya), nil
}
return string(utils.TransactionTypePembelian), nil
default:
return "", utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s paymentService) generatePaymentCode(ctx context.Context, partyType string) (string, error) {
prefix := "PAY"
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
prefix = "PAY-IN"
case utils.PaymentPartySupplier:
prefix = "PAY-OUT"
}
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
return "", err
}
return fmt.Sprintf("%s-%05d", prefix, sequence), nil
}
func (s paymentService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func (s paymentService) ensurePartyExists(ctx context.Context, partyType string, partyId uint) error {
switch utils.PaymentParty(partyType) {
case utils.PaymentPartyCustomer:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Customer", ID: &partyId, Exists: s.Repository.CustomerExists},
)
case utils.PaymentPartySupplier:
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Supplier", ID: &partyId, Exists: s.Repository.SupplierExists},
)
default:
return utils.BadRequest("`party_type` must be `customer` or `supplier`")
}
}
func (s paymentService) ensureBankExists(ctx context.Context, bankId *uint) error {
return commonSvc.EnsureRelations(ctx,
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
func (s paymentService) getSupplierCategory(ctx context.Context, supplierId uint) (string, error) {
category, err := s.Repository.SupplierCategory(ctx, supplierId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", utils.NotFound("Supplier not found")
}
return "", err
}
return strings.ToUpper(strings.TrimSpace(category)), nil
}
func isSupplierCategoryBiaya(category string) bool {
switch strings.ToUpper(strings.TrimSpace(category)) {
case string(utils.SupplierCategoryBOP), "BIAYA":
return true
default:
return false
}
}
@@ -0,0 +1,29 @@
package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,min=1,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
PaymentDate string `json:"payment_date" validate:"required_strict,datetime=2006-01-02"`
Nominal float64 `json:"nominal" validate:"required_strict"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod string `json:"payment_method" validate:"required_strict,max=20"`
BankId *uint `json:"bank_id" validate:"omitempty,number,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
PartyType *string `json:"party_type,omitempty" validate:"omitempty,max=50"`
PartyId *uint `json:"party_id,omitempty" validate:"omitempty,number,gt=0"`
PaymentDate *string `json:"payment_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
ReferenceNumber *string `json:"reference_number,omitempty"`
PaymentMethod *string `json:"payment_method,omitempty" validate:"omitempty,max=20"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
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"`
}
+31
View File
@@ -0,0 +1,31 @@
package finance
import (
"gitlab.com/mbugroup/lti-api.git/internal/modules"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
payments "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/payments"
initials "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/initials"
injections "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/injections"
transactions "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions"
// MODULE IMPORTS
)
func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
group := router.Group("/finance")
allModules := []modules.Module{
payments.PaymentModule{},
initials.InitialModule{},
injections.InjectionModule{},
transactions.TransactionModule{},
// MODULE REGISTRY
}
for _, m := range allModules {
m.RegisterRoutes(group, db, validate)
}
}
@@ -0,0 +1,96 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type TransactionController struct {
TransactionService service.TransactionService
}
func NewTransactionController(transactionService service.TransactionService) *TransactionController {
return &TransactionController{
TransactionService: transactionService,
}
}
func (u *TransactionController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.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 := u.TransactionService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.TransactionListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all transactions successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToTransactionListDTOs(result),
})
}
func (u *TransactionController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.TransactionService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get transaction successfully",
Data: dto.ToTransactionListDTO(*result),
})
}
func (u *TransactionController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.TransactionService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete transaction successfully",
})
}
@@ -0,0 +1,189 @@
package dto
import (
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
bankDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === DTO Structs ===
type TransactionRelationDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number,omitempty"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user,omitempty"`
}
type TransactionListDTO struct {
Id uint `json:"id"`
PaymentCode string `json:"payment_code"`
ReferenceNumber *string `json:"reference_number"`
TransactionType string `json:"transaction_type"`
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
CreatedUser userDTO.UserRelationDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Approval approvalDTO.ApprovalRelationDTO `json:"approval"`
}
type TransactionDetailDTO struct {
TransactionListDTO
}
type Party struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
AccountNumber string `json:"account_number"`
}
// === Mapper Functions ===
func ToTransactionRelationDTO(e entity.Payment) TransactionRelationDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
return TransactionRelationDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
}
}
func ToTransactionListDTO(e entity.Payment) TransactionListDTO {
expenseAmount, incomeAmount := paymentAmounts(e.Direction, e.Nominal)
approval := approvalDTO.ApprovalRelationDTO{}
if e.LatestApproval != nil {
approval = approvalDTO.ToApprovalDTO(*e.LatestApproval)
}
return TransactionListDTO{
Id: e.Id,
PaymentCode: paymentCodeFromPayment(e),
ReferenceNumber: e.ReferenceNumber,
TransactionType: transactionTypeFromPayment(e),
Party: partyFromPayment(e),
PaymentDate: e.PaymentDate,
PaymentMethod: e.PaymentMethod,
Bank: bankFromPayment(e),
ExpenseAmount: expenseAmount,
IncomeAmount: incomeAmount,
Nominal: e.Nominal,
Notes: e.Notes,
CreatedUser: userFromPayment(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Approval: approval,
}
}
func ToTransactionListDTOs(e []entity.Payment) []TransactionListDTO {
result := make([]TransactionListDTO, len(e))
for i, r := range e {
result[i] = ToTransactionListDTO(r)
}
return result
}
func ToTransactionDetailDTO(e entity.Payment) TransactionDetailDTO {
return TransactionDetailDTO{
TransactionListDTO: ToTransactionListDTO(e),
}
}
func partyFromPayment(e entity.Payment) Party {
party := Party{
Id: e.PartyId,
Type: e.PartyType,
}
switch utils.PaymentParty(e.PartyType) {
case utils.PaymentPartyCustomer:
if e.Customer != nil && e.Customer.Id != 0 {
party.Name = e.Customer.Name
party.AccountNumber = e.Customer.AccountNumber
}
case utils.PaymentPartySupplier:
if e.Supplier != nil && e.Supplier.Id != 0 {
party.Name = e.Supplier.Name
if e.Supplier.AccountNumber != nil {
party.AccountNumber = *e.Supplier.AccountNumber
}
}
}
return party
}
func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return bankDTO.BankRelationDTO{}
}
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
if e.CreatedUser.Id == 0 {
return userDTO.UserRelationDTO{}
}
return userDTO.ToUserRelationDTO(e.CreatedUser)
}
func paymentCodeFromPayment(e entity.Payment) string {
if e.PaymentCode != "" {
return e.PaymentCode
}
if e.ReferenceNumber != nil {
return *e.ReferenceNumber
}
return ""
}
func transactionTypeFromPayment(e entity.Payment) string {
if e.TransactionType != "" {
return e.TransactionType
}
return e.Direction
}
func paymentAmounts(direction string, nominal float64) (float64, float64) {
switch strings.ToUpper(direction) {
case "IN":
return 0, nominal
case "OUT":
return nominal, 0
default:
return 0, 0
}
}
@@ -0,0 +1,42 @@
package transactions
import (
"fmt"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
rTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories"
sTransaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type TransactionModule struct{}
func (TransactionModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
transactionRepo := rTransaction.NewTransactionRepository(db)
userRepo := rUser.NewUserRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPayment, utils.PaymentApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register payment approval workflow: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInitial, utils.InitialApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register initial approval workflow: %v", err))
}
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowInjection, utils.InjectionApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register injection approval workflow: %v", err))
}
transactionService := sTransaction.NewTransactionService(transactionRepo, approvalService, validate)
userService := sUser.NewUserService(userRepo, validate)
TransactionRoutes(router, userService, transactionService)
}
@@ -0,0 +1,21 @@
package repository
import (
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type TransactionRepository interface {
repository.BaseRepository[entity.Payment]
}
type TransactionRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Payment]
}
func NewTransactionRepository(db *gorm.DB) TransactionRepository {
return &TransactionRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Payment](db),
}
}
@@ -0,0 +1,21 @@
package transactions
import (
// m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
controller "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/controllers"
transaction "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"github.com/gofiber/fiber/v2"
)
func TransactionRoutes(v1 fiber.Router, u user.UserService, s transaction.TransactionService) {
ctrl := controller.NewTransactionController(s)
route := v1.Group("/transactions")
// route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll)
route.Get("/:id", ctrl.GetOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -0,0 +1,175 @@
package service
import (
"context"
"errors"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type TransactionService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Payment, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
}
type transactionService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.TransactionRepository
ApprovalSvc commonSvc.ApprovalService
approvalWorkflows map[string]approvalutils.ApprovalWorkflowKey
}
func NewTransactionService(
repo repository.TransactionRepository,
approvalSvc commonSvc.ApprovalService,
validate *validator.Validate,
) TransactionService {
return &transactionService{
Log: utils.Log,
Validate: validate,
Repository: repo,
ApprovalSvc: approvalSvc,
approvalWorkflows: map[string]approvalutils.ApprovalWorkflowKey{
string(utils.TransactionTypeSaldoAwal): utils.ApprovalWorkflowInitial,
string(utils.TransactionTypeInjection): utils.ApprovalWorkflowInjection,
},
}
}
func (s transactionService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser").
Preload("BankWarehouse").
Preload("Customer").
Preload("Supplier")
}
func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Payment, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
return db.Where(
`LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ?`,
like, like, like, like,
)
}
return db.Order("payment_date DESC").Order("created_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get transactions: %+v", err)
return nil, 0, err
}
s.attachApprovals(c.Context(), transactions)
return transactions, total, nil
}
func (s transactionService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
transaction, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transaction not found")
}
if err != nil {
s.Log.Errorf("Failed get transaction by id: %+v", err)
return nil, err
}
if s.ApprovalSvc != nil {
approval, err := s.ApprovalSvc.LatestByTarget(
c.Context(),
s.workflowForTransaction(transaction),
id,
s.approvalQueryModifier(),
)
if err != nil {
s.Log.Warnf("Unable to load latest approval for transaction %d: %+v", id, err)
} else {
transaction.LatestApproval = approval
}
}
return transaction, nil
}
func (s transactionService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "Transaction not found")
}
s.Log.Errorf("Failed to delete transaction: %+v", err)
return err
}
return nil
}
func (s transactionService) attachApprovals(ctx context.Context, transactions []entity.Payment) {
if s.ApprovalSvc == nil || len(transactions) == 0 {
return
}
workflowIDs := map[approvalutils.ApprovalWorkflowKey][]uint{}
for _, transaction := range transactions {
workflow := s.workflowForTransaction(&transaction)
workflowIDs[workflow] = append(workflowIDs[workflow], transaction.Id)
}
approvalByWorkflow := make(map[approvalutils.ApprovalWorkflowKey]map[uint]*entity.Approval, len(workflowIDs))
for workflow, ids := range workflowIDs {
if len(ids) == 0 {
continue
}
approvals, err := s.ApprovalSvc.LatestByTargets(ctx, workflow, ids, s.approvalQueryModifier())
if err != nil {
s.Log.Warnf("Unable to load latest approvals for transactions: %+v", err)
continue
}
approvalByWorkflow[workflow] = approvals
}
for i := range transactions {
workflow := s.workflowForTransaction(&transactions[i])
if approvals, ok := approvalByWorkflow[workflow]; ok {
transactions[i].LatestApproval = approvals[transactions[i].Id]
}
}
}
func (s transactionService) workflowForTransaction(transaction *entity.Payment) approvalutils.ApprovalWorkflowKey {
if transaction == nil {
return utils.ApprovalWorkflowPayment
}
transactionType := strings.TrimSpace(strings.ToUpper(transaction.TransactionType))
if transactionType == "" {
return utils.ApprovalWorkflowPayment
}
if workflow, ok := s.approvalWorkflows[transactionType]; ok {
return workflow
}
return utils.ApprovalWorkflowPayment
}
func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
@@ -0,0 +1,15 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
}
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"`
}
@@ -5,6 +5,9 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" 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" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
@@ -13,19 +16,67 @@ import (
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
) )
type AdjustmentModule struct{} type AdjustmentModule struct{}
func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
// Repositories
stockLogsRepo := rStockLogs.NewStockLogRepository(db) stockLogsRepo := rStockLogs.NewStockLogRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
productRepo := rproduct.NewProductRepository(db) productRepo := rproduct.NewProductRepository(db)
adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKey("ADJUSTMENT_IN"),
Table: "adjustment_stocks",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error())
}
err = fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKey("ADJUSTMENT_OUT"),
Table: "adjustment_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error())
}
adjustmentService := sAdjustment.NewAdjustmentService(
productRepo,
stockLogsRepo,
warehouseRepo,
productWarehouseRepo,
adjustmentStockRepo,
fifoService,
validate,
projectFlockKandangRepo,
)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
AdjustmentRoutes(router, userService, adjustmentService) AdjustmentRoutes(router, userService, adjustmentService)
@@ -0,0 +1,50 @@
package repositories
import (
"context"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
}
type adjustmentStockRepositoryImpl struct {
db *gorm.DB
}
func NewAdjustmentStockRepository(db *gorm.DB) AdjustmentStockRepository {
return &adjustmentStockRepositoryImpl{db: db}
}
func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error {
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
return q.Create(data).Error
}
func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
err := r.db.WithContext(ctx).
Where("stock_log_id = ?", stockLogID).
First(&record).Error
if err != nil {
return nil, err
}
return &record, nil
}
func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository {
return &adjustmentStockRepositoryImpl{db: tx}
}
func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB {
return r.db
}
@@ -12,6 +12,7 @@ import (
common "gitlab.com/mbugroup/lti-api.git/internal/common/service" common "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
adjustmentStockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations"
ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" 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" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
@@ -29,24 +30,37 @@ type AdjustmentService interface {
} }
type adjustmentService struct { type adjustmentService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
ProductRepo productRepo.ProductRepository ProductRepo productRepo.ProductRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository
FifoSvc common.FifoService
} }
func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { func NewAdjustmentService(
productRepo productRepo.ProductRepository,
stockLogsRepo stockLogsRepo.StockLogRepository,
warehouseRepo warehouseRepo.WarehouseRepository,
productWarehouseRepo ProductWarehouse.ProductWarehouseRepository,
adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository,
fifoSvc common.FifoService,
validate *validator.Validate,
projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository,
) AdjustmentService {
return &adjustmentService{ return &adjustmentService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
StockLogsRepository: stockLogsRepo, StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
ProductRepo: productRepo, ProductRepo: productRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
AdjustmentStockRepository: adjustmentStockRepo,
FifoSvc: fifoSvc,
} }
} }
@@ -70,7 +84,7 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err
return nil, err return nil, err
} }
if stockLog.LoggableType != entity.LogTypeAdjustment { if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
} }
@@ -97,7 +111,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
} }
transactionType := strings.ToUpper(req.TransactionType) transactionType := strings.ToUpper(req.TransactionType)
if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type")
} }
@@ -152,16 +166,17 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
} }
// Create StockLog for history tracking
afterQuantity := productWarehouse.Quantity afterQuantity := productWarehouse.Quantity
newLog := &entity.StockLog{ newLog := &entity.StockLog{
// TransactionType: transactionType, LoggableType: string(utils.StockLogTypeAdjustment),
LoggableType: entity.LogTypeAdjustment,
LoggableId: 0, LoggableId: 0,
Notes: req.Note, Notes: req.Note,
ProductWarehouseId: productWarehouse.Id, ProductWarehouseId: productWarehouse.Id,
CreatedBy: actorID, // TODO: should Get from auth middleware CreatedBy: actorID,
} }
if transactionType == entity.TransactionTypeIncrease {
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
afterQuantity += req.Quantity afterQuantity += req.Quantity
newLog.Increase = afterQuantity newLog.Increase = afterQuantity
} else { } else {
@@ -177,6 +192,57 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return err return err
} }
// Create AdjustmentStock record for FIFO tracking
adjustmentStock := &entity.AdjustmentStock{
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id,
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
// Adjustment INCREASE → Replenish stock (Stockable)
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
replenishResult, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{
StockableKey: "ADJUSTMENT_IN",
StockableID: newLog.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err))
}
// Update stockable tracking fields
adjustmentStock.TotalQty = replenishResult.AddedQuantity
adjustmentStock.TotalUsed = 0
} else {
// Adjustment DECREASE → Consume stock (Usable)
consumeResult, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{
UsableKey: "ADJUSTMENT_OUT",
UsableID: newLog.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false, // Don't allow pending for adjustment
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err))
}
// Update usable tracking fields
adjustmentStock.UsageQty = consumeResult.UsageQuantity
adjustmentStock.PendingQty = consumeResult.PendingQuantity
}
// Save AdjustmentStock record
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
// Update ProductWarehouse quantity (for backward compatibility/reporting)
productWarehouse.Quantity = afterQuantity productWarehouse.Quantity = afterQuantity
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil {
s.Log.Errorf("Failed to update product warehouse quantity: %+v", err) s.Log.Errorf("Failed to update product warehouse quantity: %+v", err)
@@ -248,7 +314,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
db = s.withRelations(db) db = s.withRelations(db)
db = db.Where("loggable_type = ?", entity.LogTypeAdjustment) db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment))
if query.TransactionType != "" { if query.TransactionType != "" {
db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType))
@@ -24,11 +24,12 @@ type ProductWarehousNestedDTO struct {
type ProductWarehouseListDTO struct { type ProductWarehouseListDTO struct {
ProductWarehouseRelationDTO ProductWarehouseRelationDTO
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"` ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
type UserRelationDTO struct { type UserRelationDTO struct {
@@ -71,6 +72,19 @@ type AreaRelationDTO struct {
Name string `json:"name"` Name string `json:"name"`
} }
type ProjectFlockKandangRelationDTO struct {
Id uint `json:"id"`
ProjectFlockId uint `json:"project_flock_id"`
KandangId uint `json:"kandang_id"`
Period int `json:"period"`
ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"`
}
type ProjectFlockRelationDTO struct {
Id uint `json:"id"`
FlockName string `json:"flock_name"`
}
// === Mapper Functions === // === Mapper Functions ===
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO { func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
@@ -105,6 +119,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
// Map Product relation jika ada // Map Product relation jika ada
if e.Product.Id != 0 { if e.Product.Id != 0 {
product := productDTO.ToProductRelationDTO(e.Product) product := productDTO.ToProductRelationDTO(e.Product)
// Tambahkan flock name ke product name jika ada project flock
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
}
dto.Product = &product dto.Product = &product
} }
@@ -139,6 +159,26 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
dto.Warehouse = &warehouse dto.Warehouse = &warehouse
} }
// Map ProjectFlockKandang relation jika ada
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.Id != 0 {
pfkDTO := &ProjectFlockKandangRelationDTO{
Id: e.ProjectFlockKandang.Id,
ProjectFlockId: e.ProjectFlockKandang.ProjectFlockId,
KandangId: e.ProjectFlockKandang.KandangId,
Period: e.ProjectFlockKandang.Period,
}
// Map ProjectFlock jika ada
if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{
Id: e.ProjectFlockKandang.ProjectFlock.Id,
FlockName: e.ProjectFlockKandang.ProjectFlock.FlockName,
}
}
dto.ProjectFlockKandang = pfkDTO
}
// Map CreatedUser relation jika ada // Map CreatedUser relation jika ada
// if e.CreatedUser.Id != 0 { // if e.CreatedUser.Id != 0 {
// user := UserRelationDTO{ // user := UserRelationDTO{
@@ -81,9 +81,29 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWareho
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse var productWarehouse entity.ProductWarehouse
if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil {
err := r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NOT NULL", productId, warehouseId).
Order("id DESC").
Preload("ProjectFlockKandang").
First(&productWarehouse).Error
if err == nil {
if productWarehouse.ProjectFlockKandang.ClosedAt == nil {
return &productWarehouse, nil
}
}
err = r.DB().WithContext(ctx).
Where("product_id = ? AND warehouse_id = ? AND project_flock_kandang_id IS NULL", productId, warehouseId).
First(&productWarehouse).Error
if err != nil {
return nil, err return nil, err
} }
return &productWarehouse, nil return &productWarehouse, nil
} }
@@ -244,6 +264,8 @@ func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id u
Preload("Warehouse"). Preload("Warehouse").
Preload("Warehouse.Area"). Preload("Warehouse.Area").
Preload("Warehouse.Location"). Preload("Warehouse.Location").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock").
First(&productWarehouse, id).Error First(&productWarehouse, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -44,7 +44,8 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Warehouse.Location"). Preload("Warehouse.Location").
Preload("Warehouse.Area"). Preload("Warehouse.Area").
Preload("Warehouse.Kandang"). Preload("Warehouse.Kandang").
Preload("ProjectFlockKandang") Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock")
} }
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
@@ -68,7 +68,7 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error {
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get transfer successfully", Message: "Get transfer successfully",
Data: dto.ToTransferListDTO(*result), Data: dto.ToTransferDetailDTO(*result),
}) })
} }
@@ -80,15 +80,19 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
} }
// ambil file
form, err := c.MultipartForm() form, err := c.MultipartForm()
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
} }
_ = form.File["documents"]
// todo: tunggu ada aws baru proses
result, err := u.TransferService.CreateOne(c, &req) files := form.File["documents"]
if len(files) != len(req.Deliveries) {
return fiber.NewError(fiber.StatusBadRequest,
fiber.NewError(fiber.StatusBadRequest, "Jumlah dokumen harus sama dengan jumlah deliveries").Message)
}
result, err := u.TransferService.CreateOne(c, &req, files)
if err != nil { if err != nil {
return err return err
} }
@@ -98,6 +102,6 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
Code: fiber.StatusCreated, Code: fiber.StatusCreated,
Status: "success", Status: "success",
Message: "Create transfer successfully", Message: "Create transfer successfully",
Data: dto.ToTransferListDTO(*result), Data: dto.ToTransferDetailDTO(*result),
}) })
} }
@@ -7,8 +7,6 @@ import (
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
) )
// === DTO Structs ===
type TransferRelationDTO struct { type TransferRelationDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
TransferReason string `json:"transfer_reason"` TransferReason string `json:"transfer_reason"`
@@ -17,7 +15,6 @@ type TransferRelationDTO struct {
DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"`
} }
// Only id and name for warehouse simple view
type WarehouseSimpleDTO struct { type WarehouseSimpleDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -43,6 +40,14 @@ type SupplierSimpleDTO struct {
Name string `json:"name"` Name string `json:"name"`
} }
type DocumentDTO struct {
Id uint `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
Ext string `json:"ext"`
Size float64 `json:"size"`
}
type WarehouseDetailDTO struct { type WarehouseDetailDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -65,24 +70,22 @@ type TransferDetailDTO struct {
Deliveries []TransferDeliveryDTO `json:"deliveries"` Deliveries []TransferDeliveryDTO `json:"deliveries"`
} }
// Detail produk
type TransferDetailItemDTO struct { type TransferDetailItemDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Proudct ProductSimpleDTO `json:"product"` Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
} }
// Delivery ekspedisi
type TransferDeliveryDTO struct { type TransferDeliveryDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Supplier SupplierSimpleDTO `json:"supplier"` Supplier SupplierSimpleDTO `json:"supplier"`
VehiclePlate string `json:"vehicle_plate"` VehiclePlate string `json:"vehicle_plate"`
DriverName string `json:"driver_name"` DriverName string `json:"driver_name"`
DocumentNumber string `json:"document_number"` DocumentNumber string `json:"document_number"`
DocumentPath string `json:"document_path"`
ShippingCostItem float64 `json:"shipping_cost_item"` ShippingCostItem float64 `json:"shipping_cost_item"`
ShippingCostTotal float64 `json:"shipping_cost_total"` ShippingCostTotal float64 `json:"shipping_cost_total"`
Items []TransferDeliveryItemDTO `json:"items"` Items []TransferDeliveryItemDTO `json:"items"`
Document *DocumentDTO `json:"document,omitempty"`
} }
type TransferDeliveryItemDTO struct { type TransferDeliveryItemDTO struct {
@@ -91,10 +94,7 @@ type TransferDeliveryItemDTO struct {
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
} }
// === Mapper Functions ===
func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO {
var sourceWarehouse *WarehouseDetailDTO var sourceWarehouse *WarehouseDetailDTO
if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 {
sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse)
@@ -140,7 +140,7 @@ func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO {
Id: w.Id, Id: w.Id,
Name: w.Name, Name: w.Name,
Location: toLocationDTO(w.Location), Location: toLocationDTO(w.Location),
Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) Area: toAreaDTO(&w.Area),
} }
} }
@@ -150,22 +150,21 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) mapped := userDTO.ToUserRelationDTO(*e.CreatedUser)
createdUser = &mapped createdUser = &mapped
} }
// Map details
var details []TransferDetailItemDTO var details []TransferDetailItemDTO
for _, d := range e.Details { for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{ details = append(details, TransferDetailItemDTO{
Id: d.Id, Id: d.Id,
Proudct: ProductSimpleDTO{ Product: ProductSimpleDTO{
Id: d.Product.Id, Id: d.Product.Id,
Name: d.Product.Name, Name: d.Product.Name,
}, },
Quantity: d.Quantity, Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
}) })
} }
// Map deliveries
var deliveries []TransferDeliveryDTO var deliveries []TransferDeliveryDTO
for _, del := range e.Deliveries { for _, del := range e.Deliveries {
// Map delivery items
var items []TransferDeliveryItemDTO var items []TransferDeliveryItemDTO
for _, item := range del.Items { for _, item := range del.Items {
items = append(items, TransferDeliveryItemDTO{ items = append(items, TransferDeliveryItemDTO{
@@ -174,6 +173,19 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
Quantity: item.Quantity, Quantity: item.Quantity,
}) })
} }
var document *DocumentDTO
if len(del.Documents) > 0 {
doc := del.Documents[0] // Take first document
document = &DocumentDTO{
Id: doc.Id,
Path: doc.Path,
Name: doc.Name,
Ext: doc.Ext,
Size: doc.Size,
}
}
deliveries = append(deliveries, TransferDeliveryDTO{ deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id, Id: del.Id,
Supplier: SupplierSimpleDTO{ Supplier: SupplierSimpleDTO{
@@ -183,12 +195,13 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
VehiclePlate: del.VehiclePlate, VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName, DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber, DocumentNumber: del.DocumentNumber,
DocumentPath: del.DocumentPath,
ShippingCostItem: del.ShippingCostItem, ShippingCostItem: del.ShippingCostItem,
ShippingCostTotal: del.ShippingCostTotal, ShippingCostTotal: del.ShippingCostTotal,
Items: items, Items: items,
Document: document,
}) })
} }
return TransferListDTO{ return TransferListDTO{
TransferRelationDTO: ToTransferRelationDTO(e), TransferRelationDTO: ToTransferRelationDTO(e),
CreatedUser: createdUser, CreatedUser: createdUser,
@@ -208,21 +221,32 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO {
} }
func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
// Map details
var details []TransferDetailItemDTO var details []TransferDetailItemDTO
for _, d := range e.Details { for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{ details = append(details, TransferDetailItemDTO{
Id: d.Id, Id: d.Id,
Proudct: ProductSimpleDTO{ Product: ProductSimpleDTO{
Id: d.Product.Id, Id: d.Product.Id,
Name: d.Product.Name, Name: d.Product.Name,
}, },
Quantity: d.Quantity, Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
}) })
} }
// Map deliveries
var deliveries []TransferDeliveryDTO var deliveries []TransferDeliveryDTO
for _, del := range e.Deliveries { for _, del := range e.Deliveries {
var document *DocumentDTO
if len(del.Documents) > 0 {
doc := del.Documents[0] // Take first document
document = &DocumentDTO{
Id: doc.Id,
Path: doc.Path,
Name: doc.Name,
Ext: doc.Ext,
Size: doc.Size,
}
}
deliveries = append(deliveries, TransferDeliveryDTO{ deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id, Id: del.Id,
Supplier: SupplierSimpleDTO{ Supplier: SupplierSimpleDTO{
@@ -232,11 +256,12 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
VehiclePlate: del.VehiclePlate, VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName, DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber, DocumentNumber: del.DocumentNumber,
DocumentPath: del.DocumentPath,
ShippingCostItem: del.ShippingCostItem, ShippingCostItem: del.ShippingCostItem,
ShippingCostTotal: del.ShippingCostTotal, ShippingCostTotal: del.ShippingCostTotal,
Document: document,
}) })
} }
return TransferDetailDTO{ return TransferDetailDTO{
TransferListDTO: ToTransferListDTO(e), TransferListDTO: ToTransferListDTO(e),
Details: details, Details: details,
+51 -1
View File
@@ -1,10 +1,14 @@
package transfers package transfers
import ( import (
"context"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "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" 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" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories"
sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
@@ -14,6 +18,8 @@ import (
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
) )
type TransferModule struct{} type TransferModule struct{}
@@ -29,8 +35,52 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
documentRepo := commonRepo.NewDocumentRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) documentSvc, err := commonSvc.NewDocumentServiceFromConfig(context.Background(), documentRepo)
if err != nil {
panic(err)
}
// Initialize FIFO Service
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
// Register Transfer as Stockable (adds stock to destination warehouse)
err = fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKey("STOCK_TRANSFER_IN"),
Table: "stock_transfer_details",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "dest_product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic(err)
}
// Register Transfer as Usable (consumes stock from source warehouse)
err = fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKey("STOCK_TRANSFER_OUT"),
Table: "stock_transfer_details",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
})
if err != nil {
panic(err)
}
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService) TransferRoutes(router, userService, transferService)
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"mime/multipart"
"strings" "strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -27,7 +28,7 @@ import (
type TransferService interface { type TransferService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
} }
type transferService struct { type transferService struct {
@@ -42,9 +43,11 @@ type transferService struct {
SupplierRepo rSupplier.SupplierRepository SupplierRepo rSupplier.SupplierRepository
WarehouseRepo warehouseRepo.WarehouseRepository WarehouseRepo warehouseRepo.WarehouseRepository
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService
} }
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 { 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, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService) TransferService {
return &transferService{ return &transferService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -57,6 +60,8 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
SupplierRepo: supplierRepo, SupplierRepo: supplierRepo,
WarehouseRepo: warehouseRepo, WarehouseRepo: warehouseRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc,
FifoSvc: fifoSvc,
} }
} }
@@ -72,7 +77,10 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
Preload("Details"). Preload("Details").
Preload("Details.Product"). Preload("Details.Product").
Preload("Deliveries.Items"). Preload("Deliveries.Items").
Preload("Deliveries.Supplier") Preload("Deliveries.Supplier").
Preload("Deliveries.Documents", func(db *gorm.DB) *gorm.DB {
return db.Where("documentable_type = ?", string(utils.DocumentableTypeTransfer))
})
} }
func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) {
@@ -94,32 +102,33 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
return nil, 0, err return nil, 0, err
} }
s.Log.Infof("Retrieved %d transfers", len(transfers))
return transfers, total, nil return transfers, total, nil
} }
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
var transfer entity.StockTransfer s.Log.Infof("Attempting to get StockTransfer with ID: %d", id)
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db) return s.withRelations(db)
}) })
if err != nil { if err != nil {
s.Log.Errorf("Error getting StockTransfer ID %d: %+v", id, err)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
} }
s.Log.Errorf("Failed to get transfer by ID: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer")
} }
s.Log.Infof("Retrieved transfer: %+v", transfer) if transferPtr != nil {
s.Log.Infof("StockTransfer %d has %d documents", transferPtr.Id, len(transferPtr.Documents))
}
return transferPtr, nil return transferPtr, nil
} }
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error) {
// === VALIDASI SOURCE WAREHOUSE ===
pwIDs := make([]uint, 0, len(req.Products)) pwIDs := make([]uint, 0, len(req.Products))
for _, product := range req.Products { for _, product := range req.Products {
@@ -146,6 +155,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err return nil, err
} }
destPfkID, err := s.getActiveProjectFlockKandangID(c.Context(), uint(req.DestinationWarehouseID))
if err != nil {
return nil, err
}
if s.ProjectFlockKandangRepo != nil {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing")
}
}
actorID, err := m.ActorIDFromContext(c) actorID, err := m.ActorIDFromContext(c)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -180,7 +204,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context())
if err != nil { if err != nil {
s.Log.Errorf("Failed to get next movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number")
} }
movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum)
@@ -198,107 +221,33 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil {
s.Log.Errorf("Failed to create stock transfer: %+v", err)
return err
}
s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id)
var details []*entity.StockTransferDetail
for _, product := range req.Products {
details = append(details, &entity.StockTransferDetail{
StockTransferId: entityTransfer.Id,
ProductId: uint64(product.ProductID),
Quantity: product.ProductQty,
})
}
if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil {
s.Log.Errorf("Failed to create stock transfer details: %+v", err)
return err
}
s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id)
var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries {
deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id,
SupplierId: uint64(delivery.SupplierID),
VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName,
DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf",
ShippingCostItem: delivery.DeliveryCostPerItem,
ShippingCostTotal: delivery.DeliveryCost,
})
}
if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil {
s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err)
return err return err
} }
detailMap := make(map[uint64]uint64) // Prepare details and fetch product warehouses
for _, d := range details { details := make([]*entity.StockTransferDetail, 0, len(req.Products))
detailMap[d.ProductId] = d.Id detailMap := make(map[uint64]*entity.StockTransferDetail)
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
detailID, ok := detailMap[uint64(prod.ProductID)]
if !ok {
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID)
}
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: delivery.Id,
StockTransferDetailId: detailID,
Quantity: prod.ProductQty,
})
}
}
if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil {
s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err)
return err
}
s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id)
for _, product := range req.Products { for _, product := range req.Products {
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) // Get source product warehouse
sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID),
)
if err != nil { if err != nil {
s.Log.Errorf("Failed to get source product warehouse: %+v", err) if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID))
} }
if sourcePW.Quantity < product.ProductQty { return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source")
s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID)
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID))
}
sourcePW.Quantity -= product.ProductQty
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil {
s.Log.Errorf("Failed to update source product warehouse: %+v", err)
return err
}
s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id)
decreaseLog := &entity.StockLog{
Decrease: product.ProductQty,
Notes: "",
LoggableType: entity.LogTypeTransfer,
LoggableId: uint(entityTransfer.Id),
ProductWarehouseId: sourcePW.Id,
CreatedBy: actorID,
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), decreaseLog, nil); err != nil {
s.Log.Errorf("Failed to create stock log decrease: %+v", err)
return err
} }
// Get or create destination product warehouse
destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
) )
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to get destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse")
} }
if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context() ctx := c.Context()
projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID))
if err != nil { if err != nil {
@@ -311,30 +260,135 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ProjectFlockKandangId: &projectFlockKandangID, ProjectFlockKandangId: &projectFlockKandangID,
} }
if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil {
s.Log.Errorf("Failed to create destination product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination")
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse")
} }
s.Log.Infof("Destination product warehouse created: %+v", destPW.Id)
} }
destPW.Quantity += product.ProductQty detail := &entity.StockTransferDetail{
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { StockTransferId: entityTransfer.Id,
s.Log.Errorf("Failed to update destination product warehouse: %+v", err) ProductId: uint64(product.ProductID),
return err
}
s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id)
increaseLog := &entity.StockLog{ SourceProductWarehouseID: func() *uint64 { id := uint64(sourcePW.Id); return &id }(),
Increase: product.ProductQty, UsageQty: 0,
LoggableType: entity.LogTypeTransfer, PendingQty: 0,
LoggableId: uint(entityTransfer.Id),
Notes: "", DestProductWarehouseID: func() *uint64 { id := uint64(destPW.Id); return &id }(),
ProductWarehouseId: destPW.Id, TotalQty: 0,
CreatedBy: actorID, TotalUsed: 0,
} }
if err := s.StockLogsRepository.WithTx(tx).CreateOne(c.Context(), increaseLog, nil); err != nil { details = append(details, detail)
s.Log.Errorf("Failed to create stock log increase: %+v", err) detailMap[uint64(product.ProductID)] = detail
return err }
if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil {
return err
}
var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries {
deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id,
SupplierId: uint64(delivery.SupplierID),
VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName,
ShippingCostItem: delivery.DeliveryCostPerItem,
ShippingCostTotal: delivery.DeliveryCost,
})
}
if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil {
return err
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range deliveries {
item := req.Deliveries[i]
for _, prod := range item.Products {
detail, ok := detailMap[uint64(prod.ProductID)]
if !ok {
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID)
}
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: delivery.Id,
StockTransferDetailId: detail.Id,
Quantity: prod.ProductQty,
})
}
}
if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil {
return err
}
if s.DocumentSvc != nil && len(files) > 0 {
for idx, file := range files {
documentFiles := []commonSvc.DocumentFile{
{
File: file,
Type: string(utils.DocumentTypeTransfer),
Index: &idx,
},
}
_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
DocumentableType: string(utils.DocumentableTypeTransfer),
DocumentableID: deliveries[idx].Id,
CreatedBy: &actorID,
Files: documentFiles,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d", idx+1))
}
}
}
// Execute FIFO operations for each product
for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)]
// Step 1: Consume stock from source warehouse (STOCK_TRANSFER_OUT)
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: "STOCK_TRANSFER_OUT",
UsableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Quantity: product.ProductQty,
AllowPending: false, // Don't allow pending, must have actual stock
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err))
}
// Update usage tracking fields for source warehouse
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil {
return fmt.Errorf("gagal update usage tracking: %w", err)
}
// Step 2: Replenish stock to destination warehouse (STOCK_TRANSFER_IN)
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: "STOCK_TRANSFER_IN",
StockableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.DestProductWarehouseID),
Quantity: product.ProductQty,
Note: &note,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err))
}
// Update total tracking fields for destination warehouse
if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id).
Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity,
}).Error; err != nil {
return fmt.Errorf("gagal update total tracking: %w", err)
} }
} }
@@ -343,7 +397,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if err != nil { if err != nil {
s.Log.Errorf("Transaction failed in CreateOne: %+v", err) s.Log.Errorf("Transaction failed in CreateOne: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err))
} }
result, err := s.GetOne(c, uint(entityTransfer.Id)) result, err := s.GetOne(c, uint(entityTransfer.Id))
@@ -359,7 +413,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) return 0, fiber.NewError(fiber.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") return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
} }
@@ -372,7 +425,6 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("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 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang")
} }
@@ -121,7 +121,7 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD
return MarketingDeliveryProductDTO{ return MarketingDeliveryProductDTO{
Id: e.Id, Id: e.Id,
MarketingProductId: e.MarketingProductId, MarketingProductId: e.MarketingProductId,
Qty: e.Qty, Qty: e.UsageQty,
UnitPrice: e.UnitPrice, UnitPrice: e.UnitPrice,
TotalWeight: e.TotalWeight, TotalWeight: e.TotalWeight,
AvgWeight: e.AvgWeight, AvgWeight: e.AvgWeight,
+26 -8
View File
@@ -2,6 +2,7 @@ package marketing
import ( import (
"fmt" "fmt"
"strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -13,11 +14,12 @@ import (
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" 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" 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" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/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" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
) )
type MarketingModule struct{} type MarketingModule struct{}
@@ -31,22 +33,38 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
customerRepo := rCustomer.NewCustomerRepository(db) customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
// Initialize approval service stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyMarketingDelivery,
Table: "marketing_delivery_products",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
// Register workflow steps for marketing approval
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil {
panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err))
} }
warehouseRepo := rWarehouse.NewWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db)
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
// Initialize services
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
// Register routes
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
} }
@@ -17,6 +17,9 @@ type MarketingDeliveryProductRepository interface {
GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error)
GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error)
GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error)
UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error
GetUsageQty(ctx context.Context, id uint) (float64, error)
ResetFifoFields(ctx context.Context, id uint) error
} }
type MarketingDeliveryProductRepositoryImpl struct { type MarketingDeliveryProductRepositoryImpl struct {
@@ -241,3 +244,33 @@ func containsJoin(db *gorm.DB, tableName string) bool {
joinSQL := statement.SQL.String() joinSQL := statement.SQL.String()
return strings.Contains(joinSQL, "JOIN "+tableName) return strings.Contains(joinSQL, "JOIN "+tableName)
} }
func (r *MarketingDeliveryProductRepositoryImpl) UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error {
return r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"usage_qty": usageQty,
"pending_qty": pendingQty,
}).Error
}
func (r *MarketingDeliveryProductRepositoryImpl) GetUsageQty(ctx context.Context, id uint) (float64, error) {
var usageQty float64
err := r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Where("id = ?", id).
Select("usage_qty").
Scan(&usageQty).Error
return usageQty, err
}
func (r *MarketingDeliveryProductRepositoryImpl) ResetFifoFields(ctx context.Context, id uint) error {
return r.DB().WithContext(ctx).
Model(&entity.MarketingDeliveryProduct{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"usage_qty": 0,
"pending_qty": 0,
}).Error
}
+9 -6
View File
@@ -16,12 +16,15 @@ func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrde
route := router.Group("/marketing") route := router.Group("/marketing")
route.Use(m.Auth(userService)) route.Use(m.Auth(userService))
route.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll) route.Get("/", m.RequirePermissions(m.P_DeliveryGetAll), deliveryOrdersCtrl.GetAll)
route.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_DeliveryGetOne), deliveryOrdersCtrl.GetOne)
route.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_SalesOrderDelete), salesOrdersCtrl.DeleteOne)
route.Post("/sales-orders",m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne) route.Post("/sales-orders", m.RequirePermissions(m.P_SalesOrderCreateOne), salesOrdersCtrl.CreateOne)
route.Patch("/sales-orders/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne) route.Patch("/sales-orders/:id", m.RequirePermissions(m.P_SalesOrderUpdateOne), salesOrdersCtrl.UpdateOne)
route.Post("/sales-orders/approvals",m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval) route.Post("/sales-orders/approvals", m.RequirePermissions(m.P_SalesOrderApproval), salesOrdersCtrl.Approval)
route.Post("/delivery-orders", m.RequirePermissions(m.P_DeliveryCreateOne), deliveryOrdersCtrl.CreateOne)
route.Patch("/delivery-orders/:id", m.RequirePermissions(m.P_DeliveryUpdateOne), deliveryOrdersCtrl.UpdateOne)
} }
@@ -15,10 +15,10 @@ import (
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -30,12 +30,12 @@ type DeliveryOrdersService interface {
} }
type deliveryOrdersService struct { type deliveryOrdersService struct {
Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
MarketingRepo marketingRepo.MarketingRepository MarketingRepo marketingRepo.MarketingRepository
MarketingProductRepo marketingRepo.MarketingProductRepository MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
} }
func NewDeliveryOrdersService( func NewDeliveryOrdersService(
@@ -43,15 +43,16 @@ func NewDeliveryOrdersService(
marketingProductRepo marketingRepo.MarketingProductRepository, marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
validate *validator.Validate, validate *validator.Validate,
) DeliveryOrdersService { ) DeliveryOrdersService {
return &deliveryOrdersService{ return &deliveryOrdersService{
Log: utils.Log,
Validate: validate, Validate: validate,
MarketingRepo: marketingRepo, MarketingRepo: marketingRepo,
MarketingProductRepo: marketingProductRepo, MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc,
} }
} }
@@ -108,7 +109,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to get marketings: %+v", err)
return nil, 0, err return nil, 0, err
} }
for i := range marketings { for i := range marketings {
@@ -116,7 +116,7 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO
return db.Preload("ActionUser") return db.Preload("ActionUser")
}) })
if err != nil { if err != nil {
s.Log.Warnf("Failed to load approval for marketing %d: %+v", marketings[i].Id, err) continue
} }
marketings[i].LatestApproval = latestApproval marketings[i].LatestApproval = latestApproval
} }
@@ -247,7 +247,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate itemDeliveryDate = &parsedDate
} }
deliveryProduct.Qty = requestedProduct.Qty deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.TotalWeight = requestedProduct.TotalWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight
@@ -256,7 +256,8 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
if requestedProduct.Qty > 0 { if requestedProduct.Qty > 0 {
if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, requestedProduct.Qty); err != nil {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil {
return err return err
} }
} }
@@ -354,8 +355,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
itemDeliveryDate = deliveryProduct.DeliveryDate itemDeliveryDate = deliveryProduct.DeliveryDate
} }
oldQty := deliveryProduct.Qty oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
deliveryProduct.Qty = requestedProduct.Qty
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
deliveryProduct.AvgWeight = requestedProduct.AvgWeight deliveryProduct.AvgWeight = requestedProduct.AvgWeight
deliveryProduct.TotalWeight = requestedProduct.TotalWeight deliveryProduct.TotalWeight = requestedProduct.TotalWeight
@@ -363,14 +365,18 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
deliveryProduct.DeliveryDate = itemDeliveryDate deliveryProduct.DeliveryDate = itemDeliveryDate
deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber
qtyChange := requestedProduct.Qty - oldQty if requestedProduct.Qty != oldRequestedQty {
if qtyChange > 0 {
if err := s.validateAndReduceProductWarehouse(c.Context(), dbTransaction, foundMarketingProduct, qtyChange); err != nil { if oldRequestedQty > 0 {
return err if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil {
return err
}
} }
} else if qtyChange < 0 {
if err := s.restoreProductWarehouseStock(c.Context(), dbTransaction, foundMarketingProduct, -qtyChange); err != nil { if requestedProduct.Qty > 0 {
return err if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil {
return err
}
} }
} }
@@ -393,50 +399,79 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return s.getMarketingWithDeliveries(c, id) return s.getMarketingWithDeliveries(c, id)
} }
func (s deliveryOrdersService) validateAndReduceProductWarehouse(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyDeliver float64) error { func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error {
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
} }
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) if deliveryProduct == nil || deliveryProduct.Id == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found")
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
ProductWarehouseID: marketingProduct.ProductWarehouseId,
Quantity: requestedQty,
AllowPending: false,
Tx: tx,
})
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx)
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found") pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil)
if err2 != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock")
} }
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock")
if pw == nil || pw.Quantity < requestedQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty))
}
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
return nil
} }
if pw.Quantity < qtyDeliver { if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for warehouse - available: %.2f, requested: %.2f", pw.Quantity, qtyDeliver)) return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
} }
pw.Quantity = pw.Quantity - qtyDeliver
if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock")
}
return nil return nil
} }
func (s deliveryOrdersService) restoreProductWarehouseStock(ctx context.Context, tx *gorm.DB, marketingProduct *entity.MarketingProduct, qtyRestore float64) error { func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return nil
}
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
} }
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
pw, err := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { currentUsage = 0
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check stock")
} }
pw.Quantity = pw.Quantity + qtyRestore if currentUsage == 0 {
if err := pwRepo.UpdateOne(ctx, pw.Id, pw, nil); err != nil { return nil
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update stock") }
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: fifo.UsableKeyMarketingDelivery,
UsableID: deliveryProduct.Id,
Tx: tx,
}); err != nil {
return err
}
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil {
return err
} }
return nil return nil
@@ -307,15 +307,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil { if _, err := invDeliveryRepoTx.GetByMarketingProductID(c.Context(), old.Id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
mdp := &entity.MarketingDeliveryProduct{ mdp := &entity.MarketingDeliveryProduct{
MarketingProductId: old.Id, MarketingProductId: old.Id,
Qty: 0,
UnitPrice: 0, UnitPrice: 0,
TotalWeight: 0, TotalWeight: 0,
AvgWeight: 0, AvgWeight: 0,
TotalPrice: 0, TotalPrice: 0,
DeliveryDate: nil, DeliveryDate: nil,
VehicleNumber: rp.VehicleNumber, VehicleNumber: rp.VehicleNumber,
UsageQty: 0,
PendingQty: 0,
} }
if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil { if err := invDeliveryRepoTx.CreateOne(c.Context(), mdp, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing delivery product")
@@ -340,7 +342,7 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
} }
if err == nil { if err == nil {
if deliveryProduct.DeliveryDate != nil || deliveryProduct.Qty > 0 { if deliveryProduct.DeliveryDate != nil || deliveryProduct.UsageQty > 0 || deliveryProduct.PendingQty > 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has delivery records", old.Id))
} }
@@ -601,14 +603,16 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
} }
marketingDeliveryProduct := &entity.MarketingDeliveryProduct{ marketingDeliveryProduct := &entity.MarketingDeliveryProduct{
MarketingProductId: marketingProduct.Id, MarketingProductId: marketingProduct.Id,
Qty: 0, ProductWarehouseId: marketingProduct.ProductWarehouseId,
UnitPrice: 0, UnitPrice: 0,
TotalWeight: 0, TotalWeight: 0,
AvgWeight: 0, AvgWeight: 0,
TotalPrice: 0, TotalPrice: 0,
DeliveryDate: nil, DeliveryDate: nil,
VehicleNumber: rp.VehicleNumber, VehicleNumber: rp.VehicleNumber,
UsageQty: 0,
PendingQty: 0,
} }
if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil { if err := invDeliveryRepo.CreateOne(ctx, marketingDeliveryProduct, nil); err != nil {
return err return err
@@ -84,6 +84,7 @@ func ToKandangListDTO(e entity.Kandang) KandangListDTO {
Name: e.Name, Name: e.Name,
Status: e.Status, Status: e.Status,
Location: location, Location: location,
Capacity: e.Capacity,
Pic: pic, Pic: pic,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
@@ -0,0 +1,145 @@
package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"github.com/gofiber/fiber/v2"
)
type ProductionStandardController struct {
ProductionStandardService service.ProductionStandardService
}
func NewProductionStandardController(productionStandardService service.ProductionStandardService) *ProductionStandardController {
return &ProductionStandardController{
ProductionStandardService: productionStandardService,
}
}
func (u *ProductionStandardController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProjectCategory: c.Query("project_category", ""),
}
if query.Page < 1 || query.Limit < 1 {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
}
result, totalResults, err := u.ProductionStandardService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.ProductionStandardListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all productionStandards successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToProductionStandardListDTOs(result),
})
}
func (u *ProductionStandardController) GetOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, err := u.ProductionStandardService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get productionStandard successfully",
Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails),
})
}
func (u *ProductionStandardController) CreateOne(c *fiber.Ctx) error {
req := new(validation.Create)
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.ProductionStandardService.CreateOne(c, req)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create productionStandard successfully",
Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails),
})
}
func (u *ProductionStandardController) 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.ProductionStandardService.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 productionStandard successfully",
Data: dto.ToProductionStandardDetailDTO(*result, result.StandardGrowthDetails, result.ProductionStandardDetails),
})
}
func (u *ProductionStandardController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("id")
id, err := strconv.Atoi(param)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
if err := u.ProductionStandardService.DeleteOne(c, uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete productionStandard successfully",
})
}
@@ -0,0 +1,155 @@
package dto
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
// === DTO Structs ===
type ProductionStandardListDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
ProjectCategory string `json:"project_category"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user"`
}
type ProductionStandardDetailDTO struct {
ProductionStandardListDTO
Details []WeeklyProductionStandardDTO `json:"details"`
}
type GrowthStandardDetailDTO struct {
Id uint `json:"id"`
TargetMeanBW *float64 `json:"target_mean_bw"`
MaxDepletion *float64 `json:"max_depletion"`
MinUniformity float64 `json:"min_uniformity"`
FeedIntake *float64 `json:"feed_intake"`
}
type EggProductionStandardDetailDTO struct {
Id uint `json:"id"`
TargetHenDayProduction *float64 `json:"target_hen_day_production"`
TargetHenHouseProduction *float64 `json:"target_hen_house_production"`
TargetEggWeight *float64 `json:"target_egg_weight"`
TargetEggMass *float64 `json:"target_egg_mass"`
}
type WeeklyProductionStandardDTO struct {
Week int `json:"week"`
GrowthStandardDetail GrowthStandardDetailDTO `json:"growth_standard_detail"`
EggProductionStandardDetailDTO *EggProductionStandardDetailDTO `json:"egg_production_standard_detail,omitempty"`
}
// === Mapper Functions ===
func ToProductionStandardListDTO(e entity.ProductionStandard) ProductionStandardListDTO {
var createdUser *userDTO.UserRelationDTO
if e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserRelationDTO(e.CreatedUser)
createdUser = &mapped
}
return ProductionStandardListDTO{
Id: e.Id,
Name: e.Name,
ProjectCategory: e.ProjectCategory,
CreatedUser: createdUser,
}
}
func ToProductionStandardListDTOs(e []entity.ProductionStandard) []ProductionStandardListDTO {
result := make([]ProductionStandardListDTO, len(e))
for i, r := range e {
result[i] = ToProductionStandardListDTO(r)
}
return result
}
func ToWeeklyProductionStandardDTO(e entity.StandardGrowthDetail) WeeklyProductionStandardDTO {
return WeeklyProductionStandardDTO{
Week: e.Week,
GrowthStandardDetail: GrowthStandardDetailDTO{
Id: e.Id,
TargetMeanBW: e.TargetMeanBw,
MaxDepletion: e.MaxDepletion,
MinUniformity: e.MinUniformity,
FeedIntake: e.FeedIntake,
},
EggProductionStandardDetailDTO: nil, // GROWING category - no egg production details
}
}
func ToWeeklyProductionStandardDTOWithDetails(growth entity.StandardGrowthDetail, detail entity.ProductionStandardDetail) WeeklyProductionStandardDTO {
eggDetail := &EggProductionStandardDetailDTO{
Id: detail.Id,
TargetHenDayProduction: detail.TargetHenDayProduction,
TargetHenHouseProduction: detail.TargetHenHouseProduction,
TargetEggWeight: detail.TargetEggWeight,
TargetEggMass: detail.TargetEggMass,
}
return WeeklyProductionStandardDTO{
Week: growth.Week,
GrowthStandardDetail: GrowthStandardDetailDTO{
Id: growth.Id,
TargetMeanBW: growth.TargetMeanBw,
MaxDepletion: growth.MaxDepletion,
MinUniformity: growth.MinUniformity,
FeedIntake: growth.FeedIntake,
},
EggProductionStandardDetailDTO: eggDetail, // LAYING category - with egg production details
}
}
func ToWeeklyProductionStandardDTOs(e []entity.StandardGrowthDetail) []WeeklyProductionStandardDTO {
result := make([]WeeklyProductionStandardDTO, len(e))
for i, r := range e {
result[i] = ToWeeklyProductionStandardDTO(r)
}
return result
}
func ToWeeklyProductionStandardDTOsWithDetails(
growthDetails []entity.StandardGrowthDetail,
productionStandardDetails []entity.ProductionStandardDetail,
) []WeeklyProductionStandardDTO {
result := make([]WeeklyProductionStandardDTO, len(growthDetails))
// Create map for production standard details by week
prodDetailMap := make(map[int]entity.ProductionStandardDetail)
for _, detail := range productionStandardDetails {
prodDetailMap[detail.Week] = detail
}
// Map growth details and combine with production standard details
for i, growth := range growthDetails {
if prodDetail, exists := prodDetailMap[growth.Week]; exists {
result[i] = ToWeeklyProductionStandardDTOWithDetails(growth, prodDetail)
} else {
result[i] = ToWeeklyProductionStandardDTO(growth)
}
}
return result
}
func ToEggProductionStandardDetailDTO(e entity.ProductionStandardDetail) EggProductionStandardDetailDTO {
return EggProductionStandardDetailDTO{
TargetHenDayProduction: e.TargetHenDayProduction,
TargetHenHouseProduction: e.TargetHenHouseProduction,
TargetEggWeight: e.TargetEggWeight,
TargetEggMass: e.TargetEggMass,
}
}
func ToProductionStandardDetailDTO(
standard entity.ProductionStandard,
growthDetails []entity.StandardGrowthDetail,
productionStandardDetails []entity.ProductionStandardDetail,
) ProductionStandardDetailDTO {
return ProductionStandardDetailDTO{
ProductionStandardListDTO: ToProductionStandardListDTO(standard),
Details: ToWeeklyProductionStandardDTOsWithDetails(growthDetails, productionStandardDetails),
}
}
@@ -0,0 +1,33 @@
package productionstandards
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
)
type ProductionStandardModule struct{}
func (ProductionStandardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
userRepo := rUser.NewUserRepository(db)
productionStandardService := sProductionStandard.NewProductionStandardService(
productionStandardRepo,
productionStandardDetailRepo,
standardGrowthDetailRepo,
validate,
)
userService := sUser.NewUserService(userRepo, validate)
ProductionStandardRoutes(router, userService, productionStandardService)
}
@@ -0,0 +1,103 @@
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"
)
type ProductionStandardRepository interface {
repository.BaseRepository[entity.ProductionStandard]
GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error)
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error)
NameExists(ctx context.Context, name string, excludeID *uint) (bool, error)
IdExists(ctx context.Context, id uint) (bool, error)
GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error)
}
type ProductionStandardRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.ProductionStandard]
db *gorm.DB
}
func NewProductionStandardRepository(db *gorm.DB) ProductionStandardRepository {
return &ProductionStandardRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandard](db),
db: db,
}
}
func (r *ProductionStandardRepositoryImpl) GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProductionStandard, int64, error) {
var standards []entity.ProductionStandard
var total int64
// Build base query
q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{})
// Apply modifier for filters
if modifier != nil {
q = modifier(q)
}
// Count total
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
// Re-apply modifier and add preloads for Find
q = r.db.WithContext(ctx).Model(&entity.ProductionStandard{})
if modifier != nil {
q = modifier(q)
}
q = q.Preload("CreatedUser")
// Find with offset and limit
if err := q.Offset(offset).Limit(limit).Find(&standards).Error; err != nil {
return nil, 0, err
}
return standards, total, nil
}
func (r *ProductionStandardRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.ProductionStandard, error) {
var standard entity.ProductionStandard
q := r.db.WithContext(ctx).Model(&entity.ProductionStandard{})
// Apply modifier
if modifier != nil {
q = modifier(q)
}
// Ensure CreatedUser is preloaded
q = q.Preload("CreatedUser")
if err := q.First(&standard, id).Error; err != nil {
return nil, err
}
return &standard, nil
}
func (r *ProductionStandardRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) {
return repository.ExistsByName[entity.ProductionStandard](ctx, r.db, name, excludeID)
}
func (r *ProductionStandardRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductionStandard](ctx, r.db, id)
}
func (r *ProductionStandardRepositoryImpl) GetByProjectCategory(ctx context.Context, projectCategory string) ([]entity.ProductionStandard, error) {
var standards []entity.ProductionStandard
err := r.db.WithContext(ctx).
Preload("CreatedUser").
Where("project_category = ?", projectCategory).
Where("deleted_at IS NULL").
Find(&standards).Error
if err != nil {
return nil, err
}
return standards, nil
}
@@ -0,0 +1,63 @@
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"
)
type ProductionStandardDetailRepository interface {
repository.BaseRepository[entity.ProductionStandardDetail]
IdExists(ctx context.Context, id uint) (bool, error)
GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error)
GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error)
DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error
}
type ProductionStandardDetailRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.ProductionStandardDetail]
db *gorm.DB
}
func NewProductionStandardDetailRepository(db *gorm.DB) ProductionStandardDetailRepository {
return &ProductionStandardDetailRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductionStandardDetail](db),
db: db,
}
}
func (r *ProductionStandardDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductionStandardDetail](ctx, r.db, id)
}
func (r *ProductionStandardDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.ProductionStandardDetail, error) {
var details []entity.ProductionStandardDetail
err := r.db.WithContext(ctx).
Where("production_standard_id = ?", productionStandardId).
Order("week ASC").
Find(&details).Error
if err != nil {
return nil, err
}
return details, nil
}
func (r *ProductionStandardDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.ProductionStandardDetail, error) {
var detail entity.ProductionStandardDetail
err := r.db.WithContext(ctx).
Where("production_standard_id = ?", standardId).
Where("week = ?", week).
First(&detail).Error
if err != nil {
return nil, err
}
return &detail, nil
}
func (r *ProductionStandardDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error {
return r.db.WithContext(ctx).
Where("production_standard_id = ?", productionStandardId).
Delete(&entity.ProductionStandardDetail{}).Error
}
@@ -0,0 +1,63 @@
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"
)
type StandardGrowthDetailRepository interface {
repository.BaseRepository[entity.StandardGrowthDetail]
IdExists(ctx context.Context, id uint) (bool, error)
GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error)
GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error)
DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error
}
type StandardGrowthDetailRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.StandardGrowthDetail]
db *gorm.DB
}
func NewStandardGrowthDetailRepository(db *gorm.DB) StandardGrowthDetailRepository {
return &StandardGrowthDetailRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.StandardGrowthDetail](db),
db: db,
}
}
func (r *StandardGrowthDetailRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.StandardGrowthDetail](ctx, r.db, id)
}
func (r *StandardGrowthDetailRepositoryImpl) GetByProductionStandardID(ctx context.Context, productionStandardId uint) ([]entity.StandardGrowthDetail, error) {
var details []entity.StandardGrowthDetail
err := r.db.WithContext(ctx).
Where("production_standard_id = ?", productionStandardId).
Order("week ASC").
Find(&details).Error
if err != nil {
return nil, err
}
return details, nil
}
func (r *StandardGrowthDetailRepositoryImpl) GetByStandardIDAndWeek(ctx context.Context, standardId uint, week int) (*entity.StandardGrowthDetail, error) {
var detail entity.StandardGrowthDetail
err := r.db.WithContext(ctx).
Where("production_standard_id = ?", standardId).
Where("week = ?", week).
First(&detail).Error
if err != nil {
return nil, err
}
return &detail, nil
}
func (r *StandardGrowthDetailRepositoryImpl) DeleteByProductionStandardID(ctx context.Context, productionStandardId uint) error {
return r.db.WithContext(ctx).
Where("production_standard_id = ?", productionStandardId).
Delete(&entity.StandardGrowthDetail{}).Error
}

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