Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop

This commit is contained in:
김보곤
2026-02-10 10:02:16 +09:00
7 changed files with 2125 additions and 63 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-02-07 09:56:46
> **자동 생성**: 2026-02-07 01:10:55
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -499,6 +499,8 @@ ### orders
- **item()**: belongsTo → `items`
- **sale()**: belongsTo → `sales`
- **items()**: hasMany → `order_items`
- **nodes()**: hasMany → `order_nodes`
- **rootNodes()**: hasMany → `order_nodes`
- **histories()**: hasMany → `order_histories`
- **versions()**: hasMany → `order_versions`
- **workOrders()**: hasMany → `work_orders`
@@ -514,6 +516,7 @@ ### order_items
**모델**: `App\Models\Orders\OrderItem`
- **order()**: belongsTo → `orders`
- **node()**: belongsTo → `order_nodes`
- **item()**: belongsTo → `items`
- **quote()**: belongsTo → `quotes`
- **quoteItem()**: belongsTo → `quote_items`
@@ -524,6 +527,14 @@ ### order_item_components
- **orderItem()**: belongsTo → `order_items`
### order_nodes
**모델**: `App\Models\Orders\OrderNode`
- **parent()**: belongsTo → `order_nodes`
- **order()**: belongsTo → `orders`
- **children()**: hasMany → `order_nodes`
- **items()**: hasMany → `order_items`
### order_versions
**모델**: `App\Models\Orders\OrderVersion`
@@ -597,6 +608,7 @@ ### work_orders
- **primaryAssignee()**: hasMany → `work_order_assignees`
- **items()**: hasMany → `work_order_items`
- **issues()**: hasMany → `work_order_issues`
- **stepProgress()**: hasMany → `work_order_step_progress`
- **shipments()**: hasMany → `shipments`
- **bendingDetail()**: hasOne → `work_order_bending_details`
@@ -624,6 +636,14 @@ ### work_order_items
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
### work_order_step_progress
**모델**: `App\Models\Production\WorkOrderStepProgress`
- **workOrder()**: belongsTo → `work_orders`
- **processStep()**: belongsTo → `process_steps`
- **workOrderItem()**: belongsTo → `work_order_items`
- **completedByUser()**: belongsTo → `users`
### work_results
**모델**: `App\Models\Production\WorkResult`

View File

