merge: develop + kkk 브랜치 병합 (code/lot_no 분리 + origin 신규 기능 통합)
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 조정 이력 조회
|
||||
*/
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공정 옵션 목록 (드롭다운용)
|
||||
*/
|
||||
|
||||
@@ -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 (문서양식 섹션 이미지)
|
||||
];
|
||||
|
||||
// 현재 라우트 확인 (경로 또는 이름)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:생산,검사,포장,조립'],
|
||||
|
||||
@@ -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:생산,검사,포장,조립'],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 중간검사 양식
|
||||
*/
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => '특수 후면',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운용 공정 옵션 목록
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 조정 이력 조회
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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에 있으므로 자동 롤백 불가
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -456,6 +456,7 @@
|
||||
'inspection' => [
|
||||
'not_found' => '검사를 찾을 수 없습니다.',
|
||||
'cannot_delete_completed' => '완료된 검사는 삭제할 수 없습니다.',
|
||||
'cannot_modify_completed' => '완료된 검사는 수정할 수 없습니다.',
|
||||
'already_completed' => '이미 완료된 검사입니다.',
|
||||
],
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user