diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 4ea9940..f37f762 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-14 19:19:09 +> **자동 생성**: 2026-01-16 14:58:07 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -254,6 +254,7 @@ ### item_receipts **모델**: `App\Models\Items\ItemReceipt` - **item()**: belongsTo → `items` +- **creator()**: belongsTo → `users` ### login_tokens **모델**: `App\Models\LoginToken` @@ -342,6 +343,7 @@ ### orders - **items()**: hasMany → `order_items` - **histories()**: hasMany → `order_histories` - **versions()**: hasMany → `order_versions` +- **workOrders()**: hasMany → `work_orders` ### order_historys **모델**: `App\Models\Orders\OrderHistory` @@ -487,6 +489,8 @@ ### inspections **모델**: `App\Models\Qualitys\Inspection` - **item()**: belongsTo → `items` +- **inspector()**: belongsTo → `users` +- **creator()**: belongsTo → `users` ### lots **모델**: `App\Models\Qualitys\Lot` @@ -511,6 +515,7 @@ ### quotes - **updater()**: belongsTo → `users` - **items()**: hasMany → `quote_items` - **revisions()**: hasMany → `quote_revisions` +- **orders()**: hasMany → `orders` ### quote_formulas **모델**: `App\Models\Quote\QuoteFormula` diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index 70c7895..2e82d3d 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -27,6 +27,7 @@ public function index(Request $request) 'q' => $request->input('q') ?? $request->input('search'), 'category_id' => $request->input('category_id'), 'item_type' => $request->input('type') ?? $request->input('item_type'), + 'item_category' => $request->input('item_category'), 'group_id' => $request->input('group_id'), 'active' => $request->input('is_active') ?? $request->input('active'), ]; diff --git a/app/Http/Controllers/Api/V1/SaleController.php b/app/Http/Controllers/Api/V1/SaleController.php index 942b6b3..3828353 100644 --- a/app/Http/Controllers/Api/V1/SaleController.php +++ b/app/Http/Controllers/Api/V1/SaleController.php @@ -4,6 +4,7 @@ use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; +use App\Http\Requests\V1\Common\BulkUpdateAccountCodeRequest; use App\Http\Requests\V1\Sale\SendStatementRequest; use App\Http\Requests\V1\Sale\StoreSaleRequest; use App\Http\Requests\V1\Sale\UpdateSaleRequest; @@ -88,6 +89,22 @@ public function confirm(int $id) return ApiResponse::success($sale, __('message.sale.confirmed')); } + /** + * 계정과목 일괄 변경 + */ + public function bulkUpdateAccountCode(BulkUpdateAccountCodeRequest $request) + { + $updatedCount = $this->service->bulkUpdateAccountCode( + $request->getIds(), + $request->getAccountCode() + ); + + return ApiResponse::success( + ['updated_count' => $updatedCount], + __('message.bulk_updated') + ); + } + /** * 매출 요약 (기간별 합계) */ diff --git a/app/Http/Requests/Attendance/StoreRequest.php b/app/Http/Requests/Attendance/StoreRequest.php index b5bb91e..08063b3 100644 --- a/app/Http/Requests/Attendance/StoreRequest.php +++ b/app/Http/Requests/Attendance/StoreRequest.php @@ -19,7 +19,20 @@ public function rules(): array 'status' => 'nullable|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote', 'remarks' => 'nullable|string|max:500', - // json_details 필드 + // json_details 객체로 전달되는 경우 (프론트엔드 기본 형식) + 'json_details' => 'nullable|array', + 'json_details.check_in' => 'nullable|date_format:H:i:s', + 'json_details.check_out' => 'nullable|date_format:H:i:s', + 'json_details.gps_data' => 'nullable|array', + 'json_details.work_minutes' => 'nullable|integer|min:0', + 'json_details.overtime_minutes' => 'nullable|integer|min:0', + 'json_details.late_minutes' => 'nullable|integer|min:0', + 'json_details.early_leave_minutes' => 'nullable|integer|min:0', + 'json_details.vacation_type' => 'nullable|string|max:50', + 'json_details.reason' => 'nullable|string|max:500', + 'json_details.break_time' => 'nullable|string|max:50', + + // 최상위 레벨 필드 (호환성 유지) 'check_in' => 'nullable|date_format:H:i:s', 'check_out' => 'nullable|date_format:H:i:s', 'gps_data' => 'nullable|array', diff --git a/app/Http/Requests/Attendance/UpdateRequest.php b/app/Http/Requests/Attendance/UpdateRequest.php index 12fbfcc..ed48026 100644 --- a/app/Http/Requests/Attendance/UpdateRequest.php +++ b/app/Http/Requests/Attendance/UpdateRequest.php @@ -17,7 +17,20 @@ public function rules(): array 'status' => 'nullable|in:onTime,late,absent,vacation,businessTrip,fieldWork,overtime,remote', 'remarks' => 'nullable|string|max:500', - // json_details 필드 + // json_details 객체로 전달되는 경우 (프론트엔드 기본 형식) + 'json_details' => 'nullable|array', + 'json_details.check_in' => 'nullable|date_format:H:i:s', + 'json_details.check_out' => 'nullable|date_format:H:i:s', + 'json_details.gps_data' => 'nullable|array', + 'json_details.work_minutes' => 'nullable|integer|min:0', + 'json_details.overtime_minutes' => 'nullable|integer|min:0', + 'json_details.late_minutes' => 'nullable|integer|min:0', + 'json_details.early_leave_minutes' => 'nullable|integer|min:0', + 'json_details.vacation_type' => 'nullable|string|max:50', + 'json_details.reason' => 'nullable|string|max:500', + 'json_details.break_time' => 'nullable|string|max:50', + + // 최상위 레벨 필드 (호환성 유지) 'check_in' => 'nullable|date_format:H:i:s', 'check_out' => 'nullable|date_format:H:i:s', 'gps_data' => 'nullable|array', diff --git a/app/Http/Requests/Employee/StoreRequest.php b/app/Http/Requests/Employee/StoreRequest.php index 31726e4..faff953 100644 --- a/app/Http/Requests/Employee/StoreRequest.php +++ b/app/Http/Requests/Employee/StoreRequest.php @@ -38,21 +38,27 @@ public function rules(): array 'resident_number' => 'nullable|string|max:255', 'gender' => 'nullable|in:male,female', 'address' => 'nullable|array', - 'address.zipCode' => 'nullable|string|max:10', + 'address.zip_code' => 'nullable|string|max:10', 'address.address1' => 'nullable|string|max:255', 'address.address2' => 'nullable|string|max:255', 'salary' => 'nullable|numeric|min:0', 'hire_date' => 'nullable|date', 'rank' => 'nullable|string|max:50', 'bank_account' => 'nullable|array', - 'bank_account.bankName' => 'nullable|string|max:50', - 'bank_account.accountNumber' => 'nullable|string|max:50', - 'bank_account.accountHolder' => 'nullable|string|max:50', + 'bank_account.bank_name' => 'nullable|string|max:50', + 'bank_account.account_number' => 'nullable|string|max:50', + 'bank_account.account_holder' => 'nullable|string|max:50', 'work_type' => 'nullable|in:regular,contract,parttime,intern', 'contract_info' => 'nullable|array', 'contract_info.start_date' => 'nullable|date', 'contract_info.end_date' => 'nullable|date', 'contract_info.external_company' => 'nullable|string|max:100', + + // 추가 json_extra 필드 (프론트엔드에서 전송) + 'resignation_date' => 'nullable|date', + 'resignation_reason' => 'nullable|string|max:500', + 'clock_in_location' => 'nullable|string|max:255', + 'clock_out_location' => 'nullable|string|max:255', ]; } diff --git a/app/Http/Requests/Employee/UpdateRequest.php b/app/Http/Requests/Employee/UpdateRequest.php index bfaf3cc..80cdc4b 100644 --- a/app/Http/Requests/Employee/UpdateRequest.php +++ b/app/Http/Requests/Employee/UpdateRequest.php @@ -44,21 +44,27 @@ public function rules(): array 'resident_number' => 'nullable|string|max:255', 'gender' => 'nullable|in:male,female', 'address' => 'nullable|array', - 'address.zipCode' => 'nullable|string|max:10', + 'address.zip_code' => 'nullable|string|max:10', 'address.address1' => 'nullable|string|max:255', 'address.address2' => 'nullable|string|max:255', 'salary' => 'nullable|numeric|min:0', 'hire_date' => 'nullable|date', 'rank' => 'nullable|string|max:50', 'bank_account' => 'nullable|array', - 'bank_account.bankName' => 'nullable|string|max:50', - 'bank_account.accountNumber' => 'nullable|string|max:50', - 'bank_account.accountHolder' => 'nullable|string|max:50', + 'bank_account.bank_name' => 'nullable|string|max:50', + 'bank_account.account_number' => 'nullable|string|max:50', + 'bank_account.account_holder' => 'nullable|string|max:50', 'work_type' => 'nullable|in:regular,contract,parttime,intern', 'contract_info' => 'nullable|array', 'contract_info.start_date' => 'nullable|date', 'contract_info.end_date' => 'nullable|date', 'contract_info.external_company' => 'nullable|string|max:100', + + // 추가 json_extra 필드 (프론트엔드에서 전송) + 'resignation_date' => 'nullable|date', + 'resignation_reason' => 'nullable|string|max:500', + 'clock_in_location' => 'nullable|string|max:255', + 'clock_out_location' => 'nullable|string|max:255', ]; } diff --git a/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php b/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php index a61aa2c..6758ded 100644 --- a/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php +++ b/app/Http/Requests/WorkOrder/WorkOrderStoreRequest.php @@ -14,6 +14,9 @@ public function authorize(): bool public function rules(): array { + // 수주 연동 등록 시 담당자 필수 + $assigneeRequired = $this->filled('sales_order_id') ? 'required' : 'nullable'; + return [ // 기본 정보 'sales_order_id' => 'nullable|integer|exists:orders,id', @@ -21,6 +24,8 @@ public function rules(): array 'process_id' => 'required|integer|exists:processes,id', 'status' => ['nullable', 'in:'.implode(',', WorkOrder::STATUSES)], 'assignee_id' => 'nullable|integer|exists:users,id', + 'assignee_ids' => [$assigneeRequired, 'array'], + 'assignee_ids.*' => 'integer|exists:users,id', 'team_id' => 'nullable|integer|exists:departments,id', 'scheduled_date' => 'nullable|date', 'memo' => 'nullable|string', @@ -53,6 +58,7 @@ public function messages(): array return [ 'process_id.required' => __('validation.required', ['attribute' => '공정']), 'process_id.exists' => __('validation.exists', ['attribute' => '공정']), + 'assignee_ids.required' => __('error.work_order.assignee_required_for_linked'), 'items.*.item_name.required' => __('validation.required', ['attribute' => '품목명']), ]; } diff --git a/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php b/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php index 2297e94..83d43c0 100644 --- a/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php +++ b/app/Http/Requests/WorkOrder/WorkOrderUpdateRequest.php @@ -21,8 +21,11 @@ public function rules(): array 'process_id' => 'nullable|integer|exists:processes,id', 'status' => ['nullable', 'in:'.implode(',', WorkOrder::STATUSES)], 'assignee_id' => 'nullable|integer|exists:users,id', + 'assignee_ids' => 'nullable|array', + 'assignee_ids.*' => 'integer|exists:users,id', 'team_id' => 'nullable|integer|exists:departments,id', 'scheduled_date' => 'nullable|date', + 'priority' => 'nullable|integer|min:1|max:9', 'memo' => 'nullable|string', 'is_active' => 'nullable|boolean', diff --git a/app/Models/Production/WorkOrder.php b/app/Models/Production/WorkOrder.php index 9b7b509..6eb4998 100644 --- a/app/Models/Production/WorkOrder.php +++ b/app/Models/Production/WorkOrder.php @@ -32,6 +32,7 @@ class WorkOrder extends Model 'process_id', 'project_name', 'status', + 'priority', 'assignee_id', 'team_id', 'scheduled_date', diff --git a/app/Models/Tenants/Sale.php b/app/Models/Tenants/Sale.php index 0520579..029b6f3 100644 --- a/app/Models/Tenants/Sale.php +++ b/app/Models/Tenants/Sale.php @@ -21,6 +21,7 @@ class Sale extends Model 'total_amount', 'description', 'status', + 'account_code', 'tax_invoice_issued', 'transaction_statement_issued', 'tax_invoice_id', diff --git a/app/Services/BankTransactionService.php b/app/Services/BankTransactionService.php index 9f84f5e..8d754c6 100644 --- a/app/Services/BankTransactionService.php +++ b/app/Services/BankTransactionService.php @@ -32,7 +32,7 @@ public function index(array $params): LengthAwarePaginator // 입금 쿼리 (payment_method = 'transfer') $depositsQuery = DB::table('deposits') - ->join('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id') + ->leftJoin('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id') ->leftJoin('clients', 'deposits.client_id', '=', 'clients.id') ->where('deposits.tenant_id', $tenantId) ->where('deposits.payment_method', 'transfer') @@ -58,7 +58,7 @@ public function index(array $params): LengthAwarePaginator // 출금 쿼리 (payment_method = 'transfer') $withdrawalsQuery = DB::table('withdrawals') - ->join('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id') + ->leftJoin('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id') ->leftJoin('clients', 'withdrawals.client_id', '=', 'clients.id') ->where('withdrawals.tenant_id', $tenantId) ->where('withdrawals.payment_method', 'transfer') diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php index 7a3bca2..28393f9 100644 --- a/app/Services/ItemService.php +++ b/app/Services/ItemService.php @@ -352,6 +352,7 @@ public function index(array $params): LengthAwarePaginator $q = trim((string) ($params['q'] ?? $params['search'] ?? '')); $categoryId = $params['category_id'] ?? null; $itemType = $params['item_type'] ?? null; + $itemCategory = $params['item_category'] ?? null; $groupId = $params['group_id'] ?? null; $active = $params['active'] ?? null; @@ -398,6 +399,11 @@ public function index(array $params): LengthAwarePaginator $query->where('category_id', (int) $categoryId); } + // 품목 카테고리 (SCREEN, STEEL, BENDING 등) + if ($itemCategory) { + $query->where('item_category', $itemCategory); + } + // 활성 상태 if ($active !== null && $active !== '') { $query->where('is_active', (bool) $active); diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index 3f3f77a..238d2db 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -212,6 +212,27 @@ public function confirm(int $id): Sale }); } + /** + * 매출 계정과목 일괄 변경 + * + * @param array $ids 대상 ID 배열 + * @param string $accountCode 변경할 계정과목 코드 + * @return int 변경된 레코드 수 + */ + public function bulkUpdateAccountCode(array $ids, string $accountCode): int + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return Sale::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->update([ + 'account_code' => $accountCode, + 'updated_by' => $userId, + ]); + } + /** * 매출 요약 (기간별 합계) */ diff --git a/app/Services/WorkResultService.php b/app/Services/WorkResultService.php index e0e5527..6e595eb 100644 --- a/app/Services/WorkResultService.php +++ b/app/Services/WorkResultService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Members\User; use App\Models\Production\WorkOrderItem; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -81,7 +82,32 @@ public function index(array $params) // 최신 완료순 정렬 $query->orderByDesc('options->result->completed_at')->orderByDesc('id'); - return $query->paginate($size, ['*'], 'page', $page); + $paginated = $query->paginate($size, ['*'], 'page', $page); + + // worker_id로 작업자 이름 조회하여 추가 + $workerIds = $paginated->getCollection() + ->map(fn ($item) => $item->options['result']['worker_id'] ?? null) + ->filter() + ->unique() + ->values() + ->toArray(); + + $workers = []; + if (! empty($workerIds)) { + $workers = User::whereIn('id', $workerIds) + ->pluck('name', 'id') + ->toArray(); + } + + // 각 아이템에 worker_name 추가 + $paginated->getCollection()->transform(function ($item) use ($workers) { + $workerId = $item->options['result']['worker_id'] ?? null; + $item->worker_name = $workerId ? ($workers[$workerId] ?? null) : null; + + return $item; + }); + + return $paginated; } /** @@ -155,6 +181,14 @@ public function show(int $id) throw new NotFoundHttpException(__('error.not_found')); } + // worker_name 추가 + $workerId = $item->options['result']['worker_id'] ?? null; + if ($workerId) { + $item->worker_name = User::where('id', $workerId)->value('name'); + } else { + $item->worker_name = null; + } + return $item; } diff --git a/database/migrations/2026_01_15_172302_add_account_code_to_sales_table.php b/database/migrations/2026_01_15_172302_add_account_code_to_sales_table.php new file mode 100644 index 0000000..c44470c --- /dev/null +++ b/database/migrations/2026_01_15_172302_add_account_code_to_sales_table.php @@ -0,0 +1,30 @@ +string('account_code', 50)->nullable()->after('status')->comment('계정과목코드'); + $table->index('account_code', 'idx_account_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sales', function (Blueprint $table) { + $table->dropIndex('idx_account_code'); + $table->dropColumn('account_code'); + }); + } +}; diff --git a/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php b/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php new file mode 100644 index 0000000..68b1cef --- /dev/null +++ b/database/migrations/2026_01_16_100000_add_priority_to_work_orders_table.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('priority')->default(5)->after('status') + ->comment('우선순위: 1(긴급)~9(낮음), 기본값 5(일반)'); + $table->index(['tenant_id', 'priority'], 'idx_work_orders_tenant_priority'); + }); + } + + public function down(): void + { + Schema::table('work_orders', function (Blueprint $table) { + $table->dropIndex('idx_work_orders_tenant_priority'); + $table->dropColumn('priority'); + }); + } +}; \ No newline at end of file diff --git a/lang/ko/error.php b/lang/ko/error.php index c25e8e7..c256f7c 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -379,6 +379,7 @@ 'cannot_delete_in_progress' => '진행중이거나 완료된 작업지시는 삭제할 수 없습니다.', 'not_bending_process' => '벤딩 공정이 아닙니다.', 'invalid_transition' => "상태를 ':from'에서 ':to'(으)로 변경할 수 없습니다. 허용된 상태: :allowed", + 'assignee_required_for_linked' => '수주 연동 등록 시 담당자를 선택해주세요.', ], // 검사 관련 diff --git a/routes/api.php b/routes/api.php index 215edac..73cf538 100644 --- a/routes/api.php +++ b/routes/api.php @@ -672,6 +672,7 @@ Route::put('/{id}', [SaleController::class, 'update'])->whereNumber('id')->name('v1.sales.update'); Route::delete('/{id}', [SaleController::class, 'destroy'])->whereNumber('id')->name('v1.sales.destroy'); Route::post('/{id}/confirm', [SaleController::class, 'confirm'])->whereNumber('id')->name('v1.sales.confirm'); + Route::put('/bulk-update-account', [SaleController::class, 'bulkUpdateAccountCode'])->name('v1.sales.bulk-update-account'); // 거래명세서 API Route::get('/{id}/statement', [SaleController::class, 'getStatement'])->whereNumber('id')->name('v1.sales.statement.show'); Route::post('/{id}/statement/issue', [SaleController::class, 'issueStatement'])->whereNumber('id')->name('v1.sales.statement.issue');