feat: 견적확정 밸리데이션, 작업지시 통계 공정별 카운트, 입고/재고 개선

- 견적확정 시 업체명/현장명/담당자/연락처 필수 검증 추가 (QuoteService)
- 작업지시 stats API에 by_process 공정별 카운트 반환 추가
- 작업지시 목록/상세 쿼리에 수주 개소(rootNodes) 연관 로딩
- 작업지시 품목에 sourceOrderItem.node 관계 추가
- 입고관리 완료건 수정 허용 및 재고 차이 조정
- work_order_step_progress 테이블 마이그레이션
- receivings 테이블 options 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 03:27:07 +09:00
parent 6b3e5c3e87
commit 487e651845
22 changed files with 1422 additions and 72 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-02-05 15:42:10
> **자동 생성**: 2026-02-07 01:10:55
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -308,6 +308,36 @@ ### folders
**모델**: `App\Models\Folder`
### interview_answers
**모델**: `App\Models\Interview\InterviewAnswer`
- **session()**: belongsTo → `interview_sessions`
- **question()**: belongsTo → `interview_questions`
- **template()**: belongsTo → `interview_templates`
### interview_categorys
**모델**: `App\Models\Interview\InterviewCategory`
- **templates()**: hasMany → `interview_templates`
- **sessions()**: hasMany → `interview_sessions`
### interview_questions
**모델**: `App\Models\Interview\InterviewQuestion`
- **template()**: belongsTo → `interview_templates`
### interview_sessions
**모델**: `App\Models\Interview\InterviewSession`
- **category()**: belongsTo → `interview_categories`
- **answers()**: hasMany → `interview_answers`
### interview_templates
**모델**: `App\Models\Interview\InterviewTemplate`
- **category()**: belongsTo → `interview_categories`
- **questions()**: hasMany → `interview_questions`
### custom_tabs
**모델**: `App\Models\ItemMaster\CustomTab`
@@ -469,6 +499,8 @@ ### orders
- **item()**: belongsTo → `items`
- **sale()**: belongsTo → `sales`
- **items()**: hasMany → `order_items`
- **nodes()**: hasMany → `order_nodes`
- **rootNodes()**: hasMany → `order_nodes`
- **histories()**: hasMany → `order_histories`
- **versions()**: hasMany → `order_versions`
- **workOrders()**: hasMany → `work_orders`
@@ -484,6 +516,7 @@ ### order_items
**모델**: `App\Models\Orders\OrderItem`
- **order()**: belongsTo → `orders`
- **node()**: belongsTo → `order_nodes`
- **item()**: belongsTo → `items`
- **quote()**: belongsTo → `quotes`
- **quoteItem()**: belongsTo → `quote_items`
@@ -494,6 +527,14 @@ ### order_item_components
- **orderItem()**: belongsTo → `order_items`
### order_nodes
**모델**: `App\Models\Orders\OrderNode`
- **parent()**: belongsTo → `order_nodes`
- **order()**: belongsTo → `orders`
- **children()**: hasMany → `order_nodes`
- **items()**: hasMany → `order_items`
### order_versions
**모델**: `App\Models\Orders\OrderVersion`
@@ -567,6 +608,7 @@ ### work_orders
- **primaryAssignee()**: hasMany → `work_order_assignees`
- **items()**: hasMany → `work_order_items`
- **issues()**: hasMany → `work_order_issues`
- **stepProgress()**: hasMany → `work_order_step_progress`
- **shipments()**: hasMany → `shipments`
- **bendingDetail()**: hasOne → `work_order_bending_details`
@@ -594,6 +636,14 @@ ### work_order_items
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
### work_order_step_progress
**모델**: `App\Models\Production\WorkOrderStepProgress`
- **workOrder()**: belongsTo → `work_orders`
- **processStep()**: belongsTo → `process_steps`
- **workOrderItem()**: belongsTo → `work_order_items`
- **completedByUser()**: belongsTo → `users`
### work_results
**모델**: `App\Models\Production\WorkResult`

View File

