merge: develop + kkk 브랜치 병합 (code/lot_no 분리 + origin 신규 기능 통합)

This commit is contained in:
2026-03-21 17:35:26 +09:00
30 changed files with 911 additions and 80 deletions

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-19 16:09:46
> **자동 생성**: 2026-03-20 16:30:28
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -88,6 +88,16 @@ ### hometax_invoice_journals
- **tenant()**: belongsTo → `tenants`
- **invoice()**: belongsTo → `hometax_invoices`
### bending_items
**모델**: `App\Models\BendingItem`
- **files()**: hasMany → `files`
### bending_models
**모델**: `App\Models\BendingModel`
- **files()**: hasMany → `files`
### biddings
**모델**: `App\Models\Bidding\Bidding`
@@ -563,6 +573,25 @@ ### material_receipts
- **material()**: belongsTo → `materials`
- **inspections()**: hasMany → `material_inspections`
### nonconforming_reports
**모델**: `App\Models\Materials\NonconformingReport`
- **approval()**: belongsTo → `approvals`
- **order()**: belongsTo → `orders`
- **item()**: belongsTo → `items`
- **department()**: belongsTo → `departments`
- **creator()**: belongsTo → `users`
- **actionManager()**: belongsTo → `users`
- **relatedEmployee()**: belongsTo → `users`
- **items()**: hasMany → `nonconforming_report_items`
- **files()**: morphMany → `files`
### nonconforming_report_items
**모델**: `App\Models\Materials\NonconformingReportItem`
- **report()**: belongsTo → `nonconforming_reports`
- **item()**: belongsTo → `items`
### users
**모델**: `App\Models\Members\User`
@@ -723,11 +752,6 @@ ### process_steps
- **process()**: belongsTo → `processes`
### bending_item_mappings
**모델**: `App\Models\Production\BendingItemMapping`
- **item()**: belongsTo → `items`
### work_orders
**모델**: `App\Models\Production\WorkOrder`
@@ -815,6 +839,7 @@ ### parts
### prices
**모델**: `App\Models\Products\Price`
- **item()**: belongsTo → `items`
- **clientGroup()**: belongsTo → `client_groups`
- **revisions()**: hasMany → `price_revisions`
@@ -1087,6 +1112,10 @@ ### cards
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### condolence_expenses
**모델**: `App\Models\Tenants\CondolenceExpense`
### data_exports
**모델**: `App\Models\Tenants\DataExport`

View File

@@ -53,6 +53,19 @@ public function filters(Request $request): JsonResponse
);
}
public function prefixes(Request $request): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => [
'prefixes' => $this->service->prefixes(),
'labels' => BendingItemService::PREFIX_LABELS,
],
__('message.fetched')
);
}
public function show(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
@@ -83,6 +96,16 @@ public function update(BendingItemUpdateRequest $request, int $id): JsonResponse
);
}
public function duplicate(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);
return ApiResponse::handle(
fn () => new BendingItemResource($this->service->duplicate($id)),
__('message.created')
);
}
public function destroy(Request $request, int $id): JsonResponse
{
$this->ensureContext($request);

View File

@@ -73,6 +73,32 @@ public function statsByItemType(): JsonResponse
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 재고 수정 (안전재고, 최대재고, 사용상태)
*/
public function update(int $id, Request $request): JsonResponse
{
try {
$data = $request->validate([
'safety_stock' => 'nullable|numeric|min:0',
'max_stock' => 'nullable|numeric|min:0',
'is_active' => 'nullable|boolean',
]);
// 최대재고가 설정된 경우 안전재고 이상이어야 함
if (isset($data['max_stock']) && $data['max_stock'] > 0
&& isset($data['safety_stock']) && $data['safety_stock'] > $data['max_stock']) {
return ApiResponse::error('최대재고는 안전재고 이상이어야 합니다.', 422);
}
$stock = $this->service->updateStock($id, $data);
return ApiResponse::success($stock, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.stock.not_found'), 404);
}
}
/**
* 재고 조정 이력 조회
*/

View File

@@ -95,6 +95,17 @@ public function toggleActive(int $id): JsonResponse
);
}
/**
* 공정 복제
*/
public function duplicate(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->processService->duplicate($id),
'message.created'
);
}
/**
* 공정 옵션 목록 (드롭다운용)
*/

View File

@@ -143,6 +143,13 @@ public function handle(Request $request, Closure $next)
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
'api/v1/bending-items', // 절곡 기초관리 (MNG에서 API Key + X-TENANT-ID로 접근)
'api/v1/bending-items/*', // 절곡 기초관리 상세/수정/삭제
'api/v1/guiderail-models', // 절곡품 가이드레일 (MNG에서 API Key + X-TENANT-ID로 접근)
'api/v1/guiderail-models/*', // 절곡품 가이드레일 상세
'api/v1/items/*/files', // 품목 파일 (절곡품 이미지 업로드/조회)
'api/v1/files/*/presigned-url', // 파일 presigned URL (이미지 표시)
'api/v1/files/presigned-url-by-path', // 파일 경로 기반 presigned URL (문서양식 섹션 이미지)
];
// 현재 라우트 확인 (경로 또는 이름)

View File

@@ -57,6 +57,7 @@ public function rules(): array
'options.manager_name' => 'nullable|string|max:100',
'options.production_reason' => 'nullable|string|max:500',
'options.target_stock_qty' => 'nullable|numeric|min:0',
'options.reg_date' => 'nullable|date',
// 절곡품 LOT 정보 (STOCK 전용)
'options.bending_lot' => 'nullable|array',
@@ -70,7 +71,7 @@ public function rules(): array
// 품목 배열
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer|exists:items,id',
'items.*.item_id' => 'nullable|integer',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'required|string|max:200',
'items.*.specification' => 'nullable|string|max:500',

View File

