feat: implement component-item management with CRUD operations and status updates
This commit is contained in:
56
internal/mapper/component_item_mapper.go
Normal file
56
internal/mapper/component_item_mapper.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"wm-backend/internal/models"
|
||||
db "wm-backend/sqlc_gen"
|
||||
)
|
||||
|
||||
func ToDomainComponentItem(r db.ComponentItem) *models.ComponentItem {
|
||||
return &models.ComponentItem{
|
||||
ID: r.ID,
|
||||
ComponentID: r.ComponentID,
|
||||
ContainerID: r.ContainerID,
|
||||
Quantity: r.Quantity,
|
||||
Status: string(r.Status),
|
||||
Metadata: json.RawMessage(r.Metadata),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToModelComponentItem(r *models.ComponentItem) *db.CreateComponentItemParams {
|
||||
return &db.CreateComponentItemParams{
|
||||
ComponentID: r.ComponentID,
|
||||
ContainerID: r.ContainerID,
|
||||
Quantity: r.Quantity,
|
||||
Status: db.ComponentItemStatusEnum(r.Status),
|
||||
Metadata: []byte(r.Metadata),
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToUpdateModelComponentItem(r *models.ComponentItem) *db.UpdateComponentItemParams {
|
||||
return &db.UpdateComponentItemParams{
|
||||
ComponentID: r.ComponentID,
|
||||
ContainerID: r.ContainerID,
|
||||
Metadata: []byte(r.Metadata),
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
ID: r.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func ToDomainFindComponentItem(r db.FindComponentItemRow) *models.FindComponentItemResult {
|
||||
return &models.FindComponentItemResult{
|
||||
ComponentName: r.ComponentName,
|
||||
TypeName: r.TypeName,
|
||||
Quantity: r.Quantity,
|
||||
Status: string(r.Status),
|
||||
ContainerName: r.ContainerName,
|
||||
ContainerType: string(r.ContainerType),
|
||||
ShelfName: r.ShelfName,
|
||||
CabinetName: r.CabinetName,
|
||||
RoomName: r.RoomName,
|
||||
WarehouseName: r.WarehouseName,
|
||||
}
|
||||
}
|
||||
45
internal/mapper/component_status_history_mapper.go
Normal file
45
internal/mapper/component_status_history_mapper.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"wm-backend/internal/models"
|
||||
db "wm-backend/sqlc_gen"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func ToDomainComponentStatusHistory(r db.ComponentStatusHistory) *models.ComponentStatusHistory {
|
||||
return &models.ComponentStatusHistory{
|
||||
ID: r.ID,
|
||||
ComponentItemID: r.ComponentItemID,
|
||||
OldStatus: string(r.OldStatus.ComponentItemStatusEnum),
|
||||
NewStatus: string(r.NewStatus),
|
||||
ChangedQuantity: r.ChangedQuantity.Int32,
|
||||
Note: r.Note.String,
|
||||
ChangedBy: r.ChangedBy.String,
|
||||
ChangedAt: r.ChangedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToModelComponentStatusHistory(r *models.ComponentStatusHistory) *db.CreateComponentStatusHistoryParams {
|
||||
return &db.CreateComponentStatusHistoryParams{
|
||||
ComponentItemID: r.ComponentItemID,
|
||||
OldStatus: db.NullComponentItemStatusEnum{
|
||||
ComponentItemStatusEnum: db.ComponentItemStatusEnum(r.OldStatus),
|
||||
Valid: r.OldStatus != "",
|
||||
},
|
||||
NewStatus: db.ComponentItemStatusEnum(r.NewStatus),
|
||||
ChangedQuantity: pgtype.Int4{
|
||||
Int32: r.ChangedQuantity,
|
||||
Valid: r.ChangedQuantity != 0,
|
||||
},
|
||||
Note: pgtype.Text{
|
||||
String: r.Note,
|
||||
Valid: r.Note != "",
|
||||
},
|
||||
ChangedBy: pgtype.Text{
|
||||
String: r.ChangedBy,
|
||||
Valid: r.ChangedBy != "",
|
||||
},
|
||||
ChangedAt: r.ChangedAt,
|
||||
}
|
||||
}
|
||||
42
internal/models/component_item_model.go
Normal file
42
internal/models/component_item_model.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ComponentItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ComponentID int64 `json:"componentId"`
|
||||
ContainerID int64 `json:"containerId"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
Status string `json:"status"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UpdateStatusResult holds the results of a component item status change operation.
|
||||
// Different fields are populated depending on the case:
|
||||
// - Case 1 (change all): only ComponentItem and StatusHistory are set
|
||||
// - Case 2 (split): NewComponentItemID is also set
|
||||
// - Case 3 (merge): MergedComponentItemID is also set
|
||||
type UpdateStatusResult struct {
|
||||
ComponentItem ComponentItem
|
||||
StatusHistory ComponentStatusHistory
|
||||
NewComponentItemID *int64
|
||||
MergedComponentItemID *int64
|
||||
}
|
||||
|
||||
type FindComponentItemResult struct {
|
||||
ComponentName string `json:"componentName"`
|
||||
TypeName string `json:"typeName"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
Status string `json:"status"`
|
||||
ContainerName string `json:"containerName"`
|
||||
ContainerType string `json:"containerType"`
|
||||
ShelfName string `json:"shelfName"`
|
||||
CabinetName string `json:"cabinetName"`
|
||||
RoomName string `json:"roomName"`
|
||||
WarehouseName string `json:"warehouseName"`
|
||||
}
|
||||
14
internal/models/component_status_history_model.go
Normal file
14
internal/models/component_status_history_model.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type ComponentStatusHistory struct {
|
||||
ID int64 `json:"id"`
|
||||
ComponentItemID int64 `json:"componentItemId"`
|
||||
OldStatus string `json:"oldStatus"`
|
||||
NewStatus string `json:"newStatus"`
|
||||
ChangedQuantity int32 `json:"changedQuantity"`
|
||||
Note string `json:"note"`
|
||||
ChangedBy string `json:"changedBy"`
|
||||
ChangedAt time.Time `json:"changedAt"`
|
||||
}
|
||||
24
internal/models/requests/component_item_request.go
Normal file
24
internal/models/requests/component_item_request.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package requests
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type CreateComponentItemRequest struct {
|
||||
ComponentID int64 `json:"componentId" binding:"required"`
|
||||
ContainerID int64 `json:"containerId" binding:"required"`
|
||||
Quantity int32 `json:"quantity" binding:"required"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateComponentItemRequest struct {
|
||||
ComponentID *int64 `json:"componentId"`
|
||||
ContainerID *int64 `json:"containerId"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
// UpdateComponentItemStatusRequest represents the request body for changing the status of a component item.
|
||||
type UpdateComponentItemStatusRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=normal damaged long_unused expired pending_inspection"`
|
||||
ChangedQuantity *int32 `json:"changedQuantity"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package requests
|
||||
|
||||
type CreateComponentStatusHistoryRequest struct {
|
||||
OldStatus string `json:"oldStatus"`
|
||||
NewStatus string `json:"newStatus" binding:"required"`
|
||||
ChangedQuantity *int32 `json:"changedQuantity"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
28
internal/models/responses/component_item_response.go
Normal file
28
internal/models/responses/component_item_response.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package responses
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type CreateComponentItemResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateComponentItemResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ComponentID int64 `json:"componentId"`
|
||||
ContainerID int64 `json:"containerId"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
Status string `json:"status"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
|
||||
// UpdateComponentItemStatusResponse represents the response for a status change operation.
|
||||
// Different fields are populated depending on the case (change all, split, merge).
|
||||
type UpdateComponentItemStatusResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
OldStatus string `json:"oldStatus"`
|
||||
NewStatus string `json:"newStatus"`
|
||||
ChangedQuantity int32 `json:"changedQuantity"`
|
||||
HistoryID int64 `json:"historyId"`
|
||||
NewComponentItemID *int64 `json:"newComponentItemId,omitempty"`
|
||||
MergedComponentItemID *int64 `json:"mergedComponentItemId,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package responses
|
||||
|
||||
type CreateComponentStatusHistoryResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
238
internal/repositories/component_item_repository.go
Normal file
238
internal/repositories/component_item_repository.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
"wm-backend/global"
|
||||
"wm-backend/internal/mapper"
|
||||
"wm-backend/internal/models"
|
||||
db "wm-backend/sqlc_gen"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func CreateComponentItem(ctx context.Context, queries *db.Queries, body models.ComponentItem) (models.ComponentItem, error) {
|
||||
result, err := queries.CreateComponentItem(ctx, *mapper.ToModelComponentItem(&body))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create component item")
|
||||
return models.ComponentItem{}, err
|
||||
}
|
||||
return *mapper.ToDomainComponentItem(result), nil
|
||||
}
|
||||
|
||||
func GetComponentItemByID(ctx context.Context, queries *db.Queries, id int64) (models.ComponentItem, error) {
|
||||
result, err := queries.GetComponentItemByID(ctx, id)
|
||||
if err != nil {
|
||||
return models.ComponentItem{}, err
|
||||
}
|
||||
return *mapper.ToDomainComponentItem(result), nil
|
||||
}
|
||||
|
||||
func ListComponentItems(ctx context.Context, queries *db.Queries) ([]models.ComponentItem, error) {
|
||||
results, err := queries.ListComponentItems(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []models.ComponentItem
|
||||
for _, r := range results {
|
||||
items = append(items, *mapper.ToDomainComponentItem(r))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func UpdateComponentItem(ctx context.Context, queries *db.Queries, body models.ComponentItem) (models.ComponentItem, error) {
|
||||
result, err := queries.UpdateComponentItem(ctx, *mapper.ToUpdateModelComponentItem(&body))
|
||||
if err != nil {
|
||||
return models.ComponentItem{}, err
|
||||
}
|
||||
return *mapper.ToDomainComponentItem(result), nil
|
||||
}
|
||||
|
||||
func DeleteComponentItem(ctx context.Context, queries *db.Queries, id int64) (int64, error) {
|
||||
rowsAffected, err := queries.DeleteComponentItem(ctx, id)
|
||||
if err != nil {
|
||||
return rowsAffected, err
|
||||
}
|
||||
return rowsAffected, nil
|
||||
}
|
||||
|
||||
func FindComponentItems(ctx context.Context, queries *db.Queries, componentID int64) ([]models.FindComponentItemResult, error) {
|
||||
results, err := queries.FindComponentItem(ctx, componentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []models.FindComponentItemResult
|
||||
for _, r := range results {
|
||||
items = append(items, *mapper.ToDomainFindComponentItem(r))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// UpdateComponentItemStatus changes the status of a component item within a transaction.
|
||||
// It handles three cases:
|
||||
// - Case 1: changedQuantity is nil or >= quantity → change status of entire record
|
||||
// - Case 2: changedQuantity < quantity, no existing record with target status → split into 2 records
|
||||
// - Case 3: changedQuantity < quantity, existing record with target status → merge quantities
|
||||
//
|
||||
// Returns UpdateStatusResult with case-specific fields populated.
|
||||
func UpdateComponentItemStatus(ctx context.Context, dbPool *pgxpool.Pool, id int64, newStatus db.ComponentItemStatusEnum,
|
||||
changedQuantity *int32,
|
||||
note string,
|
||||
changedBy string) (result models.UpdateStatusResult, err error) {
|
||||
|
||||
// 1. Begin transaction
|
||||
tx, err := dbPool.Begin(ctx)
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
txQueries := global.Queries.WithTx(tx)
|
||||
|
||||
// 2. Get existing item
|
||||
existingItem, err := txQueries.GetComponentItemByID(ctx, id)
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("item not found: %w", err)
|
||||
}
|
||||
|
||||
// 3. Check status unchanged
|
||||
if existingItem.Status == newStatus {
|
||||
tx.Rollback(ctx)
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("status unchanged")
|
||||
}
|
||||
|
||||
// 4. Determine changed quantity (nil = change all)
|
||||
var changedQty int32
|
||||
if changedQuantity == nil {
|
||||
changedQty = existingItem.Quantity
|
||||
} else {
|
||||
changedQty = *changedQuantity
|
||||
}
|
||||
|
||||
// 5. Validate changed quantity
|
||||
if changedQty <= 0 {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("changed_quantity must be positive, got %d", changedQty)
|
||||
}
|
||||
if changedQty > existingItem.Quantity {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("changed_quantity (%d) exceeds current quantity (%d)", changedQty, existingItem.Quantity)
|
||||
}
|
||||
|
||||
// 6. Branch by case
|
||||
if changedQty >= existingItem.Quantity {
|
||||
// ── Case 1: Change status of entire record ──
|
||||
updatedItem, err := txQueries.UpdateComponentItemStatus(ctx, db.UpdateComponentItemStatusParams{
|
||||
ID: id,
|
||||
Status: newStatus,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("update status: %w", err)
|
||||
}
|
||||
result.ComponentItem = *mapper.ToDomainComponentItem(updatedItem)
|
||||
} else {
|
||||
// ── Case 2 & 3: Partial change ──
|
||||
|
||||
// 6a. Reduce quantity on original record (or delete if qty becomes 0)
|
||||
newQty := existingItem.Quantity - changedQty
|
||||
if newQty == 0 {
|
||||
_, err = txQueries.DeleteComponentItem(ctx, id)
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("delete zero-qty record: %w", err)
|
||||
}
|
||||
} else {
|
||||
_, err = txQueries.UpdateComponentItemQuantity(ctx, db.UpdateComponentItemQuantityParams{
|
||||
ID: id,
|
||||
Quantity: newQty,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("update quantity: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Find existing record with same (component_id, container_id, new_status)
|
||||
existingNewStatus, findErr := txQueries.GetComponentItemByComponentContainerStatus(ctx,
|
||||
db.GetComponentItemByComponentContainerStatusParams{
|
||||
ComponentID: existingItem.ComponentID,
|
||||
ContainerID: existingItem.ContainerID,
|
||||
Status: newStatus,
|
||||
})
|
||||
|
||||
if findErr != nil {
|
||||
if errors.Is(findErr, pgx.ErrNoRows) {
|
||||
// ── Case 2: No existing record → create new one ──
|
||||
newItem, createErr := txQueries.CreateComponentItem(ctx, db.CreateComponentItemParams{
|
||||
ComponentID: existingItem.ComponentID,
|
||||
ContainerID: existingItem.ContainerID,
|
||||
Quantity: changedQty,
|
||||
Status: newStatus,
|
||||
Metadata: existingItem.Metadata,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if createErr != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("create new item: %w", createErr)
|
||||
}
|
||||
newID := newItem.ID
|
||||
result.NewComponentItemID = &newID
|
||||
} else {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("find existing new status: %w", findErr)
|
||||
}
|
||||
} else {
|
||||
// ── Case 3: Existing record → merge quantities ──
|
||||
mergedQty := existingNewStatus.Quantity + changedQty
|
||||
_, err = txQueries.UpdateComponentItemQuantity(ctx, db.UpdateComponentItemQuantityParams{
|
||||
ID: existingNewStatus.ID,
|
||||
Quantity: mergedQty,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("merge quantity: %w", err)
|
||||
}
|
||||
mergedID := existingNewStatus.ID
|
||||
result.MergedComponentItemID = &mergedID
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Insert status history record
|
||||
history, err := txQueries.CreateComponentStatusHistory(ctx, db.CreateComponentStatusHistoryParams{
|
||||
ComponentItemID: id,
|
||||
OldStatus: db.NullComponentItemStatusEnum{
|
||||
ComponentItemStatusEnum: existingItem.Status,
|
||||
Valid: true,
|
||||
},
|
||||
NewStatus: newStatus,
|
||||
ChangedQuantity: pgtype.Int4{
|
||||
Int32: changedQty,
|
||||
Valid: true,
|
||||
},
|
||||
Note: pgtype.Text{
|
||||
String: note,
|
||||
Valid: note != "",
|
||||
},
|
||||
ChangedBy: pgtype.Text{
|
||||
String: changedBy,
|
||||
Valid: true,
|
||||
},
|
||||
ChangedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("insert history: %w", err)
|
||||
}
|
||||
result.StatusHistory = *mapper.ToDomainComponentStatusHistory(history)
|
||||
|
||||
// 8. Commit transaction
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return models.UpdateStatusResult{}, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
518
internal/repositories/component_item_repository_test.go
Normal file
518
internal/repositories/component_item_repository_test.go
Normal file
@@ -0,0 +1,518 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
"wm-backend/internal/models"
|
||||
db "wm-backend/sqlc_gen"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// testDB returns a connection pool for tests. Set WM_TEST_DB_URL to override
|
||||
// the default connection string. If the DB is not available the test is skipped.
|
||||
func testDB(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("WM_TEST_DB_URL")
|
||||
if dsn == "" {
|
||||
dsn = "postgres://root:Smatec2026@localhost:5432/warehouse_management?sslmode=disable"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Skipf("skipping integration test: cannot connect to DB: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
t.Skipf("skipping integration test: cannot ping DB: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// seedComponentItem inserts all required parent records and returns
|
||||
// the created component_item ID, plus its component_id and container_id.
|
||||
func seedComponentItem(t *testing.T, pool *pgxpool.Pool, q *db.Queries, quantity int32, status db.ComponentItemStatusEnum) (itemID, componentID, containerID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// warehouse
|
||||
w, err := q.CreateWarehouse(ctx, db.CreateWarehouseParams{
|
||||
Name: fmt.Sprintf("test-wh-%d", time.Now().UnixNano()),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed warehouse: %v", err)
|
||||
}
|
||||
// room
|
||||
r, err := q.CreateRoom(ctx, db.CreateRoomParams{
|
||||
WarehouseID: w.ID,
|
||||
Name: fmt.Sprintf("test-room-%d", time.Now().UnixNano()),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed room: %v", err)
|
||||
}
|
||||
// cabinet
|
||||
cb, err := q.CreateCabinet(ctx, db.CreateCabinetParams{
|
||||
RoomID: r.ID,
|
||||
Name: fmt.Sprintf("test-cab-%d", time.Now().UnixNano()),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed cabinet: %v", err)
|
||||
}
|
||||
// shelf
|
||||
s, err := q.CreateShelve(ctx, db.CreateShelveParams{
|
||||
CabinetID: cb.ID,
|
||||
Name: fmt.Sprintf("test-shelf-%d", time.Now().UnixNano()),
|
||||
LevelIndex: 1,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed shelf: %v", err)
|
||||
}
|
||||
// container
|
||||
cn, err := q.CreateContainer(ctx, db.CreateContainerParams{
|
||||
ShelfID: s.ID,
|
||||
Name: fmt.Sprintf("test-cont-%d", time.Now().UnixNano()),
|
||||
ContainerType: db.ContainerTypeEnumOther,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed container: %v", err)
|
||||
}
|
||||
containerID = cn.ID
|
||||
|
||||
// component_type
|
||||
ct, err := q.CreateComponentType(ctx, db.CreateComponentTypeParams{
|
||||
Name: fmt.Sprintf("test-ctype-%d", time.Now().UnixNano()),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed component_type: %v", err)
|
||||
}
|
||||
// component
|
||||
c, err := q.CreateComponent(ctx, db.CreateComponentParams{
|
||||
ComponentTypeID: ct.ID,
|
||||
Name: fmt.Sprintf("test-comp-%d", time.Now().UnixNano()),
|
||||
Unit: "cái",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed component: %v", err)
|
||||
}
|
||||
componentID = c.ID
|
||||
|
||||
// component_item
|
||||
ci, err := q.CreateComponentItem(ctx, db.CreateComponentItemParams{
|
||||
ComponentID: componentID,
|
||||
ContainerID: containerID,
|
||||
Quantity: quantity,
|
||||
Status: status,
|
||||
Metadata: []byte("{}"),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed component_item: %v", err)
|
||||
}
|
||||
return ci.ID, componentID, containerID
|
||||
}
|
||||
|
||||
// cleanupSeeded removes test data in reverse dependency order.
|
||||
func cleanupSeeded(t *testing.T, pool *pgxpool.Pool, itemID, componentID, containerID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
q := db.New(pool)
|
||||
// Delete history rows referencing this item
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM component_status_history WHERE component_item_id = $1", itemID)
|
||||
// Delete component_items (including those possibly created by the test)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM component_items WHERE component_id = $1", componentID)
|
||||
_, _ = q.DeleteComponent(ctx, componentID)
|
||||
// Delete component_type – we need to find it from the component
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM component_types WHERE id = (SELECT component_type_id FROM components WHERE id = $1)", componentID)
|
||||
_, _ = q.DeleteContainer(ctx, containerID)
|
||||
// Delete container's hierarchy
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1)", containerID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM cabinets WHERE id IN (SELECT cabinet_id FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1))", containerID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM rooms WHERE id IN (SELECT room_id FROM cabinets WHERE id IN (SELECT cabinet_id FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1)))", containerID)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM warehouses WHERE id IN (SELECT warehouse_id FROM rooms WHERE id IN (SELECT room_id FROM cabinets WHERE id IN (SELECT cabinet_id FROM shelves WHERE id = (SELECT shelf_id FROM containers WHERE id = $1))))", containerID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestUpdateComponentItemStatus_Case1_ChangeAll_NilQuantity(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 20, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, nil, "test note", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify result
|
||||
if result.StatusHistory.OldStatus != "normal" {
|
||||
t.Errorf("expected old status 'normal', got '%s'", result.StatusHistory.OldStatus)
|
||||
}
|
||||
if result.StatusHistory.NewStatus != "damaged" {
|
||||
t.Errorf("expected new status 'damaged', got '%s'", result.StatusHistory.NewStatus)
|
||||
}
|
||||
if result.StatusHistory.ChangedQuantity != 20 {
|
||||
t.Errorf("expected changed quantity 20, got %d", result.StatusHistory.ChangedQuantity)
|
||||
}
|
||||
if result.StatusHistory.ChangedBy != "system" {
|
||||
t.Errorf("expected changed_by 'system', got '%s'", result.StatusHistory.ChangedBy)
|
||||
}
|
||||
if result.NewComponentItemID != nil {
|
||||
t.Error("expected NewComponentItemID to be nil for case 1")
|
||||
}
|
||||
if result.MergedComponentItemID != nil {
|
||||
t.Error("expected MergedComponentItemID to be nil for case 1")
|
||||
}
|
||||
|
||||
// Verify DB state
|
||||
updated, err := q.GetComponentItemByID(context.Background(), itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("get updated item: %v", err)
|
||||
}
|
||||
if updated.Status != db.ComponentItemStatusEnumDamaged {
|
||||
t.Errorf("expected status 'damaged', got '%s'", updated.Status)
|
||||
}
|
||||
if updated.Quantity != 20 {
|
||||
t.Errorf("expected quantity 20, got %d", updated.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Case1_ChangeAll_ExplicitQuantity(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 15, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
changedQty := int32(15)
|
||||
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.StatusHistory.ChangedQuantity != 15 {
|
||||
t.Errorf("expected changed quantity 15, got %d", result.StatusHistory.ChangedQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Case2_Split_NoExistingTarget(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 20, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
changedQty := int32(5)
|
||||
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "split test", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify result
|
||||
if result.NewComponentItemID == nil {
|
||||
t.Fatal("expected NewComponentItemID to be set for case 2")
|
||||
}
|
||||
if result.MergedComponentItemID != nil {
|
||||
t.Error("expected MergedComponentItemID to be nil for case 2")
|
||||
}
|
||||
|
||||
// Original record should have reduced quantity
|
||||
orig, err := q.GetComponentItemByID(context.Background(), itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("get original item: %v", err)
|
||||
}
|
||||
if orig.Quantity != 15 {
|
||||
t.Errorf("expected original quantity 15, got %d", orig.Quantity)
|
||||
}
|
||||
if orig.Status != db.ComponentItemStatusEnumNormal {
|
||||
t.Errorf("expected original status 'normal', got '%s'", orig.Status)
|
||||
}
|
||||
|
||||
// New record should have the split quantity with target status
|
||||
newItem, err := q.GetComponentItemByID(context.Background(), *result.NewComponentItemID)
|
||||
if err != nil {
|
||||
t.Fatalf("get new item: %v", err)
|
||||
}
|
||||
if newItem.Quantity != 5 {
|
||||
t.Errorf("expected new item quantity 5, got %d", newItem.Quantity)
|
||||
}
|
||||
if newItem.Status != db.ComponentItemStatusEnumDamaged {
|
||||
t.Errorf("expected new item status 'damaged', got '%s'", newItem.Status)
|
||||
}
|
||||
if newItem.ComponentID != orig.ComponentID {
|
||||
t.Error("new item should have same component_id")
|
||||
}
|
||||
if newItem.ContainerID != orig.ContainerID {
|
||||
t.Error("new item should have same container_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Case3_Merge_ExistingTarget(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 20, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
// Pre-create a damaged record to merge into
|
||||
ctx := context.Background()
|
||||
existingDamaged, err := q.CreateComponentItem(ctx, db.CreateComponentItemParams{
|
||||
ComponentID: compID,
|
||||
ContainerID: contID,
|
||||
Quantity: 3,
|
||||
Status: db.ComponentItemStatusEnumDamaged,
|
||||
Metadata: []byte("{}"),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("pre-create damaged record: %v", err)
|
||||
}
|
||||
|
||||
changedQty := int32(5)
|
||||
result, err := UpdateComponentItemStatus(ctx, pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "merge test", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify result
|
||||
if result.NewComponentItemID != nil {
|
||||
t.Error("expected NewComponentItemID to be nil for case 3")
|
||||
}
|
||||
if result.MergedComponentItemID == nil {
|
||||
t.Fatal("expected MergedComponentItemID to be set for case 3")
|
||||
}
|
||||
if *result.MergedComponentItemID != existingDamaged.ID {
|
||||
t.Errorf("expected merged ID %d, got %d", existingDamaged.ID, *result.MergedComponentItemID)
|
||||
}
|
||||
|
||||
// Original record should have reduced quantity
|
||||
orig, err := q.GetComponentItemByID(ctx, itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("get original item: %v", err)
|
||||
}
|
||||
if orig.Quantity != 15 {
|
||||
t.Errorf("expected original quantity 15, got %d", orig.Quantity)
|
||||
}
|
||||
|
||||
// Merged record should have increased quantity
|
||||
merged, err := q.GetComponentItemByID(ctx, existingDamaged.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get merged item: %v", err)
|
||||
}
|
||||
if merged.Quantity != 8 { // 3 + 5
|
||||
t.Errorf("expected merged quantity 8, got %d", merged.Quantity)
|
||||
}
|
||||
if merged.Status != db.ComponentItemStatusEnumDamaged {
|
||||
t.Errorf("expected merged status 'damaged', got '%s'", merged.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Edge_StatusUnchanged(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumNormal, nil, "", "system")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for status unchanged")
|
||||
}
|
||||
if err.Error() != "status unchanged" {
|
||||
t.Errorf("expected 'status unchanged', got '%s'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Edge_ChangedQuantityZero(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
changedQty := int32(0)
|
||||
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for zero changed_quantity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Edge_ChangedQuantityNegative(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
changedQty := int32(-5)
|
||||
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative changed_quantity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Edge_ChangedQuantityExceedsQuantity(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
changedQty := int32(25)
|
||||
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "", "system")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for changed_quantity > quantity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Edge_ItemNotFound(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
_, err := UpdateComponentItemStatus(context.Background(), pool, 999999,
|
||||
db.ComponentItemStatusEnumDamaged, nil, "", "system")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_Edge_QuantityBecomesZero(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 5, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
// Change all 5 to damaged → original record quantity becomes 0 → should be deleted
|
||||
changedQty := int32(5)
|
||||
_, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, &changedQty, "zero qty test", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// The original record should be deleted (quantity became 0)
|
||||
_, err = q.GetComponentItemByID(context.Background(), itemID)
|
||||
if err == nil {
|
||||
t.Error("expected original item to be deleted when quantity becomes 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_ChangeToPendingInspection(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 30, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumPendingInspection, nil, "pending inspection check", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.StatusHistory.NewStatus != "pending_inspection" {
|
||||
t.Errorf("expected new status 'pending_inspection', got '%s'", result.StatusHistory.NewStatus)
|
||||
}
|
||||
|
||||
updated, _ := q.GetComponentItemByID(context.Background(), itemID)
|
||||
if updated.Status != db.ComponentItemStatusEnumPendingInspection {
|
||||
t.Errorf("expected status 'pending_inspection', got '%s'", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateComponentItemStatus_HistoryRecordIntegrity(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumLongUnused, nil, "history integrity test note", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
history := result.StatusHistory
|
||||
if history.ComponentItemID != itemID {
|
||||
t.Errorf("expected component_item_id %d, got %d", itemID, history.ComponentItemID)
|
||||
}
|
||||
if history.OldStatus != "normal" {
|
||||
t.Errorf("expected old_status 'normal', got '%s'", history.OldStatus)
|
||||
}
|
||||
if history.NewStatus != "long_unused" {
|
||||
t.Errorf("expected new_status 'long_unused', got '%s'", history.NewStatus)
|
||||
}
|
||||
if history.Note != "history integrity test note" {
|
||||
t.Errorf("expected note 'history integrity test note', got '%s'", history.Note)
|
||||
}
|
||||
if history.ChangedBy != "system" {
|
||||
t.Errorf("expected changed_by 'system', got '%s'", history.ChangedBy)
|
||||
}
|
||||
if history.ChangedQuantity != 10 {
|
||||
t.Errorf("expected changed_quantity 10, got %d", history.ChangedQuantity)
|
||||
}
|
||||
if history.ID == 0 {
|
||||
t.Error("expected history ID to be non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateComponentItemStatus_ResultType ensures the UpdateStatusResult domain
|
||||
// model is populated correctly.
|
||||
func TestUpdateComponentItemStatus_ResultType(t *testing.T) {
|
||||
pool := testDB(t)
|
||||
defer pool.Close()
|
||||
q := db.New(pool)
|
||||
itemID, compID, contID := seedComponentItem(t, pool, q, 10, db.ComponentItemStatusEnumNormal)
|
||||
defer cleanupSeeded(t, pool, itemID, compID, contID)
|
||||
|
||||
result, err := UpdateComponentItemStatus(context.Background(), pool, itemID,
|
||||
db.ComponentItemStatusEnumDamaged, nil, "", "system")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify UpdateStatusResult fields
|
||||
var _ models.UpdateStatusResult = result // compile-time check
|
||||
if result.ComponentItem.ID == 0 && result.NewComponentItemID == nil && result.MergedComponentItemID == nil {
|
||||
// Case 1 doesn't set ComponentItem either in current impl — that's fine
|
||||
// ComponentItem is only set in Case 1 (change all). Let's verify.
|
||||
// Actually looking at the repo code, ComponentItem is only set in Case 1.
|
||||
// For Case 2/3 it's not set because the original item is only partially changed.
|
||||
// This is intentional.
|
||||
|
||||
// For Case 1, ComponentItem should be set
|
||||
if result.ComponentItem.ID == 0 {
|
||||
t.Error("expected ComponentItem.ID to be set for Case 1 (change all)")
|
||||
}
|
||||
}
|
||||
if result.StatusHistory.ID == 0 {
|
||||
t.Error("expected StatusHistory.ID to be non-zero")
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,17 @@ func NewRouter() *gin.Engine {
|
||||
componentCode.PUT("/:id", utils.AsyncHandler(services.ComponentCodeUpdate))
|
||||
componentCode.DELETE("/:id", utils.AsyncHandler(services.ComponentCodeDelete))
|
||||
}
|
||||
|
||||
componentItem := v1.Group(constants.API_GROUP_COMPONENT_ITEM)
|
||||
{
|
||||
componentItem.GET("", utils.AsyncHandler(services.ComponentItemList))
|
||||
componentItem.GET("/find/:componentId", utils.AsyncHandler(services.ComponentItemFind))
|
||||
componentItem.GET("/:id", utils.AsyncHandler(services.ComponentItemGetByID))
|
||||
componentItem.POST("", utils.AsyncHandler(services.ComponentItemCreate))
|
||||
componentItem.PUT("/:id/status", utils.AsyncHandler(services.ComponentItemUpdateStatus))
|
||||
componentItem.PUT("/:id", utils.AsyncHandler(services.ComponentItemUpdate))
|
||||
componentItem.DELETE("/:id", utils.AsyncHandler(services.ComponentItemDelete))
|
||||
}
|
||||
}
|
||||
|
||||
r.GET(constants.API_PATH_PING, services.PingHandler)
|
||||
|
||||
290
internal/services/component_item_service.go
Normal file
290
internal/services/component_item_service.go
Normal file
@@ -0,0 +1,290 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"wm-backend/global"
|
||||
"wm-backend/internal/models"
|
||||
"wm-backend/internal/models/requests"
|
||||
"wm-backend/internal/models/responses"
|
||||
"wm-backend/internal/repositories"
|
||||
"wm-backend/pkg/helper"
|
||||
"wm-backend/response"
|
||||
db "wm-backend/sqlc_gen"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ComponentItemCreate creates a new component item.
|
||||
// It validates the request body and creates the component item in the database.
|
||||
//
|
||||
// @Summary Create a new component item
|
||||
// @Description Create a new component item with the provided details
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body requests.CreateComponentItemRequest true "Component item request body"
|
||||
// @Success 201 {object} response.SuccessResponse{data=responses.CreateComponentItemResponse}
|
||||
// @Failure 400 {object} response.ErrorResponse
|
||||
// @Failure 500 {object} response.ErrorResponse
|
||||
// @Router /api/v1/component-items [post]
|
||||
func ComponentItemCreate(c *gin.Context) error {
|
||||
requestBody := requests.CreateComponentItemRequest{}
|
||||
if helper.IsShouldBindJSON(c, &requestBody) {
|
||||
return nil
|
||||
}
|
||||
componentItemModel := &models.ComponentItem{
|
||||
ComponentID: requestBody.ComponentID,
|
||||
ContainerID: requestBody.ContainerID,
|
||||
Quantity: requestBody.Quantity,
|
||||
Status: requestBody.Status,
|
||||
Metadata: requestBody.Metadata,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
componentItem, err := repositories.CreateComponentItem(c.Request.Context(), global.Queries, *componentItemModel)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, http.StatusInternalServerError, "Failed to create component item")
|
||||
return nil
|
||||
}
|
||||
response.Created(c, "Component item created successfully", &responses.CreateComponentItemResponse{
|
||||
ID: componentItem.ID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComponentItemGetByID retrieves a single component item by its ID.
|
||||
//
|
||||
// @Summary Get component item by ID
|
||||
// @Description Retrieve a single component item using its unique identifier
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Component item ID"
|
||||
// @Success 200 {object} response.SuccessResponse{data=models.ComponentItem}
|
||||
// @Failure 400 {object} response.ErrorResponse
|
||||
// @Failure 404 {object} response.ErrorResponse
|
||||
// @Failure 500 {object} response.ErrorResponse
|
||||
// @Router /api/v1/component-items/{id} [get]
|
||||
func ComponentItemGetByID(c *gin.Context) error {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequestError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return nil
|
||||
}
|
||||
componentItem, err := repositories.GetComponentItemByID(c.Request.Context(), global.Queries, id)
|
||||
if err != nil {
|
||||
response.NotFoundError(c, http.StatusNotFound, "Component item not found")
|
||||
return nil
|
||||
}
|
||||
response.Ok(c, "Success", componentItem)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComponentItemList retrieves all component items.
|
||||
//
|
||||
// @Summary List all component items
|
||||
// @Description Retrieve a list of all component items ordered by creation date
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.SuccessResponse{data=[]models.ComponentItem}
|
||||
// @Failure 500 {object} response.ErrorResponse
|
||||
// @Router /api/v1/component-items [get]
|
||||
func ComponentItemList(c *gin.Context) error {
|
||||
componentItems, err := repositories.ListComponentItems(c.Request.Context(), global.Queries)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, http.StatusInternalServerError, "Failed to list component items")
|
||||
return nil
|
||||
}
|
||||
response.Ok(c, "Success", componentItems)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComponentItemUpdate updates an existing component item by its ID.
|
||||
// It validates the request body, fetches the existing record,
|
||||
// merges non-empty fields from the request, and updates the component item in the database.
|
||||
//
|
||||
// @Summary Update component item
|
||||
// @Description Update an existing component item by its ID. Only non-empty fields will be updated.
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Component item ID"
|
||||
// @Param body body requests.UpdateComponentItemRequest true "Component item request body"
|
||||
// @Success 200 {object} response.SuccessResponse{data=responses.UpdateComponentItemResponse}
|
||||
// @Failure 400 {object} response.ErrorResponse
|
||||
// @Failure 404 {object} response.ErrorResponse
|
||||
// @Failure 500 {object} response.ErrorResponse
|
||||
// @Router /api/v1/component-items/{id} [put]
|
||||
func ComponentItemUpdate(c *gin.Context) error {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequestError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return nil
|
||||
}
|
||||
requestBody := requests.UpdateComponentItemRequest{}
|
||||
if helper.IsShouldBindJSON(c, &requestBody) {
|
||||
return nil
|
||||
}
|
||||
existing, err := repositories.GetComponentItemByID(c.Request.Context(), global.Queries, id)
|
||||
if err != nil {
|
||||
response.NotFoundError(c, http.StatusNotFound, "Component item not found")
|
||||
return nil
|
||||
}
|
||||
if requestBody.ComponentID != nil {
|
||||
existing.ComponentID = *requestBody.ComponentID
|
||||
}
|
||||
if requestBody.ContainerID != nil {
|
||||
existing.ContainerID = *requestBody.ContainerID
|
||||
}
|
||||
if len(requestBody.Metadata) > 0 {
|
||||
existing.Metadata = requestBody.Metadata
|
||||
}
|
||||
existing.UpdatedAt = time.Now()
|
||||
componentItem, err := repositories.UpdateComponentItem(c.Request.Context(), global.Queries, existing)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Failed to update component item with ID: %d", id)
|
||||
response.InternalServerError(c, http.StatusInternalServerError, "Failed to update component item")
|
||||
return nil
|
||||
}
|
||||
response.Ok(c, "Component item updated successfully", &responses.UpdateComponentItemResponse{
|
||||
ID: componentItem.ID,
|
||||
ComponentID: componentItem.ComponentID,
|
||||
ContainerID: componentItem.ContainerID,
|
||||
Quantity: componentItem.Quantity,
|
||||
Status: componentItem.Status,
|
||||
Metadata: componentItem.Metadata,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComponentItemDelete deletes a component item by its ID.
|
||||
//
|
||||
// @Summary Delete component item
|
||||
// @Description Delete a component item by its unique identifier
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Component item ID"
|
||||
// @Success 200 {object} response.SuccessResponse
|
||||
// @Failure 400 {object} response.ErrorResponse
|
||||
// @Failure 500 {object} response.ErrorResponse
|
||||
// @Router /api/v1/component-items/{id} [delete]
|
||||
func ComponentItemDelete(c *gin.Context) error {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequestError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return nil
|
||||
}
|
||||
rowsAffected, err := repositories.DeleteComponentItem(c.Request.Context(), global.Queries, id)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Failed to delete component item with ID: %d", id)
|
||||
response.InternalServerError(c, http.StatusInternalServerError, "Failed to delete component item")
|
||||
return nil
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
response.NotFoundError(c, http.StatusNotFound, "Component item not found")
|
||||
return nil
|
||||
}
|
||||
response.Ok(c, "Delete Success", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComponentItemFind retrieves component items by component ID with full location info.
|
||||
//
|
||||
// @Summary Find component items by component ID
|
||||
// @Description Retrieve component items with full location details (container, shelf, cabinet, room, warehouse) for a given component ID
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param componentId path int true "Component ID"
|
||||
// @Success 200 {object} response.SuccessResponse{data=[]models.FindComponentItemResult}
|
||||
// @Failure 400 {object} response.ErrorResponse
|
||||
// @Failure 500 {object} response.ErrorResponse
|
||||
// @Router /api/v1/component-items/find/{componentId} [get]
|
||||
func ComponentItemFind(c *gin.Context) error {
|
||||
componentID, err := strconv.ParseInt(c.Param("componentId"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequestError(c, http.StatusBadRequest, "Invalid component ID")
|
||||
return nil
|
||||
}
|
||||
items, err := repositories.FindComponentItems(c.Request.Context(), global.Queries, componentID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Failed to find component items for component ID: %d", componentID)
|
||||
response.InternalServerError(c, http.StatusInternalServerError, "Failed to find component items")
|
||||
return nil
|
||||
}
|
||||
response.Ok(c, "Success", items)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComponentItemUpdateStatus changes the status of a component item.
|
||||
// It handles three cases within a single transaction:
|
||||
// - Case 1 (change all): changedQuantity is nil or equals quantity → updates status of entire record
|
||||
// - Case 2 (split): changedQuantity < quantity, no existing record with target status → reduces old record, creates new record
|
||||
// - Case 3 (merge): changedQuantity < quantity, existing record with target status → reduces old record, merges into existing
|
||||
//
|
||||
// A history record is always created. If quantity becomes 0 after the split, the old record is deleted.
|
||||
//
|
||||
// @Summary Change component item status
|
||||
// @Description Change the status of a component item. Supports partial quantity change with automatic split/merge logic. A status history record is created automatically.
|
||||
// @Tags component-item
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Component item ID"
|
||||
// @Param body body requests.UpdateComponentItemStatusRequest true "Status change request body"
|
||||
// @Success 200 {object} response.SuccessResponse{data=responses.UpdateComponentItemStatusResponse}
|
||||
// @Failure 400 {object} response.ErrorResponse "Validation error (e.g., changed_quantity > quantity, status unchanged)"
|
||||
// @Failure 404 {object} response.ErrorResponse "Component item not found"
|
||||
// @Failure 500 {object} response.ErrorResponse "Internal server error"
|
||||
// @Router /api/v1/component-items/{id}/status [put]
|
||||
func ComponentItemUpdateStatus(c *gin.Context) error {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequestError(c, http.StatusBadRequest, "Invalid ID")
|
||||
return nil
|
||||
}
|
||||
var req requests.UpdateComponentItemStatusRequest
|
||||
if helper.IsShouldBindJSON(c, &req) {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, err := repositories.UpdateComponentItemStatus(
|
||||
c.Request.Context(),
|
||||
global.DB,
|
||||
id,
|
||||
db.ComponentItemStatusEnum(req.Status),
|
||||
req.ChangedQuantity,
|
||||
req.Note,
|
||||
"system",
|
||||
)
|
||||
if err != nil {
|
||||
if err.Error() == "status unchanged" {
|
||||
response.Ok(c, "Status unchanged", nil)
|
||||
return nil
|
||||
}
|
||||
log.Error().Err(err).Msgf("Failed to update component item status for ID: %d", id)
|
||||
response.BadRequestError(c, http.StatusBadRequest, err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build response based on case
|
||||
resp := responses.UpdateComponentItemStatusResponse{
|
||||
ID: id,
|
||||
OldStatus: result.StatusHistory.OldStatus,
|
||||
NewStatus: result.StatusHistory.NewStatus,
|
||||
ChangedQuantity: result.StatusHistory.ChangedQuantity,
|
||||
HistoryID: result.StatusHistory.ID,
|
||||
}
|
||||
if result.NewComponentItemID != nil {
|
||||
resp.NewComponentItemID = result.NewComponentItemID
|
||||
}
|
||||
if result.MergedComponentItemID != nil {
|
||||
resp.MergedComponentItemID = result.MergedComponentItemID
|
||||
}
|
||||
|
||||
response.Ok(c, "Status updated successfully", resp)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user