@@ -0,0 +1,297 @@
<?php
namespace App\Console\Commands;
use App\Models\Items\Item;
use App\Models\Process;
use App\Models\ProcessItem;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 5130 기준 품목-공정 매핑 (A+B+C 전략)
*
* A. 품목명 키워드 기반:
* - "스크린용", "스크린" → P-002 스크린
* - "철재용", "철재", "슬랫" → P-001 슬랫
*
* B. BD 코드 기반:
* - BD-* → P-003 절곡
*
* C. 재고생산(LOT) 기반 (5130 lot 테이블 분석):
* - PT-* 코드 → P-004 재고생산
* - 가이드레일, 케이스, 연기차단재, L-Bar → P-004 재고생산
*/
class MapItemsToProcesses extends Command
{
protected $signature = 'items:map-to-processes
{--tenant= : 특정 테넌트 ID (기본: 모든 테넌트)}
{--dry-run : 실제 실행 없이 미리보기만}
{--clear : 기존 매핑 삭제 후 새로 매핑}';
protected $description = '5130 기준 품목-공정 자동 매핑 (A: 키워드 + B: BD코드 + C: 재고생산)';
/**
* 공정별 매핑 규칙 정의
*
* 5130 LOT 생산 품목 분류:
* - R: 가이드레일-벽면형, S: 가이드레일-측면형
* - C: 케이스 (린텔부, 전면부, 점검구, 후면코너부)
* - B: 하단마감재-스크린, T: 하단마감재-철재
* - G: 연기차단재
* - L: L-Bar
*/
private array $mappingRules = [
'P-001' => [
'name' => '슬랫',
'code_patterns' => [],
'name_keywords' => ['철재용', '철재', '슬랫'],
'name_excludes' => ['스크린', '가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외
],
'P-002' => [
'name' => '스크린',
'code_patterns' => [],
'name_keywords' => ['스크린용', '스크린', '원단', '실리카', '방충', '와이어'],
'name_excludes' => ['가이드레일', '하단마감', '연기차단', '케이스'], // 재고생산 품목 제외
],
'P-003' => [
'name' => '절곡',
'code_patterns' => ['BD-%'], // BD 코드는 절곡
'name_keywords' => ['절곡'], // 절곡 키워드만 (나머지는 P-004로)
'name_excludes' => [],
],
'P-004' => [
'name' => '재고생산',
'code_patterns' => ['PT-%'], // PT 코드는 재고생산 부품
'name_keywords' => ['가이드레일', '케이스', '연기차단', 'L-Bar', 'L-BAR', 'LBar', '하단마감', '린텔', '하장바'],
'name_excludes' => [],
'code_excludes' => ['BD-%'], // BD 코드는 P-003으로
],
];
public function handle(): int
{
$tenantId = $this->option('tenant');
$dryRun = $this->option('dry-run');
$clear = $this->option('clear');
$this->info('=== 5130 기준 품목-공정 매핑 (A+B+C 전략) ===');
$this->info('A. 품목명 키워드: 스크린용→P-002, 철재용→P-001');
$this->info('B. BD 코드: BD-* → P-003 절곡');
$this->info('C. 재고생산: PT-* 또는 가이드레일/케이스/연기차단재/L-Bar → P-004');
$this->newLine();
// 공정 조회
$processQuery = Process::query();
if ($tenantId) {
$processQuery->where('tenant_id', $tenantId);
}
$processes = $processQuery->whereIn('process_code', array_keys($this->mappingRules))->get()->keyBy('process_code');
if ($processes->isEmpty()) {
$this->error('매핑 대상 공정이 없습니다. (P-001, P-002, P-003, P-004)');
return self::FAILURE;
}
$this->info('대상 공정:');
foreach ($processes as $code => $process) {
$this->line(" - {$code}: {$process->process_name} (ID: {$process->id})");
}
$this->newLine();
// 기존 매핑 삭제 (--clear 옵션)
if ($clear) {
$processIds = $processes->pluck('id')->toArray();
$existingCount = ProcessItem::whereIn('process_id', $processIds)->count();
if ($dryRun) {
$this->warn("[DRY-RUN] 기존 매핑 {$existingCount}개 삭제 예정");
} else {
ProcessItem::whereIn('process_id', $processIds)->delete();
$this->warn("기존 매핑 {$existingCount}개 삭제 완료");
}
$this->newLine();
}
// 매핑 결과 저장
$results = [
'P-001' => ['items' => collect(), 'process' => $processes->get('P-001')],
'P-002' => ['items' => collect(), 'process' => $processes->get('P-002')],
'P-003' => ['items' => collect(), 'process' => $processes->get('P-003')],
'P-004' => ['items' => collect(), 'process' => $processes->get('P-004')],
];
// 품목 조회 및 분류
$itemQuery = Item::query();
if ($tenantId) {
$itemQuery->where('tenant_id', $tenantId);
}
$items = $itemQuery->get();
$this->info("전체 품목 수: {$items->count()}");
$this->newLine();
$mappedCount = 0;
$unmappedItems = collect();
foreach ($items as $item) {
$processCode = $this->classifyItem($item);
if ($processCode && isset($results[$processCode])) {
$results[$processCode]['items']->push($item);
$mappedCount++;
} else {
$unmappedItems->push($item);
}
}
// 결과 출력
$this->info('=== 분류 결과 ===');
$this->newLine();
$tableData = [];
foreach ($results as $code => $data) {
$count = $data['items']->count();
$processName = $data['process']?->process_name ?? '(없음)';
$tableData[] = [$code, $processName, $count];
}
$tableData[] = ['-', '미분류', $unmappedItems->count()];
$tableData[] = ['=', '합계', $items->count()];
$this->table(['공정코드', '공정명', '품목 수'], $tableData);
$this->newLine();
// 샘플 출력
foreach ($results as $code => $data) {
if ($data['items']->isNotEmpty()) {
$this->info("[{$code} {$data['process']?->process_name}] 샘플 (최대 10개):");
foreach ($data['items']->take(10) as $item) {
$this->line(" - {$item->code}: {$item->name}");
}
$this->newLine();
}
}
// 미분류 샘플
if ($unmappedItems->isNotEmpty()) {
$this->info("[미분류] 샘플 (최대 10개):");
foreach ($unmappedItems->take(10) as $item) {
$this->line(" - {$item->code}: {$item->name}");
}
$this->newLine();
}
// 실제 매핑 실행
if (! $dryRun) {
$this->info('=== 매핑 실행 ===');
DB::transaction(function () use ($results) {
foreach ($results as $code => $data) {
$process = $data['process'];
if (! $process) {
continue;
}
$priority = 0;
foreach ($data['items'] as $item) {
// 중복 체크
$exists = ProcessItem::where('process_id', $process->id)
->where('item_id', $item->id)
->exists();
if (! $exists) {
ProcessItem::create([
'process_id' => $process->id,
'item_id' => $item->id,
'priority' => $priority++,
'is_active' => true,
]);
}
}
$this->info(" {$code}: {$data['items']->count()}개 매핑 완료");
}
});
$this->newLine();
$this->info("{$mappedCount}개 품목 매핑 완료!");
} else {
$this->newLine();
$this->warn('[DRY-RUN] 실제 매핑은 수행되지 않았습니다.');
$this->line('실제 실행: php artisan items:map-to-processes --clear');
}
return self::SUCCESS;
}
/**
* 품목을 공정에 분류 (A+B+C 전략)
*/
private function classifyItem(Item $item): ?string
{
$code = $item->code ?? '';
$name = $item->name ?? '';
// B. BD 코드 → P-003 절곡 (최우선)
if (str_starts_with($code, 'BD-')) {
return 'P-003';
}
// C. PT 코드 → P-004 재고생산 (코드 기반 우선)
if (str_starts_with($code, 'PT-')) {
return 'P-004';
}
// C. P-004 재고생산 키워드 체크 (가이드레일, 케이스, 연기차단재, L-Bar, 하단마감, 린텔)
foreach ($this->mappingRules['P-004']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return 'P-004';
}
}
// A. 품목명 키워드 기반 분류
// P-002 스크린 먼저 체크 (스크린용, 스크린 키워드)
foreach ($this->mappingRules['P-002']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
// 재고생산 품목 제외
$excluded = false;
foreach ($this->mappingRules['P-002']['name_excludes'] as $exclude) {
if (mb_stripos($name, $exclude) !== false) {
$excluded = true;
break;
}
}
if (! $excluded) {
return 'P-002';
}
}
}
// P-001 슬랫 체크 (철재용, 철재, 슬랫 키워드)
foreach ($this->mappingRules['P-001']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
// 재고생산 품목 제외
$excluded = false;
foreach ($this->mappingRules['P-001']['name_excludes'] as $exclude) {
if (mb_stripos($name, $exclude) !== false) {
$excluded = true;
break;
}
}
if (! $excluded) {
return 'P-001';
}
}
}
// P-003 절곡 키워드 체크 (BD 코드 외에 키워드로도 분류)
foreach ($this->mappingRules['P-003']['name_keywords'] as $keyword) {
if (mb_stripos($name, $keyword) !== false) {
return 'P-003';
}
}
return null;
}
}

View File

@@ -5,6 +5,7 @@
use App\Exceptions\DuplicateCodeException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ApiResponse
@@ -245,6 +246,15 @@ public static function handle(
return self::success($data, $responseTitle, $debug, $statusCode);
} catch (\Throwable $e) {
// 모든 예외를 로깅 (디버깅용)
Log::error('API Exception', [
'message' => $e->getMessage(),
'exception' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'url' => request()->fullUrl(),
'method' => request()->method(),
]);
// ValidationException - 422 Unprocessable Entity
if ($e instanceof \Illuminate\Validation\ValidationException) {
@@ -279,9 +289,12 @@ public static function handle(
);
}
// 일반 예외는 500으로 처리, debug 모드에서만 스택 트레이스 포함
return self::error('서버 에러', 500, [
// 일반 예외는 500으로 처리, debug 모드에서만 상세 정보 포함
$errorMessage = config('app.debug') ? $e->getMessage() : '서버 에러';
return self::error($errorMessage, 500, [
'details' => config('app.debug') ? $e->getTraceAsString() : null,
'exception' => config('app.debug') ? get_class($e) : null,
]);
}
}

View File

@@ -157,4 +157,34 @@ public function registerMaterialInput(Request $request, int $id)
return $this->service->registerMaterialInput($id, $request->input('material_ids', []));
}, __('message.work_order.material_input_registered'));
}
/**
* 공정 단계 진행 현황 조회
*/
public function stepProgress(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getStepProgress($id);
}, __('message.work_order.fetched'));
}
/**
* 공정 단계 완료 토글
*/
public function toggleStepProgress(Request $request, int $id, int $progressId)
{
return ApiResponse::handle(function () use ($id, $progressId) {
return $this->service->toggleStepProgress($id, $progressId);
}, __('message.work_order.updated'));
}
/**
* 자재 투입 이력 조회
*/
public function materialInputHistory(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getMaterialInputHistory($id);
}, __('message.work_order.fetched'));
}
}