@@ -51,6 +51,7 @@ public function rules(): array
'options.manager_name' => 'nullable|string|max:100',
'options.production_reason' => 'nullable|string|max:500',
'options.target_stock_qty' => 'nullable|numeric|min:0',
'options.reg_date' => 'nullable|date',
// 절곡품 LOT 정보 (STOCK 전용)
'options.bending_lot' => 'nullable|array',
@@ -64,7 +65,7 @@ public function rules(): array
// 품목 배열 (전체 교체)
'items' => 'nullable|array',
'items.*.item_id' => 'nullable|integer|exists:items,id',
'items.*.item_id' => 'nullable|integer',
'items.*.item_code' => 'nullable|string|max:50',
'items.*.item_name' => 'sometimes|required|string|max:200',
'items.*.specification' => 'nullable|string|max:500',

View File

@@ -14,6 +14,7 @@ public function authorize(): bool
public function rules(): array
{
return [
'parent_id' => ['nullable', 'integer', 'exists:processes,id'],
'process_name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string'],
'process_type' => ['required', 'string', 'in:생산,검사,포장,조립'],

View File

@@ -13,7 +13,10 @@ public function authorize(): bool
public function rules(): array
{
$processId = $this->route('id');
return [
'parent_id' => ['nullable', 'integer', 'exists:processes,id', "not_in:{$processId}"],
'process_name' => ['sometimes', 'required', 'string', 'max:100'],
'description' => ['nullable', 'string'],
'process_type' => ['sometimes', 'required', 'string', 'in:생산,검사,포장,조립'],

View File

@@ -21,13 +21,13 @@ public function rules(): array
'item_name' => ['required', 'string', 'max:200'],
'specification' => ['nullable', 'string', 'max:200'],
'supplier' => ['required', 'string', 'max:100'],
'order_qty' => ['nullable', 'numeric', 'min:0'],
'order_qty' => ['required', 'numeric', 'min:0.01'],
'order_unit' => ['nullable', 'string', 'max:20'],
'due_date' => ['nullable', 'date'],
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
'receiving_date' => ['nullable', 'date'],
'lot_no' => ['nullable', 'string', 'max:50'],
'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],
'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,inspection_completed,completed'],
'remark' => ['nullable', 'string', 'max:1000'],
'manufacturer' => ['nullable', 'string', 'max:100'],
'material_no' => ['nullable', 'string', 'max:50'],

View File

@@ -77,6 +77,10 @@ private function getImageFileId(): ?int
private function getImageUrl(): ?string
{
return $this->getImageFile()?->presignedUrl();
try {
return $this->getImageFile()?->presignedUrl();
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -50,7 +50,7 @@ public function toArray(Request $request): array
'image_file_id' => $this->getImageFileId(),
'image_url' => $this->getImageUrl(),
// 부품 조합
'components' => $this->enrichComponentsWithImageUrls($components),
'components' => $this->enrichComponents($components),
'material_summary' => $materialSummary,
'component_count' => count($components),
// 메타
@@ -88,23 +88,32 @@ private function getImageUrl(): ?string
return $this->getImageFile()?->presignedUrl();
}
private function enrichComponentsWithImageUrls(array $components): array
private function enrichComponents(array $components): array
{
$fileIds = array_filter(array_column($components, 'image_file_id'));
if (empty($fileIds)) {
return $components;
}
// sam_item_id → 기초자료 품목코드 매핑
$itemIds = array_filter(array_column($components, 'sam_item_id'));
$itemCodes = ! empty($itemIds)
? \App\Models\BendingItem::withoutGlobalScopes()->whereIn('id', $itemIds)->pluck('code', 'id')->toArray()
: [];
$files = \App\Models\Commons\File::whereIn('id', $fileIds)
->whereNull('deleted_at')
->get()
->keyBy('id');
// image_file_id → presigned URL 매핑
$fileIds = array_filter(array_column($components, 'image_file_id'));
$files = ! empty($fileIds)
? \App\Models\Commons\File::whereIn('id', $fileIds)->whereNull('deleted_at')->get()->keyBy('id')
: collect();
foreach ($components as &$comp) {
$samId = $comp['sam_item_id'] ?? null;
$comp['item_code'] = $samId ? ($itemCodes[$samId] ?? null) : null;
$fileId = $comp['image_file_id'] ?? null;
$comp['image_url'] = $fileId && isset($files[$fileId])
? $files[$fileId]->presignedUrl()
: null;
try {
$comp['image_url'] = $fileId && $files->has($fileId)
? $files[$fileId]->presignedUrl()
: null;
} catch (\Throwable) {
$comp['image_url'] = null;
}
}
unset($comp);
@@ -122,6 +131,7 @@ private function calcMaterialSummary(array $components): array
$summary[$material] = ($summary[$material] ?? 0) + ($widthSum * $qty);
}
}
return $summary;
}
}

View File

@@ -21,6 +21,7 @@ class Process extends Model
protected $fillable = [
'tenant_id',
'parent_id',
'process_code',
'process_name',
'description',
@@ -47,6 +48,24 @@ class Process extends Model
'required_workers' => 'integer',
];
/** 부모 공정 */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/** 자식 공정 */
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id')->orderBy('process_code');
}
/** 루트 공정만 조회 */
public function scopeRoots($query)
{
return $query->whereNull('parent_id');
}
/**
* 중간검사 양식
*/

View File

