Compare commits

...

15 Commits

Author SHA1 Message Date
3ac64d5b76 feat: [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
- availableOrders: client_id/item_id 필터 파라미터 지원
- availableOrders: 응답에 client_id, client_name, item_id, item_name, locations(개소 상세) 추가
- show: 개소별 데이터에 거래처/모델 정보 포함
- DocumentService: fqcStatus rootNodes 기반으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2231c9a48f feat: 제품검사 요청서 Document(EAV) 자동생성 및 동기화
- document_template_sections에 description 컬럼 추가 (마이그레이션)
- DocumentTemplateSection 모델에 description fillable 추가
- QualityDocumentService에 syncRequestDocument() 메서드 추가
  - quality_document 생성/수정/수주연결 시 요청서 Document 자동생성
  - 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
  - rendered_html 초기화 (데이터 변경 시 재캡처 트리거)
- transformToFrontend에 request_document_id 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
ff8553055c chore: [API] logging, docs, seeder 등 부수 정리
- LOGICAL_RELATIONSHIPS.md 보완
- Legacy5130Calculator 수정
- logging.php 설정 추가
- KyungdongItemSeeder 수정
- docs/INDEX.md, changes 문서 경로 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
f2eede6e3a feat: [품질관리] order_ids 영속성 + location 데이터 저장
- StoreRequest/UpdateRequest에 order_ids 검증 추가
- UpdateRequest에 locations 검증 추가 (시공규격, 변경사유, 검사데이터)
- QualityDocumentLocation에 inspection_data(JSON) fillable/cast 추가
- QualityDocumentService store()에 syncOrders 연동
- QualityDocumentService update()에 syncOrders + updateLocations 연동
- inspection_data 컬럼 추가 migration 신규

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
c5d5b5d076 feat: [문서스냅샷] Lazy Snapshot API - snapshot 엔드포인트 + resolve에 snapshot_document_id 추가
- PATCH /documents/{id}/snapshot: canEdit 체크 없이 rendered_html만 업데이트
- DocumentService::patchSnapshot() 메서드 추가
- WorkOrderService::resolveInspectionDocument()에 snapshot_document_id 반환 (상태 무관, rendered_html NULL인 문서)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
5ebf940873 fix: [문서스냅샷] UpsertRequest rendered_html 검증 추가 및 upsert() 전달 누락 수정
- UpsertRequest에 rendered_html nullable string 검증 추가
- DocumentService upsert()에서 create/update 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
293330c418 feat: 문서 rendered_html 스냅샷 저장 지원
- Document 모델 $fillable에 rendered_html 추가
- DocumentService create/update에서 rendered_html 저장
- StoreRequest/UpdateRequest에 rendered_html 검증 추가
- WorkOrderService 검사문서/작업일지 생성 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a845f52fc0 fix: [생산지시] withCount에서도 보조 공정(재고생산) WO 제외
- 목록 조회 시 work_orders_count에서 is_auxiliary WO 제외
- whereNotNull(process_id) + options->is_auxiliary 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0f26ea546a feat: [품질관리] 수주선택 API에 발주처(client_name) 필드 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
3600c7b12b fix: [품질관리] 수주선택 모달 납품일 포맷 및 개소 수 수정
- delivery_date: ISO 타임스탬프 → Y-m-d 포맷으로 변환
- location_count: order_items 수 → order_nodes 루트 노드(개소) 수로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a6e29bc1f3 feat: [품질관리] 백엔드 API 구현 - 품질관리서 + 실적신고
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트
- 실적신고(performance_reports) 관리 API 6개 엔드포인트
- DB 마이그레이션 4개 테이블 (quality_documents, quality_document_orders, quality_document_locations, performance_reports)
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개
- stats() ambiguous column 버그 수정 (JOIN 시 테이블 접두사 추가)
- missing() status_code 컬럼명/값 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0aa0a8592d feat: [생산지시] 재고생산 보조 공정 일반 워크플로우에서 분리
- Process P-004 options에 is_auxiliary 플래그 도입
- WO 생성 시 Process의 is_auxiliary를 WO options에 자동 복사
- ProductionOrderService: 보조 공정 WO를 공정 진행 현황에서 제외
- WorkOrderService: 보조 공정 WO의 상태 변경이 수주 상태에 영향 주지 않도록 처리
  - syncOrderStatus(): 보조 공정이면 스킵
  - autoStartWorkOrderOnMaterialInput(): WO는 진행중 전환하되 수주 상태는 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
38c2402771 fix: [생산지시] 공정 진행 현황 WO 필터링 + BOM 파싱 수정
- 공정 진행 현황: process_id=null인 구매품/서비스 WO 제외 (withCount, 목록/상세 모두)
- extractBomProcessGroups: bom_result.items[] 구조에 맞게 파싱 수정
  - process_name → process_group 키 사용
  - 품목 필드 매핑 수정 (item_id, specification, unit, quantity, unit_price, total_price, node_name)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
59d13eeb9f fix: [생산지시] 날짜포맷·개소수·자재투입 시 자동 상태전환
- ProductionOrderService: production_ordered_at를 Y-m-d 포맷으로 변환
- ProductionOrderService: withCount('nodes')로 개소수(node_count) 응답 추가
- WorkOrderService: autoStartWorkOrderOnMaterialInput() 신규 메서드
  - 자재투입 시 WO가 unassigned/pending/waiting이면 in_progress로 자동 전환
  - syncOrderStatus()로 Order도 IN_PRODUCTION 동기화
- Swagger: node_count 필드 문서화, 날짜 포맷 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2df8ecf765 feat: [생산지시] 전용 API 엔드포인트 신규 생성
- ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현
  - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
  - workOrderProgress 가공 필드 (total/completed/inProgress)
  - production_ordered_at (첫 WorkOrder created_at 기반)
  - BOM 공정 분류 추출 (order_nodes.options.bom_result)
- ProductionOrderController: FormRequest + ApiResponse 패턴
- ProductionOrderIndexRequest: search, production_status, sort, pagination 검증
- ProductionOrderApi.php: Swagger 문서 (목록/통계/상세)
- production.php: GET /production-orders, /stats, /{orderId} 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
46 changed files with 3108 additions and 39 deletions

View File

@@ -54,4 +54,4 @@ ## 관련 파일
- `api/app/Services/ComprehensiveAnalysisService.php`
- `api/database/seeders/ComprehensiveAnalysisSeeder.php`
- `docs/plans/react-mock-remaining-tasks.md`
- `docs/dev/dev_plans/react-mock-remaining-tasks.md`

View File

@@ -15,7 +15,7 @@ ## Phase 구성
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
## 핵심 파일
- 계획 문서: docs/plans/db-backup-system-plan.md
- 계획 문서: docs/dev/dev_plans/db-backup-system-plan.md
- 개발서버: 114.203.209.83 (SSH: hskwon)
- DB: sam (메인) + sam_stat (통계)
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)

View File

