From bd500a87bdf3919d57862b5360beff249cbb72b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Tue, 10 Mar 2026 11:29:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EC=A7=88=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=C2=B7=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=C2=B7=EA=B2=B0?= =?UTF-8?q?=EC=9E=AC=20=EC=96=91=EC=8B=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품질관리 수주선택 필터링 + 검사 상태 자동 재계산 - 제품검사 요청서 Document(EAV) 자동생성 및 동기화 - 현황판 결재 카드 approvalOnly 스코프 + sub_label 추가 - 캘린더 어음 만기일 일정 연동 - QuoteStatService codebridge DB 커넥션 연결 - 테넌트 부트스트랩 기본 결재 양식 자동 시딩 --- .../Qualitys/QualityDocumentLocation.php | 2 + app/Services/CalendarService.php | 53 ++++++++- app/Services/QualityDocumentService.php | 96 +++++++++++++++- app/Services/Stats/QuoteStatService.php | 26 +---- app/Services/StatusBoardService.php | 74 ++++++++++-- .../TenantBootstrap/RecipeRegistry.php | 2 + .../Steps/ApprovalFormsStep.php | 105 ++++++++++++++++++ 7 files changed, 316 insertions(+), 42 deletions(-) create mode 100644 app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php diff --git a/app/Models/Qualitys/QualityDocumentLocation.php b/app/Models/Qualitys/QualityDocumentLocation.php index 311ed9d..fd362bc 100644 --- a/app/Models/Qualitys/QualityDocumentLocation.php +++ b/app/Models/Qualitys/QualityDocumentLocation.php @@ -12,6 +12,8 @@ class QualityDocumentLocation extends Model const STATUS_PENDING = 'pending'; + const STATUS_IN_PROGRESS = 'in_progress'; + const STATUS_COMPLETED = 'completed'; protected $fillable = [ diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index bdd87ae..9d44415 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -4,6 +4,7 @@ use App\Models\Construction\Contract; use App\Models\Production\WorkOrder; +use App\Models\Tenants\Bill; use App\Models\Tenants\Leave; use App\Models\Tenants\Schedule; use Illuminate\Support\Collection; @@ -16,6 +17,7 @@ * - 계약(Contract): 시공 일정 * - 휴가(Leave): 직원 휴가 일정 * - 일정(Schedule): 본사 공통 일정 + 테넌트 일정 (세금 신고, 공휴일 등) + * - 어음(Bill): 어음 만기일 일정 */ class CalendarService extends Service { @@ -24,7 +26,7 @@ class CalendarService extends Service * * @param string $startDate 조회 시작일 (Y-m-d) * @param string $endDate 조회 종료일 (Y-m-d) - * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|null=전체) + * @param string|null $type 일정 타입 필터 (schedule|order|construction|other|bill|null=전체) * @param string|null $departmentFilter 부서 필터 (all|department|personal) */ public function getSchedules( @@ -64,6 +66,13 @@ public function getSchedules( ); } + // 어음 만기일 + if ($type === null || $type === 'bill') { + $schedules = $schedules->merge( + $this->getBillSchedules($tenantId, $startDate, $endDate) + ); + } + // startDate 기준 정렬 $sortedSchedules = $schedules ->sortBy('startDate') @@ -331,4 +340,46 @@ private function getGeneralSchedules( ]; }); } + + /** + * 어음 만기일 일정 조회 + */ + private function getBillSchedules( + int $tenantId, + string $startDate, + string $endDate + ): Collection { + $excludedStatuses = [ + 'paymentComplete', + 'dishonored', + ]; + + $bills = Bill::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('maturity_date') + ->where('maturity_date', '>=', $startDate) + ->where('maturity_date', '<=', $endDate) + ->whereNotIn('status', $excludedStatuses) + ->orderBy('maturity_date') + ->limit(100) + ->get(); + + return $bills->map(function ($bill) { + $clientName = $bill->display_client_name ?? $bill->client_name ?? ''; + + return [ + 'id' => 'bill_'.$bill->id, + 'title' => '[만기] '.$clientName.' '.number_format($bill->amount).'원', + 'startDate' => $bill->maturity_date->format('Y-m-d'), + 'endDate' => $bill->maturity_date->format('Y-m-d'), + 'startTime' => null, + 'endTime' => null, + 'isAllDay' => true, + 'type' => 'bill', + 'department' => null, + 'personName' => null, + 'color' => null, + ]; + }); + } } diff --git a/app/Services/QualityDocumentService.php b/app/Services/QualityDocumentService.php index e3142cd..2391b2f 100644 --- a/app/Services/QualityDocumentService.php +++ b/app/Services/QualityDocumentService.php @@ -257,6 +257,9 @@ public function update(int $id, array $data) $this->updateLocations($doc->id, $locations); } + // 개소 상태 기반 문서 상태 재계산 + $this->recalculateDocumentStatus($doc); + $this->auditLogger->log( $doc->tenant_id, self::AUDIT_TARGET, @@ -476,9 +479,87 @@ private function updateLocations(int $docId, array $locations): void if (! empty($updateData)) { $location->update($updateData); } + + // 검사 데이터 내용 기반 inspection_status 재계산 + $location->refresh(); + $newStatus = $this->determineLocationStatus($location->inspection_data); + + if ($location->inspection_status !== $newStatus) { + $location->update(['inspection_status' => $newStatus]); + } } } + /** + * 개소 상태 기반 문서 상태 재계산 + */ + private function recalculateDocumentStatus(QualityDocument $doc): void + { + $doc->load('locations'); + $total = $doc->locations->count(); + + if ($total === 0) { + $doc->update(['status' => QualityDocument::STATUS_RECEIVED]); + + return; + } + + $completedCount = $doc->locations + ->where('inspection_status', QualityDocumentLocation::STATUS_COMPLETED) + ->count(); + $inProgressCount = $doc->locations + ->where('inspection_status', QualityDocumentLocation::STATUS_IN_PROGRESS) + ->count(); + + if ($completedCount === $total) { + $doc->update(['status' => QualityDocument::STATUS_COMPLETED]); + } elseif ($completedCount > 0 || $inProgressCount > 0) { + $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + } else { + $doc->update(['status' => QualityDocument::STATUS_RECEIVED]); + } + } + + /** + * 검사 데이터 내용 기반 개소 상태 판정 + * + * - 데이터 없음 or 검사항목 0개+사진 없음 → pending + * - 검사항목 일부 or 사진 없음 → in_progress + * - 15개 검사항목 전부 + 사진 있음 → completed + */ + private function determineLocationStatus(?array $inspectionData): string + { + if (empty($inspectionData)) { + return QualityDocumentLocation::STATUS_PENDING; + } + + $judgmentFields = [ + 'appearanceProcessing', 'appearanceSewing', 'appearanceAssembly', + 'appearanceSmokeBarrier', 'appearanceBottomFinish', 'motor', 'material', + 'lengthJudgment', 'heightJudgment', 'guideRailGap', 'bottomFinishGap', + 'fireResistanceTest', 'smokeLeakageTest', 'openCloseTest', 'impactTest', + ]; + + $inspected = 0; + foreach ($judgmentFields as $field) { + if (isset($inspectionData[$field]) && $inspectionData[$field] !== null && $inspectionData[$field] !== '') { + $inspected++; + } + } + + $hasPhotos = ! empty($inspectionData['productImages']) && is_array($inspectionData['productImages']) && count($inspectionData['productImages']) > 0; + + if ($inspected === 0 && ! $hasPhotos) { + return QualityDocumentLocation::STATUS_PENDING; + } + + if ($inspected < count($judgmentFields) || ! $hasPhotos) { + return QualityDocumentLocation::STATUS_IN_PROGRESS; + } + + return QualityDocumentLocation::STATUS_COMPLETED; + } + /** * 수주 동기화 (update 시 사용) */ @@ -668,6 +749,7 @@ private function transformToFrontend(QualityDocument $doc, bool $detail = false) 'id' => $doc->id, 'quality_doc_number' => $doc->quality_doc_number, 'site_name' => $doc->site_name, + 'client_id' => $doc->client_id, 'client' => $doc->client?->name ?? '', 'location_count' => $doc->locations?->count() ?? 0, 'required_info' => $this->calculateRequiredInfo($doc), @@ -784,9 +866,6 @@ public function inspectLocation(int $docId, int $locId, array $data) if (isset($data['change_reason'])) { $updateData['change_reason'] = $data['change_reason']; } - if (isset($data['inspection_status'])) { - $updateData['inspection_status'] = $data['inspection_status']; - } if (array_key_exists('inspection_data', $data)) { $updateData['inspection_data'] = $data['inspection_data']; } @@ -795,11 +874,16 @@ public function inspectLocation(int $docId, int $locId, array $data) $location->update($updateData); } - // 상태를 진행중으로 변경 (접수 상태일 때) - if ($doc->isReceived()) { - $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); + // 검사 데이터 기반 개소 상태 자동 판정 + $location->refresh(); + $newLocStatus = $this->determineLocationStatus($location->inspection_data); + if ($location->inspection_status !== $newLocStatus) { + $location->update(['inspection_status' => $newLocStatus]); } + // 문서 상태 재계산 + $this->recalculateDocumentStatus($doc); + return $location->fresh()->toArray(); }); } diff --git a/app/Services/Stats/QuoteStatService.php b/app/Services/Stats/QuoteStatService.php index 49c6694..8d5e2e8 100644 --- a/app/Services/Stats/QuoteStatService.php +++ b/app/Services/Stats/QuoteStatService.php @@ -44,23 +44,8 @@ public function aggregateDaily(int $tenantId, Carbon $date): int ") ->first(); - // 상담 (sales_prospect_consultations) - $consultationCount = DB::connection('mysql') - ->table('sales_prospect_consultations') - ->whereDate('created_at', $dateStr) - ->count(); - - // 영업 기회 (sales_prospects - tenant_id 없음, created_at 기반) - $prospectStats = DB::connection('mysql') - ->table('sales_prospects') - ->whereDate('created_at', $dateStr) - ->whereNull('deleted_at') - ->selectRaw(" - COUNT(*) as created_count, - SUM(CASE WHEN status = 'contracted' THEN 1 ELSE 0 END) as won_count, - SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) as lost_count - ") - ->first(); + // sales_prospect_consultations, sales_prospects는 codebridge DB에 이관되었고 + // tenant_id가 없어 테넌트별 집계 불가 → 제외 StatQuotePipelineDaily::updateOrCreate( ['tenant_id' => $tenantId, 'stat_date' => $dateStr], @@ -71,14 +56,9 @@ public function aggregateDaily(int $tenantId, Carbon $date): int 'quote_rejected_count' => $quoteStats->rejected_count ?? 0, 'quote_conversion_count' => $conversionCount, 'quote_conversion_rate' => $conversionRate, - 'prospect_created_count' => $prospectStats->created_count ?? 0, - 'prospect_won_count' => $prospectStats->won_count ?? 0, - 'prospect_lost_count' => $prospectStats->lost_count ?? 0, - 'prospect_amount' => 0, // sales_prospects에 금액 컬럼 없음 'bidding_count' => $biddingStats->cnt ?? 0, 'bidding_won_count' => $biddingStats->won_count ?? 0, 'bidding_amount' => $biddingStats->total_amount ?? 0, - 'consultation_count' => $consultationCount, ] ); @@ -90,4 +70,4 @@ public function aggregateMonthly(int $tenantId, int $year, int $month): int // 견적 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가) return 0; } -} +} \ No newline at end of file diff --git a/app/Services/StatusBoardService.php b/app/Services/StatusBoardService.php index b38dca5..1d3545f 100644 --- a/app/Services/StatusBoardService.php +++ b/app/Services/StatusBoardService.php @@ -67,16 +67,35 @@ private function getOrdersStatus(int $tenantId, Carbon $today): array */ private function getBadDebtStatus(int $tenantId): array { - $count = BadDebt::query() - ->where('tenant_id', $tenantId) - ->where('status', BadDebt::STATUS_COLLECTING) // 추심 진행 중 - ->where('is_active', true) // 활성 채권만 (목록 페이지와 일치) - ->count(); + $query = BadDebt::query() + ->where('bad_debts.tenant_id', $tenantId) + ->where('bad_debts.status', BadDebt::STATUS_COLLECTING) + ->where('bad_debts.is_active', true); + + $count = (clone $query)->count(); + + // 최다 금액 거래처명 조회 + $subLabel = null; + if ($count > 0) { + $topClient = (clone $query) + ->join('clients', 'bad_debts.client_id', '=', 'clients.id') + ->selectRaw('clients.name, SUM(bad_debts.debt_amount) as total_amount') + ->groupBy('clients.id', 'clients.name') + ->orderByDesc('total_amount') + ->first(); + + if ($topClient) { + $subLabel = $count > 1 + ? $topClient->name.' 외 '.($count - 1).'건' + : $topClient->name; + } + } return [ 'id' => 'bad_debts', 'label' => __('message.status_board.bad_debts'), 'count' => $count, + 'sub_label' => $subLabel, 'path' => '/accounting/bad-debt-collection', 'isHighlighted' => false, ]; @@ -152,15 +171,31 @@ private function getTaxDeadlineStatus(int $tenantId, Carbon $today): array */ private function getNewClientStatus(int $tenantId, Carbon $today): array { - $count = Client::query() + $query = Client::query() ->where('tenant_id', $tenantId) - ->where('created_at', '>=', $today->copy()->subDays(7)) - ->count(); + ->where('created_at', '>=', $today->copy()->subDays(7)); + + $count = (clone $query)->count(); + + // 가장 최근 등록 업체명 조회 + $subLabel = null; + if ($count > 0) { + $latestClient = (clone $query) + ->orderByDesc('created_at') + ->first(); + + if ($latestClient) { + $subLabel = $count > 1 + ? $latestClient->name.' 외 '.($count - 1).'건' + : $latestClient->name; + } + } return [ 'id' => 'new_clients', 'label' => __('message.status_board.new_clients'), 'count' => $count, + 'sub_label' => $subLabel, 'path' => '/accounting/vendors', 'isHighlighted' => false, ]; @@ -211,19 +246,34 @@ private function getPurchaseStatus(int $tenantId): array */ private function getApprovalStatus(int $tenantId, int $userId): array { - $count = ApprovalStep::query() - ->whereHas('approval', function ($query) use ($tenantId) { - $query->where('tenant_id', $tenantId) + $query = ApprovalStep::query() + ->whereHas('approval', function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) ->where('status', 'pending'); }) ->where('approver_id', $userId) ->where('status', 'pending') - ->count(); + ->approvalOnly(); + + $count = (clone $query)->count(); + + // 최근 결재 유형 조회 + $subLabel = null; + if ($count > 0) { + $latestStep = (clone $query)->with('approval')->latest()->first(); + if ($latestStep && $latestStep->approval) { + $typeLabel = $latestStep->approval->title ?? '결재'; + $subLabel = $count > 1 + ? $typeLabel.' 외 '.($count - 1).'건' + : $typeLabel; + } + } return [ 'id' => 'approvals', 'label' => __('message.status_board.approvals'), 'count' => $count, + 'sub_label' => $subLabel, 'path' => '/approval/inbox', 'isHighlighted' => $count > 0, ]; diff --git a/app/Services/TenantBootstrap/RecipeRegistry.php b/app/Services/TenantBootstrap/RecipeRegistry.php index 9ca16eb..cef7992 100644 --- a/app/Services/TenantBootstrap/RecipeRegistry.php +++ b/app/Services/TenantBootstrap/RecipeRegistry.php @@ -2,6 +2,7 @@ namespace App\Services\TenantBootstrap; +use App\Services\TenantBootstrap\Steps\ApprovalFormsStep; use App\Services\TenantBootstrap\Steps\CapabilityProfilesStep; use App\Services\TenantBootstrap\Steps\CategoriesStep; use App\Services\TenantBootstrap\Steps\MenusStep; @@ -24,6 +25,7 @@ public function steps(string $recipe = 'STANDARD'): array new CategoriesStep, // new MenusStep, // Disabled: Use MenuBootstrapService in RegisterService instead new SettingsStep, + new ApprovalFormsStep, ], }; } diff --git a/app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php b/app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php new file mode 100644 index 0000000..5127e8e --- /dev/null +++ b/app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php @@ -0,0 +1,105 @@ +hasTable('approval_forms')) { + return; + } + + $now = now(); + $forms = [ + [ + 'name' => '품의서', + 'code' => 'proposal', + 'category' => '일반', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'title', 'type' => 'text', 'label' => '제목', 'required' => true], + ['name' => 'vendor', 'type' => 'text', 'label' => '거래처', 'required' => false], + ['name' => 'description', 'type' => 'textarea', 'label' => '내용', 'required' => true], + ['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true], + ['name' => 'estimatedCost', 'type' => 'number', 'label' => '예상비용', 'required' => false], + ], + ]), + ], + [ + 'name' => '지출결의서', + 'code' => 'expenseReport', + 'category' => '경비', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'requestDate', 'type' => 'date', 'label' => '신청일', 'required' => true], + ['name' => 'paymentDate', 'type' => 'date', 'label' => '지급일', 'required' => true], + ['name' => 'items', 'type' => 'array', 'label' => '지출항목', 'required' => true], + ['name' => 'totalAmount', 'type' => 'number', 'label' => '총액', 'required' => true], + ], + ]), + ], + [ + 'name' => '비용견적서', + 'code' => 'expenseEstimate', + 'category' => '경비', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'items', 'type' => 'array', 'label' => '비용항목', 'required' => true], + ['name' => 'totalExpense', 'type' => 'number', 'label' => '총지출', 'required' => true], + ['name' => 'accountBalance', 'type' => 'number', 'label' => '계좌잔액', 'required' => true], + ], + ]), + ], + [ + 'name' => '근태신청', + 'code' => 'attendance_request', + 'category' => '일반', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '신청자', 'required' => true], + ['name' => 'request_type', 'type' => 'select', 'label' => '신청유형', 'required' => true], + ['name' => 'period', 'type' => 'daterange', 'label' => '기간', 'required' => true], + ['name' => 'days', 'type' => 'number', 'label' => '일수', 'required' => true], + ['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true], + ], + ]), + ], + [ + 'name' => '사유서', + 'code' => 'reason_report', + 'category' => '일반', + 'template' => json_encode([ + 'fields' => [ + ['name' => 'user_name', 'type' => 'text', 'label' => '작성자', 'required' => true], + ['name' => 'report_type', 'type' => 'select', 'label' => '사유유형', 'required' => true], + ['name' => 'target_date', 'type' => 'date', 'label' => '대상일', 'required' => true], + ['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true], + ], + ]), + ], + ]; + + foreach ($forms as $form) { + DB::table('approval_forms')->updateOrInsert( + ['tenant_id' => $tenantId, 'code' => $form['code']], + [ + 'name' => $form['name'], + 'category' => $form['category'], + 'template' => $form['template'], + 'is_active' => true, + 'updated_at' => $now, + 'created_at' => $now, + ] + ); + } + } +}