feat: 품질관리·대시보드·결재 양식 개선
- 품질관리 수주선택 필터링 + 검사 상태 자동 재계산 - 제품검사 요청서 Document(EAV) 자동생성 및 동기화 - 현황판 결재 카드 approvalOnly 스코프 + sub_label 추가 - 캘린더 어음 만기일 일정 연동 - QuoteStatService codebridge DB 커넥션 연결 - 테넌트 부트스트랩 기본 결재 양식 자동 시딩
This commit is contained in:
@@ -12,6 +12,8 @@ class QualityDocumentLocation extends Model
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
|
||||
const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
105
app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php
Normal file
105
app/Services/TenantBootstrap/Steps/ApprovalFormsStep.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user