@@ -16,7 +16,7 @@ ### 생성된 파일
| 파일 | 설명 |
|------|------|
| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest |
| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
| `api/docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
### 수정된 파일
| 파일 | 설명 |
@@ -93,9 +93,9 @@ ### QuoteCalculationService::calculateBomBulk()
- 개별 품목 실패가 전체에 영향 없음 (예외 처리)
## 관련 문서
- 계획 문서: `docs/plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md`
- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/dev/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md`
## 다음 단계
- React 프론트엔드에서 `/calculate/bom/bulk` API 연동

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-04 22:33:37
> **자동 생성**: 2026-03-06 21:25:05
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -753,6 +753,38 @@ ### lot_sales
- **lot()**: belongsTo → `lots`
### performance_reports
**모델**: `App\Models\Qualitys\PerformanceReport`
- **qualityDocument()**: belongsTo → `quality_documents`
- **confirmer()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### quality_documents
**모델**: `App\Models\Qualitys\QualityDocument`
- **client()**: belongsTo → `clients`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
- **qualityDocument()**: belongsTo → `quality_documents`
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
- **orderItem()**: belongsTo → `order_items`
- **document()**: belongsTo → `documents`
### quality_document_orders
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
- **qualityDocument()**: belongsTo → `quality_documents`
- **order()**: belongsTo → `orders`
- **locations()**: hasMany → `quality_document_locations`
### quotes
**모델**: `App\Models\Quote\Quote`
@@ -929,6 +961,16 @@ ### expense_accounts
- **vendor()**: belongsTo → `clients`
### journal_entrys
**모델**: `App\Models\Tenants\JournalEntry`
- **lines()**: hasMany → `journal_entry_lines`
### journal_entry_lines
**모델**: `App\Models\Tenants\JournalEntryLine`
- **journalEntry()**: belongsTo → `journal_entries`
### leaves
**모델**: `App\Models\Tenants\Leave`

View File

