diff --git a/configs/constants/constants.go b/configs/constants/constants.go index 53c5996..ba49664 100644 --- a/configs/constants/constants.go +++ b/configs/constants/constants.go @@ -19,6 +19,7 @@ const ( API_GROUP_COMPONENT_TYPE = "/component-types" API_GROUP_COMPONENT = "/components" API_GROUP_COMPONENT_CODE = "/component-codes" + API_GROUP_COMPONENT_ITEM = "/component-items" ) const ( diff --git a/db/queries/component_item.sql b/db/queries/component_item.sql new file mode 100644 index 0000000..7f0d3a1 --- /dev/null +++ b/db/queries/component_item.sql @@ -0,0 +1,75 @@ +-- name: GetComponentItemByID :one +SELECT * FROM component_items +WHERE id = sqlc.arg(id); + +-- name: ListComponentItems :many +SELECT * FROM component_items +ORDER BY created_at DESC; + +-- name: CreateComponentItem :one +INSERT INTO component_items (component_id,container_id,quantity, status, metadata, created_at) +VALUES ( + sqlc.arg(component_id), + sqlc.arg(container_id), + sqlc.arg(quantity), + sqlc.arg(status), + sqlc.arg(metadata), + sqlc.arg(created_at) +) +RETURNING *; + +-- name: UpdateComponentItem :one +UPDATE component_items +SET component_id = CASE WHEN sqlc.arg(component_id) = '' THEN component_id ELSE sqlc.arg(component_id) END, + container_id = CASE WHEN sqlc.arg(container_id) = '' THEN container_id ELSE sqlc.arg(container_id) END, + metadata = coalesce(sqlc.arg(metadata), metadata), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: DeleteComponentItem :execrows +DELETE FROM component_items +WHERE id = $1; + +-- name: UpdateComponentItemStatus :one +UPDATE component_items +SET status = sqlc.arg(status), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: UpdateComponentItemQuantity :one +UPDATE component_items +SET quantity = sqlc.arg(quantity), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: GetComponentItemByComponentContainerStatus :one +SELECT * FROM component_items +WHERE component_id = sqlc.arg(component_id) + AND container_id = sqlc.arg(container_id) + AND status = sqlc.arg(status); + +-- name: FindComponentItem :many +SELECT + c.name AS component_name, + ct.name AS type_name, + ci.quantity, + ci.status, + cn.name AS container_name, + cn.container_type, + s.name AS shelf_name, + cb.name AS cabinet_name, + r.name AS room_name, + w.name AS warehouse_name +FROM component_items ci +JOIN components c ON ci.component_id = c.id +JOIN component_types ct ON c.component_type_id = ct.id +JOIN containers cn ON ci.container_id = cn.id +JOIN shelves s ON cn.shelf_id = s.id +JOIN cabinets cb ON s.cabinet_id = cb.id +JOIN rooms r ON cb.room_id = r.id +JOIN warehouses w ON r.warehouse_id = w.id +WHERE ci.component_id = sqlc.arg(componentId) AND ci.quantity > 0; + diff --git a/db/queries/component_status_history.sql b/db/queries/component_status_history.sql new file mode 100644 index 0000000..ac1499b --- /dev/null +++ b/db/queries/component_status_history.sql @@ -0,0 +1,15 @@ +-- name: CreateComponentStatusHistory :one +INSERT INTO component_status_history ( + component_item_id, old_status, new_status, + changed_quantity, note, changed_by, changed_at +) +VALUES ( + sqlc.arg(component_item_id), + sqlc.arg(old_status), + sqlc.arg(new_status), + sqlc.arg(changed_quantity), + sqlc.arg(note), + sqlc.arg(changed_by), + sqlc.arg(changed_at) +) +RETURNING *; \ No newline at end of file diff --git a/docs/db/ChangeStatusComponent.md b/docs/db/ChangeStatusComponent.md new file mode 100644 index 0000000..f328f47 --- /dev/null +++ b/docs/db/ChangeStatusComponent.md @@ -0,0 +1,517 @@ +# Change Status Component - Implementation Guide (Go Service Layer) + +## Overview + +Khi người dùng cập nhật `status` của 1 bản ghi trong bảng `component_items`, hệ thống phải: + +1. **Tách/mở rộng record** trong bảng `component_items` (quản lý quantity chính xác theo status) +2. **Tự động ghi log** vào bảng `component_status_history` +3. Tất cả trong **cùng 1 transaction** + +### Nguyên tắc: Mỗi record = 1 nhóm linh kiện cùng tình trạng + +Mỗi record `component_items` đại diện cho **một nhóm linh kiện có cùng component, cùng container, cùng status**. Khi đổi status một phần số lượng → phải tách record. + +## Tables liên quan + +- `component_items` — bản ghi linh kiện tại 1 vị trí (container), mỗi record = 1 nhóm cùng status +- `component_status_history` — lịch sử thay đổi tình trạng (audit log) + +## Status Enum Values + +``` +normal | damaged | long_unused | expired | pending_inspection +``` + +--- + +## Business Logic - 3 Trường Hợp + +### Request Body + +```json +{ + "status": "damaged", + "changed_quantity": 5, + "note": "Bị cháy khi test mạch", + "changed_by": "nguyenvana" +} +``` + +### Trường hợp 1: `changed_quantity` = NULL hoặc = `quantity` (đổi toàn bộ) + +Đổi status của **toàn bộ** linh kiện trong record. + +``` +Trước: component_items (id=1): quantity=20, status="normal" +Request: { status: "damaged", changed_quantity: null } hoặc changed_quantity: 20 + +Sau: component_items (id=1): quantity=20, status="damaged" +``` + +→ Chỉ UPDATE status, không tách record. + +### Trường hợp 2: `changed_quantity` < `quantity` (đổi một phần, CHƯA có record cùng status mới) + +Tách thành 2 record: phần còn tốt + phần đổi status. + +``` +Trước: component_items (id=1): quantity=20, status="normal" + +Request: { status: "damaged", changed_quantity: 5 } + +Kiểm tra: KHÔNG có record nào có (component_id=1.component_id, container_id=1.container_id, status="damaged") + +Sau: + component_items (id=1): quantity=15, status="normal" ← giảm 5 + component_items (id=NEW): quantity=5, status="damaged" ← record mới +``` + +### Trường hợp 3: `changed_quantity` < `quantity` (đổi một phần, ĐÃ có record cùng status mới) + +Giảm quantity record cũ, **cộng dồn** vào record đã có cùng status. + +``` +Trước: + component_items (id=1): quantity=20, status="normal" + component_items (id=2): quantity=3, status="damaged" ← đã có sẵn + +Request: { status: "damaged", changed_quantity: 5 } + +Kiểm tra: ĐÃ CÓ record (id=2) cùng (component_id, container_id, status="damaged") + +Sau: + component_items (id=1): quantity=15, status="normal" ← giảm 5 + component_items (id=2): quantity=8, status="damaged" ← 3 + 5 = 8 +``` + +--- + +## Go Service Layer Flow + +### 1. API Endpoint + +``` +PUT /api/v1/component-items/:id/status +``` + +### 2. Request Model + +```go +type UpdateComponentItemStatusRequest struct { + Status string `json:"status" binding:"required,oneof=normal damaged long_unused expired pending_inspection"` + ChangedQuantity *int `json:"changed_quantity"` + Note string `json:"note"` + ChangedBy string `json:"changed_by" binding:"required"` +} +``` + +### 3. Chi tiết Transaction + +``` +BEGIN TRANSACTION +│ +├── Bước 1: SELECT component_items WHERE id = :id +│ → Lưu lại old_status, old_quantity, component_id, container_id +│ +├── Bước 2: Kiểm tra changed_quantity +│ │ +│ ├── Nếu changed_quantity IS NULL hoặc changed_quantity == old_quantity: +│ │ │ +│ │ └── Bước 2a: UPDATE component_items +│ │ SET status = :new_status, updated_at = NOW() +│ │ WHERE id = :id +│ │ +│ └── Nếu changed_quantity < old_quantity (đổi một phần): +│ │ +│ ├── Bước 2b-i: UPDATE component_items (record cũ) +│ │ SET quantity = old_quantity - changed_quantity, updated_at = NOW() +│ │ WHERE id = :id +│ │ +│ ├── Bước 2b-ii: SELECT component_items +│ │ WHERE component_id = :component_id +│ │ AND container_id = :container_id +│ │ AND status = :new_status +│ │ → Kiểm tra đã có record cùng status chưa +│ │ +│ ├── Nếu CHƯA có: +│ │ └── Bước 2b-iii: INSERT INTO component_items +│ │ (component_id, container_id, quantity, status, metadata, created_at) +│ │ VALUES (:component_id, :container_id, :changed_quantity, :new_status, :metadata, NOW()) +│ │ +│ └── Nếu ĐÃ có (existing_id): +│ └── Bước 2b-iv: UPDATE component_items +│ SET quantity = existing_quantity + changed_quantity, updated_at = NOW() +│ WHERE id = existing_id +│ +├── Bước 3: INSERT INTO component_status_history +│ (component_item_id, old_status, new_status, changed_quantity, note, changed_by, changed_at) +│ VALUES (:id, :old_status, :new_status, :changed_quantity, :note, :changed_by, NOW()) +│ +└── COMMIT (hoặc ROLLBACK nếu có lỗi) +``` + +### 4. Service Logic (Pseudocode) + +```go +func UpdateComponentItemStatus(ctx context.Context, dbPool *pgxpool.Pool, queries *db.Queries, id int64, req UpdateComponentItemStatusRequest) error { + // 1. Lấy bản ghi hiện tại + existing, err := queries.GetComponentItemByID(ctx, id) + if err != nil { + return ErrNotFound + } + + // 2. Kiểm tra status không đổi + if existing.Status == db.ComponentItemStatusEnum(req.Status) { + return ErrStatusUnchanged + } + + // 3. Bắt đầu transaction + tx, err := dbPool.Begin(ctx) + if err != nil { + return err + } + defer func() { + if err != nil { + tx.Rollback(ctx) + } + }() + + txQueries := queries.WithTx(tx) + newStatus := db.ComponentItemStatusEnum(req.Status) + + // 4. Xác định changed_qty (default = toàn bộ) + changedQty := int32(existing.Quantity) + if req.ChangedQuantity != nil { + changedQty = int32(*req.ChangedQuantity) + } + + // 5. Phân nhánh theo trường hợp + if changedQty >= existing.Quantity { + // Trường hợp 1: Đổi toàn bộ + _, err = txQueries.UpdateComponentItemStatus(ctx, db.UpdateComponentItemStatusParams{ + ID: id, + Status: newStatus, + UpdatedAt: time.Now(), + }) + } else { + // Trường hợp 2 & 3: Đổi một phần + + // 5a. Giảm quantity record cũ + _, err = txQueries.UpdateComponentItemQuantity(ctx, db.UpdateComponentItemQuantityParams{ + ID: id, + Quantity: existing.Quantity - changedQty, + UpdatedAt: time.Now(), + }) + if err != nil { + return err + } + + // 5b. Tìm record cùng (component_id, container_id, new_status) + existingNewStatus, findErr := txQueries.GetComponentItemByComponentContainerStatus(ctx, + db.GetComponentItemByComponentContainerStatusParams{ + ComponentID: existing.ComponentID, + ContainerID: existing.ContainerID, + Status: newStatus, + }, + ) + + if findErr == sql.ErrNoRows { + // Trường hợp 2: Chưa có → tạo record mới + _, err = txQueries.CreateComponentItem(ctx, db.CreateComponentItemParams{ + ComponentID: existing.ComponentID, + ContainerID: existing.ContainerID, + Quantity: changedQty, + Status: newStatus, + Metadata: existing.Metadata, + CreatedAt: time.Now(), + }) + } else if findErr == nil { + // Trường hợp 3: Đã có → cộng dồn quantity + _, err = txQueries.UpdateComponentItemQuantity(ctx, db.UpdateComponentItemQuantityParams{ + ID: existingNewStatus.ID, + Quantity: existingNewStatus.Quantity + changedQty, + UpdatedAt: time.Now(), + }) + } else { + return findErr + } + } + + if err != nil { + return err + } + + // 6. Ghi log vào component_status_history + _, err = txQueries.CreateComponentStatusHistory(ctx, db.CreateComponentStatusHistoryParams{ + ComponentItemID: id, + OldStatus: db.NullComponentItemStatusEnum{ + ComponentItemStatusEnum: existing.Status, + Valid: true, + }, + NewStatus: newStatus, + ChangedQuantity: pgtype.Int4{Int32: changedQty, Valid: true}, + Note: pgtype.Text{String: req.Note, Valid: req.Note != ""}, + ChangedBy: pgtype.Text{String: req.ChangedBy, Valid: true}, + ChangedAt: time.Now(), + }) + if err != nil { + return err + } + + // 7. Commit + return tx.Commit(ctx) +} +``` + +--- + +## SQL Queries cần viết (cho sqlc) + +### db/queries/component_item.sql + +```sql +-- name: GetComponentItemByID :one +SELECT * FROM component_items WHERE id = sqlc.arg(id); + +-- name: UpdateComponentItemStatus :one +UPDATE component_items +SET status = sqlc.arg(status), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: UpdateComponentItemQuantity :one +UPDATE component_items +SET quantity = sqlc.arg(quantity), + updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) +RETURNING *; + +-- name: GetComponentItemByComponentContainerStatus :one +SELECT * FROM component_items +WHERE component_id = sqlc.arg(component_id) + AND container_id = sqlc.arg(container_id) + AND status = sqlc.arg(status); + +-- name: CreateComponentItem :one +INSERT INTO component_items (component_id, container_id, quantity, status, metadata, created_at) +VALUES ( + sqlc.arg(component_id), + sqlc.arg(container_id), + sqlc.arg(quantity), + sqlc.arg(status), + sqlc.arg(metadata), + sqlc.arg(created_at) +) +RETURNING *; +``` + +### db/queries/component_status_history.sql + +```sql +-- name: CreateComponentStatusHistory :one +INSERT INTO component_status_history ( + component_item_id, old_status, new_status, + changed_quantity, note, changed_by, changed_at +) +VALUES ( + sqlc.arg(component_item_id), + sqlc.arg(old_status), + sqlc.arg(new_status), + sqlc.arg(changed_quantity), + sqlc.arg(note), + sqlc.arg(changed_by), + sqlc.arg(changed_at) +) +RETURNING *; +``` + +--- + +## Files cần tạo/sửa + +| File | Action | Mô tả | +|------|--------|-------| +| `internal/models/requests/component_item_request.go` | Sửa | Thêm `UpdateComponentItemStatusRequest` | +| `internal/models/responses/component_item_response.go` | Sửa | Thêm response struct cho status change | +| `internal/repositories/component_item_repository.go` | Sửa | Hàm `UpdateComponentItemStatus` (chứa transaction logic) | +| `internal/services/component_item_service.go` | Sửa | Handler `ComponentItemUpdateStatus` | +| `internal/routers/router.go` | Sửa | Thêm route `PUT /api/v1/component-items/:id/status` | +| `db/queries/component_item.sql` | Sửa | Thêm `UpdateComponentItemQuantity`, `GetComponentItemByComponentContainerStatus` | +| `db/queries/component_status_history.sql` | Đã có | `CreateComponentStatusHistory` | +| `db/migrations/` | (đã có) | Bảng đã tạo ở migration 000001 | + +--- + +## Edge Cases cần xử lý + +| Trường hợp | Xử lý | +|---|---| +| `old_status == new_status` | Không update, không ghi history, trả về `"Status unchanged"` | +| `component_item` không tồn tại | Return 404 | +| `changed_quantity > quantity` | Return 400 — không thể đổi status nhiều hơn số lượng hiện có | +| `changed_quantity == 0` | Return 400 — không hợp lý | +| `changed_quantity < 0` | Return 400 — không cho số âm | +| `changed_quantity` NULL | Đổi status toàn bộ, tương đương `changed_quantity = quantity` | +| Transaction failed | Rollback, return 500 | +| `changed_by` | Lấy từ JWT token context hoặc request body, ưu tiên JWT | +| Record cũ sau khi trừ quantity = 0 | Xóa record đó (`DELETE WHERE quantity = 0`) hoặc để số 0 (tùy chọn) | + +### Lưu ý: Xử lý khi quantity record cũ = 0 sau khi tách + +``` +Trước: (id=1): quantity=5, status="normal" +Request: changed_quantity=5, status="damaged" + +Sau khi UPDATE: (id=1): quantity=0, status="normal" +``` + +Nên **xóa** record có quantity = 0 sau khi UPDATE, để tránh record rác: + +```sql +-- name: DeleteComponentItemZeroQuantity :execrows +DELETE FROM component_items WHERE id = sqlc.arg(id) AND quantity = 0; +``` + +Hoặc thêm logic sau bước UPDATE quantity: + +```go +if existing.Quantity - changedQty == 0 { + txQueries.DeleteComponentItem(ctx, id) +} +``` + +--- + +## Ví dụ Response + +### Thành công - Đổi toàn bộ (Trường hợp 1): + +```json +{ + "code": 200, + "message": "Status updated successfully", + "data": { + "id": 1, + "old_status": "normal", + "new_status": "damaged", + "changed_quantity": 20, + "history_id": 42 + } +} +``` + +### Thành công - Đổi một phần, tạo record mới (Trường hợp 2): + +```json +{ + "code": 200, + "message": "Status updated successfully", + "data": { + "id": 1, + "old_status": "normal", + "new_status": "damaged", + "changed_quantity": 5, + "new_component_item_id": 15, + "history_id": 42 + } +} +``` + +### Thành công - Đổi một phần, cộng dồn record cũ (Trường hợp 3): + +```json +{ + "code": 200, + "message": "Status updated successfully", + "data": { + "id": 1, + "old_status": "normal", + "new_status": "damaged", + "changed_quantity": 5, + "merged_component_item_id": 2, + "history_id": 42 + } +} +``` + +### Status không đổi: + +```json +{ + "code": 200, + "message": "Status unchanged", + "data": null +} +``` + +### Validation lỗi - changed_quantity > quantity: + +```json +{ + "code": 400, + "message": "changed_quantity (25) exceeds current quantity (20)", + "data": null +} +``` + +--- + +## Ví dụ Vòng Đời Hoàn Chỉnh + +``` +Ngày 01/03: Nhập kho 20 tụ điện 100uF vào container A + → Invoice type=import, quantity=20 + → component_items (id=1): quantity=20, status="normal" + +───────────────────────────────────────────────── + +Ngày 02/04: Kiểm kho phát hiện 5 cái bị ẩm + → Request: { status: "damaged", changed_quantity: 5, note: "Bị ẩm mốc" } + + Transaction: + UPDATE (id=1): quantity = 20 - 5 = 15 + INSERT (id=2): quantity = 5, status = "damaged" + INSERT history: old=normal, new=damaged, qty=5 + + Sau: + component_items (id=1): quantity=15, status="normal" + component_items (id=2): quantity=5, status="damaged" + +───────────────────────────────────────────────── + +Ngày 05/04: Phát hiện thêm 3 cái bị ẩm nữa + → Request: { status: "damaged", changed_quantity: 3, note: "Tiếp tục bị ẩm" } + + Transaction: + UPDATE (id=1): quantity = 15 - 3 = 12 + UPDATE (id=2): quantity = 5 + 3 = 8 ← cộng dồn vào record damaged đã có + INSERT history: old=normal, new=damaged, qty=3 + + Sau: + component_items (id=1): quantity=12, status="normal" + component_items (id=2): quantity=8, status="damaged" + +───────────────────────────────────────────────── + +Ngày 10/04: Kỹ thuật xác nhận 5 cái damaged không sửa được + → Request trên record id=2: { status: "expired", changed_quantity: 5 } + + Transaction: + UPDATE (id=2): quantity = 8 - 5 = 3 + INSERT (id=3): quantity = 5, status = "expired" + INSERT history: old=damaged, new=expired, qty=5 + + Sau: + component_items (id=1): quantity=12, status="normal" + component_items (id=2): quantity=3, status="damaged" + component_items (id=3): quantity=5, status="expired" + +───────────────────────────────────────────────── + +Ngày 15/04: Xuất bỏ 5 cái expired ra khỏi kho + → Invoice type=export, note="Loại bỏ linh kiện expired" + → stock_transactions ghi nhận xuất + → component_items (id=3): bị xóa hoặc quantity = 0 +``` diff --git a/docs/db/Note.md b/docs/db/Note.md new file mode 100644 index 0000000..a83c32d --- /dev/null +++ b/docs/db/Note.md @@ -0,0 +1,4 @@ +- Ở bảng component_items số lượng chỉ được thêm khi ở lúc tạo + +* Sau này cập nhật sẽ được tự động từ invoice +* Trạng thái muốn cập nhật sẽ phải transaction cả bảng component_status_history diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 4b8a8bd..e77948d 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -288,6 +288,409 @@ const docTemplate = `{ } } }, + "/api/v1/component-items": { + "get": { + "description": "Retrieve a list of all component items ordered by creation date", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "List all component items", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ComponentItem" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a new component item with the provided details", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Create a new component item", + "parameters": [ + { + "description": "Component item request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.CreateComponentItemRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/responses.CreateComponentItemResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/api/v1/component-items/find/{componentId}": { + "get": { + "description": "Retrieve component items with full location details (container, shelf, cabinet, room, warehouse) for a given component ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Find component items by component ID", + "parameters": [ + { + "type": "integer", + "description": "Component ID", + "name": "componentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FindComponentItemResult" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/api/v1/component-items/{id}": { + "get": { + "description": "Retrieve a single component item using its unique identifier", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Get component item by ID", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.ComponentItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update an existing component item by its ID. Only non-empty fields will be updated.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Update component item", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Component item request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UpdateComponentItemRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/responses.UpdateComponentItemResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a component item by its unique identifier", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Delete component item", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/api/v1/component-items/{id}/status": { + "put": { + "description": "Change the status of a component item. Supports partial quantity change with automatic split/merge logic. A status history record is created automatically.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Change component item status", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status change request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UpdateComponentItemStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/responses.UpdateComponentItemStatusResponse" + } + } + } + ] + } + }, + "400": { + "description": "Validation error (e.g., changed_quantity \u003e quantity, status unchanged)", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "404": { + "description": "Component item not found", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, "/api/v1/component-types": { "get": { "description": "Retrieve a list of all component types ordered by creation date", @@ -2375,6 +2778,38 @@ const docTemplate = `{ } } }, + "models.ComponentItem": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, "models.ComponentType": { "type": "object", "properties": { @@ -2436,6 +2871,41 @@ const docTemplate = `{ } } }, + "models.FindComponentItemResult": { + "type": "object", + "properties": { + "cabinetName": { + "type": "string" + }, + "componentName": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "containerType": { + "type": "string" + }, + "quantity": { + "type": "integer" + }, + "roomName": { + "type": "string" + }, + "shelfName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "typeName": { + "type": "string" + }, + "warehouseName": { + "type": "string" + } + } + }, "models.Room": { "type": "object", "properties": { @@ -2576,6 +3046,35 @@ const docTemplate = `{ } } }, + "requests.CreateComponentItemRequest": { + "type": "object", + "required": [ + "componentId", + "containerId", + "quantity", + "status" + ], + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, "requests.CreateComponentRequest": { "type": "object", "required": [ @@ -2751,6 +3250,47 @@ const docTemplate = `{ } } }, + "requests.UpdateComponentItemRequest": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "requests.UpdateComponentItemStatusRequest": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "changedQuantity": { + "type": "integer" + }, + "note": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "normal", + "damaged", + "long_unused", + "expired", + "pending_inspection" + ] + } + } + }, "requests.UpdateComponentRequest": { "type": "object", "properties": { @@ -2913,6 +3453,14 @@ const docTemplate = `{ } } }, + "responses.CreateComponentItemResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, "responses.CreateComponentResponse": { "type": "object", "properties": { @@ -2998,6 +3546,58 @@ const docTemplate = `{ } } }, + "responses.UpdateComponentItemResponse": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, + "responses.UpdateComponentItemStatusResponse": { + "type": "object", + "properties": { + "changedQuantity": { + "type": "integer" + }, + "historyId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "mergedComponentItemId": { + "type": "integer" + }, + "newComponentItemId": { + "type": "integer" + }, + "newStatus": { + "type": "string" + }, + "oldStatus": { + "type": "string" + } + } + }, "responses.UpdateComponentResponse": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 88f40ab..7a35ce9 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -282,6 +282,409 @@ } } }, + "/api/v1/component-items": { + "get": { + "description": "Retrieve a list of all component items ordered by creation date", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "List all component items", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ComponentItem" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a new component item with the provided details", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Create a new component item", + "parameters": [ + { + "description": "Component item request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.CreateComponentItemRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/responses.CreateComponentItemResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/api/v1/component-items/find/{componentId}": { + "get": { + "description": "Retrieve component items with full location details (container, shelf, cabinet, room, warehouse) for a given component ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Find component items by component ID", + "parameters": [ + { + "type": "integer", + "description": "Component ID", + "name": "componentId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.FindComponentItemResult" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/api/v1/component-items/{id}": { + "get": { + "description": "Retrieve a single component item using its unique identifier", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Get component item by ID", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.ComponentItem" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update an existing component item by its ID. Only non-empty fields will be updated.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Update component item", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Component item request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UpdateComponentItemRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/responses.UpdateComponentItemResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a component item by its unique identifier", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Delete component item", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.SuccessResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, + "/api/v1/component-items/{id}/status": { + "put": { + "description": "Change the status of a component item. Supports partial quantity change with automatic split/merge logic. A status history record is created automatically.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "component-item" + ], + "summary": "Change component item status", + "parameters": [ + { + "type": "integer", + "description": "Component item ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Status change request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/requests.UpdateComponentItemStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.SuccessResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/responses.UpdateComponentItemStatusResponse" + } + } + } + ] + } + }, + "400": { + "description": "Validation error (e.g., changed_quantity \u003e quantity, status unchanged)", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "404": { + "description": "Component item not found", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.ErrorResponse" + } + } + } + } + }, "/api/v1/component-types": { "get": { "description": "Retrieve a list of all component types ordered by creation date", @@ -2369,6 +2772,38 @@ } } }, + "models.ComponentItem": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "createdAt": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + } + }, "models.ComponentType": { "type": "object", "properties": { @@ -2430,6 +2865,41 @@ } } }, + "models.FindComponentItemResult": { + "type": "object", + "properties": { + "cabinetName": { + "type": "string" + }, + "componentName": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "containerType": { + "type": "string" + }, + "quantity": { + "type": "integer" + }, + "roomName": { + "type": "string" + }, + "shelfName": { + "type": "string" + }, + "status": { + "type": "string" + }, + "typeName": { + "type": "string" + }, + "warehouseName": { + "type": "string" + } + } + }, "models.Room": { "type": "object", "properties": { @@ -2570,6 +3040,35 @@ } } }, + "requests.CreateComponentItemRequest": { + "type": "object", + "required": [ + "componentId", + "containerId", + "quantity", + "status" + ], + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, "requests.CreateComponentRequest": { "type": "object", "required": [ @@ -2745,6 +3244,47 @@ } } }, + "requests.UpdateComponentItemRequest": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "requests.UpdateComponentItemStatusRequest": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "changedQuantity": { + "type": "integer" + }, + "note": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "normal", + "damaged", + "long_unused", + "expired", + "pending_inspection" + ] + } + } + }, "requests.UpdateComponentRequest": { "type": "object", "properties": { @@ -2907,6 +3447,14 @@ } } }, + "responses.CreateComponentItemResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, "responses.CreateComponentResponse": { "type": "object", "properties": { @@ -2992,6 +3540,58 @@ } } }, + "responses.UpdateComponentItemResponse": { + "type": "object", + "properties": { + "componentId": { + "type": "integer" + }, + "containerId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "array", + "items": { + "type": "integer" + } + }, + "quantity": { + "type": "integer" + }, + "status": { + "type": "string" + } + } + }, + "responses.UpdateComponentItemStatusResponse": { + "type": "object", + "properties": { + "changedQuantity": { + "type": "integer" + }, + "historyId": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "mergedComponentItemId": { + "type": "integer" + }, + "newComponentItemId": { + "type": "integer" + }, + "newStatus": { + "type": "string" + }, + "oldStatus": { + "type": "string" + } + } + }, "responses.UpdateComponentResponse": { "type": "object", "properties": { diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 3d71ae4..734cbb4 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -59,6 +59,27 @@ definitions: type: integer type: array type: object + models.ComponentItem: + properties: + componentId: + type: integer + containerId: + type: integer + createdAt: + type: string + id: + type: integer + metadata: + items: + type: integer + type: array + quantity: + type: integer + status: + type: string + updatedAt: + type: string + type: object models.ComponentType: properties: createdAt: @@ -99,6 +120,29 @@ definitions: updatedAt: type: string type: object + models.FindComponentItemResult: + properties: + cabinetName: + type: string + componentName: + type: string + containerName: + type: string + containerType: + type: string + quantity: + type: integer + roomName: + type: string + shelfName: + type: string + status: + type: string + typeName: + type: string + warehouseName: + type: string + type: object models.Room: properties: createdAt: @@ -192,6 +236,26 @@ definitions: - code - componentId type: object + requests.CreateComponentItemRequest: + properties: + componentId: + type: integer + containerId: + type: integer + metadata: + items: + type: integer + type: array + quantity: + type: integer + status: + type: string + required: + - componentId + - containerId + - quantity + - status + type: object requests.CreateComponentRequest: properties: componentTypeId: @@ -309,6 +373,34 @@ definitions: type: integer type: array type: object + requests.UpdateComponentItemRequest: + properties: + componentId: + type: integer + containerId: + type: integer + metadata: + items: + type: integer + type: array + type: object + requests.UpdateComponentItemStatusRequest: + properties: + changedQuantity: + type: integer + note: + type: string + status: + enum: + - normal + - damaged + - long_unused + - expired + - pending_inspection + type: string + required: + - status + type: object requests.UpdateComponentRequest: properties: componentTypeId: @@ -414,6 +506,11 @@ definitions: id: type: integer type: object + responses.CreateComponentItemResponse: + properties: + id: + type: integer + type: object responses.CreateComponentResponse: properties: id: @@ -468,6 +565,40 @@ definitions: isPrimary: type: boolean type: object + responses.UpdateComponentItemResponse: + properties: + componentId: + type: integer + containerId: + type: integer + id: + type: integer + metadata: + items: + type: integer + type: array + quantity: + type: integer + status: + type: string + type: object + responses.UpdateComponentItemStatusResponse: + properties: + changedQuantity: + type: integer + historyId: + type: integer + id: + type: integer + mergedComponentItemId: + type: integer + newComponentItemId: + type: integer + newStatus: + type: string + oldStatus: + type: string + type: object responses.UpdateComponentResponse: properties: componentTypeId: @@ -723,6 +854,260 @@ paths: summary: Update component code tags: - component-code + /api/v1/component-items: + get: + consumes: + - application/json + description: Retrieve a list of all component items ordered by creation date + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + items: + $ref: '#/definitions/models.ComponentItem' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: List all component items + tags: + - component-item + post: + consumes: + - application/json + description: Create a new component item with the provided details + parameters: + - description: Component item request body + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.CreateComponentItemRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + $ref: '#/definitions/responses.CreateComponentItemResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Create a new component item + tags: + - component-item + /api/v1/component-items/{id}: + delete: + consumes: + - application/json + description: Delete a component item by its unique identifier + parameters: + - description: Component item ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.SuccessResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Delete component item + tags: + - component-item + get: + consumes: + - application/json + description: Retrieve a single component item using its unique identifier + parameters: + - description: Component item ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + $ref: '#/definitions/models.ComponentItem' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Get component item by ID + tags: + - component-item + put: + consumes: + - application/json + description: Update an existing component item by its ID. Only non-empty fields + will be updated. + parameters: + - description: Component item ID + in: path + name: id + required: true + type: integer + - description: Component item request body + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.UpdateComponentItemRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + $ref: '#/definitions/responses.UpdateComponentItemResponse' + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Update component item + tags: + - component-item + /api/v1/component-items/{id}/status: + put: + consumes: + - application/json + description: Change the status of a component item. Supports partial quantity + change with automatic split/merge logic. A status history record is created + automatically. + parameters: + - description: Component item ID + in: path + name: id + required: true + type: integer + - description: Status change request body + in: body + name: body + required: true + schema: + $ref: '#/definitions/requests.UpdateComponentItemStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + $ref: '#/definitions/responses.UpdateComponentItemStatusResponse' + type: object + "400": + description: Validation error (e.g., changed_quantity > quantity, status + unchanged) + schema: + $ref: '#/definitions/response.ErrorResponse' + "404": + description: Component item not found + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Change component item status + tags: + - component-item + /api/v1/component-items/find/{componentId}: + get: + consumes: + - application/json + description: Retrieve component items with full location details (container, + shelf, cabinet, room, warehouse) for a given component ID + parameters: + - description: Component ID + in: path + name: componentId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/response.SuccessResponse' + - properties: + data: + items: + $ref: '#/definitions/models.FindComponentItemResult' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.ErrorResponse' + summary: Find component items by component ID + tags: + - component-item /api/v1/component-types: get: consumes: diff --git a/internal/mapper/component_item_mapper.go b/internal/mapper/component_item_mapper.go new file mode 100644 index 0000000..ee3d36b --- /dev/null +++ b/internal/mapper/component_item_mapper.go @@ -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, + } +} diff --git a/internal/mapper/component_status_history_mapper.go b/internal/mapper/component_status_history_mapper.go new file mode 100644 index 0000000..a181309 --- /dev/null +++ b/internal/mapper/component_status_history_mapper.go @@ -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, + } +} diff --git a/internal/models/component_item_model.go b/internal/models/component_item_model.go new file mode 100644 index 0000000..79e2aa5 --- /dev/null +++ b/internal/models/component_item_model.go @@ -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"` +} diff --git a/internal/models/component_status_history_model.go b/internal/models/component_status_history_model.go new file mode 100644 index 0000000..7797923 --- /dev/null +++ b/internal/models/component_status_history_model.go @@ -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"` +} diff --git a/internal/models/requests/component_item_request.go b/internal/models/requests/component_item_request.go new file mode 100644 index 0000000..11fc001 --- /dev/null +++ b/internal/models/requests/component_item_request.go @@ -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"` +} diff --git a/internal/models/requests/component_status_history_request.go b/internal/models/requests/component_status_history_request.go new file mode 100644 index 0000000..912f298 --- /dev/null +++ b/internal/models/requests/component_status_history_request.go @@ -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"` +} diff --git a/internal/models/responses/component_item_response.go b/internal/models/responses/component_item_response.go new file mode 100644 index 0000000..dfdbbea --- /dev/null +++ b/internal/models/responses/component_item_response.go @@ -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"` +} diff --git a/internal/models/responses/component_status_history_response.go b/internal/models/responses/component_status_history_response.go new file mode 100644 index 0000000..7ceac47 --- /dev/null +++ b/internal/models/responses/component_status_history_response.go @@ -0,0 +1,5 @@ +package responses + +type CreateComponentStatusHistoryResponse struct { + ID int64 `json:"id"` +} diff --git a/internal/repositories/component_item_repository.go b/internal/repositories/component_item_repository.go new file mode 100644 index 0000000..f46d92b --- /dev/null +++ b/internal/repositories/component_item_repository.go @@ -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 +} diff --git a/internal/repositories/component_item_repository_test.go b/internal/repositories/component_item_repository_test.go new file mode 100644 index 0000000..c485bbf --- /dev/null +++ b/internal/repositories/component_item_repository_test.go @@ -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") + } +} diff --git a/internal/routers/router.go b/internal/routers/router.go index 9924f2c..5221db9 100644 --- a/internal/routers/router.go +++ b/internal/routers/router.go @@ -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) diff --git a/internal/services/component_item_service.go b/internal/services/component_item_service.go new file mode 100644 index 0000000..2988efb --- /dev/null +++ b/internal/services/component_item_service.go @@ -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 +} diff --git a/sqlc_gen/component_item.sql.go b/sqlc_gen/component_item.sql.go new file mode 100644 index 0000000..4524d58 --- /dev/null +++ b/sqlc_gen/component_item.sql.go @@ -0,0 +1,320 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: component_item.sql + +package db + +import ( + "context" + "time" +) + +const createComponentItem = `-- name: CreateComponentItem :one +INSERT INTO component_items (component_id,container_id,quantity, status, metadata, created_at) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) +RETURNING id, component_id, container_id, quantity, status, metadata, created_at, updated_at +` + +type CreateComponentItemParams struct { + ComponentID int64 `db:"component_id" json:"componentId"` + ContainerID int64 `db:"container_id" json:"containerId"` + Quantity int32 `db:"quantity" json:"quantity"` + Status ComponentItemStatusEnum `db:"status" json:"status"` + Metadata []byte `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` +} + +func (q *Queries) CreateComponentItem(ctx context.Context, arg CreateComponentItemParams) (ComponentItem, error) { + row := q.db.QueryRow(ctx, createComponentItem, + arg.ComponentID, + arg.ContainerID, + arg.Quantity, + arg.Status, + arg.Metadata, + arg.CreatedAt, + ) + var i ComponentItem + err := row.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteComponentItem = `-- name: DeleteComponentItem :execrows +DELETE FROM component_items +WHERE id = $1 +` + +func (q *Queries) DeleteComponentItem(ctx context.Context, id int64) (int64, error) { + result, err := q.db.Exec(ctx, deleteComponentItem, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const findComponentItem = `-- name: FindComponentItem :many +SELECT + c.name AS component_name, + ct.name AS type_name, + ci.quantity, + ci.status, + cn.name AS container_name, + cn.container_type, + s.name AS shelf_name, + cb.name AS cabinet_name, + r.name AS room_name, + w.name AS warehouse_name +FROM component_items ci +JOIN components c ON ci.component_id = c.id +JOIN component_types ct ON c.component_type_id = ct.id +JOIN containers cn ON ci.container_id = cn.id +JOIN shelves s ON cn.shelf_id = s.id +JOIN cabinets cb ON s.cabinet_id = cb.id +JOIN rooms r ON cb.room_id = r.id +JOIN warehouses w ON r.warehouse_id = w.id +WHERE ci.component_id = $1 AND ci.quantity > 0 +` + +type FindComponentItemRow struct { + ComponentName string `db:"component_name" json:"componentName"` + TypeName string `db:"type_name" json:"typeName"` + Quantity int32 `db:"quantity" json:"quantity"` + Status ComponentItemStatusEnum `db:"status" json:"status"` + ContainerName string `db:"container_name" json:"containerName"` + ContainerType ContainerTypeEnum `db:"container_type" json:"containerType"` + ShelfName string `db:"shelf_name" json:"shelfName"` + CabinetName string `db:"cabinet_name" json:"cabinetName"` + RoomName string `db:"room_name" json:"roomName"` + WarehouseName string `db:"warehouse_name" json:"warehouseName"` +} + +func (q *Queries) FindComponentItem(ctx context.Context, componentid int64) ([]FindComponentItemRow, error) { + rows, err := q.db.Query(ctx, findComponentItem, componentid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FindComponentItemRow + for rows.Next() { + var i FindComponentItemRow + if err := rows.Scan( + &i.ComponentName, + &i.TypeName, + &i.Quantity, + &i.Status, + &i.ContainerName, + &i.ContainerType, + &i.ShelfName, + &i.CabinetName, + &i.RoomName, + &i.WarehouseName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getComponentItemByComponentContainerStatus = `-- name: GetComponentItemByComponentContainerStatus :one +SELECT id, component_id, container_id, quantity, status, metadata, created_at, updated_at FROM component_items +WHERE component_id = $1 + AND container_id = $2 + AND status = $3 +` + +type GetComponentItemByComponentContainerStatusParams struct { + ComponentID int64 `db:"component_id" json:"componentId"` + ContainerID int64 `db:"container_id" json:"containerId"` + Status ComponentItemStatusEnum `db:"status" json:"status"` +} + +func (q *Queries) GetComponentItemByComponentContainerStatus(ctx context.Context, arg GetComponentItemByComponentContainerStatusParams) (ComponentItem, error) { + row := q.db.QueryRow(ctx, getComponentItemByComponentContainerStatus, arg.ComponentID, arg.ContainerID, arg.Status) + var i ComponentItem + err := row.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getComponentItemByID = `-- name: GetComponentItemByID :one +SELECT id, component_id, container_id, quantity, status, metadata, created_at, updated_at FROM component_items +WHERE id = $1 +` + +func (q *Queries) GetComponentItemByID(ctx context.Context, id int64) (ComponentItem, error) { + row := q.db.QueryRow(ctx, getComponentItemByID, id) + var i ComponentItem + err := row.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listComponentItems = `-- name: ListComponentItems :many +SELECT id, component_id, container_id, quantity, status, metadata, created_at, updated_at FROM component_items +ORDER BY created_at DESC +` + +func (q *Queries) ListComponentItems(ctx context.Context) ([]ComponentItem, error) { + rows, err := q.db.Query(ctx, listComponentItems) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ComponentItem + for rows.Next() { + var i ComponentItem + if err := rows.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateComponentItem = `-- name: UpdateComponentItem :one +UPDATE component_items +SET component_id = CASE WHEN $1 = '' THEN component_id ELSE $1 END, + container_id = CASE WHEN $2 = '' THEN container_id ELSE $2 END, + metadata = coalesce($3, metadata), + updated_at = $4 +WHERE id = $5 +RETURNING id, component_id, container_id, quantity, status, metadata, created_at, updated_at +` + +type UpdateComponentItemParams struct { + ComponentID interface{} `db:"component_id" json:"componentId"` + ContainerID interface{} `db:"container_id" json:"containerId"` + Metadata []byte `db:"metadata" json:"metadata"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` + ID int64 `db:"id" json:"id"` +} + +func (q *Queries) UpdateComponentItem(ctx context.Context, arg UpdateComponentItemParams) (ComponentItem, error) { + row := q.db.QueryRow(ctx, updateComponentItem, + arg.ComponentID, + arg.ContainerID, + arg.Metadata, + arg.UpdatedAt, + arg.ID, + ) + var i ComponentItem + err := row.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateComponentItemQuantity = `-- name: UpdateComponentItemQuantity :one +UPDATE component_items +SET quantity = $1, + updated_at = $2 +WHERE id = $3 +RETURNING id, component_id, container_id, quantity, status, metadata, created_at, updated_at +` + +type UpdateComponentItemQuantityParams struct { + Quantity int32 `db:"quantity" json:"quantity"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` + ID int64 `db:"id" json:"id"` +} + +func (q *Queries) UpdateComponentItemQuantity(ctx context.Context, arg UpdateComponentItemQuantityParams) (ComponentItem, error) { + row := q.db.QueryRow(ctx, updateComponentItemQuantity, arg.Quantity, arg.UpdatedAt, arg.ID) + var i ComponentItem + err := row.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateComponentItemStatus = `-- name: UpdateComponentItemStatus :one +UPDATE component_items +SET status = $1, + updated_at = $2 +WHERE id = $3 +RETURNING id, component_id, container_id, quantity, status, metadata, created_at, updated_at +` + +type UpdateComponentItemStatusParams struct { + Status ComponentItemStatusEnum `db:"status" json:"status"` + UpdatedAt time.Time `db:"updated_at" json:"updatedAt"` + ID int64 `db:"id" json:"id"` +} + +func (q *Queries) UpdateComponentItemStatus(ctx context.Context, arg UpdateComponentItemStatusParams) (ComponentItem, error) { + row := q.db.QueryRow(ctx, updateComponentItemStatus, arg.Status, arg.UpdatedAt, arg.ID) + var i ComponentItem + err := row.Scan( + &i.ID, + &i.ComponentID, + &i.ContainerID, + &i.Quantity, + &i.Status, + &i.Metadata, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/sqlc_gen/component_status_history.sql.go b/sqlc_gen/component_status_history.sql.go new file mode 100644 index 0000000..8b79dc9 --- /dev/null +++ b/sqlc_gen/component_status_history.sql.go @@ -0,0 +1,64 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: component_status_history.sql + +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createComponentStatusHistory = `-- name: CreateComponentStatusHistory :one +INSERT INTO component_status_history ( + component_item_id, old_status, new_status, + changed_quantity, note, changed_by, changed_at +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7 +) +RETURNING id, component_item_id, old_status, new_status, changed_quantity, note, changed_by, changed_at +` + +type CreateComponentStatusHistoryParams struct { + ComponentItemID int64 `db:"component_item_id" json:"componentItemId"` + OldStatus NullComponentItemStatusEnum `db:"old_status" json:"oldStatus"` + NewStatus ComponentItemStatusEnum `db:"new_status" json:"newStatus"` + ChangedQuantity pgtype.Int4 `db:"changed_quantity" json:"changedQuantity"` + Note pgtype.Text `db:"note" json:"note"` + ChangedBy pgtype.Text `db:"changed_by" json:"changedBy"` + ChangedAt time.Time `db:"changed_at" json:"changedAt"` +} + +func (q *Queries) CreateComponentStatusHistory(ctx context.Context, arg CreateComponentStatusHistoryParams) (ComponentStatusHistory, error) { + row := q.db.QueryRow(ctx, createComponentStatusHistory, + arg.ComponentItemID, + arg.OldStatus, + arg.NewStatus, + arg.ChangedQuantity, + arg.Note, + arg.ChangedBy, + arg.ChangedAt, + ) + var i ComponentStatusHistory + err := row.Scan( + &i.ID, + &i.ComponentItemID, + &i.OldStatus, + &i.NewStatus, + &i.ChangedQuantity, + &i.Note, + &i.ChangedBy, + &i.ChangedAt, + ) + return i, err +} diff --git a/sqlc_gen/querier.go b/sqlc_gen/querier.go index b3a1c12..db53930 100644 --- a/sqlc_gen/querier.go +++ b/sqlc_gen/querier.go @@ -16,6 +16,8 @@ type Querier interface { CreateCabinet(ctx context.Context, arg CreateCabinetParams) (Cabinet, error) CreateComponent(ctx context.Context, arg CreateComponentParams) (Component, error) CreateComponentCode(ctx context.Context, arg CreateComponentCodeParams) (ComponentCode, error) + CreateComponentItem(ctx context.Context, arg CreateComponentItemParams) (ComponentItem, error) + CreateComponentStatusHistory(ctx context.Context, arg CreateComponentStatusHistoryParams) (ComponentStatusHistory, error) CreateComponentType(ctx context.Context, arg CreateComponentTypeParams) (ComponentType, error) CreateContainer(ctx context.Context, arg CreateContainerParams) (Container, error) CreateRole(ctx context.Context, arg CreateRoleParams) (Role, error) @@ -26,15 +28,19 @@ type Querier interface { DeleteCabinet(ctx context.Context, id int64) (int64, error) DeleteComponent(ctx context.Context, id int64) (int64, error) DeleteComponentCode(ctx context.Context, id int64) (int64, error) + DeleteComponentItem(ctx context.Context, id int64) (int64, error) DeleteComponentType(ctx context.Context, id int64) (int64, error) DeleteContainer(ctx context.Context, id int64) (int64, error) DeleteRole(ctx context.Context, id uuid.UUID) (int64, error) DeleteRoom(ctx context.Context, id int64) (int64, error) DeleteShelve(ctx context.Context, id int64) (int64, error) DeleteWarehouse(ctx context.Context, id int64) (int64, error) + FindComponentItem(ctx context.Context, componentid int64) ([]FindComponentItemRow, error) GetCabinetByID(ctx context.Context, id int64) (Cabinet, error) GetComponentByID(ctx context.Context, id int64) (Component, error) GetComponentCodeByID(ctx context.Context, id int64) (ComponentCode, error) + GetComponentItemByComponentContainerStatus(ctx context.Context, arg GetComponentItemByComponentContainerStatusParams) (ComponentItem, error) + GetComponentItemByID(ctx context.Context, id int64) (ComponentItem, error) GetComponentTypeByID(ctx context.Context, id int64) (ComponentType, error) GetContainerByID(ctx context.Context, id int64) (Container, error) GetRoleByID(ctx context.Context, id uuid.UUID) (Role, error) @@ -49,6 +55,7 @@ type Querier interface { GetWarehouseByID(ctx context.Context, id int64) (Warehouse, error) ListCabinets(ctx context.Context) ([]Cabinet, error) ListComponentCodes(ctx context.Context) ([]ComponentCode, error) + ListComponentItems(ctx context.Context) ([]ComponentItem, error) ListComponentTypes(ctx context.Context) ([]ComponentType, error) ListComponents(ctx context.Context) ([]Component, error) ListContainers(ctx context.Context) ([]Container, error) @@ -61,6 +68,9 @@ type Querier interface { UpdateCabinet(ctx context.Context, arg UpdateCabinetParams) (Cabinet, error) UpdateComponent(ctx context.Context, arg UpdateComponentParams) (Component, error) UpdateComponentCode(ctx context.Context, arg UpdateComponentCodeParams) (ComponentCode, error) + UpdateComponentItem(ctx context.Context, arg UpdateComponentItemParams) (ComponentItem, error) + UpdateComponentItemQuantity(ctx context.Context, arg UpdateComponentItemQuantityParams) (ComponentItem, error) + UpdateComponentItemStatus(ctx context.Context, arg UpdateComponentItemStatusParams) (ComponentItem, error) UpdateComponentType(ctx context.Context, arg UpdateComponentTypeParams) (ComponentType, error) UpdateContainer(ctx context.Context, arg UpdateContainerParams) (Container, error) UpdateRole(ctx context.Context, arg UpdateRoleParams) (Role, error)