feat: implement component-item management with CRUD operations and status updates

This commit is contained in:
Tran Anh Tuan
2026-05-11 17:49:18 +07:00
parent 9ea72b4eea
commit 0ff65a18c0
23 changed files with 3870 additions and 0 deletions

View 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,
}
}

View 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,
}
}

View 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"`
}

View 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"`
}

View 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"`
}

View File

@@ -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"`
}

View 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"`
}

View File

@@ -0,0 +1,5 @@
package responses
type CreateComponentStatusHistoryResponse struct {
ID int64 `json:"id"`
}

View 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
}

View 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")
}
}

View File

@@ -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)

View 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
}