@@ -254,6 +254,7 @@ public function update(int $id, array $data)
public function destroy(int $id)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$order = Order::where('tenant_id', $tenantId)->find($id);
if (! $order) {
@@ -263,16 +264,49 @@ public function destroy(int $id)
// 진행 중이거나 완료된 수주는 삭제 불가
if (in_array($order->status_code, [
Order::STATUS_IN_PROGRESS,
Order::STATUS_IN_PRODUCTION,
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
Order::STATUS_COMPLETED,
])) {
throw new BadRequestHttpException(__('error.order.cannot_delete_in_progress'));
}
$order->deleted_by = $this->apiUserId();
$order->save();
$order->delete();
// 작업지시가 존재하면 삭제 불가
if ($order->workOrders()->exists()) {
throw new BadRequestHttpException(__('error.order.cannot_delete_has_work_orders'));
}
return 'success';
// 출하 정보가 존재하면 삭제 불가
if ($order->shipments()->exists()) {
throw new BadRequestHttpException(__('error.order.cannot_delete_has_shipments'));
}
return DB::transaction(function () use ($order, $userId) {
// 1. order_item_components soft delete
foreach ($order->items as $item) {
$item->components()->update(['deleted_by' => $userId]);
$item->components()->delete();
}
// 2. order_items soft delete
$order->items()->update(['deleted_by' => $userId]);
$order->items()->delete();
// 3. order_nodes soft delete
$order->nodes()->update(['deleted_by' => $userId]);
$order->nodes()->delete();
// 4. order 마스터 soft delete
$order->deleted_by = $userId;
$order->save();
$order->delete();
// order_histories, order_versions는 감사 기록이므로 보존
return 'success';
});
}
/**

View File

@@ -1092,7 +1092,7 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status)
* 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 로트별 재고)
*
* 작업지시 품목의 BOM 자재별로 StockLot(입고 로트)를 FIFO 순서로 반환합니다.
* 로트번호는 입고관리(Receiving)에서 생성된 실제 로트번호입니다.
* 동일 자재가 여러 작업지시 품목에 걸쳐 있으면 필요수량을 합산하고 로트는 중복 없이 반환합니다.
*
* @param int $workOrderId 작업지시 ID
* @return array 자재 목록 (로트 단위)
@@ -1109,8 +1109,8 @@ public function getMaterials(int $workOrderId): array
throw new NotFoundHttpException(__('error.not_found'));
}
$materials = [];
$rank = 1;
// Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산)
$uniqueMaterials = [];
foreach ($workOrder->items as $woItem) {
$materialItems = [];
@@ -1140,7 +1140,6 @@ public function getMaterials(int $workOrderId): array
'item' => $childItem,
'bom_qty' => $bomQty,
'required_qty' => $bomQty * ($woItem->quantity ?? 1),
'work_order_item_id' => $woItem->id,
];
}
}
@@ -1152,73 +1151,83 @@ public function getMaterials(int $workOrderId): array
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
'work_order_item_id' => $woItem->id,
];
}
// 각 자재별로 StockLot(입고 로트) 조회
// 유니크 자재 수집 (같은 item_id면 required_qty 합산)
foreach ($materialItems as $matInfo) {
$materialItem = $matInfo['item'];
// Stock 조회
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id)
->first();
if ($stock) {
// 가용 로트를 FIFO 순서로 조회
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
->where('stock_id', $stock->id)
->where('status', 'available')
->where('available_qty', '>', 0)
->orderBy('fifo_order', 'asc')
->get();
foreach ($lots as $lot) {
$materials[] = [
'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id,
'work_order_item_id' => $matInfo['work_order_item_id'],
'lot_no' => $lot->lot_no,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $lot->unit ?? $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'lot_qty' => (float) $lot->qty,
'lot_available_qty' => (float) $lot->available_qty,
'lot_reserved_qty' => (float) $lot->reserved_qty,
'receipt_date' => $lot->receipt_date,
'supplier' => $lot->supplier,
'fifo_rank' => $rank++,
];
}
$itemId = $matInfo['item']->id;
if (isset($uniqueMaterials[$itemId])) {
$uniqueMaterials[$itemId]['required_qty'] += $matInfo['required_qty'];
} else {
$uniqueMaterials[$itemId] = $matInfo;
}
}
}
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
$hasLots = collect($materials)->where('item_id', $materialItem->id)->isNotEmpty();
if (! $hasLots) {
// Phase 2: 유니크 자재별로 StockLot 조회
$materials = [];
$rank = 1;
foreach ($uniqueMaterials as $matInfo) {
$materialItem = $matInfo['item'];
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->where('item_id', $materialItem->id)
->first();
$lotsFound = false;
if ($stock) {
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
->where('stock_id', $stock->id)
->where('status', 'available')
->where('available_qty', '>', 0)
->orderBy('fifo_order', 'asc')
->get();
foreach ($lots as $lot) {
$lotsFound = true;
$materials[] = [
'stock_lot_id' => null,
'stock_lot_id' => $lot->id,
'item_id' => $materialItem->id,
'work_order_item_id' => $matInfo['work_order_item_id'],
'lot_no' => null,
'lot_no' => $lot->lot_no,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $materialItem->unit ?? 'EA',
'unit' => $lot->unit ?? $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'lot_qty' => 0,
'lot_available_qty' => 0,
'lot_reserved_qty' => 0,
'receipt_date' => null,
'supplier' => null,
'lot_qty' => (float) $lot->qty,
'lot_available_qty' => (float) $lot->available_qty,
'lot_reserved_qty' => (float) $lot->reserved_qty,
'receipt_date' => $lot->receipt_date,
'supplier' => $lot->supplier,
'fifo_rank' => $rank++,
];
}
}
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
if (! $lotsFound) {
$materials[] = [
'stock_lot_id' => null,
'item_id' => $materialItem->id,
'lot_no' => null,
'material_code' => $materialItem->code,
'material_name' => $materialItem->name,
'specification' => $materialItem->specification,
'unit' => $materialItem->unit ?? 'EA',
'bom_qty' => $matInfo['bom_qty'],
'required_qty' => $matInfo['required_qty'],
'lot_qty' => 0,
'lot_available_qty' => 0,
'lot_reserved_qty' => 0,
'receipt_date' => null,
'supplier' => null,
'fifo_rank' => $rank++,
];
}
}
return $materials;

View File

@@ -0,0 +1,234 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Trigger Audit",
* description="DB 트리거 기반 데이터 변경 추적 로그"
* )
*
* @OA\Schema(
* schema="TriggerAuditLog",
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="table_name", type="string", example="products"),
* @OA\Property(property="row_id", type="string", example="42"),
* @OA\Property(property="dml_type", type="string", enum={"INSERT","UPDATE","DELETE"}),
* @OA\Property(property="old_values", type="object", nullable=true),
* @OA\Property(property="new_values", type="object", nullable=true),
* @OA\Property(property="changed_columns", type="array", nullable=true, @OA\Items(type="string")),
* @OA\Property(property="tenant_id", type="integer", nullable=true),
* @OA\Property(property="actor_id", type="integer", nullable=true),
* @OA\Property(property="session_info", type="object", nullable=true,
* @OA\Property(property="ip", type="string"),
* @OA\Property(property="ua", type="string"),
* @OA\Property(property="route", type="string")
* ),
* @OA\Property(property="db_user", type="string", nullable=true, example="samuser@%"),
* @OA\Property(property="created_at", type="string", format="date-time")
* )
*/
class TriggerAuditLogApi
{
/**
* @OA\Get(
* path="/api/v1/trigger-audit-logs",
* tags={"Trigger Audit"},
* summary="감사 로그 목록 조회",
* description="DB 트리거 기반 변경 로그를 페이지네이션으로 조회합니다.",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", minimum=1)),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", minimum=1, maximum=200)),
* @OA\Parameter(name="table_name", in="query", description="테이블명 필터", @OA\Schema(type="string")),
* @OA\Parameter(name="row_id", in="query", description="레코드 PK 필터", @OA\Schema(type="string")),
* @OA\Parameter(name="dml_type", in="query", description="DML 유형 필터", @OA\Schema(type="string", enum={"INSERT","UPDATE","DELETE"})),
* @OA\Parameter(name="tenant_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="actor_id", in="query", @OA\Schema(type="integer")),
* @OA\Parameter(name="db_user", in="query", @OA\Schema(type="string")),
* @OA\Parameter(name="from", in="query", description="시작일", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="to", in="query", description="종료일", @OA\Schema(type="string", format="date")),
* @OA\Parameter(name="sort", in="query", @OA\Schema(type="string", enum={"created_at","id"})),
* @OA\Parameter(name="order", in="query", @OA\Schema(type="string", enum={"asc","desc"})),
*
* @OA\Response(
* response=200,
* description="목록 조회 성공",
*
* @OA\JsonContent(type="object",
*
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="current_page", type="integer"),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/TriggerAuditLog")),
* @OA\Property(property="last_page", type="integer"),
* @OA\Property(property="per_page", type="integer"),
* @OA\Property(property="total", type="integer")
* )
* )
* )
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/trigger-audit-logs/stats",
* tags={"Trigger Audit"},
* summary="감사 로그 통계",
* description="전체/오늘/DML별 건수, 상위 테이블, 저장소 크기 통계를 반환합니다.",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="tenant_id", in="query", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="통계 조회 성공",
*
* @OA\JsonContent(type="object",
*
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="total", type="integer"),
* @OA\Property(property="today", type="integer"),
* @OA\Property(property="by_dml_type", type="object",
* @OA\Property(property="INSERT", type="integer"),
* @OA\Property(property="UPDATE", type="integer"),
* @OA\Property(property="DELETE", type="integer")
* ),
* @OA\Property(property="top_tables", type="object"),
* @OA\Property(property="storage_mb", type="number")
* )
* )
* )
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/trigger-audit-logs/{id}",
* tags={"Trigger Audit"},
* summary="감사 로그 상세 조회",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="상세 조회 성공",
*
* @OA\JsonContent(type="object",
*
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/TriggerAuditLog")
* )
* ),
*
* @OA\Response(response=404, description="Not Found")
* )
*/
public function show() {}
/**
* @OA\Get(
* path="/api/v1/trigger-audit-logs/{tableName}/{rowId}/history",
* tags={"Trigger Audit"},
* summary="레코드 변경 이력 조회",
* description="특정 테이블의 특정 레코드에 대한 전체 변경 이력을 조회합니다.",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="tableName", in="path", required=true, description="테이블명", @OA\Schema(type="string")),
* @OA\Parameter(name="rowId", in="path", required=true, description="레코드 PK", @OA\Schema(type="string")),
*
* @OA\Response(
* response=200,
* description="이력 조회 성공",
*
* @OA\JsonContent(type="object",
*
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/TriggerAuditLog"))
* )
* )
* )
*/
public function recordHistory() {}
/**
* @OA\Get(
* path="/api/v1/trigger-audit-logs/{id}/rollback-preview",
* tags={"Trigger Audit"},
* summary="롤백 SQL 미리보기",
* description="해당 변경을 되돌리기 위한 SQL문을 미리 확인합니다.",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="롤백 SQL 반환",
*
* @OA\JsonContent(type="object",
*
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="audit_id", type="integer"),
* @OA\Property(property="rollback_sql", type="string")
* )
* )
* ),
*
* @OA\Response(response=404, description="Not Found")
* )
*/
public function rollbackPreview() {}
/**
* @OA\Post(
* path="/api/v1/trigger-audit-logs/{id}/rollback",
* tags={"Trigger Audit"},
* summary="롤백 실행",
* description="해당 변경을 실제로 되돌립니다. confirm=true 필수.",
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
*
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(
* required={"confirm"},
*
* @OA\Property(property="confirm", type="boolean", example=true, description="롤백 확인 (true 필수)")
* )
* ),
*
* @OA\Response(
* response=200,
* description="롤백 성공",
*
* @OA\JsonContent(type="object",
*
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="rolled_back", type="boolean"),
* @OA\Property(property="sql_executed", type="string")
* )
* )
* ),
*
* @OA\Response(response=404, description="Not Found"),
* @OA\Response(response=422, description="Validation Error")
* )
*/
public function rollbackExecute() {}
}