View File

@@ -28,6 +28,9 @@ public function rules(): array
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
'receiving_date' => ['nullable', 'date'],
'lot_no' => ['nullable', 'string', 'max:50'],
'inspection_status' => ['nullable', 'string', 'max:10'],
'inspection_date' => ['nullable', 'date'],
'inspection_result' => ['nullable', 'string', 'max:20'],
];
}

View File

@@ -195,6 +195,14 @@ public function issues(): HasMany
return $this->hasMany(WorkOrderIssue::class);
}
/**
* 공정 단계 진행 추적
*/
public function stepProgress(): HasMany
{
return $this->hasMany(WorkOrderStepProgress::class);
}
/**
* 출하 목록
*/

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Items\Item;
use App\Models\Orders\OrderItem;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
@@ -72,6 +73,14 @@ public function item(): BelongsTo
return $this->belongsTo(Item::class);
}
/**
* 원본 수주 품목
*/
public function sourceOrderItem(): BelongsTo
{
return $this->belongsTo(OrderItem::class, 'source_order_item_id');
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Models\Production;
use App\Models\Members\User;
use App\Models\ProcessStep;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 작업지시 공정 단계 진행 추적 모델
*
* 각 작업지시별 공정 단계의 완료 상태를 추적
*/
class WorkOrderStepProgress extends Model
{
use Auditable, BelongsToTenant;
protected $table = 'work_order_step_progress';
protected $fillable = [
'tenant_id',
'work_order_id',
'process_step_id',
'work_order_item_id',
'status',
'completed_at',
'completed_by',
];
protected $casts = [
'completed_at' => 'datetime',
];
// ──────────────────────────────────────────────────────────────
// 상수
// ──────────────────────────────────────────────────────────────
public const STATUS_WAITING = 'waiting';
public const STATUS_IN_PROGRESS = 'in_progress';
public const STATUS_COMPLETED = 'completed';
public const STATUSES = [
self::STATUS_WAITING,
self::STATUS_IN_PROGRESS,
self::STATUS_COMPLETED,
];
// ──────────────────────────────────────────────────────────────
// 관계
// ──────────────────────────────────────────────────────────────
/**
* 작업지시
*/
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
/**
* 공정 단계
*/
public function processStep(): BelongsTo
{
return $this->belongsTo(ProcessStep::class);
}
/**
* 작업지시 품목 (선택적)
*/
public function workOrderItem(): BelongsTo
{
return $this->belongsTo(WorkOrderItem::class);
}
/**
* 완료 처리자
*/
public function completedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'completed_by');
}
// ──────────────────────────────────────────────────────────────
// 스코프
// ──────────────────────────────────────────────────────────────
public function scopeCompleted($query)
{
return $query->where('status', self::STATUS_COMPLETED);
}
public function scopeWaiting($query)
{
return $query->where('status', self::STATUS_WAITING);
}
// ──────────────────────────────────────────────────────────────
// 헬퍼 메서드
// ──────────────────────────────────────────────────────────────
/**
* 완료 처리
*/
public function markCompleted(?int $userId = null): void
{
$this->status = self::STATUS_COMPLETED;
$this->completed_at = now();
$this->completed_by = $userId;
$this->save();
}
/**
* 대기 상태로 되돌리기
*/
public function markWaiting(): void
{
$this->status = self::STATUS_WAITING;
$this->completed_at = null;
$this->completed_by = null;
$this->save();
}
/**
* 완료 여부
*/
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
/**
* 완료 토글
*/
public function toggle(?int $userId = null): void
{
if ($this->isCompleted()) {
$this->markWaiting();
} else {
$this->markCompleted($userId);
}
}
}

View File

