Files
sam-docs/dev/dev_plans/archive/mng-item-formula-integration-plan.md
권혁성 db63fcff85 refactor: [docs] 팀별 폴더 구조 재편 (공유/개발/프론트/기획)
- 개발팀 전용 폴더 dev/ 생성 (standards, guides, quickstart, changes, deploys, data, history, dev_plans 이동)
- 프론트엔드 전용 폴더 frontend/ 생성 (api/ → frontend/api-specs/)
- 기획팀 폴더 requests/ 생성
- plans/ → dev/dev_plans/ 이름 변경
- README.md 신규 (사람용 안내), INDEX.md 재작성 (Claude Code용)
- resources.md 신규 (노션 링크용, assets/brochure 이관 예정)
- CURRENT_WORKS.md 삭제, TODO.md → dev/ 이동
- 전체 참조 경로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:46:03 +09:00

838 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# MNG 품목관리 - 견적수식 엔진(FormulaEvaluatorService) 연동 계획
> **작성일**: 2026-02-19
> **목적**: 가변사이즈 완제품(FG) 선택 시 오픈사이즈(W,H) 입력 → FormulaEvaluatorService로 동적 자재 산출 → 중앙 패널에 트리 표시
> **기준 문서**: docs/dev_plans/mng-item-management-plan.md, api/app/Services/Quote/FormulaEvaluatorService.php
> **선행 작업**: 3-Panel 품목관리 페이지 구현 완료 (Phase 1~2 of mng-item-management-plan.md)
> **상태**: 🔄 진행중
---
## 📍 현재 진행 상태
| 항목 | 내용 |
|------|------|
| **마지막 완료 작업** | Phase 2.5: 로딩/에러 상태 처리 (전체 구현 완료) |
| **다음 작업** | 검증 (브라우저 테스트) |
| **진행률** | 8/8 (100%) |
| **마지막 업데이트** | 2026-02-19 |
---
## 1. 개요
### 1.1 배경
MNG 품목관리 페이지(`mng.sam.kr/item-management`)에서 완제품(FG) `FG-KQTS01-벽면형-SUS`를 선택하면 `items.bom` JSON에 등록된 정적 BOM(PT 2개: 가이드레일, 하단마감재)만 표시된다.
그러나 견적관리(`dev.sam.kr/sales/quote-management/46`)에서는 `FormulaEvaluatorService`가 W=3000, H=3000 입력으로 17종 51개의 자재를 동적 산출한다.
**핵심 문제**: items.bom(정적)과 FormulaEvaluatorService(동적) 두 시스템이 분리되어 있어, 품목관리 페이지에서 실제 필요 자재를 볼 수 없다.
**해결**: 가변사이즈 품목(`item_details.is_variable_size = true`) 선택 시 오픈사이즈(W, H) 입력 UI를 제공하고, API의 기존 엔드포인트(`POST /api/v1/quotes/calculate/bom`)를 MNG에서 HTTP로 호출하여 산출 결과를 중앙 패널에 표시한다.
### 1.2 기준 원칙
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎯 핵심 원칙 │
├─────────────────────────────────────────────────────────────────┤
│ - MNG에서 마이그레이션 파일 생성 금지 (API에서만) │
│ - MNG ↔ API 통신은 HTTP API 호출 (코드 공유/직접 서비스 호출 X) │
│ - 기존 API 엔드포인트 재사용 (POST /api/v1/quotes/calculate/bom)│
│ - Docker nginx 내부 라우팅 + SSL 우회 패턴 사용 │
│ - Blade + HTMX + Tailwind + Vanilla JS (Alpine.js 미사용) │
│ - 정적 BOM과 수식 산출 결과를 탭으로 전환 가능하게 │
│ - Controller에서 직접 DB 쿼리 금지 (Service-First) │
│ - Controller에서 직접 validate() 금지 (FormRequest 필수) │
└─────────────────────────────────────────────────────────────────┘
```
### 1.3 변경 승인 정책
| 분류 | 예시 | 승인 |
|------|------|------|
| ✅ 즉시 가능 | 서비스 생성, 컨트롤러 메서드 추가, Blade 수정, JS 추가 | 불필요 |
| ⚠️ 컨펌 필요 | API 라우트 추가, 기존 Blade 구조 변경 | **필수** |
| 🔴 금지 | mng에서 마이그레이션 생성, API 소스 수정 | 별도 협의 |
### 1.4 MNG 절대 금지 규칙
```
❌ mng/database/migrations/ 에 파일 생성 금지
❌ docker exec sam-mng-1 php artisan migrate 실행 금지
❌ php artisan db:seed --class=*MenuSeeder 실행 금지
❌ Controller에서 직접 DB 쿼리 금지 (Service-First)
❌ Controller에서 직접 validate() 금지 (FormRequest 필수)
❌ api/ 프로젝트 소스 코드 수정 금지
```
---
## 2. 대상 범위
### 2.1 Phase 1: MNG 백엔드 (HTTP API 호출 서비스)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1.1 | FormulaApiService 생성 (MNG→API HTTP 호출 래퍼) | ✅ | 신규 파일 |
| 1.2 | ItemManagementApiController에 calculateFormula 메서드 추가 | ✅ | 기존 파일 수정 |
| 1.3 | API 라우트 추가 (POST /api/admin/items/{id}/calculate-formula) | ✅ | 기존 파일 수정 |
### 2.2 Phase 2: MNG 프론트엔드 (UI 연동)
| # | 작업 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 2.1 | 중앙 패널 헤더에 탭 UI 추가 (정적 BOM / 수식 산출) | ✅ | index.blade.php 수정 |
| 2.2 | 오픈사이즈 입력 폼 (W, H, 수량) + 산출 버튼 | ✅ | index.blade.php 수정 |
| 2.3 | 수식 산출 결과 트리 렌더링 (카테고리 그룹별) | ✅ | JS 추가 |
| 2.4 | 가변사이즈 품목 감지 → 자동 탭 전환 | ✅ | item-detail.blade.php + JS 수정 |
| 2.5 | 로딩/에러 상태 처리 | ✅ | JS 추가 |
---
## 3. 이미 구현된 코드 (선행 작업 - 수정 대상)
> 새 세션에서 현재 코드 상태를 파악할 수 있도록 이미 존재하는 파일 전체 목록과 핵심 구조를 기록.
### 3.1 파일 구조 (이미 존재)
```
mng/
├── app/
│ ├── Http/Controllers/
│ │ ├── ItemManagementController.php # Web (HX-Redirect 패턴)
│ │ └── Api/Admin/
│ │ └── ItemManagementApiController.php # API (index, bomTree, detail)
│ ├── Models/
│ │ ├── Items/
│ │ │ ├── Item.php # BelongsToTenant, 관계, 스코프, 상수
│ │ │ └── ItemDetail.php # 1:1 확장 (is_variable_size 필드 포함)
│ │ └── Commons/
│ │ └── File.php # 파일 모델
│ ├── Services/
│ │ └── ItemManagementService.php # getItemList, getBomTree, getItemDetail
│ └── Traits/
│ └── BelongsToTenant.php # 테넌트 격리 Trait
├── resources/views/item-management/
│ ├── index.blade.php # 3-Panel 메인 (★ 수정 대상)
│ └── partials/
│ ├── item-list.blade.php # 좌측 패널 (변경 없음)
│ ├── bom-tree.blade.php # 중앙 패널 초기 상태 (변경 없음)
│ └── item-detail.blade.php # 우측 패널 (★ 수정 대상)
├── routes/
│ ├── web.php # Route: GET /item-management (변경 없음)
│ └── api.php # Route: items group (★ 수정 대상 - 라우트 추가)
└── config/
└── api-explorer.php # FLOW_TESTER_API_KEY 설정 참조
```
### 3.2 현재 ItemManagementApiController 전체 (수정 대상)
```php
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\ItemManagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ItemManagementApiController extends Controller
{
public function __construct(
private readonly ItemManagementService $service
) {}
public function index(Request $request): View
{
$items = $this->service->getItemList([
'search' => $request->input('search'),
'item_type' => $request->input('item_type'),
'per_page' => $request->input('per_page', 50),
]);
return view('item-management.partials.item-list', compact('items'));
}
public function bomTree(int $id, Request $request): JsonResponse
{
$maxDepth = $request->input('max_depth', 10);
$tree = $this->service->getBomTree($id, $maxDepth);
return response()->json($tree);
}
public function detail(int $id): View
{
$data = $this->service->getItemDetail($id);
return view('item-management.partials.item-detail', [
'item' => $data['item'],
'bomChildren' => $data['bom_children'],
]);
}
}
```
### 3.3 현재 API 라우트 (items 그룹, mng/routes/api.php:866~)
```php
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/items')->name('api.admin.items.')->group(function () {
Route::get('/search', [ItemApiController::class, 'search'])->name('search');
// 품목관리 페이지 API
Route::get('/', [ItemManagementApiController::class, 'index'])->name('index');
Route::get('/{id}/bom-tree', [ItemManagementApiController::class, 'bomTree'])->name('bom-tree');
Route::get('/{id}/detail', [ItemManagementApiController::class, 'detail'])->name('detail');
// ★ 여기에 calculate-formula 라우트 추가 예정
});
```
### 3.4 현재 index.blade.php 중앙 패널 (수정 대상 부분)
```html
<!-- 현재 중앙 패널 -->
<div class="flex-1 bg-white rounded-lg shadow-sm flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">BOM 구성 (재귀 트리)</h2>
</div>
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
</div>
</div>
```
### 3.5 현재 JS 구조 (index.blade.php @push('scripts'))
핵심 함수:
- `loadItemList()` - 좌측 품목 리스트 HTMX 로드
- `selectItem(itemId, updateTree)` - 품목 선택 (좌측 하이라이트 + 중앙 트리 fetch + 우측 상세 HTMX)
- `selectTreeNode(itemId)` - 중앙 트리 노드 클릭 (우측만 갱신, 트리 유지)
- `renderBomTree(node, container)` - BOM 트리 재귀 렌더링
- `getTypeBadgeClass(type)` - 유형별 뱃지 CSS 클래스
### 3.6 테넌트 필터링 패턴 (중요)
MNG의 HQ 관리자는 헤더에서 테넌트를 선택하며, `session('selected_tenant_id')`에 저장된다.
그러나 `BelongsToTenant``TenantScope``request->attributes`, `X-TENANT-ID 헤더`, `auth user`에서 tenant_id를 읽으므로 **세션 값과 불일치**할 수 있다.
**따라서 Service에서는 `Item::withoutGlobalScopes()->where('tenant_id', session('selected_tenant_id'))` 패턴을 사용한다.**
```php
// ✅ 올바른 패턴 (현재 ItemManagementService에서 사용 중)
Item::withoutGlobalScopes()
->where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
// ❌ 잘못된 패턴 (HQ 관리자 세션과 불일치)
Item::findOrFail($id); // TenantScope가 auth user의 tenant_id 사용
```
---
## 4. 상세 작업 내용
### 4.1 Phase 1: MNG 백엔드
#### 1.1 FormulaApiService 생성
**파일 경로**: `mng/app/Services/FormulaApiService.php` (신규 생성)
**역할**: MNG에서 API 프로젝트의 `POST /api/v1/quotes/calculate/bom` 엔드포인트를 HTTP로 호출하는 래퍼
**호출 대상 API 엔드포인트 상세**:
```
POST /api/v1/quotes/calculate/bom
라우트 정의: api/routes/api/v1/sales.php:64
미들웨어: 글로벌(ApiKeyMiddleware, CorsMiddleware, ApiRateLimiter)
FormRequest: QuoteBomCalculateRequest (authorize = true, 제한 없음)
```
**API 인증 요구사항** (확인 완료):
| 헤더 | 필수 | 설명 |
|------|:----:|------|
| `X-API-KEY` | ✅ 필수 | `api_keys` 테이블에 `is_active=true`로 등록된 키 |
| `Authorization: Bearer {token}` | ❌ 선택 | Sanctum 토큰, 있으면 tenant_id 자동 설정 |
| `X-TENANT-ID` | ❌ 선택 | 테넌트 식별 (Bearer 없을 때 대안) |
**API Key 취득 방법**: `env('FLOW_TESTER_API_KEY')` (mng/.env에 설정됨, `config/api-explorer.php:26`에서 참조)
**요청 페이로드**:
```json
{
"finished_goods_code": "FG-KQTS01",
"variables": {
"W0": 3000,
"H0": 3000,
"QTY": 1
},
"tenant_id": 287
}
```
**응답 구조** (FormulaEvaluatorService::calculateBomWithDebug 반환값):
```json
{
"success": true,
"finished_goods": { "code": "FG-KQTS01", "name": "벽면형-SUS", "id": 123 },
"variables": { "W0": 3000, "H0": 3000, "QTY": 1 },
"items": [
{
"item_code": "PT-강재-C형강",
"item_name": "C형강 65×32×10t",
"specification": "65×32×10t",
"unit": "mm",
"quantity": 6038,
"unit_price": 1.0,
"total_price": 6038,
"category_group": "steel"
}
],
"grouped_items": {
"steel": [ ... ],
"part": [ ... ],
"motor": [ ... ]
},
"subtotals": { "steel": 123456, "part": 78900, "motor": 50000 },
"grand_total": 252356,
"debug_steps": [ ... ]
}
```
**구현 코드**:
```php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FormulaApiService
{
/**
* API 서버의 FormulaEvaluatorService를 HTTP로 호출하여 BOM 산출
*
* Docker 내부 통신 패턴:
* - URL: https://nginx/api/v1/quotes/calculate/bom (Docker nginx 컨테이너)
* - Host 헤더: api.sam.kr (nginx가 올바른 서버 블록으로 라우팅)
* - SSL 우회: withoutVerifying() (내부 자체 서명 인증서)
* - 인증: X-API-KEY 헤더 (FLOW_TESTER_API_KEY 환경변수)
*/
public function calculateBom(string $finishedGoodsCode, array $variables, int $tenantId): array
{
try {
$apiKey = config('api-explorer.default_environments.0.api_key')
?: env('FLOW_TESTER_API_KEY', '');
$response = Http::timeout(30)
->withoutVerifying()
->withHeaders([
'Host' => 'api.sam.kr',
'Accept' => 'application/json',
'Content-Type' => 'application/json',
'X-API-KEY' => $apiKey,
'X-TENANT-ID' => (string) $tenantId,
])
->post('https://nginx/api/v1/quotes/calculate/bom', [
'finished_goods_code' => $finishedGoodsCode,
'variables' => $variables,
'tenant_id' => $tenantId,
]);
if ($response->successful()) {
$json = $response->json();
// ApiResponse::handle()는 {success, message, data} 구조로 래핑
return $json['data'] ?? $json;
}
Log::warning('FormulaApiService: API 호출 실패', [
'status' => $response->status(),
'body' => $response->body(),
'code' => $finishedGoodsCode,
]);
return [
'success' => false,
'error' => 'API 응답 오류: HTTP ' . $response->status(),
];
} catch (\Exception $e) {
Log::error('FormulaApiService: 예외 발생', [
'message' => $e->getMessage(),
'code' => $finishedGoodsCode,
]);
return [
'success' => false,
'error' => '수식 계산 서버 연결 실패: ' . $e->getMessage(),
];
}
}
}
```
**트러블슈팅 가이드**:
- `401 Unauthorized` → API Key 확인: `docker exec sam-mng-1 php artisan tinker --execute="echo env('FLOW_TESTER_API_KEY');"`
- `Connection refused` → nginx 컨테이너 확인: `docker ps | grep nginx`
- `SSL certificate problem``withoutVerifying()` 누락 확인
- `422 Validation` → finished_goods_code가 items 테이블에 존재하는지 확인
#### 1.2 ItemManagementApiController::calculateFormula 추가
**파일**: `mng/app/Http/Controllers/Api/Admin/ItemManagementApiController.php`
**변경**: 기존 컨트롤러에 메서드 1개 추가 + use 문 추가
```php
// 파일 상단 use 추가
use App\Services\FormulaApiService;
// 기존 메서드 아래에 추가
/**
* 수식 기반 BOM 산출 (API 서버의 FormulaEvaluatorService HTTP 호출)
*/
public function calculateFormula(Request $request, int $id): JsonResponse
{
$item = \App\Models\Items\Item::withoutGlobalScopes()
->where('tenant_id', session('selected_tenant_id'))
->findOrFail($id);
$width = (int) $request->input('width', 1000);
$height = (int) $request->input('height', 1000);
$qty = (int) $request->input('qty', 1);
$variables = [
'W0' => $width,
'H0' => $height,
'QTY' => $qty,
];
$formulaService = new FormulaApiService();
$result = $formulaService->calculateBom(
$item->code,
$variables,
(int) session('selected_tenant_id')
);
return response()->json($result);
}
```
#### 1.3 API 라우트 추가
**파일**: `mng/routes/api.php` (라인 866~ 기존 items 그룹 내)
**추가 위치**: 기존 detail 라우트 아래
```php
// 기존 라우트 아래에 추가
Route::post('/{id}/calculate-formula', [ItemManagementApiController::class, 'calculateFormula'])->name('calculate-formula');
```
---
### 4.2 Phase 2: MNG 프론트엔드
#### 2.1 중앙 패널 탭 UI
**수정 파일**: `mng/resources/views/item-management/index.blade.php`
**변경 대상 (현재 HTML)**:
```html
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">BOM 구성 (재귀 트리)</h2>
</div>
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
```
**변경 후**:
```html
<div class="px-4 py-3 border-b border-gray-200">
<div class="flex items-center gap-1">
<button type="button" id="tab-static-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-blue-100 text-blue-800"
onclick="switchBomTab('static')">
정적 BOM
</button>
<button type="button" id="tab-formula-bom"
class="bom-tab px-3 py-1.5 text-xs font-medium rounded-md bg-gray-100 text-gray-600 hover:bg-gray-200"
onclick="switchBomTab('formula')"
style="display:none;">
수식 산출
</button>
</div>
</div>
<!-- 수식 산출 입력 폼 (가변사이즈 품목 선택 시에만 표시) -->
<div id="formula-input-panel" style="display:none;" class="p-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-end gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">폭 W (mm)</label>
<input type="number" id="input-width" value="1000" min="100" max="10000" step="1"
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">높이 H (mm)</label>
<input type="number" id="input-height" value="1000" min="100" max="10000" step="1"
class="w-24 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">수량</label>
<input type="number" id="input-qty" value="1" min="1" max="100" step="1"
class="w-16 px-2 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<button type="button" id="btn-calculate" onclick="calculateFormula()"
class="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors">
산출
</button>
</div>
</div>
<!-- 정적 BOM 영역 -->
<div id="bom-tree-container" class="flex-1 overflow-y-auto p-4">
<p class="text-gray-400 text-center py-10">좌측에서 품목을 선택하세요.</p>
</div>
<!-- 수식 산출 결과 영역 (초기 숨김) -->
<div id="formula-result-container" class="flex-1 overflow-y-auto p-4" style="display:none;">
<p class="text-gray-400 text-center py-10">오픈사이즈를 입력하고 산출 버튼을 클릭하세요.</p>
</div>
```
#### 2.2 item-detail.blade.php에 메타 데이터 추가
**수정 파일**: `mng/resources/views/item-management/partials/item-detail.blade.php`
**파일 맨 위에 추가** (기존 `<div class="space-y-4">` 앞):
```html
<!-- 품목 메타 데이터 (JS에서 가변사이즈 감지용) -->
<div id="item-meta-data"
data-item-id="{{ $item->id }}"
data-item-code="{{ $item->code }}"
data-is-variable-size="{{ $item->details?->is_variable_size ? 'true' : 'false' }}"
style="display:none;"></div>
```
#### 2.3 JS 추가 (index.blade.php @push('scripts'))
**기존 IIFE 내부에 추가할 변수와 함수**:
```javascript
// ── 추가 변수 ──
let currentBomTab = 'static'; // 'static' | 'formula'
let currentItemId = null;
let currentItemCode = null;
// ── 탭 전환 ──
window.switchBomTab = function(tab) {
currentBomTab = tab;
// 탭 버튼 스타일
document.querySelectorAll('.bom-tab').forEach(btn => {
btn.classList.remove('bg-blue-100', 'text-blue-800');
btn.classList.add('bg-gray-100', 'text-gray-600');
});
const activeBtn = document.getElementById(tab === 'static' ? 'tab-static-bom' : 'tab-formula-bom');
if (activeBtn) {
activeBtn.classList.remove('bg-gray-100', 'text-gray-600');
activeBtn.classList.add('bg-blue-100', 'text-blue-800');
}
// 콘텐츠 영역 전환
document.getElementById('bom-tree-container').style.display = (tab === 'static') ? '' : 'none';
document.getElementById('formula-input-panel').style.display = (tab === 'formula') ? '' : 'none';
document.getElementById('formula-result-container').style.display = (tab === 'formula') ? '' : 'none';
};
// ── 가변사이즈 탭 표시/숨김 ──
function showFormulaTab() {
document.getElementById('tab-formula-bom').style.display = '';
switchBomTab('formula'); // 자동으로 수식 산출 탭으로 전환
}
function hideFormulaTab() {
document.getElementById('tab-formula-bom').style.display = 'none';
document.getElementById('formula-input-panel').style.display = 'none';
document.getElementById('formula-result-container').style.display = 'none';
switchBomTab('static');
}
// ── 상세 로드 완료 후 가변사이즈 감지 ──
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'item-detail') {
const meta = document.getElementById('item-meta-data');
if (meta) {
currentItemId = meta.dataset.itemId;
currentItemCode = meta.dataset.itemCode;
if (meta.dataset.isVariableSize === 'true') {
showFormulaTab();
} else {
hideFormulaTab();
}
}
}
});
// ── 수식 산출 API 호출 ──
window.calculateFormula = function() {
if (!currentItemId) return;
const width = parseInt(document.getElementById('input-width').value) || 1000;
const height = parseInt(document.getElementById('input-height').value) || 1000;
const qty = parseInt(document.getElementById('input-qty').value) || 1;
// 입력값 범위 검증
if (width < 100 || width > 10000 || height < 100 || height > 10000) {
alert('폭과 높이는 100~10000 범위로 입력하세요.');
return;
}
const container = document.getElementById('formula-result-container');
container.innerHTML = '<div class="flex justify-center py-10"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>';
fetch(`/api/admin/items/${currentItemId}/calculate-formula`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
},
body: JSON.stringify({ width, height, qty }),
})
.then(res => res.json())
.then(data => {
if (data.success === false) {
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 text-sm mb-2">${data.error || '산출 실패'}</p>
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
</div>`;
return;
}
renderFormulaTree(data, container);
})
.catch(err => {
container.innerHTML = `
<div class="text-center py-10">
<p class="text-red-500 text-sm mb-2">서버 연결 실패</p>
<button onclick="calculateFormula()" class="text-blue-600 text-sm hover:underline">다시 시도</button>
</div>`;
});
};
// ── 수식 산출 결과 트리 렌더링 ──
function renderFormulaTree(data, container) {
container.innerHTML = '';
// 카테고리 그룹 한글 매핑
const groupLabels = { steel: '강재', part: '부품', motor: '모터/컨트롤러' };
const groupIcons = { steel: '🏗️', part: '🔧', motor: '⚡' };
const groupedItems = data.grouped_items || {};
// 합계 영역
if (data.grand_total) {
const totalDiv = document.createElement('div');
totalDiv.className = 'mb-4 p-3 bg-blue-50 rounded-lg flex justify-between items-center';
totalDiv.innerHTML = `
<span class="text-sm font-medium text-blue-800">
${data.finished_goods?.name || ''} (${data.finished_goods?.code || ''})
<span class="text-xs text-blue-600 ml-2">W:${data.variables?.W0} H:${data.variables?.H0}</span>
</span>
<span class="text-sm font-bold text-blue-900">합계: ${Number(data.grand_total).toLocaleString()}원</span>
`;
container.appendChild(totalDiv);
}
// 카테고리 그룹별 렌더링
Object.entries(groupedItems).forEach(([group, items]) => {
if (!items || items.length === 0) return;
const groupDiv = document.createElement('div');
groupDiv.className = 'mb-3';
const subtotal = data.subtotals?.[group] || 0;
// 그룹 헤더
const header = document.createElement('div');
header.className = 'flex items-center gap-2 py-2 px-3 bg-gray-50 rounded-t-lg cursor-pointer';
header.innerHTML = `
<span class="text-xs text-gray-400">▼</span>
<span>${groupIcons[group] || '📦'}</span>
<span class="text-sm font-semibold text-gray-700">${groupLabels[group] || group}</span>
<span class="text-xs text-gray-500">(${items.length}건)</span>
<span class="ml-auto text-xs font-medium text-gray-600">소계: ${Number(subtotal).toLocaleString()}원</span>
`;
const listDiv = document.createElement('div');
listDiv.className = 'border border-gray-100 rounded-b-lg divide-y divide-gray-50';
// 그룹 접기/펼치기
header.onclick = function() {
const toggle = header.querySelector('.text-gray-400');
if (listDiv.style.display === 'none') {
listDiv.style.display = '';
toggle.textContent = '▼';
} else {
listDiv.style.display = 'none';
toggle.textContent = '▶';
}
};
// 아이템 목록
items.forEach(item => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 py-1.5 px-3 hover:bg-gray-50 cursor-pointer text-sm';
row.innerHTML = `
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">PT</span>
<span class="font-mono text-xs text-gray-500 w-32 truncate">${item.item_code || ''}</span>
<span class="text-gray-700 flex-1 truncate">${item.item_name || ''}</span>
<span class="text-xs text-gray-500 w-16 text-right">${item.quantity || 0} ${item.unit || ''}</span>
<span class="text-xs text-blue-600 font-medium w-20 text-right">${Number(item.total_price || 0).toLocaleString()}원</span>
`;
// 아이템 클릭 시 items 테이블에서 해당 코드로 검색하여 상세 표시
row.onclick = function() {
// item_code로 좌측 검색 → 해당 품목 상세 로드
const searchInput = document.getElementById('item-search');
searchInput.value = item.item_code;
loadItemList();
};
listDiv.appendChild(row);
});
groupDiv.appendChild(header);
groupDiv.appendChild(listDiv);
container.appendChild(groupDiv);
});
if (Object.keys(groupedItems).length === 0) {
container.innerHTML = '<p class="text-gray-400 text-center py-10">산출된 자재가 없습니다.</p>';
}
}
```
---
## 5. 컨펌 대기 목록
| # | 항목 | 변경 내용 | 영향 범위 | 상태 |
|---|------|----------|----------|------|
| 1 | ~~API 인증 방식~~ | X-API-KEY 필수 (FLOW_TESTER_API_KEY) | MNG→API 통신 | ✅ 확인 완료 |
| 2 | API 라우트 추가 | POST /api/admin/items/{id}/calculate-formula | mng/routes/api.php | ✅ 즉시 가능 |
---
## 6. 변경 이력
| 날짜 | 항목 | 변경 내용 | 파일 | 승인 |
|------|------|----------|------|------|
| 2026-02-19 | - | 계획 문서 초안 작성 | - | - |
| 2026-02-19 | - | 자기완결성 보강 (기존 코드 현황, API 인증 분석, 트러블슈팅) | - | - |
| 2026-02-19 | 1.1~1.3 | Phase 1 백엔드 구현 완료 (FormulaApiService, Controller, Route) | FormulaApiService.php, ItemManagementApiController.php, api.php | ✅ |
| 2026-02-19 | 2.1~2.5 | Phase 2 프론트엔드 구현 완료 (탭 UI, 입력 폼, 트리 렌더링, 감지, 에러) | index.blade.php, item-detail.blade.php | ✅ |
---
## 7. 참고 문서
- **기존 품목관리 계획**: `docs/dev_plans/mng-item-management-plan.md`
- **FormulaEvaluatorService**: `api/app/Services/Quote/FormulaEvaluatorService.php`
- 메서드: `calculateBomWithDebug(string $finishedGoodsCode, array $inputVariables, ?int $tenantId): array`
- tenant_id=287 자동 감지 → KyungdongFormulaHandler 라우팅
- **KyungdongFormulaHandler**: `api/app/Services/Quote/Handlers/KyungdongFormulaHandler.php`
- `calculateDynamicItems(array $inputs)` → steel(10종), part(3종), motor/controller 산출
- **API 라우트**: `api/routes/api/v1/sales.php:64``QuoteController::calculateBom`
- **QuoteBomCalculateRequest**: `api/app/Http/Requests/V1/QuoteBomCalculateRequest.php`
- `finished_goods_code` (required|string)
- `variables` (required|array), `variables.W0` (required|numeric), `variables.H0` (required|numeric)
- `tenant_id` (nullable|integer)
- **MNG-API HTTP 패턴**: `mng/app/Services/FlowTester/HttpClient.php`
- **API Key 설정**: `mng/config/api-explorer.php:26``env('FLOW_TESTER_API_KEY')`
- **현재 품목관리 뷰**: `mng/resources/views/item-management/index.blade.php`
- **MNG 프로젝트 규칙**: `mng/CLAUDE.md`
---
## 8. 세션 및 메모리 관리 정책
### 8.1 세션 시작 시 (Load Strategy)
```
1. 이 문서 읽기 (docs/dev_plans/mng-item-formula-integration-plan.md)
2. 📍 현재 진행 상태 확인 → 다음 작업 파악
3. 섹션 3 "이미 구현된 코드" 확인 → 수정 대상 파일 파악
4. 필요시 Serena 메모리 로드:
read_memory("item-formula-state")
read_memory("item-formula-snapshot")
read_memory("item-formula-active-symbols")
```
### 8.2 작업 중 관리 (Context Defense)
| 컨텍스트 잔량 | Action | 내용 |
|--------------|--------|------|
| **30% 이하** | Snapshot | `write_memory("item-formula-snapshot", "코드변경+논의요약")` |
| **20% 이하** | Context Purge | `write_memory("item-formula-active-symbols", "주요 수정 파일/함수")` |
| **10% 이하** | Stop & Save | 최종 상태 저장 후 세션 교체 권고 |
---
## 9. 검증 결과
### 9.1 테스트 케이스
| # | 테스트 | 입력 | 예상 결과 | 실제 결과 | 상태 |
|---|--------|------|----------|----------|------|
| 1 | FG 가변사이즈 품목 선택 | FG-KQTS01 클릭 | 수식 산출 탭 자동 표시, 입력 폼 노출 | | ⏳ |
| 2 | 오픈사이즈 입력 후 산출 | W:3000, H:3000, QTY:1 | 17종 자재 트리 (steel/part/motor 그룹별), 소계/합계 표시 | | ⏳ |
| 3 | 비가변사이즈 품목 선택 | PT 품목 클릭 | 수식 산출 탭 숨김, 정적 BOM만 표시 | | ⏳ |
| 4 | 정적 BOM ↔ 수식 산출 탭 전환 | 탭 클릭 | 각 탭 콘텐츠 전환, 입력 폼 표시/숨김 | | ⏳ |
| 5 | 산출 결과에서 품목 클릭 | 트리 노드 클릭 | 좌측 검색에 품목코드 입력 → 품목 리스트 필터링 | | ⏳ |
| 6 | API Key 미설정 | FLOW_TESTER_API_KEY 없음 | 에러 메시지 "API 응답 오류: HTTP 401" + 재시도 버튼 | | ⏳ |
| 7 | 입력값 범위 초과 | W:0, H:-1 | alert 표시, API 호출 안 함 | | ⏳ |
| 8 | 서버 연결 실패 | nginx 중지 상태 | 에러 메시지 "서버 연결 실패" + 재시도 버튼 | | ⏳ |
### 9.2 성공 기준 달성 현황
| 기준 | 달성 | 비고 |
|------|------|------|
| FG 가변사이즈 품목에서 수식 산출 가능 | ⏳ | |
| 산출 결과가 견적관리와 동일한 17종 자재 표시 | ⏳ | |
| 정적 BOM과 수식 산출 탭 전환 작동 | ⏳ | |
| 비가변사이즈 품목은 기존 정적 BOM만 표시 | ⏳ | |
| 에러 처리 및 로딩 상태 표시 | ⏳ | |
---
## 10. 자기완결성 점검 결과
### 10.1 체크리스트 검증
| # | 검증 항목 | 상태 | 비고 |
|---|----------|:----:|------|
| 1 | 작업 목적이 명확한가? | ✅ | 가변사이즈 FG 품목의 동적 자재 산출 표시 |
| 2 | 성공 기준이 정의되어 있는가? | ✅ | 9.2 성공 기준 5개 항목 |
| 3 | 작업 범위가 구체적인가? | ✅ | Phase 1 백엔드 3건, Phase 2 프론트 5건 |
| 4 | 의존성이 명시되어 있는가? | ✅ | API 엔드포인트 인증 분석 완료, Docker 라우팅 패턴 |
| 5 | 참고 파일 경로가 정확한가? | ✅ | 모든 파일 경로 검증 완료 |
| 6 | 단계별 절차가 실행 가능한가? | ✅ | 전체 구현 코드 포함 |
| 7 | 검증 방법이 명시되어 있는가? | ✅ | 9.1 테스트 케이스 8개 |
| 8 | 모호한 표현이 없는가? | ✅ | 구체적 수치/코드로 기술 |
| 9 | 기존 코드 현황이 명시되어 있는가? | ✅ | 섹션 3에 전체 파일 구조 + 핵심 코드 인라인 |
| 10 | API 인증 방식이 확정되었는가? | ✅ | X-API-KEY 필수 (FLOW_TESTER_API_KEY) |
| 11 | 트러블슈팅 가이드가 있는가? | ✅ | 4.1 FormulaApiService 트러블슈팅 섹션 |
### 10.2 새 세션 시뮬레이션 테스트
| 질문 | 답변 가능 | 참조 섹션 |
|------|:--------:|----------|
| Q1. 이 작업의 목적은 무엇인가? | ✅ | 1.1 배경 |
| Q2. 어디서부터 시작해야 하는가? | ✅ | 📍 현재 진행 상태 + 4.1 Phase 1 |
| Q3. 어떤 파일을 수정/생성해야 하는가? | ✅ | 3.1 파일 구조 + 2. 대상 범위 |
| Q4. 현재 코드 상태는 어떤가? | ✅ | 3.2~3.6 기존 코드 현황 |
| Q5. API 인증은 어떻게 하는가? | ✅ | 4.1 FormulaApiService (인증 테이블) |
| Q6. 테넌트 필터링은 어떻게 동작하는가? | ✅ | 3.6 테넌트 필터링 패턴 |
| Q7. 작업 완료 확인 방법은? | ✅ | 9. 검증 결과 |
| Q8. 막혔을 때 참고 문서는? | ✅ | 7. 참고 문서 + 4.1 트러블슈팅 |
**결과**: 8/8 통과 → ✅ 자기완결성 확보
---
*이 문서는 /sc:plan 스킬로 생성되었습니다. 자기완결성 보강: 2026-02-19*