feat: 품질관리·대시보드·결재 양식 개선

- 품질관리 수주선택 필터링 + 검사 상태 자동 재계산
- 제품검사 요청서 Document(EAV) 자동생성 및 동기화
- 현황판 결재 카드 approvalOnly 스코프 + sub_label 추가
- 캘린더 어음 만기일 일정 연동
- QuoteStatService codebridge DB 커넥션 연결
- 테넌트 부트스트랩 기본 결재 양식 자동 시딩
This commit is contained in:
2026-03-10 11:29:56 +09:00
parent c46b950fde
commit bd500a87bd
7 changed files with 316 additions and 42 deletions

View File

@@ -12,6 +12,8 @@ class QualityDocumentLocation extends Model
const STATUS_PENDING = 'pending';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
protected $fillable = [

View File

@@ -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,
];
});
}
}

View File

@@ -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();
});
}

View File

@@ -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;
}
}
}

View File

@@ -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,
];

View File

@@ -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,
],
};
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services\TenantBootstrap\Steps;
use App\Services\TenantBootstrap\Contracts\TenantBootstrapStep;
use Illuminate\Support\Facades\DB;
class ApprovalFormsStep implements TenantBootstrapStep
{
public function key(): string
{
return 'approval_forms_seed';
}
public function run(int $tenantId): void
{
if (! DB::getSchemaBuilder()->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,
]
);
}
}
}