@@ -33,6 +33,7 @@ class Receiving extends Model
'receiving_manager',
'status',
'remark',
'options',
'created_by',
'updated_by',
'deleted_by',
@@ -45,8 +46,33 @@ class Receiving extends Model
'order_qty' => 'decimal:2',
'receiving_qty' => 'decimal:2',
'item_id' => 'integer',
'options' => 'array',
];
/**
* JSON 직렬화 시 자동 포함되는 접근자
*/
protected $appends = [
'manufacturer',
'material_no',
'inspection_status',
'inspection_date',
'inspection_result',
];
/**
* Options 키 상수 (확장 필드)
*/
public const OPTION_MANUFACTURER = 'manufacturer'; // 제조사
public const OPTION_MATERIAL_NO = 'material_no'; // 거래처 자재번호
public const OPTION_INSPECTION_STATUS = 'inspection_status'; // 수입검사 (적/부적/-)
public const OPTION_INSPECTION_DATE = 'inspection_date'; // 검사일
public const OPTION_INSPECTION_RESULT = 'inspection_result'; // 검사결과 (합격/불합격)
/**
* 상태 목록
*/
@@ -82,12 +108,72 @@ public function getStatusLabelAttribute(): string
return self::STATUSES[$this->status] ?? $this->status;
}
/**
* Options에서 값 가져오기
*/
public function getOption(string $key, mixed $default = null): mixed
{
return $this->options[$key] ?? $default;
}
/**
* Options에 값 설정
*/
public function setOption(string $key, mixed $value): self
{
$options = $this->options ?? [];
$options[$key] = $value;
$this->options = $options;
return $this;
}
/**
* 제조사 접근자
*/
public function getManufacturerAttribute(): ?string
{
return $this->getOption(self::OPTION_MANUFACTURER);
}
/**
* 거래처 자재번호 접근자
*/
public function getMaterialNoAttribute(): ?string
{
return $this->getOption(self::OPTION_MATERIAL_NO);
}
/**
* 수입검사 상태 접근자
*/
public function getInspectionStatusAttribute(): ?string
{
return $this->getOption(self::OPTION_INSPECTION_STATUS);
}
/**
* 검사일 접근자
*/
public function getInspectionDateAttribute(): ?string
{
return $this->getOption(self::OPTION_INSPECTION_DATE);
}
/**
* 검사결과 접근자
*/
public function getInspectionResultAttribute(): ?string
{
return $this->getOption(self::OPTION_INSPECTION_RESULT);
}
/**
* 수정 가능 여부
*/
public function canEdit(): bool
{
return $this->status !== 'completed';
return true;
}
/**

View File

@@ -489,21 +489,12 @@ public function resolve(array $params): array
];
$categoryName = $categoryMapping[$category] ?? $category;
$template = DocumentTemplate::query()
$baseQuery = DocumentTemplate::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->where(function ($q) use ($category, $categoryName) {
// category 필드가 code 또는 name과 매칭
$q->where('category', $category)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->whereHas('links', function ($q) use ($itemId) {
// 해당 item_id가 연결된 템플릿만
$q->where('source_table', 'items')
->whereHas('linkValues', function ($q2) use ($itemId) {
$q2->where('linkable_id', $itemId);
});
->where(function ($q) use ($itemId) {
$q->whereJsonContains('linked_item_ids', (int) $itemId)
->orWhereJsonContains('linked_item_ids', (string) $itemId);
})
->with([
'approvalLines',
@@ -511,10 +502,22 @@ public function resolve(array $params): array
'sections.items',
'columns',
'sectionFields',
'links.linkValues',
])
]);
// 1차: category 매칭 + item_id
$template = (clone $baseQuery)
->where(function ($q) use ($category, $categoryName) {
$q->where('category', $category)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->first();
// 2차: category 무관, item_id 연결만으로 fallback
if (! $template) {
$template = $baseQuery->first();
}
if (! $template) {
throw new NotFoundHttpException(__('error.document.template_not_found'));
}
@@ -607,6 +610,19 @@ public function upsert(array $data): Document
*/
private function formatTemplateForReact(DocumentTemplate $template): array
{
// common_codes에서 inspection_method 코드 목록 조회 (code → name 매핑)
$tenantId = $this->tenantId();
$methodCodes = DB::table('common_codes')
->where('code_group', 'inspection_method')
->where('is_active', true)
->where(function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId)
->orWhereNull('tenant_id');
})
->orderByRaw('tenant_id IS NULL') // tenant 우선
->pluck('name', 'code')
->toArray();
return [
'id' => $template->id,
'name' => $template->name,
@@ -620,6 +636,8 @@ private function formatTemplateForReact(DocumentTemplate $template): array
'footer_judgement_options' => $template->footer_judgement_options,
'approval_lines' => $template->approvalLines->map(fn ($line) => [
'id' => $line->id,
'name' => $line->name,
'dept' => $line->dept,
'role' => $line->role,
'user_id' => $line->user_id,
'sort_order' => $line->sort_order,
@@ -648,23 +666,29 @@ private function formatTemplateForReact(DocumentTemplate $template): array
'id' => $section->id,
'name' => $section->name,
'sort_order' => $section->sort_order,
'items' => $section->items->map(fn ($item) => [
'id' => $item->id,
'field_values' => $item->field_values ?? [],
// 레거시 필드도 포함 (하위 호환)
'category' => $item->category,
'item' => $item->item,
'standard' => $item->standard,
'standard_criteria' => $item->standard_criteria,
'tolerance' => $item->tolerance,
'method' => $item->method,
'measurement_type' => $item->measurement_type,
'frequency' => $item->frequency,
'frequency_n' => $item->frequency_n,
'frequency_c' => $item->frequency_c,
'regulation' => $item->regulation,
'sort_order' => $item->sort_order,
])->toArray(),
'items' => $section->items->map(function ($item) use ($methodCodes) {
// method 코드를 한글 이름으로 변환
$methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null;
return [
'id' => $item->id,
'field_values' => $item->field_values ?? [],
// 레거시 필드도 포함 (하위 호환)
'category' => $item->category,
'item' => $item->item,
'standard' => $item->standard,
'standard_criteria' => $item->standard_criteria,
'tolerance' => $item->tolerance,
'method' => $item->method,
'method_name' => $methodName, // 검사방식 한글 이름 추가
'measurement_type' => $item->measurement_type,
'frequency' => $item->frequency,
'frequency_n' => $item->frequency_n,
'frequency_c' => $item->frequency_c,
'regulation' => $item->regulation,
'sort_order' => $item->sort_order,
];
})->toArray(),
])->toArray(),
'columns' => $template->columns->map(fn ($col) => [
'id' => $col->id,
@@ -707,9 +731,11 @@ private function formatDocumentForReact(Document $document): array
'description' => $a->description,
'file' => $a->file ? [
'id' => $a->file->id,
'original_name' => $a->file->original_name,
'original_name' => $a->file->original_name ?? $a->file->display_name ?? $a->file->stored_name,
'display_name' => $a->file->display_name,
'file_path' => $a->file->file_path,
'file_size' => $a->file->file_size,
'mime_type' => $a->file->mime_type,
] : null,
])->toArray(),
'approvals' => $document->approvals->map(fn ($ap) => [

View File

@@ -1029,23 +1029,34 @@ private function getItemsWithInspectionTemplate(array $itemIds): array
$tenantId = $this->tenantId();
// document_templates에서 category='incoming_inspection'이고
// linked_item_ids JSON 배열에 품목 ID가 포함된 템플릿 조회
// DocumentService::resolve()와 동일한 category 매칭 조건
$categoryCode = 'incoming_inspection';
$categoryName = '수입검사';
$templates = \DB::table('document_templates')
->where('tenant_id', $tenantId)
->where('category', 'incoming_inspection')
->where('is_active', true)
->whereNotNull('linked_item_ids')
->where(function ($q) use ($categoryCode, $categoryName) {
$q->where('category', $categoryCode)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->get(['linked_item_ids']);
$linkedItemIds = [];
foreach ($templates as $template) {
$ids = json_decode($template->linked_item_ids, true) ?? [];
$linkedItemIds = array_merge($linkedItemIds, $ids);
// int/string 타입 모두 매칭되도록 정수로 통일
foreach ($ids as $id) {
$linkedItemIds[] = (int) $id;
}
}
// 요청된 품목 ID와 연결된 품목 ID의 교집합
return array_values(array_intersect($itemIds, array_unique($linkedItemIds)));
// 요청된 품목 ID도 정수로 통일하여 교집합
$intItemIds = array_map('intval', $itemIds);
return array_values(array_intersect($intItemIds, array_unique($linkedItemIds)));
}
/**

View File

@@ -199,6 +199,7 @@ public static function getUserInfoForLogin(int $userId): array
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'department' => null,
];
// 2. 활성 테넌트 조회 (1순위: is_default=1, 2순위: is_active=1 첫 번째)
@@ -221,6 +222,18 @@ public static function getUserInfoForLogin(int $userId): array
$defaultUserTenant = $userTenants->first();
$tenant = $defaultUserTenant->tenant;
// 2-1. 소속 부서 조회 (tenant_user_profiles → departments)
$profile = DB::table('tenant_user_profiles')
->where('user_id', $userId)
->where('tenant_id', $tenant->id)
->first();
if ($profile && $profile->department_id) {
$dept = DB::table('departments')->where('id', $profile->department_id)->first();
if ($dept) {
$userInfo['department'] = $dept->name;
}
}
// 3. 테넌트 정보 구성
$tenantInfo = [
'id' => $tenant->id,

View File

@@ -753,9 +753,9 @@ public function createProductionOrder(int $orderId, array $data)
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 수주 조회
// 수주 + 노드 조회
$order = Order::where('tenant_id', $tenantId)
->with('items')
->with(['items', 'rootNodes'])
->find($orderId);
if (! $order) {
@@ -776,16 +776,31 @@ public function createProductionOrder(int $orderId, array $data)
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
}
// order_items의 item_id를 기반으로 공정별 자동 분류
$itemIds = $order->items->pluck('item_id')->filter()->unique()->values()->toArray();
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
$bomItemIds = [];
$nodesBomMap = []; // node_id => [item_name => bom_item]
foreach ($order->rootNodes as $node) {
$bomResult = $node->options['bom_result'] ?? [];
$bomItems = $bomResult['items'] ?? [];
foreach ($bomItems as $bomItem) {
if (! empty($bomItem['item_id'])) {
$bomItemIds[] = $bomItem['item_id'];
$nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem;
}
}
}
$bomItemIds = array_unique($bomItemIds);
// process_items 테이블에서 item_id → process_id 매핑 조회
$itemProcessMap = [];
if (! empty($itemIds)) {
if (! empty($bomItemIds)) {
$processItems = DB::table('process_items as pi')
->join('processes as p', 'pi.process_id', '=', 'p.id')
->where('p.tenant_id', $tenantId)
->whereIn('pi.item_id', $itemIds)
->whereIn('pi.item_id', $bomItemIds)
->where('pi.is_active', true)
->select('pi.item_id', 'pi.process_id')
->get();
@@ -795,11 +810,25 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// order_items를 공정별로 그룹화
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
$processId = $itemProcessMap[$orderItem->item_id] ?? null;
$key = $processId ?? 'none'; // null은 'none' 키로 그룹화
$processId = null;
// 1. order_item의 item_id가 있으면 직접 매핑
if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) {
$processId = $itemProcessMap[$orderItem->item_id];
}
// 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기
elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$nodeBom = $nodesBomMap[$orderItem->order_node_id];
$bomItem = $nodeBom[$orderItem->item_name] ?? null;
if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) {
$processId = $itemProcessMap[$bomItem['item_id']];
}
}
$key = $processId ?? 'none';
if (! isset($itemsByProcess[$key])) {
$itemsByProcess[$key] = [
@@ -810,7 +839,7 @@ public function createProductionOrder(int $orderId, array $data)
$itemsByProcess[$key]['items'][] = $orderItem;
}
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess) {
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) {
$workOrders = [];
foreach ($itemsByProcess as $key => $group) {
@@ -840,11 +869,18 @@ public function createProductionOrder(int $orderId, array $data)
// work_order_items에 아이템 추가
$sortOrder = 1;
foreach ($items as $orderItem) {
// item_id 결정: order_item에 있으면 사용, 없으면 BOM에서 가져오기
$itemId = $orderItem->item_id;
if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) {
$bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null;
$itemId = $bomItem['item_id'] ?? null;
}
DB::table('work_order_items')->insert([
'tenant_id' => $tenantId,
'work_order_id' => $workOrder->id,
'source_order_item_id' => $orderItem->id,
'item_id' => $orderItem->item_id,
'item_id' => $itemId,
'item_name' => $orderItem->item_name,
'specification' => $orderItem->specification,
'quantity' => $orderItem->quantity,

View File

@@ -526,6 +526,26 @@ public function finalize(int $id): Quote
throw new BadRequestHttpException(__('error.quote_not_finalizable'));
}
// 확정 시 필수 필드 검증 (업체명, 현장명, 담당자, 연락처)
$missing = [];
if (empty($quote->client_name)) {
$missing[] = '업체명';
}
if (empty($quote->site_name)) {
$missing[] = '현장명';
}
if (empty($quote->manager)) {
$missing[] = '담당자';
}
if (empty($quote->contact)) {
$missing[] = '연락처';
}
if (! empty($missing)) {
throw new BadRequestHttpException(
__('error.quote_finalize_missing_fields', ['fields' => implode(', ', $missing)])
);
}
$quote->update([
'status' => Quote::STATUS_FINALIZED,
'is_final' => true,

View File

@@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator
$tenantId = $this->tenantId();
$query = Receiving::query()
->with('creator:id,name')
->with(['creator:id,name', 'item:id,item_type,code,name'])
->where('tenant_id', $tenantId);
// 검색어 필터
@@ -57,8 +57,67 @@ public function index(array $params): LengthAwarePaginator
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
$paginator = $query->paginate($perPage);
return $query->paginate($perPage);
// 수입검사 템플릿 연결 여부 계산
$itemIds = $paginator->pluck('item_id')->filter()->unique()->values()->toArray();
$itemsWithInspection = $this->getItemsWithInspectionTemplate($itemIds);
// has_inspection_template 필드 추가
$paginator->getCollection()->transform(function ($receiving) use ($itemsWithInspection) {
$receiving->has_inspection_template = $receiving->item_id
? in_array($receiving->item_id, $itemsWithInspection)
: false;
return $receiving;
});
return $paginator;
}
/**
* 수입검사 템플릿에 연결된 품목 ID 조회
*
* DocumentService::resolve()와 동일한 조건 사용:
* - category: 영문 코드('incoming_inspection'), 한글('수입검사'), 부분 매칭 모두 지원
* - linked_item_ids: int/string 타입 모두 매칭
*/
private function getItemsWithInspectionTemplate(array $itemIds): array
{
if (empty($itemIds)) {
return [];
}
$tenantId = $this->tenantId();
// DocumentService::resolve()와 동일한 category 매칭 조건
$categoryCode = 'incoming_inspection';
$categoryName = '수입검사';
$templates = DB::table('document_templates')
->where('tenant_id', $tenantId)
->where('is_active', true)
->whereNotNull('linked_item_ids')
->where(function ($q) use ($categoryCode, $categoryName) {
$q->where('category', $categoryCode)
->orWhere('category', $categoryName)
->orWhere('category', 'LIKE', "%{$categoryName}%");
})
->get(['linked_item_ids']);
$linkedItemIds = [];
foreach ($templates as $template) {
$ids = json_decode($template->linked_item_ids, true) ?? [];
// int/string 타입 모두 매칭되도록 정수로 통일
foreach ($ids as $id) {
$linkedItemIds[] = (int) $id;
}
}
// 요청된 품목 ID도 정수로 통일하여 교집합
$intItemIds = array_map('intval', $itemIds);
return array_values(array_intersect($intItemIds, array_unique($linkedItemIds)));
}
/**
@@ -103,7 +162,7 @@ public function show(int $id): Receiving
return Receiving::query()
->where('tenant_id', $tenantId)
->with(['creator:id,name'])
->with(['creator:id,name', 'item:id,item_type,code,name'])
->findOrFail($id);
}
@@ -119,12 +178,18 @@ public function store(array $data): Receiving
// 입고번호 자동 생성
$receivingNumber = $this->generateReceivingNumber($tenantId);
// item_id 조회 (전달되지 않은 경우 item_code로 조회)
$itemId = $data['item_id'] ?? null;
if (! $itemId && ! empty($data['item_code'])) {
$itemId = $this->findItemIdByCode($tenantId, $data['item_code']);
}
$receiving = new Receiving;
$receiving->tenant_id = $tenantId;
$receiving->receiving_number = $receivingNumber;
$receiving->order_no = $data['order_no'] ?? null;
$receiving->order_date = $data['order_date'] ?? null;
$receiving->item_id = $data['item_id'] ?? null;
$receiving->item_id = $itemId;
$receiving->item_code = $data['item_code'];
$receiving->item_name = $data['item_name'];
$receiving->specification = $data['specification'] ?? null;
@@ -134,6 +199,10 @@ public function store(array $data): Receiving
$receiving->due_date = $data['due_date'] ?? null;
$receiving->status = $data['status'] ?? 'order_completed';
$receiving->remark = $data['remark'] ?? null;
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
$receiving->options = $this->buildOptions($data);
$receiving->created_by = $userId;
$receiving->updated_by = $userId;
$receiving->save();
@@ -167,6 +236,13 @@ public function update(int $id, array $data): Receiving
}
if (isset($data['item_code'])) {
$receiving->item_code = $data['item_code'];
// item_code 변경 시 item_id도 업데이트
if (! isset($data['item_id'])) {
$receiving->item_id = $this->findItemIdByCode($tenantId, $data['item_code']);
}
}
if (isset($data['item_id'])) {
$receiving->item_id = $data['item_id'];
}
if (isset($data['item_name'])) {
$receiving->item_name = $data['item_name'];
@@ -190,10 +266,13 @@ public function update(int $id, array $data): Receiving
$receiving->remark = $data['remark'];
}
// 입고완료(completed) 상태 변경 시 입고처리 로직 실행
$isCompletingReceiving = isset($data['status'])
&& $data['status'] === 'completed'
&& $receiving->status !== 'completed';
// 상태 변경 감지
$oldStatus = $receiving->status;
$newStatus = $data['status'] ?? $oldStatus;
$wasCompleted = $oldStatus === 'completed';
// 입고완료(completed) 상태로 신규 전환
$isCompletingReceiving = $newStatus === 'completed' && ! $wasCompleted;
if ($isCompletingReceiving) {
// 입고수량 설정 (없으면 발주수량 사용)
@@ -201,16 +280,44 @@ public function update(int $id, array $data): Receiving
$receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString();
$receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo();
$receiving->status = 'completed';
} elseif (isset($data['status'])) {
$receiving->status = $data['status'];
} else {
// 일반 필드 업데이트
if (isset($data['receiving_qty'])) {
$receiving->receiving_qty = $data['receiving_qty'];
}
if (isset($data['receiving_date'])) {
$receiving->receiving_date = $data['receiving_date'];
}
if (isset($data['lot_no'])) {
$receiving->lot_no = $data['lot_no'];
}
if (isset($data['status'])) {
$receiving->status = $data['status'];
}
}
// options 필드 업데이트 (제조사, 수입검사 등 확장 필드)
$receiving->options = $this->mergeOptions($receiving->options, $data);
$receiving->updated_by = $userId;
$receiving->save();
// 입고완료 시 재고 연동
if ($isCompletingReceiving && $receiving->item_id) {
app(StockService::class)->increaseFromReceiving($receiving);
// 재고 연동
if ($receiving->item_id) {
$stockService = app(StockService::class);
if ($isCompletingReceiving) {
// 대기 → 완료: 전량 재고 증가
$stockService->increaseFromReceiving($receiving);
} elseif ($wasCompleted) {
// 기존 완료 상태에서 수정: 차이만큼 조정
// 완료→완료(수량변경): newQty = 변경된 수량
// 완료→대기: newQty = 0 (전량 차감)
$newQty = $newStatus === 'completed'
? (float) $receiving->receiving_qty
: 0;
$stockService->adjustFromReceiving($receiving, $newQty);
}
}
return $receiving->fresh();
@@ -318,4 +425,110 @@ private function generateLotNo(): string
return "{$year}{$month}{$day}-{$seq}";
}
/**
* 품목코드로 품목 ID 조회
*/
private function findItemIdByCode(int $tenantId, string $itemCode): ?int
{
$item = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first(['id']);
return $item?->id;
}
/**
* options 필드 빌드 (등록 시)
*/
private function buildOptions(array $data): ?array
{
$options = [];
// 제조사
if (isset($data['manufacturer'])) {
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
}
// 거래처 자재번호
if (isset($data['material_no'])) {
$options[Receiving::OPTION_MATERIAL_NO] = $data['material_no'];
}
// 수입검사 상태 (적/부적/-)
if (isset($data['inspection_status'])) {
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
}
// 검사일
if (isset($data['inspection_date'])) {
$options[Receiving::OPTION_INSPECTION_DATE] = $data['inspection_date'];
}
// 검사결과 (합격/불합격)
if (isset($data['inspection_result'])) {
$options[Receiving::OPTION_INSPECTION_RESULT] = $data['inspection_result'];
}
// 추가 확장 필드가 있으면 여기에 계속 추가 가능
return ! empty($options) ? $options : null;
}
/**
* options 필드 병합 (수정 시)
*/
private function mergeOptions(?array $existing, array $data): ?array
{
$options = $existing ?? [];
// 제조사
if (array_key_exists('manufacturer', $data)) {
if ($data['manufacturer'] === null || $data['manufacturer'] === '') {
unset($options[Receiving::OPTION_MANUFACTURER]);
} else {
$options[Receiving::OPTION_MANUFACTURER] = $data['manufacturer'];
}
}
// 거래처 자재번호
if (array_key_exists('material_no', $data)) {
if ($data['material_no'] === null || $data['material_no'] === '') {
unset($options[Receiving::OPTION_MATERIAL_NO]);
} else {
$options[Receiving::OPTION_MATERIAL_NO] = $data['material_no'];
}
}
// 수입검사 상태
if (array_key_exists('inspection_status', $data)) {
if ($data['inspection_status'] === null || $data['inspection_status'] === '') {
unset($options[Receiving::OPTION_INSPECTION_STATUS]);
} else {
$options[Receiving::OPTION_INSPECTION_STATUS] = $data['inspection_status'];
}
}
// 검사일
if (array_key_exists('inspection_date', $data)) {
if ($data['inspection_date'] === null || $data['inspection_date'] === '') {
unset($options[Receiving::OPTION_INSPECTION_DATE]);
} else {
$options[Receiving::OPTION_INSPECTION_DATE] = $data['inspection_date'];
}
}
// 검사결과
if (array_key_exists('inspection_result', $data)) {
if ($data['inspection_result'] === null || $data['inspection_result'] === '') {
unset($options[Receiving::OPTION_INSPECTION_RESULT]);
} else {
$options[Receiving::OPTION_INSPECTION_RESULT] = $data['inspection_result'];
}
}
return ! empty($options) ? $options : null;
}
}

View File

@@ -38,4 +38,16 @@ protected function apiUserId(): int
return (int) $uid;
}
/**
* 서비스 컨텍스트 설정 (다른 서비스에서 호출 시 사용)
* tenant_id, api_user를 명시적으로 설정
*/
public function setContext(int $tenantId, int $userId): self
{
app()->instance('tenant_id', $tenantId);
app()->instance('api_user', $userId);
return $this;
}
}

View File

@@ -313,6 +313,107 @@ public function increaseFromReceiving(Receiving $receiving): StockLot
});
}
/**
* 입고 수정 시 재고 조정 (차이만큼 증감)
*
* - completed→completed 수량변경: 차이만큼 조정 (50→60 = +10)
* - completed→대기: 전량 차감 (newQty = 0)
*
* @param Receiving $receiving 입고 레코드
* @param float $newQty 새 수량 (상태가 completed가 아니면 0)
*/
public function adjustFromReceiving(Receiving $receiving, float $newQty): void
{
if (! $receiving->item_id) {
return;
}
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
DB::transaction(function () use ($receiving, $newQty, $tenantId, $userId) {
// 1. 해당 입고로 생성된 StockLot 조회
$stockLot = StockLot::where('tenant_id', $tenantId)
->where('receiving_id', $receiving->id)
->first();
if (! $stockLot) {
Log::warning('StockLot not found for receiving adjustment', [
'receiving_id' => $receiving->id,
]);
return;
}
$stock = Stock::where('id', $stockLot->stock_id)
->lockForUpdate()
->first();
if (! $stock) {
return;
}
$oldQty = (float) $stockLot->qty;
$diff = $newQty - $oldQty;
// 차이가 없으면 스킵
if (abs($diff) < 0.001) {
return;
}
// 2. StockLot 수량 조정
$stockLot->qty = $newQty;
$stockLot->available_qty = max(0, $newQty - $stockLot->reserved_qty);
$stockLot->updated_by = $userId;
if ($newQty <= 0) {
$stockLot->qty = 0;
$stockLot->available_qty = 0;
$stockLot->reserved_qty = 0;
$stockLot->status = 'used';
} else {
$stockLot->status = 'available';
}
$stockLot->save();
// 3. Stock 정보 갱신
$stock->refreshFromLots();
// 4. 거래 이력 기록
$this->recordTransaction(
stock: $stock,
type: $diff > 0 ? StockTransaction::TYPE_IN : StockTransaction::TYPE_OUT,
qty: $diff,
reason: StockTransaction::REASON_RECEIVING,
referenceType: 'receiving',
referenceId: $receiving->id,
lotNo: $receiving->lot_no,
stockLotId: $stockLot->id
);
// 5. 감사 로그 기록
$this->logStockChange(
stock: $stock,
action: $diff > 0 ? 'stock_increase' : 'stock_decrease',
reason: 'receiving_adjustment',
referenceType: 'receiving',
referenceId: $receiving->id,
qtyChange: $diff,
lotNo: $receiving->lot_no
);
Log::info('Stock adjusted from receiving modification', [
'receiving_id' => $receiving->id,
'item_id' => $receiving->item_id,
'stock_id' => $stock->id,
'old_qty' => $oldQty,
'new_qty' => $newQty,
'diff' => $diff,
]);
});
}
/**
* Stock 조회 또는 생성
*

View File

@@ -7,6 +7,7 @@
use App\Models\Production\WorkOrderAssignee;
use App\Models\Production\WorkOrderBendingDetail;
use App\Models\Production\WorkOrderItem;
use App\Models\Production\WorkOrderStepProgress;
use App\Models\Tenants\Shipment;
use App\Models\Tenants\ShipmentItem;
use App\Services\Audit\AuditLogger;
@@ -37,6 +38,7 @@ public function index(array $params)
$processCode = $params['process_code'] ?? null;
$assigneeId = $params['assignee_id'] ?? null;
$assignedToMe = isset($params['assigned_to_me']) && $params['assigned_to_me'];
$workerScreen = isset($params['worker_screen']) && $params['worker_screen'];
$teamId = $params['team_id'] ?? null;
$scheduledFrom = $params['scheduled_from'] ?? null;
$scheduledTo = $params['scheduled_to'] ?? null;
@@ -47,10 +49,12 @@ public function index(array $params)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,client_id,client_name',
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'client_id', 'client_name', 'site_name', 'quantity', 'received_at', 'delivery_date')->withCount('rootNodes'),
'salesOrder.client:id,name',
'process:id,process_name,process_code,department',
'items:id,work_order_id,item_name,quantity',
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem.node:id,name,code',
]);
// 검색어
@@ -96,6 +100,35 @@ public function index(array $params)
});
}
// 작업자 화면용 캐스케이드 필터: 개인 배정 → 부서 → 전체
if ($workerScreen) {
$userId = $this->apiUserId();
// 1차: 개인 배정된 작업이 있는지 확인
$hasPersonal = (clone $query)->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
})->exists();
if ($hasPersonal) {
$query->where(function ($q) use ($userId) {
$q->where('assignee_id', $userId)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
});
} else {
// 2차: 사용자 소속 부서의 작업지시 필터
$departmentIds = DB::table('department_user')
->where('user_id', $userId)
->where('tenant_id', $tenantId)
->pluck('department_id');
if ($departmentIds->isNotEmpty()) {
$query->whereIn('team_id', $departmentIds);
}
// 3차: 부서도 없으면 필터 없이 전체 노출
}
}
// 팀 필터
if ($teamId !== null) {
$query->where('team_id', $teamId);
@@ -127,14 +160,34 @@ public function stats(): array
->pluck('count', 'status')
->toArray();
// 공정별 카운트 (탭 숫자 표시용)
$byProcess = WorkOrder::where('tenant_id', $tenantId)
->select('process_id', DB::raw('count(*) as count'))
->groupBy('process_id')
->pluck('count', 'process_id')
->toArray();
$total = array_sum($counts);
$noneCount = $byProcess[''] ?? $byProcess[0] ?? 0;
// null 키는 빈 문자열로 변환되므로 별도 처리
$processedByProcess = [];
foreach ($byProcess as $key => $count) {
if ($key === '' || $key === 0 || $key === null) {
$processedByProcess['none'] = $count;
} else {
$processedByProcess[(string) $key] = $count;
}
}
return [
'total' => array_sum($counts),
'total' => $total,
'unassigned' => $counts[WorkOrder::STATUS_UNASSIGNED] ?? 0,
'pending' => $counts[WorkOrder::STATUS_PENDING] ?? 0,
'waiting' => $counts[WorkOrder::STATUS_WAITING] ?? 0,
'in_progress' => $counts[WorkOrder::STATUS_IN_PROGRESS] ?? 0,
'completed' => $counts[WorkOrder::STATUS_COMPLETED] ?? 0,
'shipped' => $counts[WorkOrder::STATUS_SHIPPED] ?? 0,
'by_process' => $processedByProcess,
];
}
@@ -150,13 +203,16 @@ public function show(int $id)
'assignee:id,name',
'assignees.user:id,name',
'team:id,name',
'salesOrder:id,order_no,site_name,client_id,client_contact,received_at,writer_id,created_at,quantity',
'salesOrder' => fn ($q) => $q->select('id', 'order_no', 'site_name', 'client_id', 'client_contact', 'received_at', 'writer_id', 'created_at', 'quantity')->withCount('rootNodes'),
'salesOrder.client:id,name',
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department',
'items',
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items.sourceOrderItem:id,order_node_id',
'items.sourceOrderItem.node:id,name,code',
'bendingDetail',
'issues' => fn ($q) => $q->orderByDesc('created_at'),
'stepProgress.processStep:id,process_id,step_code,step_name,sort_order,needs_inspection,connection_type,completion_type',
])
->find($id);
@@ -1291,4 +1347,146 @@ private function syncWorkOrderStatusFromItems(WorkOrder $workOrder): bool
return false;
}
// ──────────────────────────────────────────────────────────────
// 공정 단계 진행 관리
// ──────────────────────────────────────────────────────────────
/**
* 작업지시의 공정 단계 진행 현황 조회
*
* process_steps 마스터 기준으로 진행 레코드를 자동 생성(없으면)하고 반환
*/
public function getStepProgress(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)
->with(['process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order')])
->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$processSteps = $workOrder->process?->steps ?? collect();
if ($processSteps->isEmpty()) {
return [];
}
// 기존 진행 레코드 조회
$existingProgress = WorkOrderStepProgress::where('work_order_id', $workOrderId)
->whereNull('work_order_item_id')
->get()
->keyBy('process_step_id');
// 없는 단계는 자동 생성
$result = [];
foreach ($processSteps as $step) {
if ($existingProgress->has($step->id)) {
$progress = $existingProgress->get($step->id);
} else {
$progress = WorkOrderStepProgress::create([
'tenant_id' => $tenantId,
'work_order_id' => $workOrderId,
'process_step_id' => $step->id,
'work_order_item_id' => null,
'status' => WorkOrderStepProgress::STATUS_WAITING,
]);
}
$result[] = [
'id' => $progress->id,
'process_step_id' => $step->id,
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'sort_order' => $step->sort_order,
'needs_inspection' => $step->needs_inspection,
'connection_type' => $step->connection_type,
'completion_type' => $step->completion_type,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
return $result;
}
/**
* 공정 단계 완료 토글
*/
public function toggleStepProgress(int $workOrderId, int $progressId): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
$progress = WorkOrderStepProgress::where('id', $progressId)
->where('work_order_id', $workOrderId)
->first();
if (! $progress) {
throw new NotFoundHttpException(__('error.not_found'));
}
$before = ['status' => $progress->status];
$progress->toggle($userId);
$after = ['status' => $progress->status];
$this->auditLogger->log(
$tenantId,
self::AUDIT_TARGET,
$workOrderId,
'step_progress_toggled',
$before,
$after
);
return [
'id' => $progress->id,
'status' => $progress->status,
'is_completed' => $progress->isCompleted(),
'completed_at' => $progress->completed_at?->toDateTimeString(),
'completed_by' => $progress->completed_by,
];
}
/**
* 자재 투입 이력 조회
*/
public function getMaterialInputHistory(int $workOrderId): array
{
$tenantId = $this->tenantId();
$workOrder = WorkOrder::where('tenant_id', $tenantId)->find($workOrderId);
if (! $workOrder) {
throw new NotFoundHttpException(__('error.not_found'));
}
// audit_logs에서 material_input 액션 이력 조회
$logs = DB::table('audit_logs')
->where('tenant_id', $tenantId)
->where('target_type', self::AUDIT_TARGET)
->where('target_id', $workOrderId)
->where('action', 'material_input')
->orderByDesc('created_at')
->get();
return $logs->map(function ($log) {
$after = json_decode($log->after_data ?? '{}', true);
return [
'id' => $log->id,
'materials' => $after['materials'] ?? [],
'created_at' => $log->created_at,
'actor_id' => $log->actor_id,
];
})->toArray();
}
}

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
{
/**
* Run the migrations.
*
* options JSON 구조 예시:
* {
* "manufacturer": "포스코", // 제조사
* "inspection_status": "적", // 수입검사 (적/부적/-)
* "inspection_date": "2026-02-05", // 검사일
* "inspection_result": "합격", // 검사결과
* ... 추가 확장 필드
* }
*/
public function up(): void
{
Schema::table('receivings', function (Blueprint $table) {
$table->json('options')->nullable()->after('remark')->comment('확장 필드 (제조사, 수입검사 등)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('receivings', function (Blueprint $table) {
$table->dropColumn('options');
});
}
};

View File

@@ -0,0 +1,33 @@
<?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('work_order_step_progress', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->foreignId('work_order_id')->constrained('work_orders')->cascadeOnDelete()->comment('작업지시 ID');
$table->foreignId('process_step_id')->constrained('process_steps')->cascadeOnDelete()->comment('공정 단계 ID');
$table->unsignedBigInteger('work_order_item_id')->nullable()->comment('작업지시 품목 ID (특정 품목 연결 시)');
$table->string('status', 20)->default('waiting')->comment('상태: waiting/in_progress/completed');
$table->timestamp('completed_at')->nullable()->comment('완료 일시');
$table->unsignedBigInteger('completed_by')->nullable()->comment('완료 처리자 ID');
$table->timestamps();
// 인덱스
$table->unique(['work_order_id', 'process_step_id', 'work_order_item_id'], 'uq_wo_step_progress');
$table->index(['tenant_id', 'work_order_id'], 'idx_wo_step_tenant');
$table->index(['work_order_id', 'status'], 'idx_wo_step_status');
});
}
public function down(): void
{
Schema::dropIfExists('work_order_step_progress');
}
};

