feat(BE-47,48,49,50): implement inventory adjustment system

- Extend DB schema with product_warehouses and stock_logs tables
- Implement stock adjustment API (increase/decrease operations)
- Add comprehensive validation for all adjustment operations
- Implement audit log system for each adjustment with history tracking
- Include transaction handling, DTOs, seeders, and proper error handling
- Add adjustment history API with pagination and filtering

TODO: Integration testing pending
This commit is contained in:
aguhh18
2025-10-10 09:24:17 +07:00
parent a0bdc7b23c
commit 91b320d489
23 changed files with 719 additions and 502 deletions
@@ -1,4 +1,9 @@
DROP TABLE IF EXISTS stock_logs;
DROP INDEX IF EXISTS idx_product_warehouses_unique;
DROP INDEX IF EXISTS idx_product_warehouses_deleted_at;
DROP INDEX IF EXISTS idx_product_warehouses_warehouse_id;
DROP INDEX IF EXISTS idx_product_warehouses_product_id;
DROP TABLE IF EXISTS product_warehouses;
DROP TABLE IF EXISTS fcr_standards;
DROP INDEX IF EXISTS suppliers_name_unique;
DROP TABLE IF EXISTS product_suppliers;
@@ -35,10 +40,4 @@ DROP TABLE IF EXISTS fcrs;
DROP TABLE IF EXISTS projects;
DROP INDEX IF EXISTS users_id_user_unique;
DROP INDEX IF EXISTS users_email_unique;
DROP TABLE IF EXISTS users;
DROP INDEX IF EXISTS idx_product_warehouses_unique;
DROP INDEX IF EXISTS idx_product_warehouses_deleted_at;
DROP INDEX IF EXISTS idx_product_warehouses_warehouse_id;
DROP INDEX IF EXISTS idx_product_warehouses_product_id;
DROP TABLE IF EXISTS product_warehouses;
DROP TABLE IF EXISTS stock_logs;
DROP TABLE IF EXISTS users;
@@ -1,276 +1,337 @@
-- USERS
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
id_user BIGINT NOT NULL,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
id BIGSERIAL PRIMARY KEY,
id_user BIGINT NOT NULL,
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX users_id_user_unique ON users (id_user)
WHERE
deleted_at IS NULL;
CREATE UNIQUE INDEX users_email_unique ON users (email)
WHERE
deleted_at IS NULL;
-- FLAGS
CREATE TABLE flags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
flagable_id BIGINT NOT NULL,
flagable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
flagable_id BIGINT NOT NULL,
flagable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX flags_unique_flagable ON flags (
name,
flagable_id,
flagable_type
);
CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type);
CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id);
-- PRODUCT CATEGORIES
CREATE TABLE product_categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
code VARCHAR(10) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
code VARCHAR(10) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX product_categories_name_unique ON product_categories (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX product_categories_code_unique ON product_categories (code) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX product_categories_name_unique ON product_categories (name)
WHERE
deleted_at IS NULL;
CREATE UNIQUE INDEX product_categories_code_unique ON product_categories (code)
WHERE
deleted_at IS NULL;
-- UOM
CREATE TABLE uoms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX uoms_name_unique ON uoms (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uoms_name_unique ON uoms (name)
WHERE
deleted_at IS NULL;
-- BANKS
CREATE TABLE banks (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
alias VARCHAR(5) NOT NULL,
owner VARCHAR,
account_number VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
alias VARCHAR(5) NOT NULL,
owner VARCHAR,
account_number VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX banks_name_unique ON banks (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX banks_name_unique ON banks (name)
WHERE
deleted_at IS NULL;
-- AREAS
CREATE TABLE areas (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX areas_name_unique ON areas (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX areas_name_unique ON areas (name)
WHERE
deleted_at IS NULL;
-- LOCATIONS
CREATE TABLE locations (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
address TEXT NOT NULL,
area_id BIGINT NOT NULL REFERENCES areas(id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
address TEXT NOT NULL,
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX locations_name_unique ON locations (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX locations_name_unique ON locations (name)
WHERE
deleted_at IS NULL;
-- KANDANG
CREATE TABLE kandangs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
location_id BIGINT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE,
pic_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX kandangs_name_unique ON kandangs (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX kandangs_name_unique ON kandangs (name)
WHERE
deleted_at IS NULL;
-- WAREHOUSES
CREATE TABLE warehouses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
type VARCHAR(50) NOT NULL,
area_id BIGINT NOT NULL REFERENCES areas(id) ON DELETE RESTRICT ON UPDATE CASCADE,
location_id BIGINT REFERENCES locations(id) ON DELETE SET NULL ON UPDATE CASCADE,
kandang_id BIGINT REFERENCES kandangs(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
type VARCHAR(50) NOT NULL,
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
location_id BIGINT REFERENCES locations (id) ON DELETE SET NULL ON UPDATE CASCADE,
kandang_id BIGINT REFERENCES kandangs (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX warehouses_name_unique ON warehouses (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX warehouses_name_unique ON warehouses (name)
WHERE
deleted_at IS NULL;
-- CUSTOMERS
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
pic_id BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
type VARCHAR(50) NOT NULL,
address TEXT NOT NULL,
phone VARCHAR(20) NOT NULL,
email VARCHAR NOT NULL,
account_number VARCHAR(50) NOT NULL,
balance NUMERIC(15,3) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
type VARCHAR(50) NOT NULL,
address TEXT NOT NULL,
phone VARCHAR(20) NOT NULL,
email VARCHAR NOT NULL,
account_number VARCHAR(50) NOT NULL,
balance NUMERIC(15, 3) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX customers_name_unique ON customers (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX customers_name_unique ON customers (name)
WHERE
deleted_at IS NULL;
-- NONSTOCK
CREATE TABLE nonstocks (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
uom_id BIGINT NOT NULL REFERENCES uoms(id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX nonstocks_name_unique ON nonstocks (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX nonstocks_name_unique ON nonstocks (name)
WHERE
deleted_at IS NULL;
-- FCR
CREATE TABLE fcrs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX fcrs_name_unique ON fcrs (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX fcrs_name_unique ON fcrs (name)
WHERE
deleted_at IS NULL;
CREATE TABLE fcr_standards (
id BIGSERIAL PRIMARY KEY,
fcr_id BIGINT NOT NULL REFERENCES fcrs(id) ON DELETE CASCADE ON UPDATE CASCADE,
weight NUMERIC(15,3) NOT NULL,
fcr_number NUMERIC(15,3) NOT NULL,
mortality NUMERIC(15,3) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
id BIGSERIAL PRIMARY KEY,
fcr_id BIGINT NOT NULL REFERENCES fcrs (id) ON DELETE CASCADE ON UPDATE CASCADE,
weight NUMERIC(15, 3) NOT NULL,
fcr_number NUMERIC(15, 3) NOT NULL,
mortality NUMERIC(15, 3) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- SUPPLIERS
CREATE TABLE suppliers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
alias VARCHAR(5) NOT NULL,
pic VARCHAR NOT NULL,
type VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
hatchery VARCHAR,
phone VARCHAR(20) NOT NULL,
email VARCHAR NOT NULL,
address TEXT NOT NULL,
npwp VARCHAR(50),
account_number VARCHAR(50),
balance NUMERIC(15,3) DEFAULT 0,
due_date INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
alias VARCHAR(5) NOT NULL,
pic VARCHAR NOT NULL,
type VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
hatchery VARCHAR,
phone VARCHAR(20) NOT NULL,
email VARCHAR NOT NULL,
address TEXT NOT NULL,
npwp VARCHAR(50),
account_number VARCHAR(50),
balance NUMERIC(15, 3) DEFAULT 0,
due_date INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX suppliers_name_unique ON suppliers (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX suppliers_name_unique ON suppliers (name)
WHERE
deleted_at IS NULL;
CREATE TABLE nonstock_suppliers (
nonstock_id BIGINT NOT NULL REFERENCES nonstocks(id) ON DELETE CASCADE ON UPDATE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (nonstock_id, supplier_id)
nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (nonstock_id, supplier_id)
);
-- PRODUCTS
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
brand VARCHAR NOT NULL,
sku VARCHAR(100),
uom_id BIGINT NOT NULL REFERENCES uoms(id) ON DELETE RESTRICT ON UPDATE CASCADE,
product_category_id BIGINT NOT NULL REFERENCES product_categories(id) ON DELETE RESTRICT ON UPDATE CASCADE,
product_price NUMERIC(15,3) NOT NULL,
selling_price NUMERIC(15,3),
tax NUMERIC(15,3),
expiry_period INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
brand VARCHAR NOT NULL,
sku VARCHAR(100),
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE,
product_price NUMERIC(15, 3) NOT NULL,
selling_price NUMERIC(15, 3),
tax NUMERIC(15, 3),
expiry_period INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX products_name_unique ON products (name) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX products_sku_unique ON products (sku) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX products_name_unique ON products (name)
WHERE
deleted_at IS NULL;
CREATE UNIQUE INDEX products_sku_unique ON products (sku)
WHERE
deleted_at IS NULL;
CREATE TABLE product_suppliers (
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE ON UPDATE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (product_id, supplier_id)
product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (product_id, supplier_id)
);
-- PROJECTS
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
-- PRODUCT WAREHOUSES TABLE
CREATE TABLE product_warehouses (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id),
warehouse_id BIGINT NOT NULL REFERENCES warehouses(id),
product_id BIGINT NOT NULL REFERENCES products (id),
warehouse_id BIGINT NOT NULL REFERENCES warehouses (id),
quantity INTEGER NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL REFERENCES users(id),
created_by BIGINT NOT NULL REFERENCES users (id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- INDEXES
CREATE INDEX idx_product_warehouses_product_id ON product_warehouses(product_id);
CREATE INDEX idx_product_warehouses_warehouse_id ON product_warehouses(warehouse_id);
CREATE INDEX idx_product_warehouses_deleted_at ON product_warehouses(deleted_at);
CREATE UNIQUE INDEX idx_product_warehouses_unique ON product_warehouses(product_id, warehouse_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_product_warehouses_product_id ON product_warehouses (product_id);
CREATE INDEX idx_product_warehouses_warehouse_id ON product_warehouses (warehouse_id);
CREATE INDEX idx_product_warehouses_deleted_at ON product_warehouses (deleted_at);
CREATE UNIQUE INDEX idx_product_warehouses_unique ON product_warehouses (product_id, warehouse_id)
WHERE
deleted_at IS NULL;
-- STOCK LOGS
CREATE TABLE stock_logs (
id BIGSERIAL PRIMARY KEY,
trancaction_type VARCHAR(20) NOT NULL,
quantity NUMERIC(15,3) NOT NULL,
before_quantity NUMERIC(15,3) NOT NULL,
after_quantity NUMERIC(15,3) NOT NULL,
log_type VARCHAR(50) NOT NULL,
log_id BIGINT NOT NULL,
note TEXT,
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id) ON DELETE CASCADE ON UPDATE CASCADE,
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
id BIGSERIAL PRIMARY KEY,
transaction_type VARCHAR(20) NOT NULL,
quantity NUMERIC(15, 3) NOT NULL,
before_quantity NUMERIC(15, 3) NOT NULL,
after_quantity NUMERIC(15, 3) NOT NULL,
log_type VARCHAR(50) NOT NULL,
log_id BIGINT ,
note TEXT,
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Create indexes for better performance
CREATE INDEX stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id);
CREATE INDEX stock_logs_log_type_log_id_idx ON stock_logs (log_type, log_id);
CREATE INDEX stock_logs_created_by_idx ON stock_logs (created_by);
CREATE INDEX stock_logs_created_at_idx ON stock_logs (created_at);
CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at);
+37 -4
View File
@@ -74,6 +74,10 @@ func Run(db *gorm.DB) error {
return err
}
if err := seedProductWarehouse(tx, adminID); err != nil {
return err
}
fmt.Println("✅ Master data seeding completed")
return nil
})
@@ -675,10 +679,6 @@ func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers
}
// nanti saya isi
func seedProductWarehouse(tx *gorm.DB, createdBy uint, products map[string]uint, warehouses map[string]uint) error {
return nil
}
func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils.FlagType) error {
if len(flags) == 0 {
@@ -766,6 +766,39 @@ func seedBanks(tx *gorm.DB, createdBy uint) error {
return nil
}
func seedProductWarehouse(tx *gorm.DB, createdBy uint) error {
seeds := []struct {
ProductID uint
WarehouseID uint
Quantity float64
}{
{ProductID: 1, WarehouseID: 1, Quantity: 100},
{ProductID: 2, WarehouseID: 2, Quantity: 200},
{ProductID: 1, WarehouseID: 1, Quantity: 300},
}
for _, seed := range seeds {
var productWarehouse entity.ProductWarehouse
err := tx.Where("product_id = ? AND warehouse_id = ?", seed.ProductID, seed.WarehouseID).First(&productWarehouse).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
productWarehouse = entity.ProductWarehouse{
ProductId: seed.ProductID,
WarehouseId: seed.WarehouseID,
Quantity: seed.Quantity,
CreatedBy: createdBy,
}
if err := tx.Create(&productWarehouse).Error; err != nil {
return err
}
} else if err != nil {
return err
}
}
return nil
}
func ptr[T any](v T) *T {
return &v
}
-18
View File
@@ -1,18 +0,0 @@
package entities
import (
"time"
"gorm.io/gorm"
)
type Adjustment 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"`
}
+1 -1
View File
@@ -10,7 +10,7 @@ type ProductWarehouse struct {
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
ProductId uint `json:"product_id" gorm:"not null"`
WarehouseId uint `json:"warehouse_id" gorm:"not null"`
Quantity int `json:"quantity" gorm:"default:0"`
Quantity float64 `json:"quantity" gorm:"default:0"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
CreatedBy uint `json:"created_by" gorm:"not null"`
+22 -15
View File
@@ -6,23 +6,30 @@ import (
"gorm.io/gorm"
)
const (
LogTypeAdjustment = "ADJUSTMENT"
)
const (
TransactionTypeIncrease = "INCREASE"
TransactionTypeDecrease = "DECREASE"
)
type StockLog struct {
Id uint `json:"id" gorm:"primaryKey;"`
TransactionType string `json:"transaction_type" gorm:"type:varchar(20);not null"`
Quantity float64 `json:"quantity" gorm:"type:numeric(15,3);not null"`
BeforeQuantity float64 `json:"before_quantity" gorm:"type:numeric(15,3);not null"`
AfterQuantity float64 `json:"after_quantity" gorm:"type:numeric(15,3);not null"`
LogType string `json:"log_type" gorm:"type:varchar(50);not null"`
LogId uint `json:"log_id" gorm:"not null"`
Note string `json:"note" gorm:"type:text"`
ProductWarehouseId uint `json:"product_warehouse_id" gorm:"not null;index"`
CreatedBy uint `json:"created_by" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
Id uint `json:"id" gorm:"primaryKey;column:id"`
TransactionType string `json:"transaction_type" gorm:"column:transaction_type;type:varchar(20);not null"`
Quantity float64 `json:"quantity" gorm:"column:quantity;type:numeric(15,3);not null"`
BeforeQuantity float64 `json:"before_quantity" gorm:"column:before_quantity;type:numeric(15,3);not null"`
AfterQuantity float64 `json:"after_quantity" gorm:"column:after_quantity;type:numeric(15,3);not null"`
LogType string `json:"log_type" gorm:"column:log_type;type:varchar(50);not null;index:stock_logs_flaggable_lookup,priority:1"`
LogId uint `json:"log_id" gorm:"column:log_id;not null;index:stock_logs_flaggable_lookup,priority:2"`
Note string `json:"note" gorm:"column:note;type:text"`
ProductWarehouseId uint `json:"product_warehouse_id" gorm:"column:product_warehouse_id;not null;index"`
CreatedBy uint `json:"created_by" gorm:"column:created_by;not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at;autoUpdateTime"`
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"column:deleted_at;index"`
ProductWarehouse *ProductWarehouse `json:"product_warehouse,omitempty" gorm:"foreignKey:ProductWarehouseId;references:Id"`
CreatedUser *User `json:"created_user,omitempty" gorm:"foreignKey:CreatedBy;references:Id"`
}
@@ -2,7 +2,6 @@ package controller
import (
"math"
"strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services"
@@ -22,119 +21,61 @@ func NewAdjustmentController(adjustmentService service.AdjustmentService) *Adjus
}
}
func (u *AdjustmentController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
result, totalResults, err := u.AdjustmentService.GetAll(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.AdjustmentListDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get all adjustments successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: dto.ToAdjustmentListDTOs(result),
})
}
func (u *AdjustmentController) 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.AdjustmentService.GetOne(c, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get adjustment successfully",
Data: dto.ToAdjustmentListDTO(*result),
})
}
func (u *AdjustmentController) CreateOne(c *fiber.Ctx) error {
func (u *AdjustmentController) Adjustment(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.AdjustmentService.CreateOne(c, req)
stockLog, err := u.AdjustmentService.Adjustment(c, req)
if err != nil {
return err
}
adjustmentDTO := dto.ToAdjustmentDetailDTO(stockLog)
return c.Status(fiber.StatusCreated).
JSON(response.Success{
Code: fiber.StatusCreated,
Status: "success",
Message: "Create adjustment successfully",
Data: dto.ToAdjustmentListDTO(*result),
Data: adjustmentDTO,
})
}
func (u *AdjustmentController) 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")
func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProductID: c.QueryInt("product_id", 0),
WarehouseID: c.QueryInt("warehouse_id", 0),
TransactionType: c.Query("transaction_type", ""),
}
if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
result, err := u.AdjustmentService.UpdateOne(c, req, uint(id))
result, totalResults, err := u.AdjustmentService.AdjustmentHistory(c, query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Update adjustment successfully",
Data: dto.ToAdjustmentListDTO(*result),
})
}
func (u *AdjustmentController) 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.AdjustmentService.DeleteOne(c, uint(id)); err != nil {
return err
// Convert to DTOs
adjustmentDTOs := make([]dto.AdjustmentDetailDTO, len(result))
for i, stockLog := range result {
adjustmentDTOs[i] = dto.ToAdjustmentDetailDTO(stockLog)
}
return c.Status(fiber.StatusOK).
JSON(response.Common{
JSON(response.SuccessWithPaginate[dto.AdjustmentDetailDTO]{
Code: fiber.StatusOK,
Status: "success",
Message: "Delete adjustment successfully",
Message: "Get adjustment history successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: adjustmentDTOs,
})
}
@@ -9,56 +9,123 @@ import (
// === DTO Structs ===
type ProductBaseDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
SKU string `json:"sku"`
}
type WarehouseBaseDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type ProductWarehouseDTO struct {
Id uint `json:"id"`
ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"`
Quantity float64 `json:"quantity"`
Product *ProductBaseDTO `json:"product,omitempty"`
Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"`
}
type AdjustmentBaseDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Id uint `json:"id"`
TransactionType string `json:"transaction_type"`
Quantity float64 `json:"quantity"`
BeforeQuantity float64 `json:"before_quantity"`
AfterQuantity float64 `json:"after_quantity"`
Note string `json:"note,omitempty"`
ProductWarehouseId uint `json:"product_warehouse_id"`
ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"`
}
type AdjustmentListDTO struct {
AdjustmentBaseDTO
CreatedUser *userDTO.UserBaseDTO `json:"created_user"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type AdjustmentDetailDTO struct {
AdjustmentListDTO
UpdatedAt time.Time `json:"updated_at"`
}
// === Mapper Functions ===
func ToAdjustmentBaseDTO(e entity.Adjustment) AdjustmentBaseDTO {
return AdjustmentBaseDTO{
Id: e.Id,
Name: e.Name,
func ToProductBaseDTO(e *entity.Product) *ProductBaseDTO {
if e == nil {
return nil
}
sku := ""
if e.Sku != nil {
sku = *e.Sku
}
return &ProductBaseDTO{
Id: e.Id,
Name: e.Name,
SKU: sku,
}
}
func ToAdjustmentListDTO(e entity.Adjustment) AdjustmentListDTO {
func ToWarehouseBaseDTO(e *entity.Warehouse) *WarehouseBaseDTO {
if e == nil {
return nil
}
return &WarehouseBaseDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
if e == nil {
return nil
}
return &ProductWarehouseDTO{
Id: e.Id,
ProductId: e.ProductId,
WarehouseId: e.WarehouseId,
Quantity: e.Quantity,
Product: ToProductBaseDTO(&e.Product),
Warehouse: ToWarehouseBaseDTO(&e.Warehouse),
}
}
func ToAdjustmentBaseDTO(e *entity.StockLog) AdjustmentBaseDTO {
return AdjustmentBaseDTO{
Id: e.Id,
TransactionType: e.TransactionType,
Quantity: e.Quantity,
BeforeQuantity: e.BeforeQuantity,
AfterQuantity: e.AfterQuantity,
Note: e.Note,
ProductWarehouseId: e.ProductWarehouseId,
ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse),
}
}
func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO {
var createdUser *userDTO.UserBaseDTO
if e.CreatedUser.Id != 0 {
mapped := userDTO.ToUserBaseDTO(e.CreatedUser)
createdUser = &mapped
if e.CreatedUser != nil {
createdUser = &userDTO.UserBaseDTO{
Id: e.CreatedUser.Id,
IdUser: e.CreatedUser.IdUser,
Email: e.CreatedUser.Email,
Name: e.CreatedUser.Name,
}
}
return AdjustmentListDTO{
AdjustmentBaseDTO: ToAdjustmentBaseDTO(e),
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
CreatedUser: createdUser,
CreatedAt: e.CreatedAt,
}
}
func ToAdjustmentListDTOs(e []entity.Adjustment) []AdjustmentListDTO {
result := make([]AdjustmentListDTO, len(e))
for i, r := range e {
result[i] = ToAdjustmentListDTO(r)
}
return result
}
func ToAdjustmentDetailDTO(e entity.Adjustment) AdjustmentDetailDTO {
func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO {
return AdjustmentDetailDTO{
AdjustmentListDTO: ToAdjustmentListDTO(e),
UpdatedAt: e.UpdatedAt,
}
}
@@ -5,8 +5,10 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
rAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
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"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
@@ -15,12 +17,13 @@ import (
type AdjustmentModule struct{}
func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
adjustmentRepo := rAdjustment.NewAdjustmentRepository(db)
stockLogsRepo := rStockLogs.NewStockLogRepository(db)
warehouseRepo := rWarehouse.NewWarehouseRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
userRepo := rUser.NewUserRepository(db)
adjustmentService := sAdjustment.NewAdjustmentService(adjustmentRepo, validate)
adjustmentService := sAdjustment.NewAdjustmentService(stockLogsRepo, warehouseRepo, productWarehouseRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
AdjustmentRoutes(router, userService, adjustmentService)
}
@@ -1,21 +0,0 @@
package repository
import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
type AdjustmentRepository interface {
repository.BaseRepository[entity.Adjustment]
}
type AdjustmentRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.Adjustment]
}
func NewAdjustmentRepository(db *gorm.DB) AdjustmentRepository {
return &AdjustmentRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.Adjustment](db),
}
}
@@ -14,15 +14,8 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme
route := v1.Group("/adjustments")
// route.Get("/", m.Auth(u), ctrl.GetAll)
// route.Post("/", m.Auth(u), ctrl.CreateOne)
// route.Get("/:id", m.Auth(u), ctrl.GetOne)
// route.Patch("/:id", m.Auth(u), ctrl.UpdateOne)
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
// Standard CRUD routes following master data pattern
route.Get("/", ctrl.AdjustmentHistory) // Get all with pagination and filters
route.Post("/", ctrl.Adjustment) // Create adjustment
route.Get("/", ctrl.GetAll)
route.Post("/", ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne)
}
@@ -2,128 +2,184 @@ package service
import (
"errors"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories"
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"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gorm.io/gorm"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type AdjustmentService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Adjustment, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Adjustment, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Adjustment, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Adjustment, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error)
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error)
}
type adjustmentService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.AdjustmentRepository
Log *logrus.Logger
Validate *validator.Validate
StockLogsRepository stockLogsRepo.StockLogRepository
WarehouseRepo warehouseRepo.WarehouseRepository
ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository
}
func NewAdjustmentService(repo repository.AdjustmentRepository, validate *validator.Validate) AdjustmentService {
func NewAdjustmentService(stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate) AdjustmentService {
return &adjustmentService{
Log: utils.Log,
Validate: validate,
Repository: repo,
Log: utils.Log,
Validate: validate,
StockLogsRepository: stockLogsRepo,
WarehouseRepo: warehouseRepo,
ProductWarehouseRepo: productWarehouseRepo,
}
}
func (s adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser")
func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("CreatedUser")
}
func (s adjustmentService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Adjustment, int64, error) {
if err := s.Validate.Struct(params); err != nil {
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) {
stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, s.withRelations)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
s.Log.Errorf("Failed to get adjustment by id: %+v", err)
return nil, err
}
if stockLog.LogType != entity.LogTypeAdjustment {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return stockLog, nil
}
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
ctx := c.Context()
productWarehouseExists, err := s.ProductWarehouseRepo.ProductWarehouseExists(ctx, uint(req.ProductID), uint(req.WarehouseID), nil)
if err != nil {
return nil, err
}
if !productWarehouseExists {
return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse not found")
}
transactionType := strings.ToUpper(req.TransactionType)
if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type")
}
var createdLogId uint
err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Get product warehouse by product id and warehouse id (read operation, no transaction needed)
productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID))
if err != nil {
return err
}
if productWarehouse == nil {
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse not found")
}
s.Log.Infof("Product Warehouse found: %+v", productWarehouse.Id)
afterQuantity := productWarehouse.Quantity
if transactionType == entity.TransactionTypeIncrease {
afterQuantity += req.Quantity
} else {
if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment")
}
afterQuantity -= req.Quantity
}
newLog := &entity.StockLog{
TransactionType: transactionType,
Quantity: req.Quantity,
BeforeQuantity: productWarehouse.Quantity,
AfterQuantity: afterQuantity,
LogType: entity.LogTypeAdjustment,
LogId: 0,
Note: req.Note,
ProductWarehouseId: productWarehouse.Id,
CreatedBy: 1, // TODO: should Get from auth middleware
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
s.Log.Errorf("Failed to create stock log: %+v", err)
return err
}
s.Log.Infof("Stock log created: %+v", newLog.Id)
productWarehouse.Quantity = afterQuantity
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)
return err
}
s.Log.Infof("Product warehouse quantity updated: %+v", productWarehouse.Id)
// Set createdLogId to get the log with relations after transaction
createdLogId = newLog.Id
return nil
})
if err != nil {
s.Log.Errorf("Transaction failed in CreateOne: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction")
}
return s.GetOne(c, createdLogId)
}
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) {
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
offset := (query.Page - 1) * query.Limit
stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB {
adjustments, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
db = db.Where("log_type = ?", entity.LogTypeAdjustment)
if query.Search != "" {
db = db.Where("note ILIKE ?", "%"+query.Search+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
if query.TransactionType != "" {
db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType))
}
return db.Order("created_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, err
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
}
return adjustments, total, nil
}
func (s adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.Adjustment, error) {
adjustment, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
if err != nil {
s.Log.Errorf("Failed get adjustment by id: %+v", err)
return nil, err
}
return adjustment, nil
}
func (s *adjustmentService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Adjustment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
createBody := &entity.Adjustment{
Name: req.Name,
}
if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil {
s.Log.Errorf("Failed to create adjustment: %+v", err)
return nil, err
}
return s.GetOne(c, createBody.Id)
}
func (s adjustmentService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Adjustment, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
updateBody := make(map[string]any)
if req.Name != nil {
updateBody["name"] = *req.Name
}
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, "Adjustment not found")
}
s.Log.Errorf("Failed to update adjustment: %+v", err)
return nil, err
}
return s.GetOne(c, id)
}
func (s adjustmentService) 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, "Adjustment not found")
}
s.Log.Errorf("Failed to delete adjustment: %+v", err)
return err
}
return nil
// Convert to pointer slice
result := make([]*entity.StockLog, len(stockLogs))
for i, v := range stockLogs {
result[i] = &v
}
return result, total, nil
}
@@ -1,15 +1,18 @@
package validation
type Create struct {
Name string `json:"name" validate:"required_strict,min=3"`
}
type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"`
ProductID uint `json:"product_id" validate:"required"`
WarehouseID uint `json:"warehouse_id" validate:"required"`
TransactionType string `json:"transaction_type" validate:"required,oneof=increase decrease"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
Note string `json:"note" validate:"omitempty,max=255"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
Page int `query:"page" validate:"omitempty,min=1"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
Search string `query:"search" validate:"omitempty"`
ProductID int `query:"product_id" validate:"omitempty,min=0"`
WarehouseID int `query:"warehouse_id" validate:"omitempty,min=0"`
TransactionType string `query:"transaction_type" validate:"omitempty,oneof=increase decrease"`
}
@@ -26,7 +26,6 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
}
@@ -13,7 +13,7 @@ type ProductWarehouseBaseDTO struct {
Id uint `json:"id"`
ProductId uint `json:"product_id"`
WarehouseId uint `json:"warehouse_id"`
Quantity int `json:"quantity"`
Quantity float64 `json:"quantity"`
}
type ProductWarehouseListDTO struct {
@@ -14,6 +14,7 @@ type ProductWarehouseRepository interface {
IsProductExist(ctx context.Context, productId uint) (bool, error)
IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error)
ExistsByID(ctx context.Context, id uint) (bool, error)
GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error)
}
type ProductWarehouseRepositoryImpl struct {
@@ -51,3 +52,11 @@ func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, w
func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.ProductWarehouse](ctx, r.db, id)
}
func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) {
var productWarehouse entity.ProductWarehouse
if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil {
return nil, err
}
return &productWarehouse, nil
}
@@ -58,13 +58,6 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId)
}
// Search in related product or warehouse names
if params.Search != "" {
db = db.Joins("LEFT JOIN products ON products.id = product_warehouse.product_id").
Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouse.warehouse_id").
Where("products.name ILIKE ? OR warehouses.name ILIKE ?", "%"+params.Search+"%", "%"+params.Search+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -1,21 +1,20 @@
package validation
type Create struct {
ProductId uint `json:"product_id" validate:"required,number,min=1"`
WarehouseId uint `json:"warehouse_id" validate:"required,number,min=1"`
Quantity int `json:"quantity" validate:"required,number,min=0"`
ProductId uint `json:"product_id" validate:"required,number,min=1"`
WarehouseId uint `json:"warehouse_id" validate:"required,number,min=1"`
Quantity float64 `json:"quantity" validate:"required,number,min=0"`
}
type Update struct {
ProductId *uint `json:"product_id,omitempty" validate:"omitempty,number,min=1"`
WarehouseId *uint `json:"warehouse_id,omitempty" validate:"omitempty,number,min=1"`
Quantity *int `json:"quantity,omitempty" validate:"omitempty,number,min=0"`
ProductId *uint `json:"product_id,omitempty" validate:"omitempty,number,min=1"`
WarehouseId *uint `json:"warehouse_id,omitempty" validate:"omitempty,number,min=1"`
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,number,min=0"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
}
@@ -24,9 +24,10 @@ func NewProductController(productService service.ProductService) *ProductControl
func (u *ProductController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProductCategoryID: c.QueryInt("product_category_id", 0),
}
result, totalResults, err := u.ProductService.GetAll(c, query)
@@ -72,6 +72,9 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity
if params.Search != "" {
return db.Where("name LIKE ?", "%"+params.Search+"%")
}
if params.ProductCategoryID != 0 {
return db.Where("product_category_id = ?", params.ProductCategoryID)
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
@@ -29,7 +29,8 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"`
}
@@ -0,0 +1,88 @@
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 StockLogRepository interface {
repository.BaseRepository[entity.StockLog]
GetByFlaggable(ctx context.Context, logType string, logId uint) ([]*entity.StockLog, error)
GetByProductWarehouse(ctx context.Context, productWarehouseId uint, limit int) ([]*entity.StockLog, error)
GetByTransactionType(ctx context.Context, transactionType string, limit int) ([]*entity.StockLog, error)
}
type StockLogRepositoryImpl struct {
*repository.BaseRepositoryImpl[entity.StockLog]
}
func NewStockLogRepository(db *gorm.DB) StockLogRepository {
return &StockLogRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[entity.StockLog](db),
}
}
func (r *StockLogRepositoryImpl) GetByFlaggable(ctx context.Context, logType string, logId uint) ([]*entity.StockLog, error) {
var stockLogs []*entity.StockLog
err := r.DB().WithContext(ctx).
Where("log_type = ? AND log_id = ?", logType, logId).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Order("created_at DESC").
Find(&stockLogs).Error
if err != nil {
return nil, err
}
return stockLogs, nil
}
func (r *StockLogRepositoryImpl) GetByProductWarehouse(ctx context.Context, productWarehouseId uint, limit int) ([]*entity.StockLog, error) {
var stockLogs []*entity.StockLog
query := r.DB().WithContext(ctx).
Where("product_warehouse_id = ?", productWarehouseId).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&stockLogs).Error
if err != nil {
return nil, err
}
return stockLogs, nil
}
func (r *StockLogRepositoryImpl) GetByTransactionType(ctx context.Context, transactionType string, limit int) ([]*entity.StockLog, error) {
var stockLogs []*entity.StockLog
query := r.DB().WithContext(ctx).
Where("transaction_type = ?", transactionType).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Order("created_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&stockLogs).Error
if err != nil {
return nil, err
}
return stockLogs, nil
}
+2 -2
View File
@@ -9,9 +9,9 @@ import (
"gorm.io/gorm"
constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants"
inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory"
master "gitlab.com/mbugroup/lti-api.git/internal/modules/master"
users "gitlab.com/mbugroup/lti-api.git/internal/modules/users"
inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory"
// MODULE IMPORTS
)
@@ -25,7 +25,7 @@ func Routes(app *fiber.App, db *gorm.DB) {
users.UserModule{},
master.MasterModule{},
constants.ConstantModule{},
inventory.InventoryModule{},
inventory.InventoryModule{},
// MODULE REGISTRY
}