@@ -83,6 +83,7 @@ class Receiving extends Model
'shipping' => '배송중',
'inspection_pending' => '검사대기',
'receiving_pending' => '입고대기',
'inspection_completed' => '검사완료',
'completed' => '입고완료',
];
@@ -191,7 +192,7 @@ public function canEdit(): bool
*/
public function canDelete(): bool
{
return $this->status !== 'completed';
return ! in_array($this->status, ['completed', 'inspection_completed']);
}
/**

View File

@@ -23,6 +23,7 @@ class Stock extends Model
'unit',
'stock_qty',
'safety_stock',
'max_stock',
'reserved_qty',
'available_qty',
'lot_count',
@@ -39,6 +40,7 @@ class Stock extends Model
protected $casts = [
'stock_qty' => 'decimal:3',
'safety_stock' => 'decimal:3',
'max_stock' => 'decimal:3',
'reserved_qty' => 'decimal:3',
'available_qty' => 'decimal:3',
'lot_count' => 'integer',
@@ -65,6 +67,7 @@ class Stock extends Model
'normal' => '정상',
'low' => '부족',
'out' => '없음',
'over' => '초과',
];
/**
@@ -140,6 +143,10 @@ public function calculateStatus(): string
return 'low';
}
if ($this->max_stock > 0 && $this->stock_qty > $this->max_stock) {
return 'over';
}
return 'normal';
}

View File

@@ -3,7 +3,6 @@
namespace App\Services;
use App\Models\BendingItem;
use App\Models\Orders\Order;
class BendingCodeService extends Service
{
@@ -128,9 +127,9 @@ public function getCodeMap(): array
}
/**
* 드롭다운 선택 조합 → bending_items 품목 매핑 조회
* 드롭다운 선택 조합 → 품목(items) 매핑 조회
*
* legacy_code 패턴: BD-{prod}{spec}-{length} (예: BD-CP-30)
* 품목코드 패턴: BD-{prod}{spec}-{length} (예: BD-RC-24)
*/
public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array
{
@@ -202,4 +201,87 @@ public static function getMaterial(string $prodCode, string $specCode): ?string
{
return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null;
}
/**
* 품목 코드(BD-XX-YY) → 매칭되는 bending_item의 전개 폭(width_sum) 반환
*
* 매칭 로직:
* BD-{prod}{spec}-{length} 파싱
* → PRODUCTS/SPECS에서 item_bending, item_sep, 키워드 추출
* → bending_items 검색 → bending_data 마지막 sum = 전개 폭
*/
public function getBendingWidthByItemCode(string $itemCode): ?float
{
if (! preg_match('/^BD-([A-Z])([A-Z])-(\d+)$/', $itemCode, $m)) {
return null;
}
$prodCode = $m[1];
$specCode = $m[2];
// 제품명 → item_bending 추출 (가이드레일(벽면형) → 가이드레일)
$productName = null;
foreach (self::PRODUCTS as $p) {
if ($p['code'] === $prodCode) {
$productName = $p['name'];
break;
}
}
if (! $productName) {
return null;
}
// 종류명 추출
$specName = null;
foreach (self::SPECS as $s) {
if ($s['code'] === $specCode && in_array($prodCode, $s['products'])) {
$specName = $s['name'];
break;
}
}
if (! $specName) {
return null;
}
// item_bending: 괄호 제거 (가이드레일(벽면형) → 가이드레일)
$itemBending = preg_replace('/\(.*\)/', '', $productName);
// item_sep 판단: 종류명 또는 제품명에 '철재' → 철재, 아니면 스크린
$itemSep = (str_contains($specName, '철재') || str_contains($productName, '철재'))
? '철재' : '스크린';
// bending_items 검색
$query = \App\Models\BendingItem::query()
->where('tenant_id', $this->tenantId())
->where('item_bending', $itemBending)
->where('item_sep', $itemSep)
->whereNotNull('bending_data');
// 가이드레일: 벽면형/측면형 구분 (item_name 키워드 매칭)
if (str_contains($productName, '벽면형')) {
$query->where('item_name', 'LIKE', '%벽면형%');
} elseif (str_contains($productName, '측면형')) {
$query->where('item_name', 'LIKE', '%측면형%');
}
// 종류 키워드 매칭 (본체, C형, D형, 전면, 점검구, 린텔 등)
$specKeyword = preg_replace('/\(.*\)/', '', $specName); // 본체(철재) → 본체
$query->where('item_name', 'LIKE', "%{$specKeyword}%");
// 최신 코드 우선
$bendingItem = $query->orderByDesc('code')->first();
if (! $bendingItem) {
return null;
}
// bending_data 마지막 항목의 sum = 전개 폭
$data = $bendingItem->bending_data;
if (empty($data)) {
return null;
}
$last = end($data);
return isset($last['sum']) ? (float) $last['sum'] : null;
}
}

View File