View File

@@ -177,6 +177,7 @@
'quote_not_editable' => '현재 상태에서는 견적을 수정할 수 없습니다.',
'quote_not_deletable' => '현재 상태에서는 견적을 삭제할 수 없습니다.',
'quote_not_finalizable' => '현재 상태에서는 견적을 확정할 수 없습니다.',
'quote_finalize_missing_fields' => '견적확정을 위해 다음 항목을 입력해주세요: :fields',
'quote_not_finalized' => '확정되지 않은 견적입니다.',
'quote_already_converted' => '이미 수주 전환된 견적입니다.',
'quote_not_convertible' => '현재 상태에서는 수주 전환할 수 없습니다.',

View File

@@ -66,6 +66,11 @@
// 자재 관리
Route::get('/{id}/materials', [WorkOrderController::class, 'materials'])->whereNumber('id')->name('v1.work-orders.materials'); // 자재 목록 조회
Route::post('/{id}/material-inputs', [WorkOrderController::class, 'registerMaterialInput'])->whereNumber('id')->name('v1.work-orders.material-inputs'); // 자재 투입 등록
Route::get('/{id}/material-input-history', [WorkOrderController::class, 'materialInputHistory'])->whereNumber('id')->name('v1.work-orders.material-input-history'); // 자재 투입 이력
// 공정 단계 진행 관리
Route::get('/{id}/step-progress', [WorkOrderController::class, 'stepProgress'])->whereNumber('id')->name('v1.work-orders.step-progress'); // 단계 진행 조회
Route::patch('/{id}/step-progress/{progressId}/toggle', [WorkOrderController::class, 'toggleStepProgress'])->whereNumber('id')->whereNumber('progressId')->name('v1.work-orders.step-progress.toggle'); // 단계 토글
});
// Work Result API (작업실적 관리)