@@ -14,7 +14,7 @@
* - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea)
*
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
*/
class Legacy5130Calculator
{

View File

@@ -74,6 +74,22 @@ public function destroy(int $id): JsonResponse
}, __('message.deleted'));
}
/**
* rendered_html 스냅샷 저장 (Lazy Snapshot)
* PATCH /v1/documents/{id}/snapshot
*/
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
$renderedHtml = $request->validated()['rendered_html'] ?? null;
if (! $renderedHtml) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
}
return $this->service->patchSnapshot($id, $renderedHtml);
}, __('message.updated'));
}
// =========================================================================
// FQC 일괄생성 (제품검사)
// =========================================================================

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
use App\Services\PerformanceReportService;
use Illuminate\Http\Request;
class PerformanceReportController extends Controller
{
public function __construct(private PerformanceReportService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function confirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->confirm($request->validated()['ids']);
}, __('message.updated'));
}
public function unconfirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unconfirm($request->validated()['ids']);
}, __('message.updated'));
}
public function updateMemo(PerformanceReportMemoRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $request->validated();
return $this->service->updateMemo($data['ids'], $data['memo']);
}, __('message.updated'));
}
public function missing(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->missing($request->all());
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
use App\Services\ProductionOrderService;
use Illuminate\Http\JsonResponse;
class ProductionOrderController extends Controller
{
public function __construct(
private readonly ProductionOrderService $service
) {}
/**
* 생산지시 목록 조회
*/
public function index(ProductionOrderIndexRequest $request): JsonResponse
{
$result = $this->service->index($request->validated());
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 생산지시 상태별 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): JsonResponse
{
try {
$detail = $this->service->show($orderId);
return ApiResponse::success($detail, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
use App\Services\QualityDocumentService;
use Illuminate\Http\Request;
class QualityDocumentController extends Controller
{
public function __construct(private QualityDocumentService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.fetched'));
}
public function availableOrders(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->availableOrders($request->all());
}, __('message.fetched'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(QualityDocumentStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
public function update(QualityDocumentUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
public function attachOrders(Request $request, int $id)
{
$request->validate([
'order_ids' => ['required', 'array', 'min:1'],
'order_ids.*' => ['required', 'integer'],
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachOrders($id, $request->input('order_ids'));
}, __('message.updated'));
}
public function detachOrder(int $id, int $orderId)
{
return ApiResponse::handle(function () use ($id, $orderId) {
return $this->service->detachOrder($id, $orderId);
}, __('message.updated'));
}
public function inspectLocation(Request $request, int $id, int $locId)
{
$request->validate([
'post_width' => ['nullable', 'integer'],
'post_height' => ['nullable', 'integer'],
'change_reason' => ['nullable', 'string', 'max:500'],
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
]);
return ApiResponse::handle(function () use ($request, $id, $locId) {
return $this->service->inspectLocation($id, $locId, $request->all());
}, __('message.updated'));
}
public function requestDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->requestDocument($id);
}, __('message.fetched'));
}
public function resultDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->resultDocument($id);
}, __('message.fetched'));
}
}

View File

@@ -28,6 +28,9 @@ public function rules(): array
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',

View File

@@ -27,6 +27,9 @@ public function rules(): array
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',

View File

@@ -30,6 +30,9 @@ public function rules(): array
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\ProductionOrder;
use Illuminate\Foundation\Http\FormRequest;
class ProductionOrderIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => 'nullable|string|max:100',
'production_status' => 'nullable|in:waiting,in_production,completed',
'sort_by' => 'nullable|in:created_at,delivery_date,order_no',
'sort_dir' => 'nullable|in:asc,desc',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportMemoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
'memo' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'memo.required' => __('validation.required', ['attribute' => '메모']),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['required', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
];
}
public function messages(): array
{
return [
'site_name.required' => __('validation.required', ['attribute' => '현장명']),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['sometimes', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
'locations' => ['nullable', 'array'],
'locations.*.id' => ['required', 'integer'],
'locations.*.post_width' => ['nullable', 'integer'],
'locations.*.post_height' => ['nullable', 'integer'],
'locations.*.change_reason' => ['nullable', 'string', 'max:500'],
'locations.*.inspection_data' => ['nullable', 'array'],
];
}
}

View File

@@ -73,6 +73,7 @@ class Document extends Model
'linkable_id',
'submitted_at',
'completed_at',
'rendered_html',
'created_by',
'updated_by',
'deleted_by',

View File

@@ -22,6 +22,7 @@ class DocumentTemplateSection extends Model
protected $fillable = [
'template_id',
'title',
'description',
'image_path',
'sort_order',
];

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PerformanceReport extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'performance_reports';
const STATUS_UNCONFIRMED = 'unconfirmed';
const STATUS_CONFIRMED = 'confirmed';
const STATUS_REPORTED = 'reported';
protected $fillable = [
'tenant_id',
'quality_document_id',
'year',
'quarter',
'confirmation_status',
'confirmed_date',
'confirmed_by',
'memo',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'confirmed_date' => 'date',
'year' => 'integer',
'quarter' => 'integer',
];
// ===== Relationships =====
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function confirmer()
{
return $this->belongsTo(User::class, 'confirmed_by');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
// ===== Status Helpers =====
public function isUnconfirmed(): bool
{
return $this->confirmation_status === self::STATUS_UNCONFIRMED;
}
public function isConfirmed(): bool
{
return $this->confirmation_status === self::STATUS_CONFIRMED;
}
public function isReported(): bool
{
return $this->confirmation_status === self::STATUS_REPORTED;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Members\User;
use App\Models\Orders\Client;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class QualityDocument extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'quality_documents';
const STATUS_RECEIVED = 'received';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
protected $fillable = [
'tenant_id',
'quality_doc_number',
'site_name',
'status',
'client_id',
'inspector_id',
'received_date',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'options' => 'array',
'received_date' => 'date',
];
// ===== Relationships =====
public function client()
{
return $this->belongsTo(Client::class, 'client_id');
}
public function inspector()
{
return $this->belongsTo(User::class, 'inspector_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function documentOrders()
{
return $this->hasMany(QualityDocumentOrder::class);
}
public function locations()
{
return $this->hasMany(QualityDocumentLocation::class);
}
public function performanceReport()
{
return $this->hasOne(PerformanceReport::class);
}
// ===== 채번 =====
public static function generateDocNumber(int $tenantId): string
{
$prefix = 'KD-QD';
$yearMonth = now()->format('Ym');
$lastNo = static::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%")
->orderByDesc('quality_doc_number')
->value('quality_doc_number');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq);
}
// ===== Status Helpers =====
public function isReceived(): bool
{
return $this->status === self::STATUS_RECEIVED;
}
public function isInProgress(): bool
{
return $this->status === self::STATUS_IN_PROGRESS;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public static function mapStatusToFrontend(string $status): string
{
return match ($status) {
self::STATUS_RECEIVED => 'reception',
self::STATUS_IN_PROGRESS => 'in_progress',
self::STATUS_COMPLETED => 'completed',
default => $status,
};
}
public static function mapStatusFromFrontend(string $status): string
{
return match ($status) {
'reception' => self::STATUS_RECEIVED,
default => $status,
};
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Documents\Document;
use App\Models\Orders\OrderItem;
use Illuminate\Database\Eloquent\Model;
class QualityDocumentLocation extends Model
{
protected $table = 'quality_document_locations';
const STATUS_PENDING = 'pending';
const STATUS_COMPLETED = 'completed';
protected $fillable = [
'quality_document_id',
'quality_document_order_id',
'order_item_id',
'post_width',
'post_height',
'change_reason',
'inspection_data',
'document_id',
'inspection_status',
];
protected $casts = [
'inspection_data' => 'array',
];
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function qualityDocumentOrder()
{
return $this->belongsTo(QualityDocumentOrder::class);
}
public function orderItem()
{
return $this->belongsTo(OrderItem::class);
}
public function document()
{
return $this->belongsTo(Document::class);
}
public function isPending(): bool
{
return $this->inspection_status === self::STATUS_PENDING;
}
public function isCompleted(): bool
{
return $this->inspection_status === self::STATUS_COMPLETED;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Orders\Order;
use Illuminate\Database\Eloquent\Model;
class QualityDocumentOrder extends Model
{
protected $table = 'quality_document_orders';
protected $fillable = [
'quality_document_id',
'order_id',
];
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function order()
{
return $this->belongsTo(Order::class);
}
public function locations()
{
return $this->hasMany(QualityDocumentLocation::class);
}
}

View File

@@ -122,6 +122,7 @@ public function create(array $data): Document
'status' => Document::STATUS_DRAFT,
'linkable_type' => $data['linkable_type'] ?? null,
'linkable_id' => $data['linkable_id'] ?? null,
'rendered_html' => $data['rendered_html'] ?? null,
'created_by' => $userId,
'updated_by' => $userId,
]);
@@ -170,12 +171,16 @@ public function update(int $id, array $data): Document
}
// 기본 정보 수정
$document->fill([
$updateFields = [
'title' => $data['title'] ?? $document->title,
'linkable_type' => $data['linkable_type'] ?? $document->linkable_type,
'linkable_id' => $data['linkable_id'] ?? $document->linkable_id,
'updated_by' => $userId,
]);
];
if (isset($data['rendered_html'])) {
$updateFields['rendered_html'] = $data['rendered_html'];
}
$document->fill($updateFields);
// 반려 상태에서 수정 시 DRAFT로 변경
if ($document->status === Document::STATUS_REJECTED) {
@@ -658,20 +663,32 @@ public function fqcStatus(int $orderId, int $templateId): array
$tenantId = $this->tenantId();
$order = \App\Models\Orders\Order::where('tenant_id', $tenantId)
->with('items')
->with(['rootNodes.items' => fn ($q) => $q->orderBy('sort_order')])
->findOrFail($orderId);
// 해당 수주의 FQC 문서 조회
// 개소별 대표 OrderItem ID 수집
$representativeItemIds = $order->rootNodes
->map(fn ($node) => $node->items->first()?->id)
->filter()
->values();
// 해당 대표 품목의 FQC 문서 조회
$documents = Document::where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', \App\Models\Orders\OrderItem::class)
->whereIn('linkable_id', $order->items->pluck('id'))
->whereIn('linkable_id', $representativeItemIds)
->with('data')
->get()
->keyBy('linkable_id');
$items = $order->items->map(function ($orderItem) use ($documents) {
$doc = $documents->get($orderItem->id);
// 개소(root node)별 진행현황
$items = $order->rootNodes->map(function ($node) use ($documents) {
$representativeItem = $node->items->first();
if (! $representativeItem) {
return null;
}
$doc = $documents->get($representativeItem->id);
// 종합판정 값 추출
$judgement = null;
@@ -681,17 +698,17 @@ public function fqcStatus(int $orderId, int $templateId): array
}
return [
'order_item_id' => $orderItem->id,
'floor_code' => $orderItem->floor_code,
'symbol_code' => $orderItem->symbol_code,
'specification' => $orderItem->specification,
'item_name' => $orderItem->item_name,
'order_item_id' => $representativeItem->id,
'floor_code' => $representativeItem->floor_code,
'symbol_code' => $representativeItem->symbol_code,
'specification' => $representativeItem->specification,
'item_name' => $representativeItem->item_name,
'document_id' => $doc?->id,
'document_no' => $doc?->document_no,
'status' => $doc?->status ?? 'NONE',
'judgement' => $judgement,
];
});
})->filter()->values();
// 통계
$total = $items->count();
@@ -713,6 +730,28 @@ public function fqcStatus(int $orderId, int $templateId): array
];
}
// =========================================================================
// Snapshot (Lazy Snapshot)
// =========================================================================
/**
* rendered_html만 업데이트 (상태 무관, canEdit 체크 없음)
* Lazy Snapshot: 조회 시 rendered_html이 없으면 프론트에서 캡처 후 저장
*/
public function patchSnapshot(int $id, string $renderedHtml): Document
{
$tenantId = $this->tenantId();
$document = Document::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$document->rendered_html = $renderedHtml;
$document->save();
return $document;
}
// =========================================================================
// Resolve/Upsert (React 연동용)
// =========================================================================
@@ -852,24 +891,34 @@ public function upsert(array $data): Document
if ($existingDocument) {
// UPDATE: 기존 update 로직 재사용
return $this->update($existingDocument->id, [
$updatePayload = [
'title' => $data['title'] ?? $existingDocument->title,
'linkable_type' => 'item',
'linkable_id' => $itemId,
'data' => $data['data'] ?? [],
'attachments' => $data['attachments'] ?? [],
]);
];
if (isset($data['rendered_html'])) {
$updatePayload['rendered_html'] = $data['rendered_html'];
}
return $this->update($existingDocument->id, $updatePayload);
}
// CREATE: 기존 create 로직 재사용
return $this->create([
$createPayload = [
'template_id' => $templateId,
'title' => $data['title'] ?? '',
'linkable_type' => 'item',
'linkable_id' => $itemId,
'data' => $data['data'] ?? [],
'attachments' => $data['attachments'] ?? [],
]);
];
if (isset($data['rendered_html'])) {
$createPayload['rendered_html'] = $data['rendered_html'];
}
return $this->create($createPayload);
});
}

View File