@@ -46,9 +46,16 @@ public function find(int $id): BendingItem
public function create(array $data): BendingItem
{
$code = $data['code'] ?? '';
// BD-XX 접두사 → 자동 채번 (BD-XX 또는 BD-XX.nn)
if (preg_match('/^BD-([A-Z]{2})$/i', $code, $m)) {
$code = $this->generateCode($m[1]);
}
return BendingItem::create([
'tenant_id' => $this->tenantId(),
'code' => $data['code'],
'code' => $code,
'lot_no' => $data['lot_no'] ?? null,
'legacy_code' => $data['legacy_code'] ?? null,
'legacy_bending_id' => $data['legacy_bending_id'] ?? null,
@@ -78,8 +85,27 @@ public function update(int $id, array $data): BendingItem
{
$item = BendingItem::findOrFail($id);
// code 변경 시 중복 검사
if (array_key_exists('code', $data) && $data['code'] && $data['code'] !== $item->code) {
$exists = BendingItem::withoutGlobalScopes()
->where('code', $data['code'])
->where('id', '!=', $id)
->exists();
if ($exists) {
throw new \Illuminate\Validation\ValidationException(
validator([], []),
response()->json([
'success' => false,
'message' => "코드 '{$data['code']}'가 이미 존재합니다.",
'errors' => ['code' => ["코드 '{$data['code']}'는 이미 사용 중입니다. 다른 코드를 입력하세요."]],
], 422)
);
}
$item->code = $data['code'];
}
$columns = [
'code', 'lot_no', 'item_name', 'item_sep', 'item_bending',
'lot_no', 'item_name', 'item_sep', 'item_bending',
'material', 'item_spec', 'model_name', 'model_UA',
'rail_width', 'exit_direction', 'box_width', 'box_height',
'front_bottom', 'inspection_door', 'length_code', 'length_mm',
@@ -111,6 +137,95 @@ public function update(int $id, array $data): BendingItem
return $item;
}
/**
* 기초자료 복사 — 같은 분류코드의 다음 번호 자동 채번 + 이미지 복사
*/
public function duplicate(int $id): BendingItem
{
$source = BendingItem::findOrFail($id);
// 분류코드 추출 (BD-CL.001 → CL)
preg_match('/^BD-([A-Z]{2})/', $source->code, $m);
$prefix = $m[1] ?? 'XX';
$newCode = $this->generateCode($prefix);
$newItem = BendingItem::create([
'tenant_id' => $source->tenant_id,
'code' => $newCode,
'item_name' => $source->item_name,
'item_sep' => $source->item_sep,
'item_bending' => $source->item_bending,
'material' => $source->material,
'item_spec' => $source->item_spec,
'model_name' => $source->model_name,
'model_UA' => $source->model_UA,
'rail_width' => $source->rail_width,
'exit_direction' => $source->exit_direction,
'box_width' => $source->box_width,
'box_height' => $source->box_height,
'front_bottom' => $source->front_bottom,
'inspection_door' => $source->inspection_door,
'length_code' => $source->length_code,
'length_mm' => $source->length_mm,
'bending_data' => $source->bending_data,
'options' => $source->options,
'is_active' => true,
'created_by' => $this->apiUserId(),
]);
// 이미지 파일 복사 (R2)
$this->duplicateFiles($source, $newItem);
return $newItem;
}
/**
* 원본 아이템의 파일을 R2에서 복사하여 새 아이템에 연결
*/
private function duplicateFiles(BendingItem $source, BendingItem $target): void
{
$files = \App\Models\Commons\File::where('document_id', $source->id)
->where('document_type', 'bending_item')
->whereNull('deleted_at')
->get();
foreach ($files as $file) {
try {
$disk = \Illuminate\Support\Facades\Storage::disk('r2');
$newStoredName = bin2hex(random_bytes(8)).'.'.pathinfo($file->stored_name, PATHINFO_EXTENSION);
$dir = dirname($file->file_path);
$newPath = $dir.'/'.$newStoredName;
// R2 내 파일 복사
if ($file->file_path && $disk->exists($file->file_path)) {
$disk->copy($file->file_path, $newPath);
}
// 새 File 레코드 생성
\App\Models\Commons\File::create([
'tenant_id' => $target->tenant_id,
'document_id' => $target->id,
'document_type' => 'bending_item',
'field_key' => $file->field_key,
'file_path' => $newPath,
'stored_name' => $newStoredName,
'original_name' => $file->original_name,
'display_name' => $file->display_name,
'file_size' => $file->file_size,
'mime_type' => $file->mime_type,
'file_type' => $file->file_type,
'created_by' => $this->apiUserId(),
]);
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('BendingItem file duplicate failed', [
'source_id' => $source->id,
'file_id' => $file->id,
'error' => $e->getMessage(),
]);
}
}
}
public function delete(int $id): bool
{
$item = BendingItem::findOrFail($id);
@@ -136,4 +251,56 @@ private function buildOptions(array $data): ?array
'search_keyword', 'registration_date', 'author', 'memo',
'parent_num', 'modified_by',
];
/**
* 기초자료 코드 자동 채번
*
* BD-XX.001 : 대표(표준) 형상 — 재공품 코드(BD-XX-길이)의 기준
* BD-XX.002~: 표준 대비 변형 (주문 수정 형상, 최대 999종)
*
* 항상 .001부터 시작, .001 = 대표 번호
*/
private function generateCode(string $prefix): string
{
$prefix = strtoupper($prefix);
$lastCode = BendingItem::withoutGlobalScopes()
->where('code', 'like', "BD-{$prefix}.%")
->orderByRaw('CAST(SUBSTRING(code, ?) AS UNSIGNED) DESC', [strlen("BD-{$prefix}.") + 1])
->value('code');
$nextSeq = 1;
if ($lastCode && preg_match('/\.(\d+)$/', $lastCode, $m)) {
$nextSeq = (int) $m[1] + 1;
}
return "BD-{$prefix}.".str_pad($nextSeq, 3, '0', STR_PAD_LEFT);
}
/**
* 사용 가능한 분류코드 접두사 목록
*/
public function prefixes(): array
{
return BendingItem::withoutGlobalScopes()
->where('code', 'like', 'BD-%')
->selectRaw("CASE WHEN code LIKE 'BD-__.%' THEN SUBSTRING(code, 4, 2) ELSE SUBSTRING(code, 4, 2) END as prefix, COUNT(*) as cnt")
->groupBy('prefix')
->orderBy('prefix')
->pluck('cnt', 'prefix')
->toArray();
}
/** 분류코드 접두사 정의 */
public const PREFIX_LABELS = [
'RS' => '가이드레일 SUS마감재', 'RM' => '가이드레일 본체/보강', 'RC' => '가이드레일 C형',
'RD' => '가이드레일 D형', 'RE' => '가이드레일 측면마감', 'RT' => '가이드레일 절단판',
'RH' => '가이드레일 뒷보강', 'RN' => '가이드레일 비인정',
'CP' => '케이스 밑면판/점검구', 'CF' => '케이스 전면판', 'CB' => '케이스 후면코너/후면부',
'CL' => '케이스 린텔', 'CX' => '케이스 상부덮개',
'BS' => '하단마감재 SUS', 'BE' => '하단마감재 EGI', 'BH' => '하단마감재 보강평철',
'TS' => '철재 하단마감재 SUS', 'TE' => '철재 하단마감재 EGI',
'XE' => '마구리', 'LE' => 'L-BAR',
'ZP' => '특수 밑면/점검구', 'ZF' => '특수 전면판', 'ZB' => '특수 후면',
];
}

View File

@@ -977,7 +977,7 @@ public function formatTemplateForReact(DocumentTemplate $template): array
'is_required' => $field->is_required,
'sort_order' => $field->sort_order,
])->toArray(),
'sections' => $template->sections->map(function ($section) {
'sections' => $template->sections->map(function ($section) use ($methodCodes) {
$imageUrl = null;
if ($section->file_id) {
$file = \App\Models\Commons\File::withoutGlobalScopes()->find($section->file_id);
@@ -987,37 +987,37 @@ public function formatTemplateForReact(DocumentTemplate $template): array
}
return [
'id' => $section->id,
'name' => $section->title,
'title' => $section->title,
'image_path' => $section->image_path,
'file_id' => $section->file_id,
'image_url' => $imageUrl,
'sort_order' => $section->sort_order,
'items' => $section->items->map(function ($item) use ($methodCodes) {
// method 코드를 한글 이름으로 변환
$methodName = $item->method ? ($methodCodes[$item->method] ?? $item->method) : null;
'id' => $section->id,
'name' => $section->title,
'title' => $section->title,
'image_path' => $section->image_path,
'file_id' => $section->file_id,
'image_url' => $imageUrl,
'sort_order' => $section->sort_order,
'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(),
];
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,

View File

@@ -218,6 +218,10 @@ public function update(int $id, array $data)
throw new NotFoundHttpException(__('error.not_found'));
}
if ($inspection->status === Inspection::STATUS_COMPLETED) {
throw new BadRequestHttpException(__('error.inspection.cannot_modify_completed'));
}
$beforeData = $inspection->toArray();
return DB::transaction(function () use ($inspection, $data, $userId, $beforeData) {

View File

@@ -23,9 +23,11 @@ public function index(array $params)
$status = $params['status'] ?? null;
$processType = $params['process_type'] ?? null;
$eagerLoad = ['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category', 'parent:id,process_code,process_name', 'children:id,parent_id,process_code,process_name,is_active'];
$query = Process::query()
->where('tenant_id', $tenantId)
->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']);
->with($eagerLoad);
// 검색어
if ($q !== '') {
@@ -62,7 +64,7 @@ public function show(int $id)
$tenantId = $this->tenantId();
$process = Process::where('tenant_id', $tenantId)
->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category'])
->with(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category', 'parent:id,process_code,process_name', 'children:id,parent_id,process_code,process_name,is_active'])
->find($id);
if (! $process) {
@@ -81,6 +83,16 @@ public function store(array $data)
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 2depth 제한: 부모가 이미 자식이면 거부
if (! empty($data['parent_id'])) {
$parent = Process::find($data['parent_id']);
if ($parent && $parent->parent_id) {
throw \Illuminate\Validation\ValidationException::withMessages([
'parent_id' => ['2단계까지만 허용됩니다. 선택한 부모 공정이 이미 하위 공정입니다.'],
]);
}
}
// 공정코드 자동 생성
$data['process_code'] = $this->generateProcessCode($tenantId);
$data['tenant_id'] = $tenantId;
@@ -122,6 +134,22 @@ public function update(int $id, array $data)
}
return DB::transaction(function () use ($process, $data, $userId) {
// parent_id 변경 시 2depth + 순환 참조 검증
if (array_key_exists('parent_id', $data) && $data['parent_id']) {
$parent = Process::find($data['parent_id']);
if ($parent && $parent->parent_id) {
throw \Illuminate\Validation\ValidationException::withMessages([
'parent_id' => ['2단계까지만 허용됩니다.'],
]);
}
// 자기 자식을 부모로 설정하는 것 방지
if ($process->children()->where('id', $data['parent_id'])->exists()) {
throw \Illuminate\Validation\ValidationException::withMessages([
'parent_id' => ['하위 공정을 부모로 설정할 수 없습니다.'],
]);
}
}
$data['updated_by'] = $userId;
// work_steps가 문자열이면 배열로 변환
@@ -267,6 +295,93 @@ private function syncProcessItems(Process $process, array $itemIds): void
}
}
/**
* 공정 복제
*/
public function duplicate(int $id)
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$source = Process::where('tenant_id', $tenantId)
->with(['classificationRules', 'processItems', 'steps'])
->find($id);
if (! $source) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($source, $tenantId, $userId) {
$newCode = $this->generateProcessCode($tenantId);
$newProcess = Process::create([
'tenant_id' => $tenantId,
'parent_id' => $source->parent_id,
'process_code' => $newCode,
'process_name' => $source->process_name.' (복사)',
'description' => $source->description,
'process_type' => $source->process_type,
'department' => $source->department,
'manager' => $source->manager,
'process_category' => $source->process_category,
'use_production_date' => $source->use_production_date,
'work_log_template' => $source->work_log_template,
'document_template_id' => $source->document_template_id,
'work_log_template_id' => $source->work_log_template_id,
'options' => $source->options,
'required_workers' => $source->required_workers,
'equipment_info' => $source->equipment_info,
'work_steps' => $source->work_steps,
'note' => $source->note,
'is_active' => $source->is_active,
'created_by' => $userId,
]);
// 분류 규칙 복제
foreach ($source->classificationRules as $rule) {
ProcessClassificationRule::create([
'process_id' => $newProcess->id,
'registration_type' => $rule->registration_type,
'rule_type' => $rule->rule_type,
'matching_type' => $rule->matching_type,
'condition_value' => $rule->condition_value,
'priority' => $rule->priority,
'description' => $rule->description,
'is_active' => $rule->is_active,
]);
}
// 품목 연결 복제
foreach ($source->processItems as $item) {
ProcessItem::create([
'process_id' => $newProcess->id,
'item_id' => $item->item_id,
'priority' => $item->priority,
'is_active' => $item->is_active,
]);
}
// 공정 단계 복제
foreach ($source->steps as $step) {
$newProcess->steps()->create([
'step_code' => $step->step_code,
'step_name' => $step->step_name,
'is_required' => $step->is_required,
'needs_approval' => $step->needs_approval,
'needs_inspection' => $step->needs_inspection,
'is_active' => $step->is_active,
'sort_order' => $step->sort_order,
'connection_type' => $step->connection_type,
'connection_target' => $step->connection_target,
'completion_type' => $step->completion_type,
'options' => $step->options,
]);
}
return $newProcess->load(['classificationRules', 'processItems.item:id,code,name', 'steps', 'documentTemplate:id,name,category', 'workLogTemplateRelation:id,name,category']);
});
}
/**
* 드롭다운용 공정 옵션 목록
*/

View File

@@ -100,7 +100,7 @@ public function stats(array $params = []): array
->toArray();
return [
'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0,
'reception_count' => ($counts[QualityDocument::STATUS_RECEIVED] ?? 0) + ($counts['draft'] ?? 0),
'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0,
'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0,
];

View File

@@ -140,6 +140,10 @@ public function stats(): array
->where('status', 'inspection_pending')
->count();
$inspectionCompletedCount = Receiving::where('tenant_id', $tenantId)
->where('status', 'inspection_completed')
->count();
$todayReceivingCount = Receiving::where('tenant_id', $tenantId)
->where('status', 'completed')
->whereDate('receiving_date', $today)
@@ -149,6 +153,7 @@ public function stats(): array
'receiving_pending_count' => $receivingPendingCount,
'shipping_count' => $shippingCount,
'inspection_pending_count' => $inspectionPendingCount,
'inspection_completed_count' => $inspectionCompletedCount,
'today_receiving_count' => $todayReceivingCount,
];
}
@@ -277,17 +282,18 @@ public function update(int $id, array $data): Receiving
// 상태 변경 감지
$oldStatus = $receiving->status;
$newStatus = $data['status'] ?? $oldStatus;
$wasCompleted = $oldStatus === 'completed';
$stockStatuses = ['completed', 'inspection_completed'];
$wasCompleted = in_array($oldStatus, $stockStatuses);
// 입고완료(completed) 상태로 신규 전환
$isCompletingReceiving = $newStatus === 'completed' && ! $wasCompleted;
// 재고 반영 대상 상태(입고완료/검사완료)로 신규 전환
$isCompletingReceiving = in_array($newStatus, $stockStatuses) && ! $wasCompleted;
if ($isCompletingReceiving) {
// 입고수량 설정 (없으면 발주수량 사용)
$receiving->receiving_qty = $data['receiving_qty'] ?? $receiving->order_qty;
$receiving->receiving_date = $data['receiving_date'] ?? now()->toDateString();
$receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo();
$receiving->status = 'completed';
$receiving->status = $newStatus;
} else {
// 일반 필드 업데이트
if (isset($data['receiving_qty'])) {
@@ -326,7 +332,7 @@ public function update(int $id, array $data): Receiving
// 기존 완료 상태에서 수정: 차이만큼 조정
// 완료→완료(수량변경): newQty = 변경된 수량
// 완료→대기: newQty = 0 (전량 차감)
$newQty = $newStatus === 'completed'
$newQty = in_array($newStatus, $stockStatuses)
? (float) $receiving->receiving_qty
: 0;
$stockService->adjustFromReceiving($receiving, $newQty);
@@ -350,8 +356,21 @@ public function destroy(int $id): bool
->where('tenant_id', $tenantId)
->findOrFail($id);
// 관리자/슈퍼관리자/개발자는 모든 상태 삭제 가능, 일반 사용자는 완료 상태 삭제 불가
if (! $receiving->canDelete()) {
throw new \Exception(__('error.receiving.cannot_delete'));
$user = \App\Models\Members\User::find($userId);
app(\Spatie\Permission\PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
$isAdmin = $user && $user->hasRole(['admin', 'super_admin', '개발자']);
if (! $isAdmin) {
throw new \Exception(__('error.receiving.cannot_delete'));
}
}
// 완료/검사완료 상태 삭제 시 재고 차감
$stockStatuses = ['completed', 'inspection_completed'];
if (in_array($receiving->status, $stockStatuses) && $receiving->item_id) {
$stockService = app(StockService::class);
$stockService->adjustFromReceiving($receiving, 0);
}
$receiving->deleted_by = $userId;

View File

@@ -191,6 +191,36 @@ public function show(int $id): Item
->findOrFail($id);
}
/**
* 재고 수정 (안전재고, 최대재고, 사용상태)
*/
public function updateStock(int $id, array $data): Item
{
$tenantId = $this->tenantId();
$item = Item::where('tenant_id', $tenantId)->findOrFail($id);
$stock = $item->stock;
if ($stock) {
if (isset($data['safety_stock'])) {
$stock->safety_stock = $data['safety_stock'];
}
if (isset($data['max_stock'])) {
$stock->max_stock = $data['max_stock'];
}
$stock->status = $stock->calculateStatus();
$stock->updated_by = $this->apiUserId();
$stock->save();
}
if (isset($data['is_active'])) {
$item->is_active = $data['is_active'];
$item->save();
}
return $item->load(['stock.lots' => fn ($q) => $q->orderBy('fifo_order')]);
}
/**
* 재고 조정 이력 조회
*/

View File

@@ -17,6 +17,7 @@
use App\Models\Tenants\StockTransaction;
use App\Services\Audit\AuditLogger;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -61,6 +62,7 @@ public function index(array $params)
'salesOrder.client:id,name',
'process:id,process_name,process_code,department,options',
'items:id,work_order_id,item_id,item_name,specification,quantity,unit,status,options,sort_order,source_order_item_id',
'items.item:id,code',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
@@ -122,14 +124,21 @@ public function index(array $params)
->orWhereHas('assignees', fn ($aq) => $aq->where('user_id', $userId));
});
} else {
// 2차: 사용자 소속 부서의 작업지시 필터
// 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);
// 소속 부서의 상위 부서도 포함 (부서 계층 지원)
$parentIds = DB::table('departments')
->whereIn('id', $departmentIds)
->whereNotNull('parent_id')
->pluck('parent_id');
$allDeptIds = $departmentIds->merge($parentIds)->unique();
$query->whereIn('team_id', $allDeptIds);
}
// 3차: 부서도 없으면 필터 없이 전체 노출
}
@@ -150,7 +159,37 @@ public function index(array $params)
$query->orderByDesc('created_at');
return $query->paginate($size, ['*'], 'page', $page);
$result = $query->paginate($size, ['*'], 'page', $page);
// 작업자 화면: BENDING 카테고리 품목에 전개도 폭(bending_width) 추가
if ($workerScreen) {
$this->appendBendingWidths($result);
}
return $result;
}
/**
* BENDING 카테고리 품목에 전개도 폭 추가
*/
private function appendBendingWidths($paginator): void
{
$bendingService = app(BendingCodeService::class);
foreach ($paginator->items() as $workOrder) {
foreach ($workOrder->items as $item) {
$itemCode = $item->item?->code;
if (! $itemCode || ! str_starts_with($itemCode, 'BD-')) {
continue;
}
$width = $bendingService->getBendingWidthByItemCode($itemCode);
if ($width !== null) {
$options = $item->options ?? [];
$options['bending_width'] = $width;
$item->setAttribute('options', $options);
}
}
}
}
/**
@@ -214,6 +253,7 @@ public function show(int $id)
'salesOrder.writer:id,name',
'process:id,process_name,process_code,work_steps,department,options',
'process.steps' => fn ($q) => $q->where('is_active', true)->orderBy('sort_order'),
'items.item:id,code',
'items.sourceOrderItem:id,order_node_id,floor_code,symbol_code',
'items.sourceOrderItem.node:id,name,code',
'items.materialInputs:id,work_order_id,work_order_item_id,stock_lot_id,item_id,qty,input_by,input_at',
@@ -3445,7 +3485,7 @@ public function getWorkLog(int $workOrderId): array
'comment' => $a->comment,
'acted_at' => $a->acted_at,
])->toArray(),
'data' => $document->data->map(fn ($d) => [
'data' => ($document->data ?? collect())->map(fn ($d) => [
'field_key' => $d->field_key,
'field_value' => $d->field_value,
'section_id' => $d->section_id,
@@ -3456,9 +3496,41 @@ public function getWorkLog(int $workOrderId): array
'auto_values' => $this->buildWorkLogAutoValues($workOrder),
'work_order_info' => $this->buildWorkOrderInfo($workOrder),
'work_stats' => $this->calculateWorkStats($workOrder),
'bending_images' => $this->buildBendingImageUrls(),
];
}
/**
* 절곡 정적 이미지 R2 presigned URL 맵 생성
*/
private function buildBendingImageUrls(): array
{
$images = [];
$paths = [
'guiderail_KSS01_wall_120x70', 'guiderail_KSS01_side_120x120',
'guiderail_KSS02_wall_120x70', 'guiderail_KSS02_side_120x120',
'guiderail_KSE01_wall_120x70', 'guiderail_KSE01_side_120x120',
'guiderail_KWE01_wall_120x70', 'guiderail_KWE01_side_120x120',
'guiderail_KTE01_wall_130x75', 'guiderail_KTE01_side_130x125',
'guiderail_KQTS01_wall_130x75', 'guiderail_KQTS01_side_130x125',
'bottombar_KSS01', 'bottombar_KSS02', 'bottombar_KSE01',
'bottombar_KWE01', 'bottombar_KTE01', 'bottombar_KQTS01',
'box_both', 'box_bottom', 'box_rear',
'smokeban',
];
foreach ($paths as $name) {
$category = str_contains($name, 'guiderail') ? 'guiderail'
: (str_contains($name, 'bottombar') ? 'bottombar'
: (str_contains($name, 'box') ? 'box' : 'part'));
$r2Path = "images/bending/{$category}/{$name}.jpg";
$images[$name] = Storage::disk('r2')->temporaryUrl($r2Path, now()->addMinutes(30));
}
return $images;
}
/**
* 작업일지 생성/수정 (Document 기반)
*
@@ -3773,13 +3845,59 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
}
}
// BOM이 없으면 품목 자체를 자재로 사용
// BOM이 없으면 BD 품목의 재질 정보로 원자재 자동 매칭
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
$materialItems[] = [
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
$itemOptions = $woItem->item->options ?? [];
$material = $itemOptions['material'] ?? null;
$matchedRawItems = [];
if ($material && preg_match('/^(\w+)\s*(\d+\.?\d*)/i', $material, $matMatch)) {
$matType = $matMatch[1];
$matThickness = (float) $matMatch[2];
// 품목명에서 제품 길이 추출 (예: 1750mm)
$productLength = 0;
if (preg_match('/(\d{3,5})mm/', $woItem->item->name, $lenMatch)) {
$productLength = (int) $lenMatch[1];
}
// 원자재 검색: material_type + thickness 매칭, length >= 제품길이
$rawItems = \App\Models\Items\Item::where('tenant_id', $tenantId)
->where('item_type', 'RM')
->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType])
->whereRaw('CAST(JSON_EXTRACT(options, \'$.attributes.thickness\') AS DECIMAL(10,2)) = ?', [$matThickness])
->get();
foreach ($rawItems as $rawItem) {
$rawAttrs = $rawItem->options['attributes'] ?? [];
$rawLength = $rawAttrs['length'] ?? null;
// 길이 조건: 원자재 길이 >= 제품 길이 (길이 미정이면 통과)
if ($rawLength !== null && $productLength > 0 && $rawLength < $productLength) {
continue;
}
$matchedRawItems[] = $rawItem;
}
}
if (! empty($matchedRawItems)) {
// 매칭된 원자재를 자재 목록으로 추가
foreach ($matchedRawItems as $rawItem) {
$materialItems[] = [
'item' => $rawItem,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
}
} else {
// 매칭 실패 시 기존 동작 유지 (품목 자체를 자재로 표시)
$materialItems[] = [
'item' => $woItem->item,
'bom_qty' => 1,
'required_qty' => $woItem->quantity ?? 1,
];
}
}
// 이미 투입된 수량 조회 (item_id별 SUM)

View File

@@ -0,0 +1,84 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* daily_work_logs / daily_work_log_items 데이터를 sam → codebridge DB로 이관.
*
* 배경:
* - 2026_03_19_200000 마이그레이션에서 codebridge에 빈 테이블은 생성했으나
* existingTables에 daily_work_logs가 누락되어 데이터 이관이 빠짐.
* - MNG 모델은 이미 $connection = 'codebridge'로 변경되어
* 운영서버에서 데이터가 보이지 않는 상태.
*/
return new class extends Migration
{
private string $cb = 'codebridge';
// 복사 순서: 부모 → 자식 (FK 참조 순서)
private array $copyOrder = [
'daily_work_logs',
'daily_work_log_items',
];
// 삭제 순서: 자식 → 부모 (FK 역순)
private array $dropOrder = [
'daily_work_log_items',
'daily_work_logs',
];
public function up(): void
{
$samDb = config('database.connections.mysql.database', 'sam');
$cbDb = config('database.connections.codebridge.database', 'codebridge');
// ─── Phase 1: 데이터 복사 (부모 → 자식 순서) ───
foreach ($this->copyOrder as $table) {
if (! Schema::hasTable($table)) {
continue;
}
$samCount = DB::table($table)->count();
if ($samCount === 0) {
continue;
}
if (! Schema::connection($this->cb)->hasTable($table)) {
throw new \RuntimeException(
"[MIGRATION ABORT] codebridge.{$table} 테이블이 없습니다. "
.'2026_03_19_200000 마이그레이션을 먼저 실행하세요.'
);
}
$cbExisting = DB::connection($this->cb)->table($table)->count();
if ($cbExisting > 0) {
continue;
}
DB::connection($this->cb)->statement(
"INSERT INTO `{$cbDb}`.`{$table}` SELECT * FROM `{$samDb}`.`{$table}`"
);
$cbCount = DB::connection($this->cb)->table($table)->count();
if ($samCount !== $cbCount) {
throw new \RuntimeException(
"[MIGRATION ABORT] 데이터 불일치! {$table}: sam={$samCount}, codebridge={$cbCount}"
);
}
}
// ─── Phase 2: sam 테이블 삭제 (자식 → 부모 순서) ───
DB::statement('SET FOREIGN_KEY_CHECKS=0');
foreach ($this->dropOrder as $table) {
Schema::dropIfExists($table);
}
DB::statement('SET FOREIGN_KEY_CHECKS=1');
}
public function down(): void
{
// 롤백은 수동 처리 — 데이터가 이미 codebridge에 있으므로 자동 롤백 불가
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('stocks', function (Blueprint $table) {
$table->decimal('max_stock', 15, 3)->default(0)
->comment('최대 재고 (적정재고 상한)')
->after('safety_stock');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('stocks', function (Blueprint $table) {
$table->dropColumn('max_stock');
});
}
};

View File

@@ -0,0 +1,34 @@
<?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('processes', function (Blueprint $table) {
$table->unsignedBigInteger('parent_id')
->nullable()
->after('tenant_id')
->comment('부모 공정 ID (NULL이면 루트)');
$table->foreign('parent_id')
->references('id')
->on('processes')
->onDelete('set null');
$table->index(['tenant_id', 'parent_id']);
});
}
public function down(): void
{
Schema::table('processes', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
$table->dropIndex(['tenant_id', 'parent_id']);
$table->dropColumn('parent_id');
});
}
};

View File

@@ -456,6 +456,7 @@
'inspection' => [
'not_found' => '검사를 찾을 수 없습니다.',
'cannot_delete_completed' => '완료된 검사는 삭제할 수 없습니다.',
'cannot_modify_completed' => '완료된 검사는 수정할 수 없습니다.',
'already_completed' => '이미 완료된 검사입니다.',
],

View File

@@ -109,6 +109,7 @@
Route::get('/stats', [StockController::class, 'stats'])->name('v1.stocks.stats');
Route::get('/stats-by-type', [StockController::class, 'statsByItemType'])->name('v1.stocks.stats-by-type');
Route::get('/{id}', [StockController::class, 'show'])->whereNumber('id')->name('v1.stocks.show');
Route::put('/{id}', [StockController::class, 'update'])->whereNumber('id')->name('v1.stocks.update');
Route::get('/{id}/adjustments', [StockController::class, 'adjustments'])->whereNumber('id')->name('v1.stocks.adjustments');
Route::post('/{id}/adjustments', [StockController::class, 'storeAdjustment'])->whereNumber('id')->name('v1.stocks.adjustments.store');
});