View File

@@ -24,6 +24,7 @@
'failed_jobs',
'migrations',
'password_reset_tokens',
'api_request_logs',
];
/** 변경 추적 제외 컬럼 */
@@ -56,6 +57,7 @@ public function up(): void
if (in_array($tableName, $this->excludeTables, true)) {
$skipped++;
continue;
}
@@ -106,12 +108,12 @@ private function createTriggersForTable(string $dbName, string $tableName): void
$pk = $pkRow->COLUMN_NAME;
// 컬럼 목록 (제외 컬럼 필터링)
$columns = DB::select("
$columns = DB::select('
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
", [$dbName, $tableName]);
', [$dbName, $tableName]);
$cols = [];
$hasTenantId = false;
@@ -144,8 +146,8 @@ private function createTriggersForTable(string $dbName, string $tableName): void
$cols
));
$tenantNew = $hasTenantId ? "NEW.`tenant_id`" : 'NULL';
$tenantOld = $hasTenantId ? "OLD.`tenant_id`" : 'NULL';
$tenantNew = $hasTenantId ? 'NEW.`tenant_id`' : 'NULL';
$tenantOld = $hasTenantId ? 'OLD.`tenant_id`' : 'NULL';
// 기존 트리거 삭제
DB::unprepared("DROP TRIGGER IF EXISTS `trg_{$tableName}_ai`");

View File

@@ -400,6 +400,8 @@
'order' => [
'cannot_update_completed' => '완료 또는 취소된 수주는 수정할 수 없습니다.',
'cannot_delete_in_progress' => '진행 중이거나 완료된 수주는 삭제할 수 없습니다.',
'cannot_delete_has_work_orders' => '작업지시가 존재하는 수주는 삭제할 수 없습니다. 작업지시를 먼저 삭제해주세요.',
'cannot_delete_has_shipments' => '출하 정보가 존재하는 수주는 삭제할 수 없습니다. 출하를 먼저 삭제해주세요.',
'invalid_status_transition' => '유효하지 않은 상태 전환입니다.',
'already_created_from_quote' => '이미 해당 견적에서 수주가 생성되었습니다.',
'must_be_confirmed_for_production' => '확정 상태의 수주만 생산지시를 생성할 수 있습니다.',

File diff suppressed because it is too large Load Diff