@@ -1325,9 +1325,13 @@ public function createProductionOrder(int $orderId, array $data)
// 작업지시번호 생성
$workOrderNo = $this->generateWorkOrderNo($tenantId);
// 절곡 공정이면 bending_info 자동 생성
// 공정 옵션 초기화 (보조 공정 플래그 포함)
$workOrderOptions = null;
if ($processId) {
$process = \App\Models\Process::find($processId);
if ($process && ! empty($process->options['is_auxiliary'])) {
$workOrderOptions = ['is_auxiliary' => true];
}
// 이 작업지시에 포함되는 노드 ID만 추출
$nodeIds = collect($items)
->pluck('order_node_id')
@@ -1338,7 +1342,7 @@ public function createProductionOrder(int $orderId, array $data)
$buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
if ($buildResult) {
$workOrderOptions = ['bending_info' => $buildResult['bending_info']];
$workOrderOptions = array_merge($workOrderOptions ?? [], ['bending_info' => $buildResult['bending_info']]);
}
}

View File

@@ -0,0 +1,258 @@
<?php
namespace App\Services;
use App\Models\Qualitys\PerformanceReport;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class PerformanceReportService extends Service
{
private const AUDIT_TARGET = 'performance_report';
public function __construct(
private readonly AuditLogger $auditLogger,
private readonly QualityDocumentService $qualityDocumentService
) {}
/**
* 목록 조회
*/
public function index(array $params)
{
$tenantId = $this->tenantId();
$perPage = (int) ($params['per_page'] ?? 20);
$q = trim((string) ($params['q'] ?? ''));
$year = $params['year'] ?? null;
$quarter = $params['quarter'] ?? null;
$confirmStatus = $params['confirm_status'] ?? null;
$query = PerformanceReport::query()
->where('performance_reports.tenant_id', $tenantId)
->with(['qualityDocument.client', 'qualityDocument.locations', 'confirmer:id,name']);
if ($q !== '') {
$query->whereHas('qualityDocument', function ($qq) use ($q) {
$qq->where('quality_doc_number', 'like', "%{$q}%")
->orWhere('site_name', 'like', "%{$q}%");
});
}
if ($year !== null) {
$query->where('year', $year);
}
if ($quarter !== null) {
$query->where('quarter', $quarter);
}
if ($confirmStatus !== null) {
$query->where('confirmation_status', $confirmStatus);
}
$query->orderByDesc('performance_reports.id');
$paginated = $query->paginate($perPage);
$transformedData = $paginated->getCollection()->map(fn ($report) => $this->transformToFrontend($report));
return [
'items' => $transformedData,
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
];
}
/**
* 통계 조회
*/
public function stats(array $params = []): array
{
$tenantId = $this->tenantId();
$query = PerformanceReport::where('performance_reports.tenant_id', $tenantId);
if (! empty($params['year'])) {
$query->where('performance_reports.year', $params['year']);
}
if (! empty($params['quarter'])) {
$query->where('performance_reports.quarter', $params['quarter']);
}
$counts = (clone $query)
->select('confirmation_status', DB::raw('count(*) as count'))
->groupBy('confirmation_status')
->pluck('count', 'confirmation_status')
->toArray();
$totalLocations = (clone $query)
->join('quality_documents', 'quality_documents.id', '=', 'performance_reports.quality_document_id')
->join('quality_document_locations', 'quality_document_locations.quality_document_id', '=', 'quality_documents.id')
->count('quality_document_locations.id');
return [
'total_count' => array_sum($counts),
'confirmed_count' => $counts[PerformanceReport::STATUS_CONFIRMED] ?? 0,
'unconfirmed_count' => $counts[PerformanceReport::STATUS_UNCONFIRMED] ?? 0,
'total_locations' => $totalLocations,
];
}
/**
* 일괄 확정
*/
public function confirm(array $ids)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($ids, $tenantId, $userId) {
$reports = PerformanceReport::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->with(['qualityDocument'])
->get();
$errors = [];
foreach ($reports as $report) {
if ($report->isConfirmed() || $report->isReported()) {
continue;
}
// 필수정보 검증
$requiredInfo = $this->qualityDocumentService->calculateRequiredInfo($report->qualityDocument);
if ($requiredInfo !== '완료') {
$errors[] = [
'id' => $report->id,
'quality_doc_number' => $report->qualityDocument->quality_doc_number,
'reason' => $requiredInfo,
];
continue;
}
$report->update([
'confirmation_status' => PerformanceReport::STATUS_CONFIRMED,
'confirmed_date' => now()->toDateString(),
'confirmed_by' => $userId,
'updated_by' => $userId,
]);
}
if (! empty($errors)) {
throw new BadRequestHttpException(json_encode([
'message' => __('error.quality.confirm_failed'),
'errors' => $errors,
]));
}
return ['confirmed_count' => count($ids) - count($errors)];
});
}
/**
* 일괄 확정 해제
*/
public function unconfirm(array $ids)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($ids, $tenantId, $userId) {
PerformanceReport::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED)
->update([
'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED,
'confirmed_date' => null,
'confirmed_by' => null,
'updated_by' => $userId,
]);
return ['unconfirmed_count' => count($ids)];
});
}
/**
* 일괄 메모 업데이트
*/
public function updateMemo(array $ids, string $memo)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
PerformanceReport::where('tenant_id', $tenantId)
->whereIn('id', $ids)
->update([
'memo' => $memo,
'updated_by' => $userId,
]);
return ['updated_count' => count($ids)];
}
/**
* 누락체크 (출고완료 but 제품검사 미등록)
*/
public function missing(array $params): array
{
$tenantId = $this->tenantId();
// 품질관리서가 등록된 수주 ID
$registeredOrderIds = DB::table('quality_document_orders')
->join('quality_documents', 'quality_documents.id', '=', 'quality_document_orders.quality_document_id')
->where('quality_documents.tenant_id', $tenantId)
->pluck('quality_document_orders.order_id');
// 출고완료 상태이지만 품질관리서 미등록 수주
$query = DB::table('orders')
->where('tenant_id', $tenantId)
->whereNotIn('id', $registeredOrderIds)
->where('status_code', 'SHIPPED'); // TODO: 출고완료 상태 추가 시 상수 확인
if (! empty($params['year'])) {
$query->whereYear('created_at', $params['year']);
}
if (! empty($params['quarter'])) {
$quarter = (int) $params['quarter'];
$startMonth = ($quarter - 1) * 3 + 1;
$endMonth = $quarter * 3;
$query->whereMonth('created_at', '>=', $startMonth)
->whereMonth('created_at', '<=', $endMonth);
}
return $query->orderByDesc('id')
->limit(100)
->get()
->map(fn ($order) => [
'id' => $order->id,
'order_number' => $order->order_no ?? '',
'site_name' => $order->site_name ?? '',
'client' => '', // 별도 조인 필요
'delivery_date' => $order->delivery_date ?? '',
])
->toArray();
}
/**
* DB → 프론트엔드 변환
*/
private function transformToFrontend(PerformanceReport $report): array
{
$doc = $report->qualityDocument;
return [
'id' => $report->id,
'quality_doc_number' => $doc?->quality_doc_number ?? '',
'created_date' => $report->created_at?->format('Y-m-d') ?? '',
'site_name' => $doc?->site_name ?? '',
'client' => $doc?->client?->name ?? '',
'location_count' => $doc?->locations?->count() ?? 0,
'required_info' => $doc ? $this->qualityDocumentService->calculateRequiredInfo($doc) : '',
'confirm_status' => $report->confirmation_status === PerformanceReport::STATUS_CONFIRMED ? 'confirmed' : 'unconfirmed',
'confirm_date' => $report->confirmed_date?->format('Y-m-d'),
'memo' => $report->memo ?? '',
'year' => $report->year,
'quarter' => $report->quarter,
];
}
}