View File

@@ -31,6 +31,7 @@
Route::put('/{id}', [ProcessController::class, 'update'])->whereNumber('id')->name('v1.processes.update');
Route::delete('/{id}', [ProcessController::class, 'destroy'])->whereNumber('id')->name('v1.processes.destroy');
Route::patch('/{id}/toggle', [ProcessController::class, 'toggleActive'])->whereNumber('id')->name('v1.processes.toggle');
Route::post('/{id}/duplicate', [ProcessController::class, 'duplicate'])->whereNumber('id')->name('v1.processes.duplicate');
// Process Steps (공정 단계)
Route::prefix('{processId}/steps')->whereNumber('processId')->group(function () {
@@ -138,9 +139,11 @@
Route::prefix('bending-items')->group(function () {
Route::get('', [BendingItemController::class, 'index'])->name('v1.bending-items.index');
Route::get('/filters', [BendingItemController::class, 'filters'])->name('v1.bending-items.filters');
Route::get('/prefixes', [BendingItemController::class, 'prefixes'])->name('v1.bending-items.prefixes');
Route::post('', [BendingItemController::class, 'store'])->name('v1.bending-items.store');
Route::get('/{id}', [BendingItemController::class, 'show'])->whereNumber('id')->name('v1.bending-items.show');
Route::put('/{id}', [BendingItemController::class, 'update'])->whereNumber('id')->name('v1.bending-items.update');
Route::post('/{id}/duplicate', [BendingItemController::class, 'duplicate'])->whereNumber('id')->name('v1.bending-items.duplicate');
Route::delete('/{id}', [BendingItemController::class, 'destroy'])->whereNumber('id')->name('v1.bending-items.destroy');
});