View File

@@ -0,0 +1,285 @@
<?php
namespace App\Services;
use App\Models\Orders\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ProductionOrderService extends Service
{
/**
* 생산지시 대상 상태 코드
*/
private const PRODUCTION_STATUSES = [
Order::STATUS_IN_PROGRESS,
Order::STATUS_IN_PRODUCTION,
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
];
/**
* 생산지시 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Order::query()
->where('tenant_id', $tenantId)
->whereIn('status_code', self::PRODUCTION_STATUSES)
->with(['client', 'workOrders.process', 'workOrders.assignees.user'])
->withCount([
'workOrders' => fn ($q) => $q->whereNotNull('process_id')
->where(fn ($q2) => $q2->whereNull('options->is_auxiliary')
->orWhere('options->is_auxiliary', false)),
'nodes',
]);
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('order_no', 'like', "%{$search}%")
->orWhere('client_name', 'like', "%{$search}%")
->orWhere('site_name', 'like', "%{$search}%");
});
}
// 생산 상태 필터
if (! empty($params['production_status'])) {
switch ($params['production_status']) {
case 'waiting':
$query->where('status_code', Order::STATUS_IN_PROGRESS);
break;
case 'in_production':
$query->where('status_code', Order::STATUS_IN_PRODUCTION);
break;
case 'completed':
$query->whereIn('status_code', [
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
]);
break;
}
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
$result = $query->paginate($perPage);
// 가공 필드 추가
$result->getCollection()->transform(function (Order $order) {
$minCreatedAt = $order->workOrders->min('created_at');
$order->production_ordered_at = $minCreatedAt
? $minCreatedAt->format('Y-m-d')
: null;
// 개소수 (order_nodes 수)
$order->node_count = $order->nodes_count ?? 0;
// 주요 생산 공정 WO만 (구매품 + 보조 공정 제외)
$productionWOs = $this->filterMainProductionWOs($order->workOrders);
$order->work_order_progress = [
'total' => $productionWOs->count(),
'completed' => $productionWOs->where('status', 'completed')->count()
+ $productionWOs->where('status', 'shipped')->count(),
'in_progress' => $productionWOs->where('status', 'in_progress')->count(),
];
// 프론트 탭용 production_status 매핑
$order->production_status = $this->mapProductionStatus($order->status_code);
return $order;
});
return $result;
}
/**
* 상태별 통계
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$waiting = Order::where('tenant_id', $tenantId)
->where('status_code', Order::STATUS_IN_PROGRESS)
->count();
$inProduction = Order::where('tenant_id', $tenantId)
->where('status_code', Order::STATUS_IN_PRODUCTION)
->count();
$completed = Order::where('tenant_id', $tenantId)
->whereIn('status_code', [
Order::STATUS_PRODUCED,
Order::STATUS_SHIPPING,
Order::STATUS_SHIPPED,
])
->count();
return [
'total' => $waiting + $inProduction + $completed,
'waiting' => $waiting,
'in_production' => $inProduction,
'completed' => $completed,
];
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): array
{
$tenantId = $this->tenantId();
$order = Order::query()
->where('tenant_id', $tenantId)
->whereIn('status_code', self::PRODUCTION_STATUSES)
->with([
'client',
'workOrders.process',
'workOrders.items',
'workOrders.assignees.user',
'nodes',
])
->withCount('nodes')
->findOrFail($orderId);
// 생산지시일 (날짜만)
$minCreatedAt = $order->workOrders->min('created_at');
$order->production_ordered_at = $minCreatedAt
? $minCreatedAt->format('Y-m-d')
: null;
$order->production_status = $this->mapProductionStatus($order->status_code);
// 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외)
$productionWorkOrders = $this->filterMainProductionWOs($order->workOrders);
// WorkOrder 진행 현황 (생산 공정 기준)
$workOrderProgress = [
'total' => $productionWorkOrders->count(),
'completed' => $productionWorkOrders->where('status', 'completed')->count()
+ $productionWorkOrders->where('status', 'shipped')->count(),
'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(),
];
// WorkOrder 목록 가공 (생산 공정만)
$workOrders = $productionWorkOrders->values()->map(function ($wo) {
return [
'id' => $wo->id,
'work_order_no' => $wo->work_order_no,
'process_name' => $wo->process?->process_name ?? '',
'quantity' => $wo->items->count(),
'status' => $wo->status,
'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(),
];
});
// BOM 데이터 (order_nodes에서 추출)
$bomProcessGroups = $this->extractBomProcessGroups($order->nodes);
return [
'order' => $order->makeHidden(['workOrders', 'nodes']),
'production_ordered_at' => $order->production_ordered_at,
'production_status' => $order->production_status,
'node_count' => $order->nodes_count ?? 0,
'work_order_progress' => $workOrderProgress,
'work_orders' => $workOrders,
'bom_process_groups' => $bomProcessGroups,
];
}
/**
* Order status_code → 프론트 production_status 매핑
*/
private function mapProductionStatus(string $statusCode): string
{
return match ($statusCode) {
Order::STATUS_IN_PROGRESS => 'waiting',
Order::STATUS_IN_PRODUCTION => 'in_production',
default => 'completed',
};
}
/**
* order_nodes에서 BOM 공정 분류 추출
*
* bom_result 구조: { items: [...], success, subtotals, ... }
* 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... }
*/
private function extractBomProcessGroups($nodes): array
{
$groups = [];
foreach ($nodes as $node) {
$bomResult = $node->options['bom_result'] ?? null;
if (! $bomResult || ! is_array($bomResult)) {
continue;
}
// bom_result.items 배열에서 추출
$items = $bomResult['items'] ?? [];
if (! is_array($items)) {
continue;
}
$nodeName = $node->name ?? '';
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타';
if (! isset($groups[$processGroup])) {
$groups[$processGroup] = [
'process_name' => $processGroup,
'items' => [],
];
}
$groups[$processGroup]['items'][] = [
'id' => $item['item_id'] ?? null,
'item_code' => $item['item_code'] ?? '',
'item_name' => $item['item_name'] ?? '',
'spec' => $item['specification'] ?? '',
'unit' => $item['unit'] ?? '',
'quantity' => $item['quantity'] ?? 0,
'unit_price' => $item['unit_price'] ?? 0,
'total_price' => $item['total_price'] ?? 0,
'node_name' => $nodeName,
];
}
}
return array_values($groups);
}
/**
* 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외)
*
* 제외 대상:
* - process_id가 null인 WO (구매품/서비스)
* - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정)
*/
private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection
{
return $workOrders->filter(function ($wo) {
if (empty($wo->process_id)) {
return false;
}
$options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []);
return empty($options['is_auxiliary']);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -259,6 +259,17 @@ public function store(array $data)
$salesOrderId = $data['sales_order_id'] ?? null;
unset($data['items'], $data['bending_detail']);
// 공정의 is_auxiliary 플래그를 WO options에 복사
if (! empty($data['process_id'])) {
$process = \App\Models\Process::find($data['process_id']);
if ($process && ! empty($process->options['is_auxiliary'])) {
$opts = $data['options'] ?? [];
$opts = is_array($opts) ? $opts : (json_decode($opts, true) ?? []);
$opts['is_auxiliary'] = true;
$data['options'] = $opts;
}
}
$workOrder = WorkOrder::create($data);
// process 관계 로드 (isBending 체크용)
@@ -815,6 +826,11 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
return;
}
// 보조 공정(재고생산 등)은 수주 상태에 영향 주지 않음
if ($this->isAuxiliaryWorkOrder($workOrder)) {
return;
}
$order = Order::where('tenant_id', $tenantId)->find($workOrder->sales_order_id);
if (! $order) {
return;
@@ -850,6 +866,47 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
);
}
/**
* 자재 투입 시 작업지시가 대기 상태이면 자동으로 진행중으로 전환
*
* pending/waiting 상태에서 첫 자재 투입이 발생하면
* 작업지시 → in_progress, 수주 → IN_PRODUCTION 으로 자동 전환
*/
private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $tenantId): void
{
// 보조 공정(재고생산 등)은 WO 자체는 진행중으로 전환하되, 수주 상태는 변경하지 않음
$isAuxiliary = $this->isAuxiliaryWorkOrder($workOrder);
// 아직 진행 전인 상태에서만 자동 전환 (자재투입 = 실질적 작업 시작)
if (! in_array($workOrder->status, [
WorkOrder::STATUS_UNASSIGNED,
WorkOrder::STATUS_PENDING,
WorkOrder::STATUS_WAITING,
])) {
return;
}
$oldStatus = $workOrder->status;
$workOrder->status = WorkOrder::STATUS_IN_PROGRESS;
$workOrder->updated_by = $this->apiUserId();
$workOrder->save();
// 감사 로그
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrder->id,
'status_auto_changed_on_material_input',
['status' => $oldStatus],
['status' => WorkOrder::STATUS_IN_PROGRESS]
);
// 보조 공정이 아닌 경우만 수주 상태 동기화
if (! $isAuxiliary) {
$this->syncOrderStatus($workOrder, $tenantId);
}
}
/**
* 작업지시 품목에 결과 데이터 저장
*/
@@ -890,6 +947,16 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
}
}
/**
* 보조 공정(재고생산 등) 여부 판단
*/
private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool
{
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []);
return ! empty($options['is_auxiliary']);
}
/**
* LOT 번호 생성
*/
@@ -1458,6 +1525,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
$totalCount = array_sum(array_column($delegatedResults, 'material_count'));
$allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults));
// 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환
$this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId);
return [
'work_order_id' => $workOrderId,
'material_count' => $totalCount,
@@ -1536,6 +1606,9 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
$allResults = array_merge($allResults, $dr['input_results']);
}
// 자재 투입 시 작업지시가 대기 상태면 자동으로 진행중으로 전환
$this->autoStartWorkOrderOnMaterialInput($workOrder, $tenantId);
return [
'work_order_id' => $workOrderId,
'material_count' => count($allResults),
@@ -2358,11 +2431,26 @@ public function resolveInspectionDocument(int $workOrderId, array $params = []):
->latest()
->first();
// Lazy Snapshot 대상: rendered_html이 없는 문서 (상태 무관)
$snapshotDocumentId = null;
$snapshotCandidate = Document::query()
->where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', 'work_order')
->where('linkable_id', $workOrderId)
->whereNull('rendered_html')
->latest()
->value('id');
if ($snapshotCandidate) {
$snapshotDocumentId = $snapshotCandidate;
}
return [
'work_order_id' => $workOrderId,
'template_id' => $templateId,
'template' => $formattedTemplate,
'existing_document' => $existingDocument,
'snapshot_document_id' => $snapshotDocumentId,
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
];
}
@@ -2436,10 +2524,14 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
])
->toArray();
$document = $documentService->update($existingDocument->id, [
$updateData = [
'title' => $inspectionData['title'] ?? $existingDocument->title,
'data' => array_merge($existingBasicFields, $documentDataRecords),
]);
];
if (isset($inspectionData['rendered_html'])) {
$updateData['rendered_html'] = $inspectionData['rendered_html'];
}
$document = $documentService->update($existingDocument->id, $updateData);
$action = 'inspection_document_updated';
} else {
@@ -2451,6 +2543,9 @@ public function createInspectionDocument(int $workOrderId, array $inspectionData
'data' => $documentDataRecords,
'approvers' => $inspectionData['approvers'] ?? [],
];
if (isset($inspectionData['rendered_html'])) {
$documentData['rendered_html'] = $inspectionData['rendered_html'];
}
$document = $documentService->create($documentData);
$action = 'inspection_document_created';
@@ -3067,20 +3162,28 @@ public function createWorkLog(int $workOrderId, array $workLogData): array
$documentDataRecords = $this->transformWorkLogDataToRecords($workLogData, $workOrder, $template);
if ($existingDocument) {
$document = $documentService->update($existingDocument->id, [
$updateData = [
'title' => $workLogData['title'] ?? $existingDocument->title,
'data' => $documentDataRecords,
]);
];
if (isset($workLogData['rendered_html'])) {
$updateData['rendered_html'] = $workLogData['rendered_html'];
}
$document = $documentService->update($existingDocument->id, $updateData);
$action = 'work_log_updated';
} else {
$document = $documentService->create([
$createData = [
'template_id' => $templateId,
'title' => $workLogData['title'] ?? "작업일지 - {$workOrder->work_order_no}",
'linkable_type' => 'work_order',
'linkable_id' => $workOrderId,
'data' => $documentDataRecords,
'approvers' => $workLogData['approvers'] ?? [],
]);
];
if (isset($workLogData['rendered_html'])) {
$createData['rendered_html'] = $workLogData['rendered_html'];
}
$document = $documentService->create($createData);
$action = 'work_log_created';
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="ProductionOrders", description="생산지시 관리")
*
* @OA\Schema(
* schema="ProductionOrderListItem",
* type="object",
* description="생산지시 목록 아이템",
*
* @OA\Property(property="id", type="integer", example=1, description="수주 ID"),
* @OA\Property(property="order_no", type="string", example="ORD-20260301-0001", description="수주번호 (= 생산지시번호)"),
* @OA\Property(property="site_name", type="string", example="서울현장", nullable=true, description="현장명"),
* @OA\Property(property="client_name", type="string", example="(주)고객사", nullable=true, description="거래처명"),
* @OA\Property(property="quantity", type="number", example=232, description="부품수량 합계"),
* @OA\Property(property="node_count", type="integer", example=4, description="개소수 (order_nodes 수)"),
* @OA\Property(property="delivery_date", type="string", format="date", example="2026-03-15", nullable=true, description="납기일"),
* @OA\Property(property="production_ordered_at", type="string", format="date", example="2026-02-21", nullable=true, description="생산지시일 (첫 WorkOrder 생성일, Y-m-d)"),
* @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}, example="waiting", description="생산 상태"),
* @OA\Property(property="work_orders_count", type="integer", example=2, description="작업지시 수 (공정별 1건)"),
* @OA\Property(property="work_order_progress", type="object",
* @OA\Property(property="total", type="integer", example=3),
* @OA\Property(property="completed", type="integer", example=1),
* @OA\Property(property="in_progress", type="integer", example=1)
* ),
* @OA\Property(property="client", type="object", nullable=true,
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="(주)고객사")
* )
* )
*
* @OA\Schema(
* schema="ProductionOrderStats",
* type="object",
* description="생산지시 통계",
*
* @OA\Property(property="total", type="integer", example=25, description="전체"),
* @OA\Property(property="waiting", type="integer", example=10, description="생산대기"),
* @OA\Property(property="in_production", type="integer", example=8, description="생산중"),
* @OA\Property(property="completed", type="integer", example=7, description="생산완료")
* )
*
* @OA\Schema(
* schema="ProductionOrderDetail",
* type="object",
* description="생산지시 상세",
*
* @OA\Property(property="order", ref="#/components/schemas/ProductionOrderListItem"),
* @OA\Property(property="production_ordered_at", type="string", format="date", example="2026-02-21", nullable=true),
* @OA\Property(property="production_status", type="string", enum={"waiting","in_production","completed"}),
* @OA\Property(property="node_count", type="integer", example=4, description="개소수"),
* @OA\Property(property="work_order_progress", type="object",
* @OA\Property(property="total", type="integer"),
* @OA\Property(property="completed", type="integer"),
* @OA\Property(property="in_progress", type="integer")
* ),
* @OA\Property(property="work_orders", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="work_order_no", type="string"),
* @OA\Property(property="process_name", type="string"),
* @OA\Property(property="quantity", type="integer"),
* @OA\Property(property="status", type="string"),
* @OA\Property(property="assignees", type="array", @OA\Items(type="string"))
* )
* ),
* @OA\Property(property="bom_process_groups", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="process_name", type="string"),
* @OA\Property(property="size_spec", type="string", nullable=true),
* @OA\Property(property="items", type="array",
*
* @OA\Items(type="object",
*
* @OA\Property(property="id", type="integer", nullable=true),
* @OA\Property(property="item_code", type="string"),
* @OA\Property(property="item_name", type="string"),
* @OA\Property(property="spec", type="string"),
* @OA\Property(property="lot_no", type="string"),
* @OA\Property(property="required_qty", type="number"),
* @OA\Property(property="qty", type="number")
* )
* )
* )
* )
* )
*/
class ProductionOrderApi
{
/**
* @OA\Get(
* path="/api/v1/production-orders",
* tags={"ProductionOrders"},
* summary="생산지시 목록 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="search", in="query", required=false,
*
* @OA\Schema(type="string"), description="검색어 (수주번호, 거래처명, 현장명)"
* ),
*
* @OA\Parameter(name="production_status", in="query", required=false,
*
* @OA\Schema(type="string", enum={"waiting","in_production","completed"}), description="생산 상태 필터"
* ),
*
* @OA\Parameter(name="sort_by", in="query", required=false,
*
* @OA\Schema(type="string", enum={"created_at","delivery_date","order_no"}), description="정렬 기준"
* ),
*
* @OA\Parameter(name="sort_dir", in="query", required=false,
*
* @OA\Schema(type="string", enum={"asc","desc"}), description="정렬 방향"
* ),
*
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")),
* @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer")),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ProductionOrderListItem")),
* @OA\Property(property="current_page", type="integer"),
* @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/production-orders/stats",
* tags={"ProductionOrders"},
* summary="생산지시 상태별 통계",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ProductionOrderStats")
* )
* )
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/production-orders/{orderId}",
* tags={"ProductionOrders"},
* summary="생산지시 상세 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Parameter(name="orderId", in="path", required=true, @OA\Schema(type="integer"), description="수주 ID"),
*
* @OA\Response(response=200, description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ProductionOrderDetail")
* )
* ),
*
* @OA\Response(response=404, description="생산지시를 찾을 수 없음")
* )
*/
public function show() {}
}

View File

@@ -67,6 +67,7 @@
'daily' => [
'driver' => 'daily',
'permission' => 0664,
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
@@ -138,6 +139,7 @@
*/
'api' => [
'driver' => 'daily',
'permission' => 0664,
'path' => storage_path('logs/api/api.log'),
'level' => 'info',
'days' => env('API_LOG_DAYS', 14),

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('quality_documents', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->string('quality_doc_number', 30)->comment('품질관리서 번호');
$table->string('site_name')->comment('현장명');
$table->string('status', 20)->default('received')->comment('received/in_progress/completed');
$table->foreignId('client_id')->nullable()->constrained('clients')->comment('수주처');
$table->foreignId('inspector_id')->nullable()->constrained('users')->comment('검사자');
$table->date('received_date')->nullable()->comment('접수일');
$table->json('options')->nullable()->comment('관련자정보, 검사정보, 현장주소 등');
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'quality_doc_number']);
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'client_id']);
$table->index(['tenant_id', 'inspector_id']);
$table->index(['tenant_id', 'received_date']);
});
}
public function down(): void
{
Schema::dropIfExists('quality_documents');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('quality_document_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('quality_document_id')->constrained()->cascadeOnDelete();
$table->foreignId('order_id')->constrained('orders');
$table->timestamps();
$table->unique(['quality_document_id', 'order_id']);
});
}
public function down(): void
{
Schema::dropIfExists('quality_document_orders');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('quality_document_locations', function (Blueprint $table) {
$table->id();
$table->foreignId('quality_document_id')->constrained()->cascadeOnDelete();
$table->foreignId('quality_document_order_id')->constrained('quality_document_orders', 'id', 'qdl_qdo_id_fk')->cascadeOnDelete();
$table->foreignId('order_item_id')->constrained('order_items');
$table->integer('post_width')->nullable()->comment('시공후 가로');
$table->integer('post_height')->nullable()->comment('시공후 세로');
$table->string('change_reason')->nullable()->comment('규격 변경사유');
$table->foreignId('document_id')->nullable()->comment('검사성적서 문서 ID');
$table->string('inspection_status', 20)->default('pending')->comment('pending/completed');
$table->timestamps();
$table->index(['quality_document_id', 'inspection_status'], 'qdl_doc_id_status_index');
});
}
public function down(): void
{
Schema::dropIfExists('quality_document_locations');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('performance_reports', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
$table->foreignId('quality_document_id')->constrained();
$table->unsignedSmallInteger('year')->comment('연도');
$table->unsignedTinyInteger('quarter')->comment('분기 1-4');
$table->string('confirmation_status', 20)->default('unconfirmed')->comment('unconfirmed/confirmed/reported');
$table->date('confirmed_date')->nullable();
$table->foreignId('confirmed_by')->nullable()->constrained('users');
$table->text('memo')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->unsignedBigInteger('updated_by')->nullable();
$table->unsignedBigInteger('deleted_by')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'quality_document_id']);
$table->index(['tenant_id', 'year', 'quarter']);
$table->index(['tenant_id', 'confirmation_status']);
});
}
public function down(): void
{
Schema::dropIfExists('performance_reports');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('quality_document_locations', function (Blueprint $table) {
$table->json('inspection_data')->nullable()->after('change_reason')->comment('검사 데이터 JSON');
});
}
public function down(): void
{
Schema::table('quality_document_locations', function (Blueprint $table) {
$table->dropColumn('inspection_data');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('document_template_sections', function (Blueprint $table) {
$table->text('description')->nullable()->after('title')->comment('섹션 설명/안내문');
});
}
public function down(): void
{
Schema::table('document_template_sections', function (Blueprint $table) {
$table->dropColumn('description');
});
}
};

View File

@@ -17,7 +17,7 @@
* Phase 3.1: chandj.price_motor → items (SM) + prices 누락 품목
* Phase 3.2: chandj.price_raw_materials → items (RM) + prices 누락 품목
*
* @see docs/plans/kd-items-migration-plan.md
* @see docs/dev_plans/kd-items-migration-plan.md
*/
class KyungdongItemSeeder extends Seeder
{

View File

@@ -37,7 +37,7 @@ ## 🔗 프로젝트 문서 (SAM/docs로 이동됨)
| 이전 위치 | 새 위치 | 설명 |
|-----------|---------|------|
| `analysis/` | `docs/data/analysis/` | Item DB 분석 문서 |
| `analysis/` | `docs/dev/data/analysis/` | Item DB 분석 문서 |
| `front/` | `docs/front/` | 프론트엔드 요청 문서 |
| `specs/` | `docs/specs/` | 기능 스펙 문서 |
@@ -49,6 +49,6 @@ ## 📝 문서 추가 가이드
|-----------|-----------|
| Swagger 스펙 | `api/docs/swagger/` |
| API Flow 테스트 | `api/docs/api-flows/` 또는 `flow-tests/` |
| 프로젝트 분석 | `SAM/docs/data/` |
| 프로젝트 분석 | `SAM/docs/dev/data/` |
| 기능 스펙 | `SAM/docs/specs/` |
| 프론트 요청 | `SAM/docs/front/` |

View File

@@ -74,5 +74,5 @@ ## ⚠️ 배포 시 주의사항
- 테넌트별 초기 데이터 설정 필요
## 🔗 관련 문서
- `docs/plans/quote-calculation-api-plan.md`
- `docs/dev/dev_plans/quote-calculation-api-plan.md`
- `mng/app/Services/Quote/FormulaEvaluatorService.php` (원본)

View File

@@ -2,7 +2,7 @@ # 변경 내용 요약
**날짜:** 2026-01-02 13:00
**작업명:** Phase 1.2 입력 변수 처리 - React QuoteItem 매핑
**계획 문서:** docs/plans/quote-calculation-api-plan.md
**계획 문서:** docs/dev/dev_plans/quote-calculation-api-plan.md
## 변경 개요
@@ -151,4 +151,4 @@ ## API 사용 예시
## 관련 문서
- Phase 1.1: `20251230_2339_quote_calculation_mng_logic.md` (BOM 단건 산출)
- 계획 문서: `docs/plans/quote-calculation-api-plan.md`
- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md`

View File

@@ -444,6 +444,15 @@
'already_completed' => '이미 완료된 검사입니다.',
],
// 품질관리서 관련
'quality' => [
'cannot_delete_completed' => '완료된 품질관리서는 삭제할 수 없습니다.',
'already_completed' => '이미 완료된 품질관리서입니다.',
'cannot_modify_completed' => '완료된 품질관리서는 수정할 수 없습니다.',
'pending_locations' => '미완료 개소가 :count건 있습니다.',
'confirm_failed' => '필수정보가 누락된 건이 있어 확정할 수 없습니다.',
],
// 입찰 관련
'bidding' => [
'not_found' => '입찰을 찾을 수 없습니다.',

View File

@@ -41,6 +41,7 @@
require __DIR__.'/api/v1/app.php';
require __DIR__.'/api/v1/audit.php';
require __DIR__.'/api/v1/esign.php';
require __DIR__.'/api/v1/quality.php';
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');

View File

@@ -33,6 +33,7 @@
Route::get('/{id}', [DocumentController::class, 'show'])->whereNumber('id')->name('v1.documents.show');
Route::post('/', [DocumentController::class, 'store'])->name('v1.documents.store');
Route::patch('/{id}', [DocumentController::class, 'update'])->whereNumber('id')->name('v1.documents.update');
Route::patch('/{id}/snapshot', [DocumentController::class, 'patchSnapshot'])->whereNumber('id')->name('v1.documents.snapshot');
Route::delete('/{id}', [DocumentController::class, 'destroy'])->whereNumber('id')->name('v1.documents.destroy');
// 결재 워크플로우

View File

@@ -10,6 +10,7 @@
*/
use App\Http\Controllers\Api\V1\InspectionController;
use App\Http\Controllers\Api\V1\ProductionOrderController;
use App\Http\Controllers\Api\V1\WorkOrderController;
use App\Http\Controllers\Api\V1\WorkResultController;
use App\Http\Controllers\V1\ProcessController;
@@ -121,3 +122,10 @@
Route::delete('/{id}', [InspectionController::class, 'destroy'])->whereNumber('id')->name('v1.inspections.destroy'); // 삭제
Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리
});
// Production Order API (생산지시 조회)
Route::prefix('production-orders')->group(function () {
Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');
Route::get('/stats', [ProductionOrderController::class, 'stats'])->name('v1.production-orders.stats');
Route::get('/{orderId}', [ProductionOrderController::class, 'show'])->whereNumber('orderId')->name('v1.production-orders.show');
});

40
routes/api/v1/quality.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
/**
* 품질관리 API 라우트 (v1)
*
* - 제품검사 (품질관리서)
* - 실적신고
*/
use App\Http\Controllers\Api\V1\PerformanceReportController;
use App\Http\Controllers\Api\V1\QualityDocumentController;
use Illuminate\Support\Facades\Route;
// 제품검사 (품질관리서)
Route::prefix('quality/documents')->group(function () {
Route::get('', [QualityDocumentController::class, 'index'])->name('v1.quality.documents.index');
Route::get('/stats', [QualityDocumentController::class, 'stats'])->name('v1.quality.documents.stats');
Route::get('/calendar', [QualityDocumentController::class, 'calendar'])->name('v1.quality.documents.calendar');
Route::get('/available-orders', [QualityDocumentController::class, 'availableOrders'])->name('v1.quality.documents.available-orders');
Route::post('', [QualityDocumentController::class, 'store'])->name('v1.quality.documents.store');
Route::get('/{id}', [QualityDocumentController::class, 'show'])->whereNumber('id')->name('v1.quality.documents.show');
Route::put('/{id}', [QualityDocumentController::class, 'update'])->whereNumber('id')->name('v1.quality.documents.update');
Route::delete('/{id}', [QualityDocumentController::class, 'destroy'])->whereNumber('id')->name('v1.quality.documents.destroy');
Route::patch('/{id}/complete', [QualityDocumentController::class, 'complete'])->whereNumber('id')->name('v1.quality.documents.complete');
Route::post('/{id}/orders', [QualityDocumentController::class, 'attachOrders'])->whereNumber('id')->name('v1.quality.documents.attach-orders');
Route::delete('/{id}/orders/{orderId}', [QualityDocumentController::class, 'detachOrder'])->whereNumber('id')->whereNumber('orderId')->name('v1.quality.documents.detach-order');
Route::post('/{id}/locations/{locId}/inspect', [QualityDocumentController::class, 'inspectLocation'])->whereNumber('id')->whereNumber('locId')->name('v1.quality.documents.inspect-location');
Route::get('/{id}/request-document', [QualityDocumentController::class, 'requestDocument'])->whereNumber('id')->name('v1.quality.documents.request-document');
Route::get('/{id}/result-document', [QualityDocumentController::class, 'resultDocument'])->whereNumber('id')->name('v1.quality.documents.result-document');
});
// 실적신고
Route::prefix('quality/performance-reports')->group(function () {
Route::get('', [PerformanceReportController::class, 'index'])->name('v1.quality.performance-reports.index');
Route::get('/stats', [PerformanceReportController::class, 'stats'])->name('v1.quality.performance-reports.stats');
Route::get('/missing', [PerformanceReportController::class, 'missing'])->name('v1.quality.performance-reports.missing');
Route::patch('/confirm', [PerformanceReportController::class, 'confirm'])->name('v1.quality.performance-reports.confirm');
Route::patch('/unconfirm', [PerformanceReportController::class, 'unconfirm'])->name('v1.quality.performance-reports.unconfirm');
Route::patch('/memo', [PerformanceReportController::class, 'updateMemo'])->name('v1.quality.performance-reports.memo');
});