Compare commits
48 Commits
develop
...
091719e81b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091719e81b | ||
|
|
b06438cc52 | ||
|
|
1e5cd70081 | ||
|
|
b21c9de6eb | ||
|
|
91567c54bd | ||
|
|
88eb507426 | ||
|
|
0bf56931fa | ||
|
|
c55a4a42e6 | ||
|
|
428b2e2a12 | ||
|
|
64877869e6 | ||
|
|
92efe2e83b | ||
|
|
78eb9363f4 | ||
|
|
c611f551a6 | ||
|
|
48889a7250 | ||
|
|
18f39433ae | ||
|
|
3785d87df4 | ||
|
|
d9075e5da5 | ||
| 1d71b588cb | |||
|
|
521229adcf | ||
|
|
5ce2d2fcbf | ||
|
|
5f5b5db59f | ||
|
|
814b965748 | ||
|
|
c55380f1d2 | ||
|
|
4870b7e6eb | ||
|
|
88ef6a8490 | ||
|
|
2fd122feba | ||
|
|
5a0deddb58 | ||
| d68fd56232 | |||
|
|
d8abc57271 | ||
|
|
d7dd6cdbc5 | ||
|
|
2bb3a2872a | ||
|
|
6df1da9e42 | ||
|
|
93e94901b7 | ||
|
|
7028e27517 | ||
|
|
1d2876d90c | ||
|
|
bfb821698a | ||
|
|
b80f4a0392 | ||
|
|
2ed90dc6db | ||
|
|
347d351d9d | ||
|
|
87a8930c00 | ||
|
|
bbcb0205fe | ||
| 9bf0cc8df2 | |||
|
|
255fad99e7 | ||
|
|
08b07c724a | ||
|
|
91cdfe9917 | ||
|
|
10c09b9fea | ||
|
|
04bb990045 | ||
| 7543054df3 |
@@ -54,4 +54,4 @@ ## 관련 파일
|
||||
|
||||
- `api/app/Services/ComprehensiveAnalysisService.php`
|
||||
- `api/database/seeders/ComprehensiveAnalysisSeeder.php`
|
||||
- `docs/dev/dev_plans/react-mock-remaining-tasks.md`
|
||||
- `docs/plans/react-mock-remaining-tasks.md`
|
||||
|
||||
@@ -15,7 +15,7 @@ ## Phase 구성
|
||||
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
|
||||
|
||||
## 핵심 파일
|
||||
- 계획 문서: docs/dev/dev_plans/db-backup-system-plan.md
|
||||
- 계획 문서: docs/plans/db-backup-system-plan.md
|
||||
- 개발서버: 114.203.209.83 (SSH: hskwon)
|
||||
- DB: sam (메인) + sam_stat (통계)
|
||||
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)
|
||||
|
||||
@@ -16,7 +16,7 @@ ### 생성된 파일
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest |
|
||||
| `api/docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
|
||||
| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
|
||||
|
||||
### 수정된 파일
|
||||
| 파일 | 설명 |
|
||||
@@ -93,9 +93,9 @@ ### QuoteCalculationService::calculateBomBulk()
|
||||
- 개별 품목 실패가 전체에 영향 없음 (예외 처리)
|
||||
|
||||
## 관련 문서
|
||||
- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md`
|
||||
- Phase 1.1 문서: `docs/dev/changes/20260102_quote_bom_calculation_api.md`
|
||||
- Phase 1.2 문서: `docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md`
|
||||
- 계획 문서: `docs/plans/quote-calculation-api-plan.md`
|
||||
- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md`
|
||||
- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md`
|
||||
|
||||
## 다음 단계
|
||||
- React 프론트엔드에서 `/calculate/bom/bulk` API 연동
|
||||
|
||||
@@ -107,10 +107,3 @@ fixed_tools: []
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
@@ -509,7 +509,6 @@ ### 2. Multi-tenancy & Models
|
||||
- SoftDeletes by default
|
||||
- Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required)
|
||||
- FK constraints: Created during design, minimal in production
|
||||
- **🔴 쿼리 수정 시 모델 스코프 우선**: `where('컬럼', '값')` 하드코딩 전에 반드시 모델에 정의된 스코프(scopeActive 등)를 먼저 확인하고, 스코프가 있으면 `Model::active()` 형태로 사용할 것
|
||||
|
||||
### 3. Middleware Stack
|
||||
- ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 논리적 데이터베이스 관계 문서
|
||||
|
||||
> **자동 생성**: 2026-03-06 21:25:05
|
||||
> **자동 생성**: 2026-02-21 16:28:35
|
||||
> **소스**: Eloquent 모델 관계 분석
|
||||
|
||||
## 📊 모델별 관계 현황
|
||||
@@ -580,10 +580,17 @@ ### roles
|
||||
**모델**: `App\Models\Permissions\Role`
|
||||
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
- **menuPermissions()**: hasMany → `role_menu_permissions`
|
||||
- **userRoles()**: hasMany → `user_roles`
|
||||
- **users()**: belongsToMany → `users`
|
||||
- **permissions()**: belongsToMany → `permissions`
|
||||
|
||||
### role_menu_permissions
|
||||
**모델**: `App\Models\Permissions\RoleMenuPermission`
|
||||
|
||||
- **role()**: belongsTo → `roles`
|
||||
- **menu()**: belongsTo → `menus`
|
||||
|
||||
### popups
|
||||
**모델**: `App\Models\Popups\Popup`
|
||||
|
||||
@@ -630,7 +637,6 @@ ### work_orders
|
||||
- **stepProgress()**: hasMany → `work_order_step_progress`
|
||||
- **materialInputs()**: hasMany → `work_order_material_inputs`
|
||||
- **shipments()**: hasMany → `shipments`
|
||||
- **inspections()**: hasMany → `inspections`
|
||||
- **bendingDetail()**: hasOne → `work_order_bending_details`
|
||||
- **documents()**: morphMany → `documents`
|
||||
|
||||
@@ -737,7 +743,6 @@ ### push_notification_settings
|
||||
### inspections
|
||||
**모델**: `App\Models\Qualitys\Inspection`
|
||||
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **item()**: belongsTo → `items`
|
||||
- **inspector()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
@@ -753,38 +758,6 @@ ### lot_sales
|
||||
|
||||
- **lot()**: belongsTo → `lots`
|
||||
|
||||
### performance_reports
|
||||
**모델**: `App\Models\Qualitys\PerformanceReport`
|
||||
|
||||
- **qualityDocument()**: belongsTo → `quality_documents`
|
||||
- **confirmer()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
|
||||
### quality_documents
|
||||
**모델**: `App\Models\Qualitys\QualityDocument`
|
||||
|
||||
- **client()**: belongsTo → `clients`
|
||||
- **inspector()**: belongsTo → `users`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **documentOrders()**: hasMany → `quality_document_orders`
|
||||
- **locations()**: hasMany → `quality_document_locations`
|
||||
- **performanceReport()**: hasOne → `performance_reports`
|
||||
|
||||
### quality_document_locations
|
||||
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
|
||||
|
||||
- **qualityDocument()**: belongsTo → `quality_documents`
|
||||
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
|
||||
- **orderItem()**: belongsTo → `order_items`
|
||||
- **document()**: belongsTo → `documents`
|
||||
|
||||
### quality_document_orders
|
||||
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
|
||||
|
||||
- **qualityDocument()**: belongsTo → `quality_documents`
|
||||
- **order()**: belongsTo → `orders`
|
||||
- **locations()**: hasMany → `quality_document_locations`
|
||||
|
||||
### quotes
|
||||
**모델**: `App\Models\Quote\Quote`
|
||||
|
||||
@@ -863,7 +836,6 @@ ### approvals
|
||||
- **steps()**: hasMany → `approval_steps`
|
||||
- **approverSteps()**: hasMany → `approval_steps`
|
||||
- **referenceSteps()**: hasMany → `approval_steps`
|
||||
- **linkable()**: morphTo → `(Polymorphic)`
|
||||
|
||||
### approval_forms
|
||||
**모델**: `App\Models\Tenants\ApprovalForm`
|
||||
@@ -961,16 +933,6 @@ ### expense_accounts
|
||||
|
||||
- **vendor()**: belongsTo → `clients`
|
||||
|
||||
### journal_entrys
|
||||
**모델**: `App\Models\Tenants\JournalEntry`
|
||||
|
||||
- **lines()**: hasMany → `journal_entry_lines`
|
||||
|
||||
### journal_entry_lines
|
||||
**모델**: `App\Models\Tenants\JournalEntryLine`
|
||||
|
||||
- **journalEntry()**: belongsTo → `journal_entries`
|
||||
|
||||
### leaves
|
||||
**모델**: `App\Models\Tenants\Leave`
|
||||
|
||||
@@ -999,10 +961,7 @@ ### leave_policys
|
||||
### loans
|
||||
**모델**: `App\Models\Tenants\Loan`
|
||||
|
||||
- **user()**: belongsTo → `users`
|
||||
- **withdrawal()**: belongsTo → `withdrawals`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
|
||||
### payments
|
||||
**모델**: `App\Models\Tenants\Payment`
|
||||
@@ -1084,7 +1043,6 @@ ### shipments
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **items()**: hasMany → `shipment_items`
|
||||
- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches`
|
||||
|
||||
### shipment_items
|
||||
**모델**: `App\Models\Tenants\ShipmentItem`
|
||||
@@ -1092,11 +1050,6 @@ ### shipment_items
|
||||
- **shipment()**: belongsTo → `shipments`
|
||||
- **stockLot()**: belongsTo → `stock_lots`
|
||||
|
||||
### shipment_vehicle_dispatchs
|
||||
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
|
||||
|
||||
- **shipment()**: belongsTo → `shipments`
|
||||
|
||||
### sites
|
||||
**모델**: `App\Models\Tenants\Site`
|
||||
|
||||
@@ -1194,8 +1147,6 @@ ### tenant_user_profiles
|
||||
### today_issues
|
||||
**모델**: `App\Models\Tenants\TodayIssue`
|
||||
|
||||
- **reader()**: belongsTo → `users`
|
||||
- **targetUser()**: belongsTo → `users`
|
||||
|
||||
### withdrawals
|
||||
**모델**: `App\Models\Tenants\Withdrawal`
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Quote\Quote;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillQuoteProductCodeCommand extends Command
|
||||
{
|
||||
protected $signature = 'data:backfill-quote-product-code {--dry-run : 실제 저장하지 않고 결과만 출력}';
|
||||
|
||||
protected $description = 'quotes.product_code가 비어있는 레코드에 calculation_inputs.items[0].productCode 값 보정';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$quotes = Quote::whereNull('product_code')
|
||||
->whereNotNull('calculation_inputs')
|
||||
->get();
|
||||
|
||||
$this->info("대상: {$quotes->count()}건".($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($quotes as $quote) {
|
||||
$inputs = $quote->calculation_inputs;
|
||||
if (! is_array($inputs)) {
|
||||
$inputs = json_decode($inputs, true);
|
||||
}
|
||||
|
||||
$productCode = $inputs['items'][0]['productCode'] ?? null;
|
||||
|
||||
if (! $productCode) {
|
||||
$skipped++;
|
||||
$this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$quote->update(['product_code' => $productCode]);
|
||||
}
|
||||
|
||||
$updated++;
|
||||
$this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}");
|
||||
}
|
||||
|
||||
$this->info("완료: 보정 {$updated}건, 스킵 {$skipped}건");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle
|
||||
@@ -32,10 +31,10 @@ public function headings(): array
|
||||
return [
|
||||
['일일 일보 - '.$this->report['date']],
|
||||
[],
|
||||
['전월 이월', number_format($this->report['previous_balance']).'원'],
|
||||
['당월 입금', number_format($this->report['daily_deposit']).'원'],
|
||||
['당월 출금', number_format($this->report['daily_withdrawal']).'원'],
|
||||
['잔액', number_format($this->report['current_balance']).'원'],
|
||||
['전일 잔액', number_format($this->report['previous_balance']).'원'],
|
||||
['당일 입금액', number_format($this->report['daily_deposit']).'원'],
|
||||
['당일 출금액', number_format($this->report['daily_withdrawal']).'원'],
|
||||
['당일 잔액', number_format($this->report['current_balance']).'원'],
|
||||
[],
|
||||
['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'],
|
||||
];
|
||||
@@ -48,7 +47,6 @@ public function array(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
// ── 예금 입출금 내역 ──
|
||||
foreach ($this->report['details'] as $detail) {
|
||||
$rows[] = [
|
||||
$detail['type_label'],
|
||||
@@ -60,7 +58,7 @@ public function array(): array
|
||||
];
|
||||
}
|
||||
|
||||
// 합계 행
|
||||
// 합계 행 추가
|
||||
$rows[] = [];
|
||||
$rows[] = [
|
||||
'합계',
|
||||
@@ -71,37 +69,6 @@ public function array(): array
|
||||
'',
|
||||
];
|
||||
|
||||
// ── 어음 및 외상매출채권 현황 ──
|
||||
$noteReceivables = $this->report['note_receivables'] ?? [];
|
||||
|
||||
$rows[] = [];
|
||||
$rows[] = [];
|
||||
$rows[] = ['어음 및 외상매출채권 현황'];
|
||||
$rows[] = ['No.', '내용', '금액', '발행일', '만기일'];
|
||||
|
||||
$noteTotal = 0;
|
||||
$no = 1;
|
||||
foreach ($noteReceivables as $item) {
|
||||
$amount = $item['current_balance'] ?? 0;
|
||||
$noteTotal += $amount;
|
||||
$rows[] = [
|
||||
$no++,
|
||||
$item['content'] ?? '-',
|
||||
$amount > 0 ? number_format($amount) : '',
|
||||
$item['issue_date'] ?? '-',
|
||||
$item['due_date'] ?? '-',
|
||||
];
|
||||
}
|
||||
|
||||
// 어음 합계
|
||||
$rows[] = [
|
||||
'합계',
|
||||
'',
|
||||
number_format($noteTotal),
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
@@ -110,7 +77,7 @@ public function array(): array
|
||||
*/
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
$styles = [
|
||||
return [
|
||||
1 => ['font' => ['bold' => true, 'size' => 14]],
|
||||
3 => ['font' => ['bold' => true]],
|
||||
4 => ['font' => ['bold' => true]],
|
||||
@@ -119,32 +86,10 @@ public function styles(Worksheet $sheet): array
|
||||
8 => [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'E0E0E0'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// 어음 섹션 헤더 스타일 (동적 행 번호)
|
||||
// headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행
|
||||
$detailCount = count($this->report['details']);
|
||||
$noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행
|
||||
$noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행
|
||||
|
||||
$styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]];
|
||||
$styles[$noteHeaderRow] = [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => 'E0E0E0'],
|
||||
],
|
||||
];
|
||||
|
||||
// 어음 합계 행
|
||||
$noteCount = count($this->report['note_receivables'] ?? []);
|
||||
$noteTotalRow = $noteHeaderRow + $noteCount + 1;
|
||||
$styles[$noteTotalRow] = ['font' => ['bold' => true]];
|
||||
|
||||
return $styles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* - 두께 매핑 (normalizeThickness)
|
||||
* - 면적 계산 (calculateArea)
|
||||
*
|
||||
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
|
||||
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
|
||||
*/
|
||||
class Legacy5130Calculator
|
||||
{
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
|
||||
use App\Http\Requests\V1\AccountSubject\UpdateAccountSubjectRequest;
|
||||
use App\Services\AccountCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountSubjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountCodeService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정과목 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'search', 'category', 'sub_category',
|
||||
'department_type', 'depth', 'is_active', 'selectable',
|
||||
]);
|
||||
|
||||
$subjects = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($subjects, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 등록
|
||||
*/
|
||||
public function store(StoreAccountSubjectRequest $request)
|
||||
{
|
||||
$subject = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($subject, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 수정
|
||||
*/
|
||||
public function update(int $id, UpdateAccountSubjectRequest $request)
|
||||
{
|
||||
$subject = $this->service->update($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($subject, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 활성/비활성 토글
|
||||
*/
|
||||
public function toggleStatus(int $id, Request $request)
|
||||
{
|
||||
$isActive = (bool) $request->input('is_active', true);
|
||||
|
||||
$subject = $this->service->toggleStatus($id, $isActive);
|
||||
|
||||
return ApiResponse::success($subject, __('message.toggled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 삭제
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$this->service->destroy($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 계정과목표 일괄 생성 (더존 표준)
|
||||
*/
|
||||
public function seedDefaults()
|
||||
{
|
||||
$count = $this->service->seedDefaults();
|
||||
|
||||
return ApiResponse::success(
|
||||
['inserted_count' => $count],
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\BarobillService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BarobillController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BarobillService $barobillService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 연동 현황 조회
|
||||
*/
|
||||
public function status()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$setting = $this->barobillService->getSetting();
|
||||
|
||||
return [
|
||||
'bank_service_count' => 0,
|
||||
'account_link_count' => 0,
|
||||
'member' => $setting ? [
|
||||
'barobill_id' => $setting->barobill_id,
|
||||
'biz_no' => $setting->corp_num,
|
||||
'status' => $setting->isVerified() ? 'active' : 'inactive',
|
||||
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
|
||||
] : null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 로그인 정보 등록
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'barobill_id' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
return $this->barobillService->saveSetting([
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
]);
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 회원가입 정보 등록
|
||||
*/
|
||||
public function signup(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'business_number' => 'required|string|size:10',
|
||||
'company_name' => 'required|string',
|
||||
'ceo_name' => 'required|string',
|
||||
'business_type' => 'nullable|string',
|
||||
'business_category' => 'nullable|string',
|
||||
'address' => 'nullable|string',
|
||||
'barobill_id' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
'manager_name' => 'nullable|string',
|
||||
'manager_phone' => 'nullable|string',
|
||||
'manager_email' => 'nullable|email',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
return $this->barobillService->saveSetting([
|
||||
'corp_num' => $data['business_number'],
|
||||
'corp_name' => $data['company_name'],
|
||||
'ceo_name' => $data['ceo_name'],
|
||||
'biz_type' => $data['business_type'] ?? null,
|
||||
'biz_class' => $data['business_category'] ?? null,
|
||||
'addr' => $data['address'] ?? null,
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
'contact_name' => $data['manager_name'] ?? null,
|
||||
'contact_tel' => $data['manager_phone'] ?? null,
|
||||
'contact_id' => $data['manager_email'] ?? null,
|
||||
]);
|
||||
}, __('message.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 은행 빠른조회 서비스 URL 조회
|
||||
*/
|
||||
public function bankServiceUrl(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/BankAccountService'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계좌 연동 등록 URL 조회
|
||||
*/
|
||||
public function accountLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Bank/AccountLink'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 연동 등록 URL 조회
|
||||
*/
|
||||
public function cardLinkUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Card/CardLink'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공인인증서 등록 URL 조회
|
||||
*/
|
||||
public function certificateUrl()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$baseUrl = config('services.barobill.test_mode', true)
|
||||
? 'https://testws.barobill.co.kr'
|
||||
: 'https://ws.barobill.co.kr';
|
||||
|
||||
return ['url' => $baseUrl.'/Certificate/Register'];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,12 @@ public function __construct(
|
||||
*/
|
||||
public function show()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->barobillService->getSetting();
|
||||
}, __('message.fetched'));
|
||||
$setting = $this->barobillService->getSetting();
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $setting,
|
||||
message: __('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,9 +31,12 @@ public function show()
|
||||
*/
|
||||
public function save(SaveBarobillSettingRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->barobillService->saveSetting($request->validated());
|
||||
}, __('message.saved'));
|
||||
$setting = $this->barobillService->saveSetting($request->validated());
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $setting,
|
||||
message: __('message.saved')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,8 +44,11 @@ public function save(SaveBarobillSettingRequest $request)
|
||||
*/
|
||||
public function testConnection()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->barobillService->testConnection();
|
||||
}, __('message.barobill.connection_success'));
|
||||
$result = $this->barobillService->testConnection();
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $result,
|
||||
message: __('message.barobill.connection_success')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,56 +51,4 @@ public function summary(Request $request)
|
||||
);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 등록
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'start_time' => 'nullable|date_format:H:i',
|
||||
'end_time' => 'nullable|date_format:H:i',
|
||||
'is_all_day' => 'boolean',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($validated) {
|
||||
return $this->calendarService->createSchedule($validated);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 수정
|
||||
*/
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:200',
|
||||
'description' => 'nullable|string|max:1000',
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'start_time' => 'nullable|date_format:H:i',
|
||||
'end_time' => 'nullable|date_format:H:i',
|
||||
'is_all_day' => 'boolean',
|
||||
'color' => 'nullable|string|max:20',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($id, $validated) {
|
||||
return $this->calendarService->updateSchedule($id, $validated);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->calendarService->deleteSchedule($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Services\CardTransactionService;
|
||||
use App\Services\JournalSyncService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -16,8 +14,7 @@
|
||||
class CardTransactionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CardTransactionService $service,
|
||||
protected JournalSyncService $journalSyncService,
|
||||
protected CardTransactionService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -151,105 +148,4 @@ public function destroy(int $id): JsonResponse
|
||||
return $this->service->destroy($id);
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분개 (Journal Entries)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 카드 거래 분개 조회
|
||||
*/
|
||||
public function getJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "card_{$id}";
|
||||
$data = $this->journalSyncService->getForSource(
|
||||
JournalEntry::SOURCE_CARD_TRANSACTION,
|
||||
$sourceKey
|
||||
);
|
||||
|
||||
if (! $data) {
|
||||
return ['items' => []];
|
||||
}
|
||||
|
||||
// 프론트엔드가 기대하는 items 형식으로 변환
|
||||
$items = array_map(fn ($row) => [
|
||||
'id' => $row['id'],
|
||||
'supply_amount' => $row['debit_amount'],
|
||||
'tax_amount' => 0,
|
||||
'account_code' => $row['account_code'],
|
||||
'deduction_type' => 'deductible',
|
||||
'vendor_name' => $row['vendor_name'],
|
||||
'description' => $row['memo'],
|
||||
'memo' => '',
|
||||
], $data['rows']);
|
||||
|
||||
return ['items' => $items];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 거래 분개 저장
|
||||
*/
|
||||
public function storeJournalEntries(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.supply_amount' => 'required|integer|min:0',
|
||||
'items.*.tax_amount' => 'required|integer|min:0',
|
||||
'items.*.account_code' => 'required|string|max:20',
|
||||
'items.*.deduction_type' => 'nullable|string|max:20',
|
||||
'items.*.vendor_name' => 'nullable|string|max:200',
|
||||
'items.*.description' => 'nullable|string|max:500',
|
||||
'items.*.memo' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// 카드 거래 정보 조회 (날짜용)
|
||||
$transaction = $this->service->show($id);
|
||||
if (! $transaction) {
|
||||
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
}
|
||||
|
||||
$entryDate = $transaction->used_at
|
||||
? $transaction->used_at->format('Y-m-d')
|
||||
: ($transaction->withdrawal_date?->format('Y-m-d') ?? now()->format('Y-m-d'));
|
||||
|
||||
// items → journal rows 변환 (각 item을 차변 행으로)
|
||||
$rows = [];
|
||||
foreach ($validated['items'] as $item) {
|
||||
$amount = ($item['supply_amount'] ?? 0) + ($item['tax_amount'] ?? 0);
|
||||
$rows[] = [
|
||||
'side' => 'debit',
|
||||
'account_code' => $item['account_code'],
|
||||
'debit_amount' => $amount,
|
||||
'credit_amount' => 0,
|
||||
'vendor_name' => $item['vendor_name'] ?? '',
|
||||
'memo' => $item['description'] ?? $item['memo'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// 대변 합계 행 (카드미지급금)
|
||||
$totalAmount = array_sum(array_column($rows, 'debit_amount'));
|
||||
$rows[] = [
|
||||
'side' => 'credit',
|
||||
'account_code' => '25300', // 미지급금 (표준 코드)
|
||||
'account_name' => '미지급금',
|
||||
'debit_amount' => 0,
|
||||
'credit_amount' => $totalAmount,
|
||||
'vendor_name' => $transaction->merchant_name ?? '',
|
||||
'memo' => '카드결제',
|
||||
];
|
||||
|
||||
$sourceKey = "card_{$id}";
|
||||
|
||||
return $this->journalSyncService->saveForSource(
|
||||
JournalEntry::SOURCE_CARD_TRANSACTION,
|
||||
$sourceKey,
|
||||
$entryDate,
|
||||
"카드거래 분개 (#{$id})",
|
||||
$rows,
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exports\DailyReportExport;
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\DailyReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* 일일 보고서 컨트롤러
|
||||
@@ -61,19 +58,4 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->service->summary($params);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 보고서 엑셀 다운로드
|
||||
*/
|
||||
public function export(Request $request): BinaryFileResponse
|
||||
{
|
||||
$params = $request->validate([
|
||||
'date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$reportData = $this->service->exportData($params);
|
||||
$filename = '일일일보_'.$reportData['date'].'.xlsx';
|
||||
|
||||
return Excel::download(new DailyReportExport($reportData), $filename);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\DashboardCeoService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* CEO 대시보드 섹션별 API 컨트롤러
|
||||
*
|
||||
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
|
||||
*/
|
||||
class DashboardCeoController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DashboardCeoService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 매출 현황 요약
|
||||
* GET /api/v1/dashboard/sales/summary
|
||||
*/
|
||||
public function salesSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->salesSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 매입 현황 요약
|
||||
* GET /api/v1/dashboard/purchases/summary
|
||||
*/
|
||||
public function purchasesSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->purchasesSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산 현황 요약
|
||||
* GET /api/v1/dashboard/production/summary
|
||||
*/
|
||||
public function productionSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->productionSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미출고 내역 요약
|
||||
* GET /api/v1/dashboard/unshipped/summary
|
||||
*/
|
||||
public function unshippedSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->unshippedSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 시공 현황 요약
|
||||
* GET /api/v1/dashboard/construction/summary
|
||||
*/
|
||||
public function constructionSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->constructionSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 근태 현황 요약
|
||||
* GET /api/v1/dashboard/attendance/summary
|
||||
*/
|
||||
public function attendanceSummary(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->attendanceSummary(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,22 +74,6 @@ public function destroy(int $id): JsonResponse
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* rendered_html 스냅샷 저장 (Lazy Snapshot)
|
||||
* PATCH /v1/documents/{id}/snapshot
|
||||
*/
|
||||
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$renderedHtml = $request->validated()['rendered_html'] ?? null;
|
||||
if (! $renderedHtml) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
|
||||
}
|
||||
|
||||
return $this->service->patchSnapshot($id, $renderedHtml);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FQC 일괄생성 (제품검사)
|
||||
// =========================================================================
|
||||
|
||||
@@ -33,20 +33,4 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 상세 조회 (모달용)
|
||||
*/
|
||||
public function detail(Request $request): JsonResponse
|
||||
{
|
||||
$companyType = $request->query('company_type', 'medium');
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) {
|
||||
return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,16 +128,13 @@ public function summary(Request $request)
|
||||
/**
|
||||
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
|
||||
*
|
||||
* @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
|
||||
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체)
|
||||
*/
|
||||
public function dashboardDetail(Request $request)
|
||||
{
|
||||
$transactionType = $request->query('transaction_type');
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
$search = $request->query('search');
|
||||
|
||||
$data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
|
||||
$data = $this->service->dashboardDetail($transactionType);
|
||||
|
||||
return ApiResponse::success($data, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\GeneralJournalEntry\StoreManualJournalRequest;
|
||||
use App\Http\Requests\V1\GeneralJournalEntry\UpdateJournalRequest;
|
||||
use App\Services\GeneralJournalEntryService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GeneralJournalEntryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GeneralJournalEntryService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 일반전표 통합 목록 조회
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'start_date', 'end_date', 'search', 'page', 'per_page',
|
||||
]);
|
||||
|
||||
$result = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계
|
||||
*/
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'start_date', 'end_date', 'search',
|
||||
]);
|
||||
|
||||
$summary = $this->service->summary($params);
|
||||
|
||||
return ApiResponse::success($summary, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수기전표 등록
|
||||
*/
|
||||
public function store(StoreManualJournalRequest $request)
|
||||
{
|
||||
$entry = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($entry, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 상세 조회 (분개 수정 모달용)
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$detail = $this->service->show($id);
|
||||
|
||||
return ApiResponse::success($detail, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 수정
|
||||
*/
|
||||
public function updateJournal(int $id, UpdateJournalRequest $request)
|
||||
{
|
||||
$entry = $this->service->updateJournal($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($entry, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 삭제
|
||||
*/
|
||||
public function destroyJournal(int $id)
|
||||
{
|
||||
$this->service->destroyJournal($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -34,16 +34,6 @@ public function stats(Request $request)
|
||||
}, __('message.inspection.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캘린더 스케줄 조회
|
||||
*/
|
||||
public function calendar(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->calendar($request->all());
|
||||
}, __('message.inspection.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 단건 조회
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
use App\Http\Requests\Loan\LoanUpdateRequest;
|
||||
use App\Services\LoanService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LoanController extends Controller
|
||||
{
|
||||
@@ -34,10 +33,8 @@ public function index(LoanIndexRequest $request): JsonResponse
|
||||
*/
|
||||
public function summary(LoanIndexRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$userId = $validated['user_id'] ?? null;
|
||||
$category = $validated['category'] ?? null;
|
||||
$result = $this->loanService->summary($userId, $category);
|
||||
$userId = $request->validated()['user_id'] ?? null;
|
||||
$result = $this->loanService->summary($userId);
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
@@ -45,12 +42,9 @@ public function summary(LoanIndexRequest $request): JsonResponse
|
||||
/**
|
||||
* 가지급금 대시보드
|
||||
*/
|
||||
public function dashboard(Request $request): JsonResponse
|
||||
public function dashboard(): JsonResponse
|
||||
{
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
$result = $this->loanService->dashboard($startDate, $endDate);
|
||||
$result = $this->loanService->dashboard();
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
|
||||
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
|
||||
use App\Services\PerformanceReportService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PerformanceReportController extends Controller
|
||||
{
|
||||
public function __construct(private PerformanceReportService $service) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function stats(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function confirm(PerformanceReportConfirmRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->confirm($request->validated()['ids']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function unconfirm(PerformanceReportConfirmRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->unconfirm($request->validated()['ids']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function updateMemo(PerformanceReportMemoRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$data = $request->validated();
|
||||
|
||||
return $this->service->updateMemo($data['ids'], $data['memo']);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function missing(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->missing($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
|
||||
use App\Services\ProductionOrderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductionOrderService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 생산지시 목록 조회
|
||||
*/
|
||||
public function index(ProductionOrderIndexRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->index($request->validated());
|
||||
|
||||
return ApiResponse::success($result, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 상태별 통계
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->service->stats();
|
||||
|
||||
return ApiResponse::success($stats, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 상세 조회
|
||||
*/
|
||||
public function show(int $orderId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$detail = $this->service->show($orderId);
|
||||
|
||||
return ApiResponse::success($detail, __('message.fetched'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.order.not_found'), 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
|
||||
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
|
||||
use App\Services\QualityDocumentService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class QualityDocumentController extends Controller
|
||||
{
|
||||
public function __construct(private QualityDocumentService $service) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function stats(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function calendar(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->calendar($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function availableOrders(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->availableOrders($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function store(QualityDocumentStoreRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
public function update(QualityDocumentUpdateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->destroy($id);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
public function complete(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->complete($id);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function attachOrders(Request $request, int $id)
|
||||
{
|
||||
$request->validate([
|
||||
'order_ids' => ['required', 'array', 'min:1'],
|
||||
'order_ids.*' => ['required', 'integer'],
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->attachOrders($id, $request->input('order_ids'));
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function detachOrder(int $id, int $orderId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $orderId) {
|
||||
return $this->service->detachOrder($id, $orderId);
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function inspectLocation(Request $request, int $id, int $locId)
|
||||
{
|
||||
$request->validate([
|
||||
'post_width' => ['nullable', 'integer'],
|
||||
'post_height' => ['nullable', 'integer'],
|
||||
'change_reason' => ['nullable', 'string', 'max:500'],
|
||||
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id, $locId) {
|
||||
return $this->service->inspectLocation($id, $locId, $request->all());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function requestDocument(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->requestDocument($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function resultDocument(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->resultDocument($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,6 @@ public function index(Request $request): JsonResponse
|
||||
'sort_dir',
|
||||
'per_page',
|
||||
'page',
|
||||
'start_date',
|
||||
'end_date',
|
||||
]);
|
||||
|
||||
$stocks = $this->service->index($params);
|
||||
|
||||
@@ -10,17 +10,12 @@
|
||||
use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest;
|
||||
use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest;
|
||||
use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Services\JournalSyncService;
|
||||
use App\Services\TaxInvoiceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TaxInvoiceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private TaxInvoiceService $taxInvoiceService,
|
||||
private JournalSyncService $journalSyncService,
|
||||
private TaxInvoiceService $taxInvoiceService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -28,9 +23,12 @@ public function __construct(
|
||||
*/
|
||||
public function index(TaxInvoiceListRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->taxInvoiceService->list($request->validated());
|
||||
}, __('message.fetched'));
|
||||
$taxInvoices = $this->taxInvoiceService->list($request->validated());
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoices,
|
||||
message: __('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,9 +36,12 @@ public function index(TaxInvoiceListRequest $request)
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->taxInvoiceService->show($id);
|
||||
}, __('message.fetched'));
|
||||
$taxInvoice = $this->taxInvoiceService->show($id);
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoice,
|
||||
message: __('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,9 +49,13 @@ public function show(int $id)
|
||||
*/
|
||||
public function store(CreateTaxInvoiceRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->taxInvoiceService->create($request->validated());
|
||||
}, __('message.created'));
|
||||
$taxInvoice = $this->taxInvoiceService->create($request->validated());
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoice,
|
||||
message: __('message.created'),
|
||||
status: 201
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,9 +63,12 @@ public function store(CreateTaxInvoiceRequest $request)
|
||||
*/
|
||||
public function update(UpdateTaxInvoiceRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->taxInvoiceService->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
$taxInvoice = $this->taxInvoiceService->update($id, $request->validated());
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoice,
|
||||
message: __('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,11 +76,12 @@ public function update(UpdateTaxInvoiceRequest $request, int $id)
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->taxInvoiceService->delete($id);
|
||||
$this->taxInvoiceService->delete($id);
|
||||
|
||||
return null;
|
||||
}, __('message.deleted'));
|
||||
return ApiResponse::handle(
|
||||
data: null,
|
||||
message: __('message.deleted')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +89,12 @@ public function destroy(int $id)
|
||||
*/
|
||||
public function issue(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->taxInvoiceService->issue($id);
|
||||
}, __('message.tax_invoice.issued'));
|
||||
$taxInvoice = $this->taxInvoiceService->issue($id);
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoice,
|
||||
message: __('message.tax_invoice.issued')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,9 +102,12 @@ public function issue(int $id)
|
||||
*/
|
||||
public function bulkIssue(BulkIssueRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->taxInvoiceService->bulkIssue($request->getIds());
|
||||
}, __('message.tax_invoice.bulk_issued'));
|
||||
$result = $this->taxInvoiceService->bulkIssue($request->getIds());
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $result,
|
||||
message: __('message.tax_invoice.bulk_issued')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,9 +115,12 @@ public function bulkIssue(BulkIssueRequest $request)
|
||||
*/
|
||||
public function cancel(CancelTaxInvoiceRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
|
||||
}, __('message.tax_invoice.cancelled'));
|
||||
$taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoice,
|
||||
message: __('message.tax_invoice.cancelled')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,9 +128,12 @@ public function cancel(CancelTaxInvoiceRequest $request, int $id)
|
||||
*/
|
||||
public function checkStatus(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->taxInvoiceService->checkStatus($id);
|
||||
}, __('message.fetched'));
|
||||
$taxInvoice = $this->taxInvoiceService->checkStatus($id);
|
||||
|
||||
return ApiResponse::handle(
|
||||
data: $taxInvoice,
|
||||
message: __('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,79 +141,11 @@ public function checkStatus(int $id)
|
||||
*/
|
||||
public function summary(TaxInvoiceSummaryRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->taxInvoiceService->summary($request->validated());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
$summary = $this->taxInvoiceService->summary($request->validated());
|
||||
|
||||
// =========================================================================
|
||||
// 분개 (Journal Entries)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 세금계산서 분개 조회
|
||||
*/
|
||||
public function getJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "tax_invoice_{$id}";
|
||||
$data = $this->journalSyncService->getForSource(
|
||||
JournalEntry::SOURCE_TAX_INVOICE,
|
||||
$sourceKey
|
||||
);
|
||||
|
||||
return $data ?? ['rows' => []];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 분개 저장/수정
|
||||
*/
|
||||
public function storeJournalEntries(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
$validated = $request->validate([
|
||||
'rows' => 'required|array|min:1',
|
||||
'rows.*.side' => 'required|in:debit,credit',
|
||||
'rows.*.account_subject' => 'required|string|max:20',
|
||||
'rows.*.debit_amount' => 'required|integer|min:0',
|
||||
'rows.*.credit_amount' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
// 세금계산서 정보 조회 (entry_date용)
|
||||
$taxInvoice = $this->taxInvoiceService->show($id);
|
||||
|
||||
$rows = array_map(fn ($row) => [
|
||||
'side' => $row['side'],
|
||||
'account_code' => $row['account_subject'],
|
||||
'debit_amount' => $row['debit_amount'],
|
||||
'credit_amount' => $row['credit_amount'],
|
||||
], $validated['rows']);
|
||||
|
||||
$sourceKey = "tax_invoice_{$id}";
|
||||
|
||||
return $this->journalSyncService->saveForSource(
|
||||
JournalEntry::SOURCE_TAX_INVOICE,
|
||||
$sourceKey,
|
||||
$taxInvoice->issue_date?->format('Y-m-d') ?? now()->format('Y-m-d'),
|
||||
"세금계산서 분개 (#{$id})",
|
||||
$rows,
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 분개 삭제
|
||||
*/
|
||||
public function deleteJournalEntries(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$sourceKey = "tax_invoice_{$id}";
|
||||
|
||||
return $this->journalSyncService->deleteForSource(
|
||||
JournalEntry::SOURCE_TAX_INVOICE,
|
||||
$sourceKey
|
||||
);
|
||||
}, __('message.deleted'));
|
||||
return ApiResponse::handle(
|
||||
data: $summary,
|
||||
message: __('message.fetched')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,9 @@ public function __construct(
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$limit = (int) $request->input('limit', 30);
|
||||
$date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용)
|
||||
|
||||
return ApiResponse::handle(function () use ($limit, $date) {
|
||||
return $this->todayIssueService->summary($limit, null, $date);
|
||||
return ApiResponse::handle(function () use ($limit) {
|
||||
return $this->todayIssueService->summary($limit);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
|
||||
@@ -32,18 +32,4 @@ public function summary(Request $request): JsonResponse
|
||||
return $this->vatService->getSummary($periodType, $year, $period);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 부가세 상세 조회 (모달용)
|
||||
*/
|
||||
public function detail(Request $request): JsonResponse
|
||||
{
|
||||
$periodType = $request->query('period_type', 'quarter');
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$period = $request->query('period') ? (int) $request->query('period') : null;
|
||||
|
||||
return ApiResponse::handle(function () use ($periodType, $year, $period) {
|
||||
return $this->vatService->getDetail($periodType, $year, $period);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\VehicleDispatch\VehicleDispatchUpdateRequest;
|
||||
use App\Services\VehicleDispatchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VehicleDispatchController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VehicleDispatchService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 배차차량 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$params = $request->only([
|
||||
'search',
|
||||
'status',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'per_page',
|
||||
'page',
|
||||
]);
|
||||
|
||||
$dispatches = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($dispatches, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차차량 통계 조회
|
||||
*/
|
||||
public function stats(): JsonResponse
|
||||
{
|
||||
$stats = $this->service->stats();
|
||||
|
||||
return ApiResponse::success($stats, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차차량 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$dispatch = $this->service->show($id);
|
||||
|
||||
return ApiResponse::success($dispatch, __('message.fetched'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.not_found'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차차량 수정
|
||||
*/
|
||||
public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$dispatch = $this->service->update($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($dispatch, __('message.updated'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.not_found'), 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,18 +61,14 @@ public function detail(Request $request): JsonResponse
|
||||
: 0.05;
|
||||
$year = $request->query('year') ? (int) $request->query('year') : null;
|
||||
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
|
||||
return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter, $startDate, $endDate) {
|
||||
return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter) {
|
||||
return $this->welfareService->getDetail(
|
||||
$calculationType,
|
||||
$fixedAmountPerMonth,
|
||||
$ratio,
|
||||
$year,
|
||||
$quarter,
|
||||
$startDate,
|
||||
$endDate
|
||||
$quarter
|
||||
);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -230,16 +230,6 @@ public function inspectionReport(int $id)
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
|
||||
*/
|
||||
public function inspectionConfig(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->getInspectionConfig($id);
|
||||
}, __('message.work_order.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시의 검사용 문서 템플릿 조회
|
||||
*/
|
||||
@@ -320,14 +310,7 @@ public function materialsForItem(int $id, int $itemId)
|
||||
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id, $itemId) {
|
||||
$validated = $request->validated();
|
||||
|
||||
return $this->service->registerMaterialInputForItem(
|
||||
$id,
|
||||
$itemId,
|
||||
$validated['inputs'],
|
||||
(bool) ($validated['replace'] ?? false)
|
||||
);
|
||||
return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']);
|
||||
}, __('message.work_order.material_input_registered'));
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ public function rules(): array
|
||||
'sort_dir' => 'nullable|string|in:asc,desc',
|
||||
'per_page' => 'nullable|integer|min:1',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'start_date' => 'nullable|date_format:Y-m-d',
|
||||
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,6 @@ public function rules(): array
|
||||
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
|
||||
'approvers.*.role' => 'nullable|string|max:50',
|
||||
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 문서 데이터 (EAV)
|
||||
'data' => 'nullable|array',
|
||||
'data.*.section_id' => 'nullable|integer',
|
||||
|
||||
@@ -27,9 +27,6 @@ public function rules(): array
|
||||
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
|
||||
'approvers.*.role' => 'nullable|string|max:50',
|
||||
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 문서 데이터 (EAV)
|
||||
'data' => 'nullable|array',
|
||||
'data.*.section_id' => 'nullable|integer',
|
||||
|
||||
@@ -30,9 +30,6 @@ public function rules(): array
|
||||
'data.*.field_key' => 'required_with:data|string|max:100',
|
||||
'data.*.field_value' => 'nullable|string',
|
||||
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 첨부파일
|
||||
'attachments' => 'nullable|array',
|
||||
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
|
||||
|
||||
@@ -22,7 +22,6 @@ public function rules(): array
|
||||
Inspection::TYPE_FQC,
|
||||
])],
|
||||
'lot_no' => ['required', 'string', 'max:50'],
|
||||
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
|
||||
'item_name' => ['nullable', 'string', 'max:200'],
|
||||
'process_name' => ['nullable', 'string', 'max:100'],
|
||||
'quantity' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
@@ -29,7 +29,6 @@ public function rules(): array
|
||||
return [
|
||||
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
|
||||
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'search' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Loan;
|
||||
|
||||
use App\Models\Tenants\Loan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LoanStoreRequest extends FormRequest
|
||||
{
|
||||
@@ -23,27 +21,12 @@ public function authorize(): bool
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE;
|
||||
|
||||
return [
|
||||
'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'],
|
||||
'user_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
|
||||
'purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
|
||||
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
|
||||
'metadata.memo' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests\Loan;
|
||||
|
||||
use App\Models\Tenants\Loan;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class LoanUpdateRequest extends FormRequest
|
||||
{
|
||||
@@ -29,20 +27,6 @@ public function rules(): array
|
||||
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
|
||||
'purpose' => ['nullable', 'string', 'max:1000'],
|
||||
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
|
||||
'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)],
|
||||
'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)],
|
||||
'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
|
||||
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
|
||||
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
|
||||
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
|
||||
'metadata.memo' => ['nullable', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ProductionOrder;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProductionOrderIndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => 'nullable|string|max:100',
|
||||
'production_status' => 'nullable|in:waiting,in_production,completed',
|
||||
'sort_by' => 'nullable|in:created_at,delivery_date,order_no',
|
||||
'sort_dir' => 'nullable|in:asc,desc',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'per_page' => 'nullable|integer|min:1|max:100',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PerformanceReportConfirmRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
|
||||
'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PerformanceReportMemoRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
|
||||
'memo' => ['required', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
|
||||
'memo.required' => __('validation.required', ['attribute' => '메모']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class QualityDocumentStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'site_name' => ['required', 'string', 'max:200'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'received_date' => ['nullable', 'date'],
|
||||
'options' => ['nullable', 'array'],
|
||||
'options.manager' => ['nullable', 'array'],
|
||||
'options.manager.name' => ['nullable', 'string', 'max:50'],
|
||||
'options.manager.phone' => ['nullable', 'string', 'max:30'],
|
||||
'options.inspection' => ['nullable', 'array'],
|
||||
'options.inspection.request_date' => ['nullable', 'date'],
|
||||
'options.inspection.start_date' => ['nullable', 'date'],
|
||||
'options.inspection.end_date' => ['nullable', 'date'],
|
||||
'options.site_address' => ['nullable', 'array'],
|
||||
'options.construction_site' => ['nullable', 'array'],
|
||||
'options.material_distributor' => ['nullable', 'array'],
|
||||
'options.contractor' => ['nullable', 'array'],
|
||||
'options.supervisor' => ['nullable', 'array'],
|
||||
'order_ids' => ['nullable', 'array'],
|
||||
'order_ids.*' => ['integer', 'exists:orders,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'site_name.required' => __('validation.required', ['attribute' => '현장명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class QualityDocumentUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'site_name' => ['sometimes', 'string', 'max:200'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'received_date' => ['nullable', 'date'],
|
||||
'options' => ['nullable', 'array'],
|
||||
'options.manager' => ['nullable', 'array'],
|
||||
'options.manager.name' => ['nullable', 'string', 'max:50'],
|
||||
'options.manager.phone' => ['nullable', 'string', 'max:30'],
|
||||
'options.inspection' => ['nullable', 'array'],
|
||||
'options.inspection.request_date' => ['nullable', 'date'],
|
||||
'options.inspection.start_date' => ['nullable', 'date'],
|
||||
'options.inspection.end_date' => ['nullable', 'date'],
|
||||
'options.site_address' => ['nullable', 'array'],
|
||||
'options.construction_site' => ['nullable', 'array'],
|
||||
'options.material_distributor' => ['nullable', 'array'],
|
||||
'options.contractor' => ['nullable', 'array'],
|
||||
'options.supervisor' => ['nullable', 'array'],
|
||||
'order_ids' => ['nullable', 'array'],
|
||||
'order_ids.*' => ['integer', 'exists:orders,id'],
|
||||
'locations' => ['nullable', 'array'],
|
||||
'locations.*.id' => ['required', 'integer'],
|
||||
'locations.*.post_width' => ['nullable', 'integer'],
|
||||
'locations.*.post_height' => ['nullable', 'integer'],
|
||||
'locations.*.change_reason' => ['nullable', 'string', 'max:500'],
|
||||
'locations.*.inspection_data' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public function rules(): array
|
||||
'scheduled_date' => 'required|date',
|
||||
'status' => 'nullable|in:scheduled,ready,shipping,completed',
|
||||
'priority' => 'nullable|in:urgent,normal,low',
|
||||
'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup',
|
||||
'delivery_method' => 'nullable|in:pickup,direct,logistics',
|
||||
|
||||
// 발주처/배송 정보
|
||||
'client_id' => 'nullable|integer|exists:clients,id',
|
||||
@@ -55,16 +55,6 @@ public function rules(): array
|
||||
// 기타
|
||||
'remarks' => 'nullable|string',
|
||||
|
||||
// 배차정보
|
||||
'vehicle_dispatches' => 'nullable|array',
|
||||
'vehicle_dispatches.*.seq' => 'nullable|integer|min:1',
|
||||
'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100',
|
||||
'vehicle_dispatches.*.arrival_datetime' => 'nullable|date',
|
||||
'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20',
|
||||
'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20',
|
||||
'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50',
|
||||
'vehicle_dispatches.*.remarks' => 'nullable|string',
|
||||
|
||||
// 출하 품목
|
||||
'items' => 'nullable|array',
|
||||
'items.*.seq' => 'nullable|integer|min:1',
|
||||
|
||||
@@ -19,7 +19,7 @@ public function rules(): array
|
||||
'order_id' => 'nullable|integer|exists:orders,id',
|
||||
'scheduled_date' => 'nullable|date',
|
||||
'priority' => 'nullable|in:urgent,normal,low',
|
||||
'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup',
|
||||
'delivery_method' => 'nullable|in:pickup,direct,logistics',
|
||||
|
||||
// 발주처/배송 정보
|
||||
'client_id' => 'nullable|integer|exists:clients,id',
|
||||
@@ -53,16 +53,6 @@ public function rules(): array
|
||||
// 기타
|
||||
'remarks' => 'nullable|string',
|
||||
|
||||
// 배차정보
|
||||
'vehicle_dispatches' => 'nullable|array',
|
||||
'vehicle_dispatches.*.seq' => 'nullable|integer|min:1',
|
||||
'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100',
|
||||
'vehicle_dispatches.*.arrival_datetime' => 'nullable|date',
|
||||
'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20',
|
||||
'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20',
|
||||
'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50',
|
||||
'vehicle_dispatches.*.remarks' => 'nullable|string',
|
||||
|
||||
// 출하 품목
|
||||
'items' => 'nullable|array',
|
||||
'items.*.seq' => 'nullable|integer|min:1',
|
||||
|
||||
@@ -20,18 +20,18 @@ public function rules(): array
|
||||
'issue_type' => ['required', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)],
|
||||
'direction' => ['required', 'string', Rule::in(TaxInvoice::DIRECTIONS)],
|
||||
|
||||
// 공급자 정보 (매입 시 필수, 매출 시 선택)
|
||||
'supplier_corp_num' => ['required_if:direction,purchases', 'nullable', 'string', 'max:20'],
|
||||
'supplier_corp_name' => ['required_if:direction,purchases', 'nullable', 'string', 'max:100'],
|
||||
// 공급자 정보
|
||||
'supplier_corp_num' => ['required', 'string', 'max:20'],
|
||||
'supplier_corp_name' => ['required', 'string', 'max:100'],
|
||||
'supplier_ceo_name' => ['nullable', 'string', 'max:50'],
|
||||
'supplier_addr' => ['nullable', 'string', 'max:200'],
|
||||
'supplier_biz_type' => ['nullable', 'string', 'max:100'],
|
||||
'supplier_biz_class' => ['nullable', 'string', 'max:100'],
|
||||
'supplier_contact_id' => ['nullable', 'string', 'email', 'max:100'],
|
||||
|
||||
// 공급받는자 정보 (매출 시 필수, 매입 시 선택)
|
||||
'buyer_corp_num' => ['required_if:direction,sales', 'nullable', 'string', 'max:20'],
|
||||
'buyer_corp_name' => ['required_if:direction,sales', 'nullable', 'string', 'max:100'],
|
||||
// 공급받는자 정보
|
||||
'buyer_corp_num' => ['required', 'string', 'max:20'],
|
||||
'buyer_corp_name' => ['required', 'string', 'max:100'],
|
||||
'buyer_ceo_name' => ['nullable', 'string', 'max:50'],
|
||||
'buyer_addr' => ['nullable', 'string', 'max:200'],
|
||||
'buyer_biz_type' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\AccountSubject;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAccountSubjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:10'],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'],
|
||||
'sub_category' => ['nullable', 'string', 'max:50'],
|
||||
'parent_code' => ['nullable', 'string', 'max:10'],
|
||||
'depth' => ['nullable', 'integer', 'in:1,2,3'],
|
||||
'department_type' => ['nullable', 'string', 'in:common,manufacturing,admin'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'sort_order' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => '계정과목 코드를 입력하세요.',
|
||||
'name.required' => '계정과목명을 입력하세요.',
|
||||
'category.in' => '유효한 분류를 선택하세요.',
|
||||
'depth.in' => '계층은 1(대), 2(중), 3(소) 중 하나여야 합니다.',
|
||||
'department_type.in' => '부문은 common, manufacturing, admin 중 하나여야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\AccountSubject;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateAccountSubjectRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:100'],
|
||||
'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'],
|
||||
'sub_category' => ['nullable', 'string', 'max:50'],
|
||||
'parent_code' => ['nullable', 'string', 'max:10'],
|
||||
'depth' => ['nullable', 'integer', 'in:1,2,3'],
|
||||
'department_type' => ['nullable', 'string', 'in:common,manufacturing,admin'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'sort_order' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'category.in' => '유효한 분류를 선택하세요.',
|
||||
'depth.in' => '계층은 1(대), 2(중), 3(소) 중 하나여야 합니다.',
|
||||
'department_type.in' => '부문은 common, manufacturing, admin 중 하나여야 합니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ public function rules(): array
|
||||
$tenantId = app('tenant_id') ?? 0;
|
||||
|
||||
return [
|
||||
// === 기존 필드 ===
|
||||
'bill_number' => [
|
||||
'nullable',
|
||||
'string',
|
||||
@@ -31,99 +30,16 @@ public function rules(): array
|
||||
'client_name' => ['nullable', 'string', 'max:100'],
|
||||
'amount' => ['required', 'numeric', 'min:0'],
|
||||
'issue_date' => ['required', 'date'],
|
||||
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'],
|
||||
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'installment_count' => ['nullable', 'integer', 'min:0'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
'is_electronic' => ['nullable', 'boolean'],
|
||||
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
|
||||
|
||||
// === V8 증권종류/매체/구분 ===
|
||||
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
|
||||
'medium' => ['nullable', 'string', 'in:electronic,paper'],
|
||||
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
|
||||
|
||||
// === 전자어음 ===
|
||||
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
|
||||
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
|
||||
|
||||
// === 환어음 ===
|
||||
'drawee' => ['nullable', 'string', 'max:100'],
|
||||
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
|
||||
'acceptance_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
// === 받을어음 전용 ===
|
||||
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
|
||||
'endorsement_order' => ['nullable', 'string', 'max:5'],
|
||||
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
|
||||
'issuer_bank' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
// 할인
|
||||
'is_discounted' => ['nullable', 'boolean'],
|
||||
'discount_date' => ['nullable', 'date'],
|
||||
'discount_bank' => ['nullable', 'string', 'max:100'],
|
||||
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'discount_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
// 배서양도
|
||||
'endorsement_date' => ['nullable', 'date'],
|
||||
'endorsee' => ['nullable', 'string', 'max:100'],
|
||||
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
|
||||
|
||||
// 추심
|
||||
'collection_bank' => ['nullable', 'string', 'max:100'],
|
||||
'collection_request_date' => ['nullable', 'date'],
|
||||
'collection_fee' => ['nullable', 'numeric', 'min:0'],
|
||||
'collection_complete_date' => ['nullable', 'date'],
|
||||
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
|
||||
'collection_deposit_date' => ['nullable', 'date'],
|
||||
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
// === 지급어음 전용 ===
|
||||
'settlement_bank' => ['nullable', 'string', 'max:100'],
|
||||
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
|
||||
'actual_payment_date' => ['nullable', 'date'],
|
||||
|
||||
// === 공통 ===
|
||||
'payment_place' => ['nullable', 'string', 'max:30'],
|
||||
'payment_place_detail' => ['nullable', 'string', 'max:200'],
|
||||
|
||||
// 개서
|
||||
'renewal_date' => ['nullable', 'date'],
|
||||
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
|
||||
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
|
||||
|
||||
// 소구
|
||||
'recourse_date' => ['nullable', 'date'],
|
||||
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'recourse_target' => ['nullable', 'string', 'max:100'],
|
||||
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
|
||||
|
||||
// 환매
|
||||
'buyback_date' => ['nullable', 'date'],
|
||||
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'buyback_bank' => ['nullable', 'string', 'max:100'],
|
||||
|
||||
// 부도/법적절차
|
||||
'dishonored_date' => ['nullable', 'date'],
|
||||
'dishonored_reason' => ['nullable', 'string', 'max:30'],
|
||||
'has_protest' => ['nullable', 'boolean'],
|
||||
'protest_date' => ['nullable', 'date'],
|
||||
'recourse_notice_date' => ['nullable', 'date'],
|
||||
'recourse_notice_deadline' => ['nullable', 'date'],
|
||||
|
||||
// 분할배서
|
||||
'is_split' => ['nullable', 'boolean'],
|
||||
|
||||
// === 차수 관리 ===
|
||||
'installments' => ['nullable', 'array'],
|
||||
'installments.*.date' => ['required_with:installments', 'date'],
|
||||
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
|
||||
'installments.*.type' => ['nullable', 'string', 'max:30'],
|
||||
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
|
||||
'installments.*.note' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ public function authorize(): bool
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// === 기존 필드 ===
|
||||
'bill_number' => ['nullable', 'string', 'max:50'],
|
||||
'bill_type' => ['nullable', 'string', 'in:received,issued'],
|
||||
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
|
||||
@@ -22,72 +21,15 @@ public function rules(): array
|
||||
'amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'issue_date' => ['nullable', 'date'],
|
||||
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
'installment_count' => ['nullable', 'integer', 'min:0'],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
'is_electronic' => ['nullable', 'boolean'],
|
||||
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
|
||||
|
||||
// === V8 확장 ===
|
||||
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
|
||||
'medium' => ['nullable', 'string', 'in:electronic,paper'],
|
||||
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
|
||||
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
|
||||
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
|
||||
'drawee' => ['nullable', 'string', 'max:100'],
|
||||
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
|
||||
'acceptance_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_date' => ['nullable', 'date'],
|
||||
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
|
||||
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
|
||||
'endorsement_order' => ['nullable', 'string', 'max:5'],
|
||||
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
|
||||
'issuer_bank' => ['nullable', 'string', 'max:100'],
|
||||
'is_discounted' => ['nullable', 'boolean'],
|
||||
'discount_date' => ['nullable', 'date'],
|
||||
'discount_bank' => ['nullable', 'string', 'max:100'],
|
||||
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'discount_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'endorsement_date' => ['nullable', 'date'],
|
||||
'endorsee' => ['nullable', 'string', 'max:100'],
|
||||
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
|
||||
'collection_bank' => ['nullable', 'string', 'max:100'],
|
||||
'collection_request_date' => ['nullable', 'date'],
|
||||
'collection_fee' => ['nullable', 'numeric', 'min:0'],
|
||||
'collection_complete_date' => ['nullable', 'date'],
|
||||
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
|
||||
'collection_deposit_date' => ['nullable', 'date'],
|
||||
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'settlement_bank' => ['nullable', 'string', 'max:100'],
|
||||
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
|
||||
'actual_payment_date' => ['nullable', 'date'],
|
||||
'payment_place' => ['nullable', 'string', 'max:30'],
|
||||
'payment_place_detail' => ['nullable', 'string', 'max:200'],
|
||||
'renewal_date' => ['nullable', 'date'],
|
||||
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
|
||||
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
|
||||
'recourse_date' => ['nullable', 'date'],
|
||||
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'recourse_target' => ['nullable', 'string', 'max:100'],
|
||||
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
|
||||
'buyback_date' => ['nullable', 'date'],
|
||||
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'buyback_bank' => ['nullable', 'string', 'max:100'],
|
||||
'dishonored_date' => ['nullable', 'date'],
|
||||
'dishonored_reason' => ['nullable', 'string', 'max:30'],
|
||||
'has_protest' => ['nullable', 'boolean'],
|
||||
'protest_date' => ['nullable', 'date'],
|
||||
'recourse_notice_date' => ['nullable', 'date'],
|
||||
'recourse_notice_deadline' => ['nullable', 'date'],
|
||||
'is_split' => ['nullable', 'boolean'],
|
||||
|
||||
// === 차수 관리 ===
|
||||
'installments' => ['nullable', 'array'],
|
||||
'installments.*.date' => ['required_with:installments', 'date'],
|
||||
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
|
||||
'installments.*.type' => ['nullable', 'string', 'max:30'],
|
||||
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
|
||||
'installments.*.note' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\GeneralJournalEntry;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreManualJournalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'journal_date' => ['required', 'date'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'rows' => ['required', 'array', 'min:2'],
|
||||
'rows.*.side' => ['required', 'in:debit,credit'],
|
||||
'rows.*.account_subject_id' => ['required', 'string', 'max:10'],
|
||||
'rows.*.vendor_id' => ['nullable', 'integer'],
|
||||
'rows.*.debit_amount' => ['required', 'integer', 'min:0'],
|
||||
'rows.*.credit_amount' => ['required', 'integer', 'min:0'],
|
||||
'rows.*.memo' => ['nullable', 'string', 'max:300'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'journal_date.required' => '전표일자를 입력하세요.',
|
||||
'rows.required' => '분개 행을 입력하세요.',
|
||||
'rows.min' => '최소 2개 이상의 분개 행이 필요합니다.',
|
||||
'rows.*.side.required' => '차/대 구분을 선택하세요.',
|
||||
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
|
||||
'rows.*.account_subject_id.required' => '계정과목을 선택하세요.',
|
||||
'rows.*.debit_amount.required' => '차변 금액을 입력하세요.',
|
||||
'rows.*.credit_amount.required' => '대변 금액을 입력하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\GeneralJournalEntry;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateJournalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'journal_memo' => ['sometimes', 'nullable', 'string', 'max:1000'],
|
||||
'rows' => ['sometimes', 'array', 'min:1'],
|
||||
'rows.*.side' => ['required_with:rows', 'in:debit,credit'],
|
||||
'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'],
|
||||
'rows.*.vendor_id' => ['nullable', 'integer'],
|
||||
'rows.*.debit_amount' => ['required_with:rows', 'integer', 'min:0'],
|
||||
'rows.*.credit_amount' => ['required_with:rows', 'integer', 'min:0'],
|
||||
'rows.*.memo' => ['nullable', 'string', 'max:300'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'rows.*.side.required_with' => '차/대 구분을 선택하세요.',
|
||||
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
|
||||
'rows.*.account_subject_id.required_with' => '계정과목을 선택하세요.',
|
||||
'rows.*.debit_amount.required_with' => '차변 금액을 입력하세요.',
|
||||
'rows.*.credit_amount.required_with' => '대변 금액을 입력하세요.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,6 @@ public function rules(): array
|
||||
'connection_type' => ['nullable', 'string', 'max:20'],
|
||||
'connection_target' => ['nullable', 'string', 'max:255'],
|
||||
'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'],
|
||||
'options' => ['nullable', 'array'],
|
||||
'options.inspection_setting' => ['nullable', 'array'],
|
||||
'options.inspection_scope' => ['nullable', 'array'],
|
||||
'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'],
|
||||
'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'],
|
||||
'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,12 +36,6 @@ public function attributes(): array
|
||||
'connection_type' => '연결유형',
|
||||
'connection_target' => '연결대상',
|
||||
'completion_type' => '완료유형',
|
||||
'options' => '옵션',
|
||||
'options.inspection_setting' => '검사설정',
|
||||
'options.inspection_scope' => '검사범위',
|
||||
'options.inspection_scope.type' => '검사범위 유형',
|
||||
'options.inspection_scope.sample_size' => '샘플 크기',
|
||||
'options.inspection_scope.sample_base' => '샘플 기준',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,6 @@ public function rules(): array
|
||||
'connection_type' => ['nullable', 'string', 'max:20'],
|
||||
'connection_target' => ['nullable', 'string', 'max:255'],
|
||||
'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'],
|
||||
'options' => ['nullable', 'array'],
|
||||
'options.inspection_setting' => ['nullable', 'array'],
|
||||
'options.inspection_scope' => ['nullable', 'array'],
|
||||
'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'],
|
||||
'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'],
|
||||
'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,12 +36,6 @@ public function attributes(): array
|
||||
'connection_type' => '연결유형',
|
||||
'connection_target' => '연결대상',
|
||||
'completion_type' => '완료유형',
|
||||
'options' => '옵션',
|
||||
'options.inspection_setting' => '검사설정',
|
||||
'options.inspection_scope' => '검사범위',
|
||||
'options.inspection_scope.type' => '검사범위 유형',
|
||||
'options.inspection_scope.sample_size' => '샘플 크기',
|
||||
'options.inspection_scope.sample_base' => '샘플 기준',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\VehicleDispatch;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class VehicleDispatchUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'freight_cost_type' => 'nullable|in:prepaid,collect',
|
||||
'logistics_company' => 'nullable|string|max:100',
|
||||
'arrival_datetime' => 'nullable|date',
|
||||
'tonnage' => 'nullable|string|max:20',
|
||||
'vehicle_no' => 'nullable|string|max:20',
|
||||
'driver_contact' => 'nullable|string|max:50',
|
||||
'remarks' => 'nullable|string',
|
||||
'supply_amount' => 'nullable|numeric|min:0',
|
||||
'vat' => 'nullable|numeric|min:0',
|
||||
'total_amount' => 'nullable|numeric|min:0',
|
||||
'status' => 'nullable|in:draft,completed',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ public function rules(): array
|
||||
'inputs' => 'required|array|min:1',
|
||||
'inputs.*.stock_lot_id' => 'required|integer',
|
||||
'inputs.*.qty' => 'required|numeric|gt:0',
|
||||
'inputs.*.bom_group_key' => 'sometimes|nullable|string|max:100',
|
||||
'replace' => 'sometimes|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -39,16 +39,6 @@ public function rules(): array
|
||||
'inspection_data.nonConformingContent' => 'nullable|string|max:1000',
|
||||
'inspection_data.templateValues' => 'nullable|array',
|
||||
'inspection_data.templateValues.*' => 'nullable',
|
||||
// 절곡 제품별 검사 데이터
|
||||
'inspection_data.products' => 'nullable|array',
|
||||
'inspection_data.products.*.id' => 'required_with:inspection_data.products|string',
|
||||
'inspection_data.products.*.bendingStatus' => ['nullable', Rule::in(['양호', '불량'])],
|
||||
'inspection_data.products.*.lengthMeasured' => 'nullable|string|max:50',
|
||||
'inspection_data.products.*.widthMeasured' => 'nullable|string|max:50',
|
||||
'inspection_data.products.*.gapPoints' => 'nullable|array',
|
||||
'inspection_data.products.*.gapPoints.*.point' => 'nullable|string',
|
||||
'inspection_data.products.*.gapPoints.*.designValue' => 'nullable|string',
|
||||
'inspection_data.products.*.gapPoints.*.measured' => 'nullable|string|max:50',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ class Document extends Model
|
||||
'linkable_id',
|
||||
'submitted_at',
|
||||
'completed_at',
|
||||
'rendered_html',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
|
||||
@@ -22,7 +22,6 @@ class DocumentTemplateSection extends Model
|
||||
protected $fillable = [
|
||||
'template_id',
|
||||
'title',
|
||||
'description',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
@@ -22,7 +22,6 @@ class ProcessStep extends Model
|
||||
'connection_type',
|
||||
'connection_target',
|
||||
'completion_type',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -31,7 +30,6 @@ class ProcessStep extends Model
|
||||
'needs_inspection' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Process;
|
||||
use App\Models\Qualitys\Inspection;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\Tenants\Shipment;
|
||||
use App\Traits\Auditable;
|
||||
@@ -235,14 +234,6 @@ public function shipments(): HasMany
|
||||
return $this->hasMany(Shipment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품질검사 (IQC/PQC/FQC)
|
||||
*/
|
||||
public function inspections(): HasMany
|
||||
{
|
||||
return $this->hasMany(Inspection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
|
||||
@@ -27,7 +27,6 @@ class WorkOrderMaterialInput extends Model
|
||||
'work_order_item_id',
|
||||
'stock_lot_id',
|
||||
'item_id',
|
||||
'bom_group_key',
|
||||
'qty',
|
||||
'input_by',
|
||||
'input_at',
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Production\WorkOrder;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -24,7 +23,6 @@
|
||||
* @property string|null $inspection_date 검사일
|
||||
* @property int|null $item_id 품목 ID
|
||||
* @property string $lot_no LOT번호
|
||||
* @property int|null $work_order_id 작업지시 ID (PQC/FQC용)
|
||||
* @property int|null $inspector_id 검사자 ID
|
||||
* @property array|null $meta 메타정보 (process_name, quantity, unit 등)
|
||||
* @property array|null $items 검사항목 배열
|
||||
@@ -49,7 +47,6 @@ class Inspection extends Model
|
||||
'inspection_date',
|
||||
'item_id',
|
||||
'lot_no',
|
||||
'work_order_id',
|
||||
'inspector_id',
|
||||
'meta',
|
||||
'items',
|
||||
@@ -95,14 +92,6 @@ class Inspection extends Model
|
||||
|
||||
// ===== Relationships =====
|
||||
|
||||
/**
|
||||
* 작업지시 (PQC/FQC용)
|
||||
*/
|
||||
public function workOrder()
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목
|
||||
*/
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PerformanceReport extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'performance_reports';
|
||||
|
||||
const STATUS_UNCONFIRMED = 'unconfirmed';
|
||||
|
||||
const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
const STATUS_REPORTED = 'reported';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'quality_document_id',
|
||||
'year',
|
||||
'quarter',
|
||||
'confirmation_status',
|
||||
'confirmed_date',
|
||||
'confirmed_by',
|
||||
'memo',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'confirmed_date' => 'date',
|
||||
'year' => 'integer',
|
||||
'quarter' => 'integer',
|
||||
];
|
||||
|
||||
// ===== Relationships =====
|
||||
|
||||
public function qualityDocument()
|
||||
{
|
||||
return $this->belongsTo(QualityDocument::class);
|
||||
}
|
||||
|
||||
public function confirmer()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'confirmed_by');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ===== Status Helpers =====
|
||||
|
||||
public function isUnconfirmed(): bool
|
||||
{
|
||||
return $this->confirmation_status === self::STATUS_UNCONFIRMED;
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool
|
||||
{
|
||||
return $this->confirmation_status === self::STATUS_CONFIRMED;
|
||||
}
|
||||
|
||||
public function isReported(): bool
|
||||
{
|
||||
return $this->confirmation_status === self::STATUS_REPORTED;
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class QualityDocument extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'quality_documents';
|
||||
|
||||
const STATUS_RECEIVED = 'received';
|
||||
|
||||
const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'quality_doc_number',
|
||||
'site_name',
|
||||
'status',
|
||||
'client_id',
|
||||
'inspector_id',
|
||||
'received_date',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'received_date' => 'date',
|
||||
];
|
||||
|
||||
// ===== Relationships =====
|
||||
|
||||
public function client()
|
||||
{
|
||||
return $this->belongsTo(Client::class, 'client_id');
|
||||
}
|
||||
|
||||
public function inspector()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'inspector_id');
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function documentOrders()
|
||||
{
|
||||
return $this->hasMany(QualityDocumentOrder::class);
|
||||
}
|
||||
|
||||
public function locations()
|
||||
{
|
||||
return $this->hasMany(QualityDocumentLocation::class);
|
||||
}
|
||||
|
||||
public function performanceReport()
|
||||
{
|
||||
return $this->hasOne(PerformanceReport::class);
|
||||
}
|
||||
|
||||
// ===== 채번 =====
|
||||
|
||||
public static function generateDocNumber(int $tenantId): string
|
||||
{
|
||||
$prefix = 'KD-QD';
|
||||
$yearMonth = now()->format('Ym');
|
||||
|
||||
$lastNo = static::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%")
|
||||
->orderByDesc('quality_doc_number')
|
||||
->value('quality_doc_number');
|
||||
|
||||
if ($lastNo) {
|
||||
$seq = (int) substr($lastNo, -4) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq);
|
||||
}
|
||||
|
||||
// ===== Status Helpers =====
|
||||
|
||||
public function isReceived(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_RECEIVED;
|
||||
}
|
||||
|
||||
public function isInProgress(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_IN_PROGRESS;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public static function mapStatusToFrontend(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::STATUS_RECEIVED => 'reception',
|
||||
self::STATUS_IN_PROGRESS => 'in_progress',
|
||||
self::STATUS_COMPLETED => 'completed',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
public static function mapStatusFromFrontend(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'reception' => self::STATUS_RECEIVED,
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Orders\OrderItem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QualityDocumentLocation extends Model
|
||||
{
|
||||
protected $table = 'quality_document_locations';
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
|
||||
const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
|
||||
protected $fillable = [
|
||||
'quality_document_id',
|
||||
'quality_document_order_id',
|
||||
'order_item_id',
|
||||
'post_width',
|
||||
'post_height',
|
||||
'change_reason',
|
||||
'inspection_data',
|
||||
'document_id',
|
||||
'inspection_status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'inspection_data' => 'array',
|
||||
];
|
||||
|
||||
public function qualityDocument()
|
||||
{
|
||||
return $this->belongsTo(QualityDocument::class);
|
||||
}
|
||||
|
||||
public function qualityDocumentOrder()
|
||||
{
|
||||
return $this->belongsTo(QualityDocumentOrder::class);
|
||||
}
|
||||
|
||||
public function orderItem()
|
||||
{
|
||||
return $this->belongsTo(OrderItem::class);
|
||||
}
|
||||
|
||||
public function document()
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->inspection_status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->inspection_status === self::STATUS_COMPLETED;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Orders\Order;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class QualityDocumentOrder extends Model
|
||||
{
|
||||
protected $table = 'quality_document_orders';
|
||||
|
||||
protected $fillable = [
|
||||
'quality_document_id',
|
||||
'order_id',
|
||||
];
|
||||
|
||||
public function qualityDocument()
|
||||
{
|
||||
return $this->belongsTo(QualityDocument::class);
|
||||
}
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function locations()
|
||||
{
|
||||
return $this->hasMany(QualityDocumentLocation::class);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AccountCode extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'code',
|
||||
'name',
|
||||
'category',
|
||||
'sub_category',
|
||||
'parent_code',
|
||||
'depth',
|
||||
'department_type',
|
||||
'description',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'depth' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// Categories (대분류)
|
||||
public const CATEGORY_ASSET = 'asset';
|
||||
public const CATEGORY_LIABILITY = 'liability';
|
||||
public const CATEGORY_CAPITAL = 'capital';
|
||||
public const CATEGORY_REVENUE = 'revenue';
|
||||
public const CATEGORY_EXPENSE = 'expense';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_ASSET => '자산',
|
||||
self::CATEGORY_LIABILITY => '부채',
|
||||
self::CATEGORY_CAPITAL => '자본',
|
||||
self::CATEGORY_REVENUE => '수익',
|
||||
self::CATEGORY_EXPENSE => '비용',
|
||||
];
|
||||
|
||||
// Sub-categories (중분류)
|
||||
public const SUB_CATEGORIES = [
|
||||
'current_asset' => '유동자산',
|
||||
'fixed_asset' => '비유동자산',
|
||||
'current_liability' => '유동부채',
|
||||
'long_term_liability' => '비유동부채',
|
||||
'capital' => '자본',
|
||||
'sales_revenue' => '매출',
|
||||
'other_revenue' => '영업외수익',
|
||||
'cogs' => '매출원가',
|
||||
'selling_admin' => '판매비와관리비',
|
||||
'other_expense' => '영업외비용',
|
||||
];
|
||||
|
||||
// Department types (부문)
|
||||
public const DEPT_COMMON = 'common';
|
||||
public const DEPT_MANUFACTURING = 'manufacturing';
|
||||
public const DEPT_ADMIN = 'admin';
|
||||
|
||||
public const DEPARTMENT_TYPES = [
|
||||
self::DEPT_COMMON => '공통',
|
||||
self::DEPT_MANUFACTURING => '제조',
|
||||
self::DEPT_ADMIN => '관리',
|
||||
];
|
||||
|
||||
// Depth levels (계층)
|
||||
public const DEPTH_MAJOR = 1;
|
||||
public const DEPTH_MIDDLE = 2;
|
||||
public const DEPTH_MINOR = 3;
|
||||
|
||||
/**
|
||||
* 활성 계정과목만 조회
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소분류(입력 가능 계정)만 조회
|
||||
*/
|
||||
public function scopeSelectable(Builder $query): Builder
|
||||
{
|
||||
return $query->where('depth', self::DEPTH_MINOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 계정과목 관계
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_code', 'code')
|
||||
->where('tenant_id', $this->tenant_id);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
@@ -56,8 +55,6 @@ class Approval extends Model
|
||||
'completed_at',
|
||||
'current_step',
|
||||
'attachments',
|
||||
'linkable_type',
|
||||
'linkable_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -138,14 +135,6 @@ public function referenceSteps(): HasMany
|
||||
->orderBy('step_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 대상 (Document 등)
|
||||
*/
|
||||
public function linkable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자
|
||||
*/
|
||||
|
||||
@@ -31,58 +31,6 @@ class Bill extends Model
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
// V8 확장 필드
|
||||
'instrument_type',
|
||||
'medium',
|
||||
'bill_category',
|
||||
'electronic_bill_no',
|
||||
'registration_org',
|
||||
'drawee',
|
||||
'acceptance_status',
|
||||
'acceptance_date',
|
||||
'acceptance_refusal_date',
|
||||
'acceptance_refusal_reason',
|
||||
'endorsement',
|
||||
'endorsement_order',
|
||||
'storage_place',
|
||||
'issuer_bank',
|
||||
'is_discounted',
|
||||
'discount_date',
|
||||
'discount_bank',
|
||||
'discount_rate',
|
||||
'discount_amount',
|
||||
'endorsement_date',
|
||||
'endorsee',
|
||||
'endorsement_reason',
|
||||
'collection_bank',
|
||||
'collection_request_date',
|
||||
'collection_fee',
|
||||
'collection_complete_date',
|
||||
'collection_result',
|
||||
'collection_deposit_date',
|
||||
'collection_deposit_amount',
|
||||
'settlement_bank',
|
||||
'payment_method',
|
||||
'actual_payment_date',
|
||||
'payment_place',
|
||||
'payment_place_detail',
|
||||
'renewal_date',
|
||||
'renewal_new_bill_no',
|
||||
'renewal_reason',
|
||||
'recourse_date',
|
||||
'recourse_amount',
|
||||
'recourse_target',
|
||||
'recourse_reason',
|
||||
'buyback_date',
|
||||
'buyback_amount',
|
||||
'buyback_bank',
|
||||
'dishonored_date',
|
||||
'dishonored_reason',
|
||||
'has_protest',
|
||||
'protest_date',
|
||||
'recourse_notice_date',
|
||||
'recourse_notice_deadline',
|
||||
'is_split',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -93,57 +41,21 @@ class Bill extends Model
|
||||
'bank_account_id' => 'integer',
|
||||
'installment_count' => 'integer',
|
||||
'is_electronic' => 'boolean',
|
||||
// V8 확장 casts
|
||||
'acceptance_date' => 'date',
|
||||
'acceptance_refusal_date' => 'date',
|
||||
'discount_date' => 'date',
|
||||
'discount_rate' => 'decimal:2',
|
||||
'discount_amount' => 'decimal:2',
|
||||
'endorsement_date' => 'date',
|
||||
'collection_request_date' => 'date',
|
||||
'collection_fee' => 'decimal:2',
|
||||
'collection_complete_date' => 'date',
|
||||
'collection_deposit_date' => 'date',
|
||||
'collection_deposit_amount' => 'decimal:2',
|
||||
'actual_payment_date' => 'date',
|
||||
'renewal_date' => 'date',
|
||||
'recourse_date' => 'date',
|
||||
'recourse_amount' => 'decimal:2',
|
||||
'buyback_date' => 'date',
|
||||
'buyback_amount' => 'decimal:2',
|
||||
'dishonored_date' => 'date',
|
||||
'protest_date' => 'date',
|
||||
'recourse_notice_date' => 'date',
|
||||
'recourse_notice_deadline' => 'date',
|
||||
'is_discounted' => 'boolean',
|
||||
'has_protest' => 'boolean',
|
||||
'is_split' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* 배열/JSON 변환 시 날짜 형식 지정
|
||||
*/
|
||||
/**
|
||||
* 날짜 cast 필드 목록 (toArray에서 Y-m-d 형식 변환용)
|
||||
*/
|
||||
private const DATE_FIELDS = [
|
||||
'issue_date', 'maturity_date',
|
||||
'acceptance_date', 'acceptance_refusal_date',
|
||||
'discount_date', 'endorsement_date',
|
||||
'collection_request_date', 'collection_complete_date', 'collection_deposit_date',
|
||||
'actual_payment_date',
|
||||
'renewal_date', 'recourse_date', 'buyback_date',
|
||||
'dishonored_date', 'protest_date', 'recourse_notice_date', 'recourse_notice_deadline',
|
||||
];
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
$array = parent::toArray();
|
||||
|
||||
foreach (self::DATE_FIELDS as $field) {
|
||||
if (isset($array[$field]) && $this->{$field}) {
|
||||
$array[$field] = $this->{$field}->format('Y-m-d');
|
||||
}
|
||||
// 날짜 필드를 Y-m-d 형식으로 변환
|
||||
if (isset($array['issue_date']) && $this->issue_date) {
|
||||
$array['issue_date'] = $this->issue_date->format('Y-m-d');
|
||||
}
|
||||
if (isset($array['maturity_date']) && $this->maturity_date) {
|
||||
$array['maturity_date'] = $this->maturity_date->format('Y-m-d');
|
||||
}
|
||||
|
||||
return $array;
|
||||
@@ -157,42 +69,14 @@ public function toArray(): array
|
||||
'issued' => '발행',
|
||||
];
|
||||
|
||||
/**
|
||||
* 증권종류
|
||||
*/
|
||||
public const INSTRUMENT_TYPES = [
|
||||
'promissory' => '약속어음',
|
||||
'exchange' => '환어음',
|
||||
'cashierCheck' => '자기앞수표',
|
||||
'currentCheck' => '당좌수표',
|
||||
];
|
||||
|
||||
/**
|
||||
* 수취 어음 상태 목록
|
||||
*/
|
||||
public const RECEIVED_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'endorsed' => '배서양도',
|
||||
'discounted' => '할인',
|
||||
'collectionRequest' => '추심의뢰',
|
||||
'collectionComplete' => '추심완료',
|
||||
'maturityDeposit' => '만기입금',
|
||||
'paymentComplete' => '결제완료',
|
||||
'dishonored' => '부도',
|
||||
'renewed' => '개서',
|
||||
'buyback' => '환매',
|
||||
// 하위호환
|
||||
'maturityAlert' => '만기입금(7일전)',
|
||||
'maturityResult' => '만기결과',
|
||||
];
|
||||
|
||||
/**
|
||||
* 수취 수표 상태 목록
|
||||
*/
|
||||
public const RECEIVED_CHECK_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'endorsed' => '배서양도',
|
||||
'deposited' => '입금',
|
||||
'paymentComplete' => '결제완료',
|
||||
'dishonored' => '부도',
|
||||
];
|
||||
|
||||
@@ -201,25 +85,10 @@ public function toArray(): array
|
||||
*/
|
||||
public const ISSUED_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'issued' => '지급대기',
|
||||
'maturityPayment' => '만기결제',
|
||||
'paymentComplete' => '결제완료',
|
||||
'dishonored' => '부도',
|
||||
'renewed' => '개서',
|
||||
// 하위호환
|
||||
'maturityAlert' => '만기입금(7일전)',
|
||||
'collectionRequest' => '추심의뢰',
|
||||
'collectionComplete' => '추심완료',
|
||||
'suing' => '추소중',
|
||||
];
|
||||
|
||||
/**
|
||||
* 발행 수표 상태 목록
|
||||
*/
|
||||
public const ISSUED_CHECK_STATUSES = [
|
||||
'stored' => '보관중',
|
||||
'issued' => '지급대기',
|
||||
'cashed' => '현금화',
|
||||
'dishonored' => '부도',
|
||||
];
|
||||
|
||||
@@ -280,25 +149,11 @@ public function getBillTypeLabelAttribute(): string
|
||||
*/
|
||||
public function getStatusLabelAttribute(): string
|
||||
{
|
||||
$isCheck = in_array($this->instrument_type, ['cashierCheck', 'currentCheck']);
|
||||
|
||||
if ($this->bill_type === 'received') {
|
||||
$statuses = $isCheck ? self::RECEIVED_CHECK_STATUSES : self::RECEIVED_STATUSES;
|
||||
|
||||
return $statuses[$this->status] ?? self::RECEIVED_STATUSES[$this->status] ?? $this->status;
|
||||
return self::RECEIVED_STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
$statuses = $isCheck ? self::ISSUED_CHECK_STATUSES : self::ISSUED_STATUSES;
|
||||
|
||||
return $statuses[$this->status] ?? self::ISSUED_STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 증권종류 라벨
|
||||
*/
|
||||
public function getInstrumentTypeLabelAttribute(): string
|
||||
{
|
||||
return self::INSTRUMENT_TYPES[$this->instrument_type] ?? $this->instrument_type ?? '약속어음';
|
||||
return self::ISSUED_STATUSES[$this->status] ?? $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,10 +12,8 @@ class BillInstallment extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'bill_id',
|
||||
'type',
|
||||
'installment_date',
|
||||
'amount',
|
||||
'counterparty',
|
||||
'note',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
@@ -34,9 +34,6 @@ class ExpenseAccount extends Model
|
||||
'vendor_name',
|
||||
'payment_method',
|
||||
'card_no',
|
||||
'loan_id',
|
||||
'journal_entry_id',
|
||||
'journal_entry_line_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -56,9 +53,6 @@ class ExpenseAccount extends Model
|
||||
|
||||
public const TYPE_OFFICE = 'office';
|
||||
|
||||
// 세부 유형 상수 (접대비)
|
||||
public const SUB_TYPE_GIFT_CERTIFICATE = 'gift_certificate';
|
||||
|
||||
// 세부 유형 상수 (복리후생)
|
||||
public const SUB_TYPE_MEAL = 'meal';
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class JournalEntry extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'entry_no',
|
||||
'entry_date',
|
||||
'entry_type',
|
||||
'description',
|
||||
'total_debit',
|
||||
'total_credit',
|
||||
'status',
|
||||
'source_type',
|
||||
'source_key',
|
||||
'created_by_name',
|
||||
'attachment_note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'entry_date' => 'date',
|
||||
'total_debit' => 'integer',
|
||||
'total_credit' => 'integer',
|
||||
];
|
||||
|
||||
// Status
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
|
||||
// Source type
|
||||
public const SOURCE_MANUAL = 'manual';
|
||||
public const SOURCE_BANK_TRANSACTION = 'bank_transaction';
|
||||
public const SOURCE_TAX_INVOICE = 'tax_invoice';
|
||||
public const SOURCE_CARD_TRANSACTION = 'card_transaction';
|
||||
|
||||
// Entry type
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
/**
|
||||
* 분개 행 관계
|
||||
*/
|
||||
public function lines(): HasMany
|
||||
{
|
||||
return $this->hasMany(JournalEntryLine::class)->orderBy('line_no');
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class JournalEntryLine extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'journal_entry_id',
|
||||
'line_no',
|
||||
'dc_type',
|
||||
'account_code',
|
||||
'account_name',
|
||||
'trading_partner_id',
|
||||
'trading_partner_name',
|
||||
'debit_amount',
|
||||
'credit_amount',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'line_no' => 'integer',
|
||||
'debit_amount' => 'integer',
|
||||
'credit_amount' => 'integer',
|
||||
'trading_partner_id' => 'integer',
|
||||
];
|
||||
|
||||
// DC Type
|
||||
public const DC_DEBIT = 'debit';
|
||||
public const DC_CREDIT = 'credit';
|
||||
|
||||
/**
|
||||
* 전표 관계
|
||||
*/
|
||||
public function journalEntry(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(JournalEntry::class);
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,6 @@ class Loan extends Model
|
||||
|
||||
public const STATUS_PARTIAL = 'partial'; // 부분정산
|
||||
|
||||
public const STATUS_HOLDING = 'holding'; // 보유 (상품권)
|
||||
|
||||
public const STATUS_USED = 'used'; // 사용 (상품권)
|
||||
|
||||
public const STATUS_DISPOSED = 'disposed'; // 폐기 (상품권)
|
||||
|
||||
/**
|
||||
* 상태 목록
|
||||
*/
|
||||
@@ -41,40 +35,6 @@ class Loan extends Model
|
||||
self::STATUS_OUTSTANDING,
|
||||
self::STATUS_SETTLED,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_HOLDING,
|
||||
self::STATUS_USED,
|
||||
self::STATUS_DISPOSED,
|
||||
];
|
||||
|
||||
/**
|
||||
* 카테고리 상수 (D1.7 기획서)
|
||||
*/
|
||||
public const CATEGORY_CARD = 'card'; // 카드
|
||||
|
||||
public const CATEGORY_CONGRATULATORY = 'congratulatory'; // 경조사
|
||||
|
||||
public const CATEGORY_GIFT_CERTIFICATE = 'gift_certificate'; // 상품권
|
||||
|
||||
public const CATEGORY_ENTERTAINMENT = 'entertainment'; // 접대비
|
||||
|
||||
/**
|
||||
* 카테고리 목록
|
||||
*/
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_CARD,
|
||||
self::CATEGORY_CONGRATULATORY,
|
||||
self::CATEGORY_GIFT_CERTIFICATE,
|
||||
self::CATEGORY_ENTERTAINMENT,
|
||||
];
|
||||
|
||||
/**
|
||||
* 카테고리 라벨 매핑
|
||||
*/
|
||||
public const CATEGORY_LABELS = [
|
||||
self::CATEGORY_CARD => '카드',
|
||||
self::CATEGORY_CONGRATULATORY => '경조사',
|
||||
self::CATEGORY_GIFT_CERTIFICATE => '상품권',
|
||||
self::CATEGORY_ENTERTAINMENT => '접대비',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -112,8 +72,6 @@ class Loan extends Model
|
||||
'settlement_date',
|
||||
'settlement_amount',
|
||||
'status',
|
||||
'category',
|
||||
'metadata',
|
||||
'withdrawal_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
@@ -125,7 +83,6 @@ class Loan extends Model
|
||||
'settlement_date' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
'settlement_amount' => 'decimal:2',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
@@ -177,21 +134,10 @@ public function getStatusLabelAttribute(): string
|
||||
self::STATUS_OUTSTANDING => '미정산',
|
||||
self::STATUS_SETTLED => '정산완료',
|
||||
self::STATUS_PARTIAL => '부분정산',
|
||||
self::STATUS_HOLDING => '보유',
|
||||
self::STATUS_USED => '사용',
|
||||
self::STATUS_DISPOSED => '폐기',
|
||||
default => $this->status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 라벨
|
||||
*/
|
||||
public function getCategoryLabelAttribute(): string
|
||||
{
|
||||
return self::CATEGORY_LABELS[$this->category] ?? $this->category ?? '카드';
|
||||
}
|
||||
|
||||
/**
|
||||
* 미정산 잔액
|
||||
*/
|
||||
@@ -219,33 +165,19 @@ public function getElapsedDaysAttribute(): int
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 (미정산 상태 또는 상품권)
|
||||
* 수정 가능 여부 (미정산 상태만)
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
{
|
||||
if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->status, [
|
||||
self::STATUS_OUTSTANDING,
|
||||
self::STATUS_HOLDING,
|
||||
]);
|
||||
return $this->status === self::STATUS_OUTSTANDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 가능 여부 (미정산/보유 상태 또는 상품권)
|
||||
* 삭제 가능 여부 (미정산 상태만)
|
||||
*/
|
||||
public function isDeletable(): bool
|
||||
{
|
||||
if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->status, [
|
||||
self::STATUS_OUTSTANDING,
|
||||
self::STATUS_HOLDING,
|
||||
]);
|
||||
return $this->status === self::STATUS_OUTSTANDING;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -134,14 +134,6 @@ public function items(): HasMany
|
||||
return $this->hasMany(ShipmentItem::class)->orderBy('seq');
|
||||
}
|
||||
|
||||
/**
|
||||
* 배차정보 관계
|
||||
*/
|
||||
public function vehicleDispatches(): HasMany
|
||||
{
|
||||
return $this->hasMany(ShipmentVehicleDispatch::class)->orderBy('seq');
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 관계
|
||||
*/
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ShipmentVehicleDispatch extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'shipment_id',
|
||||
'seq',
|
||||
'logistics_company',
|
||||
'arrival_datetime',
|
||||
'tonnage',
|
||||
'vehicle_no',
|
||||
'driver_contact',
|
||||
'remarks',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'seq' => 'integer',
|
||||
'shipment_id' => 'integer',
|
||||
'arrival_datetime' => 'datetime',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* 출하 관계
|
||||
*/
|
||||
public function shipment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Shipment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 순번 가져오기
|
||||
*/
|
||||
public static function getNextSeq(int $shipmentId): int
|
||||
{
|
||||
$maxSeq = static::where('shipment_id', $shipmentId)->max('seq');
|
||||
|
||||
return ($maxSeq ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\AccountCode;
|
||||
use App\Models\Tenants\JournalEntryLine;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class AccountCodeService extends Service
|
||||
{
|
||||
/**
|
||||
* 계정과목 목록 조회
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = AccountCode::query()
|
||||
->where('tenant_id', $tenantId);
|
||||
|
||||
// 검색 (코드/이름)
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('code', 'like', "%{$search}%")
|
||||
->orWhere('name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 분류 필터 (대분류)
|
||||
if (! empty($params['category'])) {
|
||||
$query->where('category', $params['category']);
|
||||
}
|
||||
|
||||
// 중분류 필터
|
||||
if (! empty($params['sub_category'])) {
|
||||
$query->where('sub_category', $params['sub_category']);
|
||||
}
|
||||
|
||||
// 부문 필터
|
||||
if (! empty($params['department_type'])) {
|
||||
$query->where('department_type', $params['department_type']);
|
||||
}
|
||||
|
||||
// 계층 필터
|
||||
if (! empty($params['depth'])) {
|
||||
$query->where('depth', (int) $params['depth']);
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isset($params['is_active'])) {
|
||||
$query->where('is_active', filter_var($params['is_active'], FILTER_VALIDATE_BOOLEAN));
|
||||
}
|
||||
|
||||
// 선택 가능한 계정만 (소분류만 = Select용)
|
||||
if (! empty($params['selectable'])) {
|
||||
$query->selectable();
|
||||
}
|
||||
|
||||
return $query->orderBy('code')->orderBy('sort_order')->get()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 등록
|
||||
*/
|
||||
public function store(array $data): AccountCode
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 중복 코드 체크
|
||||
$exists = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $data['code'])
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.account_subject.duplicate_code'));
|
||||
}
|
||||
|
||||
$accountCode = new AccountCode;
|
||||
$accountCode->tenant_id = $tenantId;
|
||||
$accountCode->code = $data['code'];
|
||||
$accountCode->name = $data['name'];
|
||||
$accountCode->category = $data['category'] ?? null;
|
||||
$accountCode->sub_category = $data['sub_category'] ?? null;
|
||||
$accountCode->parent_code = $data['parent_code'] ?? null;
|
||||
$accountCode->depth = $data['depth'] ?? AccountCode::DEPTH_MINOR;
|
||||
$accountCode->department_type = $data['department_type'] ?? AccountCode::DEPT_COMMON;
|
||||
$accountCode->description = $data['description'] ?? null;
|
||||
$accountCode->sort_order = $data['sort_order'] ?? 0;
|
||||
$accountCode->is_active = true;
|
||||
$accountCode->save();
|
||||
|
||||
return $accountCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 수정
|
||||
*/
|
||||
public function update(int $id, array $data): AccountCode
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$accountCode = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 코드 변경 시 중복 체크
|
||||
if (isset($data['code']) && $data['code'] !== $accountCode->code) {
|
||||
$exists = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $data['code'])
|
||||
->where('id', '!=', $id)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw new BadRequestHttpException(__('error.account_subject.duplicate_code'));
|
||||
}
|
||||
}
|
||||
|
||||
$accountCode->fill($data);
|
||||
$accountCode->save();
|
||||
|
||||
return $accountCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 활성/비활성 토글
|
||||
*/
|
||||
public function toggleStatus(int $id, bool $isActive): AccountCode
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$accountCode = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
$accountCode->is_active = $isActive;
|
||||
$accountCode->save();
|
||||
|
||||
return $accountCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 삭제 (사용 중이면 차단)
|
||||
*/
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$accountCode = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 전표에서 사용 중인지 확인
|
||||
$inUse = JournalEntryLine::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_code', $accountCode->code)
|
||||
->exists();
|
||||
|
||||
if ($inUse) {
|
||||
throw new BadRequestHttpException(__('error.account_subject.in_use'));
|
||||
}
|
||||
|
||||
$accountCode->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 계정과목표 일괄 생성 (초기 세팅)
|
||||
*/
|
||||
public function seedDefaults(): int
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$defaults = $this->getDefaultAccountCodes();
|
||||
$insertedCount = 0;
|
||||
|
||||
DB::transaction(function () use ($tenantId, $defaults, &$insertedCount) {
|
||||
foreach ($defaults as $item) {
|
||||
$exists = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $item['code'])
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
AccountCode::create(array_merge($item, ['tenant_id' => $tenantId]));
|
||||
$insertedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $insertedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 계정과목표 데이터 (더존 Smart A 표준 기반)
|
||||
*
|
||||
* 코드 체계: 5자리 (10100~99900)
|
||||
* - 10100~24000: 자산
|
||||
* - 25000~31700: 부채
|
||||
* - 33100~38700: 자본
|
||||
* - 40100~41000: 매출
|
||||
* - 50100~53700: 매출원가/제조경비 (제조부문)
|
||||
* - 80100~84800: 판매비와관리비 (관리부문)
|
||||
* - 90100~99900: 영업외수익/비용
|
||||
*
|
||||
* 계층: depth 1(대분류) → depth 2(중분류) → depth 3(소분류=더존 실제코드)
|
||||
*/
|
||||
private function getDefaultAccountCodes(): array
|
||||
{
|
||||
$c = fn ($code, $name, $cat, $sub, $parent, $depth, $dept, $sort) => [
|
||||
'code' => $code, 'name' => $name, 'category' => $cat,
|
||||
'sub_category' => $sub, 'parent_code' => $parent,
|
||||
'depth' => $depth, 'department_type' => $dept, 'sort_order' => $sort,
|
||||
];
|
||||
|
||||
return [
|
||||
// ============================================================
|
||||
// 자산 (Assets)
|
||||
// ============================================================
|
||||
$c('1', '자산', 'asset', null, null, 1, 'common', 100),
|
||||
|
||||
// -- 유동자산 --
|
||||
$c('11', '유동자산', 'asset', 'current_asset', '1', 2, 'common', 110),
|
||||
$c('10100', '현금', 'asset', 'current_asset', '11', 3, 'common', 1010),
|
||||
$c('10200', '당좌예금', 'asset', 'current_asset', '11', 3, 'common', 1020),
|
||||
$c('10300', '보통예금', 'asset', 'current_asset', '11', 3, 'common', 1030),
|
||||
$c('10400', '기타제예금', 'asset', 'current_asset', '11', 3, 'common', 1040),
|
||||
$c('10500', '정기적금', 'asset', 'current_asset', '11', 3, 'common', 1050),
|
||||
$c('10800', '외상매출금', 'asset', 'current_asset', '11', 3, 'common', 1080),
|
||||
$c('10900', '대손충당금(외상매출금)', 'asset', 'current_asset', '11', 3, 'common', 1090),
|
||||
$c('11000', '받을어음', 'asset', 'current_asset', '11', 3, 'common', 1100),
|
||||
$c('11400', '단기대여금', 'asset', 'current_asset', '11', 3, 'common', 1140),
|
||||
$c('11600', '미수수익', 'asset', 'current_asset', '11', 3, 'common', 1160),
|
||||
$c('12000', '미수금', 'asset', 'current_asset', '11', 3, 'common', 1200),
|
||||
$c('12200', '소모품', 'asset', 'current_asset', '11', 3, 'common', 1220),
|
||||
$c('12500', '미환급세금', 'asset', 'current_asset', '11', 3, 'common', 1250),
|
||||
$c('13100', '선급금', 'asset', 'current_asset', '11', 3, 'common', 1310),
|
||||
$c('13300', '선급비용', 'asset', 'current_asset', '11', 3, 'common', 1330),
|
||||
$c('13400', '가지급금', 'asset', 'current_asset', '11', 3, 'common', 1340),
|
||||
$c('13500', '부가세대급금', 'asset', 'current_asset', '11', 3, 'common', 1350),
|
||||
$c('13600', '선납세금', 'asset', 'current_asset', '11', 3, 'common', 1360),
|
||||
$c('14000', '선납법인세', 'asset', 'current_asset', '11', 3, 'common', 1400),
|
||||
|
||||
// -- 재고자산 --
|
||||
$c('12', '재고자산', 'asset', 'current_asset', '1', 2, 'common', 120),
|
||||
$c('14600', '상품', 'asset', 'current_asset', '12', 3, 'common', 1460),
|
||||
$c('15000', '제품', 'asset', 'current_asset', '12', 3, 'common', 1500),
|
||||
$c('15300', '원재료', 'asset', 'current_asset', '12', 3, 'common', 1530),
|
||||
$c('16200', '부재료', 'asset', 'current_asset', '12', 3, 'common', 1620),
|
||||
$c('16700', '저장품', 'asset', 'current_asset', '12', 3, 'common', 1670),
|
||||
$c('16900', '재공품', 'asset', 'current_asset', '12', 3, 'common', 1690),
|
||||
|
||||
// -- 비유동자산 --
|
||||
$c('13', '비유동자산', 'asset', 'fixed_asset', '1', 2, 'common', 130),
|
||||
$c('17600', '장기성예금', 'asset', 'fixed_asset', '13', 3, 'common', 1760),
|
||||
$c('17900', '장기대여금', 'asset', 'fixed_asset', '13', 3, 'common', 1790),
|
||||
$c('18700', '투자부동산', 'asset', 'fixed_asset', '13', 3, 'common', 1870),
|
||||
$c('19200', '단체퇴직보험예치금', 'asset', 'fixed_asset', '13', 3, 'common', 1920),
|
||||
$c('20100', '토지', 'asset', 'fixed_asset', '13', 3, 'common', 2010),
|
||||
$c('20200', '건물', 'asset', 'fixed_asset', '13', 3, 'common', 2020),
|
||||
$c('20300', '감가상각누계액(건물)', 'asset', 'fixed_asset', '13', 3, 'common', 2030),
|
||||
$c('20400', '구축물', 'asset', 'fixed_asset', '13', 3, 'common', 2040),
|
||||
$c('20500', '감가상각누계액(구축물)', 'asset', 'fixed_asset', '13', 3, 'common', 2050),
|
||||
$c('20600', '기계장치', 'asset', 'fixed_asset', '13', 3, 'common', 2060),
|
||||
$c('20700', '감가상각누계액(기계장치)', 'asset', 'fixed_asset', '13', 3, 'common', 2070),
|
||||
$c('20800', '차량운반구', 'asset', 'fixed_asset', '13', 3, 'common', 2080),
|
||||
$c('20900', '감가상각누계액(차량운반구)', 'asset', 'fixed_asset', '13', 3, 'common', 2090),
|
||||
$c('21000', '공구와기구', 'asset', 'fixed_asset', '13', 3, 'common', 2100),
|
||||
$c('21200', '비품', 'asset', 'fixed_asset', '13', 3, 'common', 2120),
|
||||
$c('21300', '건설중인자산', 'asset', 'fixed_asset', '13', 3, 'common', 2130),
|
||||
$c('24000', '소프트웨어', 'asset', 'fixed_asset', '13', 3, 'common', 2400),
|
||||
|
||||
// ============================================================
|
||||
// 부채 (Liabilities)
|
||||
// ============================================================
|
||||
$c('2', '부채', 'liability', null, null, 1, 'common', 200),
|
||||
|
||||
// -- 유동부채 --
|
||||
$c('21', '유동부채', 'liability', 'current_liability', '2', 2, 'common', 210),
|
||||
$c('25100', '외상매입금', 'liability', 'current_liability', '21', 3, 'common', 2510),
|
||||
$c('25200', '지급어음', 'liability', 'current_liability', '21', 3, 'common', 2520),
|
||||
$c('25300', '미지급금', 'liability', 'current_liability', '21', 3, 'common', 2530),
|
||||
$c('25400', '예수금', 'liability', 'current_liability', '21', 3, 'common', 2540),
|
||||
$c('25500', '부가세예수금', 'liability', 'current_liability', '21', 3, 'common', 2550),
|
||||
$c('25900', '선수금', 'liability', 'current_liability', '21', 3, 'common', 2590),
|
||||
$c('26000', '단기차입금', 'liability', 'current_liability', '21', 3, 'common', 2600),
|
||||
$c('26100', '미지급세금', 'liability', 'current_liability', '21', 3, 'common', 2610),
|
||||
$c('26200', '미지급비용', 'liability', 'current_liability', '21', 3, 'common', 2620),
|
||||
$c('26400', '유동성장기차입금', 'liability', 'current_liability', '21', 3, 'common', 2640),
|
||||
$c('26500', '미지급배당금', 'liability', 'current_liability', '21', 3, 'common', 2650),
|
||||
|
||||
// -- 비유동부채 --
|
||||
$c('22', '비유동부채', 'liability', 'long_term_liability', '2', 2, 'common', 220),
|
||||
$c('29300', '장기차입금', 'liability', 'long_term_liability', '22', 3, 'common', 2930),
|
||||
$c('29400', '임대보증금', 'liability', 'long_term_liability', '22', 3, 'common', 2940),
|
||||
$c('29500', '퇴직급여충당부채', 'liability', 'long_term_liability', '22', 3, 'common', 2950),
|
||||
$c('30700', '장기임대보증금', 'liability', 'long_term_liability', '22', 3, 'common', 3070),
|
||||
|
||||
// ============================================================
|
||||
// 자본 (Capital)
|
||||
// ============================================================
|
||||
$c('3', '자본', 'capital', null, null, 1, 'common', 300),
|
||||
|
||||
// -- 자본금 --
|
||||
$c('31', '자본금', 'capital', 'capital', '3', 2, 'common', 310),
|
||||
$c('33100', '자본금', 'capital', 'capital', '31', 3, 'common', 3310),
|
||||
$c('33200', '우선주자본금', 'capital', 'capital', '31', 3, 'common', 3320),
|
||||
|
||||
// -- 잉여금 --
|
||||
$c('32', '잉여금', 'capital', 'capital', '3', 2, 'common', 320),
|
||||
$c('34100', '주식발행초과금', 'capital', 'capital', '32', 3, 'common', 3410),
|
||||
$c('35100', '이익준비금', 'capital', 'capital', '32', 3, 'common', 3510),
|
||||
$c('37500', '이월이익잉여금', 'capital', 'capital', '32', 3, 'common', 3750),
|
||||
$c('37900', '당기순이익', 'capital', 'capital', '32', 3, 'common', 3790),
|
||||
|
||||
// ============================================================
|
||||
// 수익 (Revenue)
|
||||
// ============================================================
|
||||
$c('4', '수익', 'revenue', null, null, 1, 'common', 400),
|
||||
|
||||
// -- 매출 --
|
||||
$c('41', '매출', 'revenue', 'sales_revenue', '4', 2, 'common', 410),
|
||||
$c('40100', '상품매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4010),
|
||||
$c('40400', '제품매출', 'revenue', 'sales_revenue', '41', 3, 'common', 4040),
|
||||
$c('40700', '공사수입금', 'revenue', 'sales_revenue', '41', 3, 'common', 4070),
|
||||
$c('41000', '임대료수입', 'revenue', 'sales_revenue', '41', 3, 'common', 4100),
|
||||
|
||||
// -- 영업외수익 --
|
||||
$c('42', '영업외수익', 'revenue', 'other_revenue', '4', 2, 'common', 420),
|
||||
$c('90100', '이자수익', 'revenue', 'other_revenue', '42', 3, 'common', 9010),
|
||||
$c('90300', '배당금수익', 'revenue', 'other_revenue', '42', 3, 'common', 9030),
|
||||
$c('90400', '수입임대료', 'revenue', 'other_revenue', '42', 3, 'common', 9040),
|
||||
$c('90700', '외환차익', 'revenue', 'other_revenue', '42', 3, 'common', 9070),
|
||||
$c('93000', '잡이익', 'revenue', 'other_revenue', '42', 3, 'common', 9300),
|
||||
|
||||
// ============================================================
|
||||
// 비용 (Expenses)
|
||||
// ============================================================
|
||||
$c('5', '비용', 'expense', null, null, 1, 'common', 500),
|
||||
|
||||
// -- 매출원가/제조원가 (제조부문) --
|
||||
$c('51', '매출원가', 'expense', 'cogs', '5', 2, 'manufacturing', 510),
|
||||
$c('50100', '원재료비', 'expense', 'cogs', '51', 3, 'manufacturing', 5010),
|
||||
$c('50200', '외주가공비', 'expense', 'cogs', '51', 3, 'manufacturing', 5020),
|
||||
$c('50300', '급여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5030),
|
||||
$c('50400', '임금', 'expense', 'cogs', '51', 3, 'manufacturing', 5040),
|
||||
$c('50500', '상여금(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5050),
|
||||
$c('50800', '퇴직급여(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5080),
|
||||
$c('51100', '복리후생비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5110),
|
||||
$c('51200', '여비교통비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5120),
|
||||
$c('51300', '접대비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5130),
|
||||
$c('51400', '통신비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5140),
|
||||
$c('51600', '전력비', 'expense', 'cogs', '51', 3, 'manufacturing', 5160),
|
||||
$c('51700', '세금과공과금(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5170),
|
||||
$c('51800', '감가상각비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5180),
|
||||
$c('51900', '지급임차료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5190),
|
||||
$c('52000', '수선비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5200),
|
||||
$c('52100', '보험료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5210),
|
||||
$c('52200', '차량유지비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5220),
|
||||
$c('52400', '운반비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5240),
|
||||
$c('53000', '소모품비(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5300),
|
||||
$c('53100', '지급수수료(제조)', 'expense', 'cogs', '51', 3, 'manufacturing', 5310),
|
||||
|
||||
// -- 판매비와관리비 (관리부문) --
|
||||
$c('52', '판매비와관리비', 'expense', 'selling_admin', '5', 2, 'admin', 520),
|
||||
$c('80100', '임원급여', 'expense', 'selling_admin', '52', 3, 'admin', 8010),
|
||||
$c('80200', '직원급여', 'expense', 'selling_admin', '52', 3, 'admin', 8020),
|
||||
$c('80300', '상여금', 'expense', 'selling_admin', '52', 3, 'admin', 8030),
|
||||
$c('80600', '퇴직급여', 'expense', 'selling_admin', '52', 3, 'admin', 8060),
|
||||
$c('81100', '복리후생비', 'expense', 'selling_admin', '52', 3, 'admin', 8110),
|
||||
$c('81200', '여비교통비', 'expense', 'selling_admin', '52', 3, 'admin', 8120),
|
||||
$c('81300', '접대비', 'expense', 'selling_admin', '52', 3, 'admin', 8130),
|
||||
$c('81400', '통신비', 'expense', 'selling_admin', '52', 3, 'admin', 8140),
|
||||
$c('81500', '수도광열비', 'expense', 'selling_admin', '52', 3, 'admin', 8150),
|
||||
$c('81700', '세금과공과금', 'expense', 'selling_admin', '52', 3, 'admin', 8170),
|
||||
$c('81800', '감가상각비', 'expense', 'selling_admin', '52', 3, 'admin', 8180),
|
||||
$c('81900', '지급임차료', 'expense', 'selling_admin', '52', 3, 'admin', 8190),
|
||||
$c('82000', '수선비', 'expense', 'selling_admin', '52', 3, 'admin', 8200),
|
||||
$c('82100', '보험료', 'expense', 'selling_admin', '52', 3, 'admin', 8210),
|
||||
$c('82200', '차량유지비', 'expense', 'selling_admin', '52', 3, 'admin', 8220),
|
||||
$c('82300', '경상연구개발비', 'expense', 'selling_admin', '52', 3, 'admin', 8230),
|
||||
$c('82400', '운반비', 'expense', 'selling_admin', '52', 3, 'admin', 8240),
|
||||
$c('82500', '교육훈련비', 'expense', 'selling_admin', '52', 3, 'admin', 8250),
|
||||
$c('82600', '도서인쇄비', 'expense', 'selling_admin', '52', 3, 'admin', 8260),
|
||||
$c('82700', '회의비', 'expense', 'selling_admin', '52', 3, 'admin', 8270),
|
||||
$c('82900', '사무용품비', 'expense', 'selling_admin', '52', 3, 'admin', 8290),
|
||||
$c('83000', '소모품비', 'expense', 'selling_admin', '52', 3, 'admin', 8300),
|
||||
$c('83100', '지급수수료', 'expense', 'selling_admin', '52', 3, 'admin', 8310),
|
||||
$c('83200', '보관료', 'expense', 'selling_admin', '52', 3, 'admin', 8320),
|
||||
$c('83300', '광고선전비', 'expense', 'selling_admin', '52', 3, 'admin', 8330),
|
||||
$c('83500', '대손상각비', 'expense', 'selling_admin', '52', 3, 'admin', 8350),
|
||||
$c('84800', '잡비', 'expense', 'selling_admin', '52', 3, 'admin', 8480),
|
||||
|
||||
// -- 영업외비용 --
|
||||
$c('53', '영업외비용', 'expense', 'other_expense', '5', 2, 'common', 530),
|
||||
$c('93100', '이자비용', 'expense', 'other_expense', '53', 3, 'common', 9310),
|
||||
$c('93200', '외환차손', 'expense', 'other_expense', '53', 3, 'common', 9320),
|
||||
$c('93300', '기부금', 'expense', 'other_expense', '53', 3, 'common', 9330),
|
||||
$c('96000', '잡손실', 'expense', 'other_expense', '53', 3, 'common', 9600),
|
||||
$c('99800', '법인세', 'expense', 'other_expense', '53', 3, 'common', 9980),
|
||||
$c('99900', '소득세등', 'expense', 'other_expense', '53', 3, 'common', 9990),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,7 @@ private function getReceivableData(int $tenantId, Carbon $reportDate): array
|
||||
private function callGeminiApi(array $inputData): array
|
||||
{
|
||||
$apiKey = config('services.gemini.api_key');
|
||||
$model = config('services.gemini.model', 'gemini-2.5-flash');
|
||||
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
||||
$baseUrl = config('services.gemini.base_url');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Tenants\ApprovalForm;
|
||||
use App\Models\Tenants\ApprovalLine;
|
||||
@@ -447,14 +446,6 @@ public function inbox(array $params): LengthAwarePaginator
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (! empty($params['start_date'])) {
|
||||
$query->whereDate('created_at', '>=', $params['start_date']);
|
||||
}
|
||||
if (! empty($params['end_date'])) {
|
||||
$query->whereDate('created_at', '<=', $params['end_date']);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
$sortBy = $params['sort_by'] ?? 'created_at';
|
||||
$sortDir = $params['sort_dir'] ?? 'desc';
|
||||
@@ -568,7 +559,7 @@ public function show(int $id): Approval
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$approval = Approval::query()
|
||||
return Approval::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with([
|
||||
'form:id,name,code,category,template',
|
||||
@@ -580,19 +571,6 @@ public function show(int $id): Approval
|
||||
'steps.approver.tenantProfile.department:id,name',
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
// Document 브릿지: 연결된 문서 데이터 로딩
|
||||
if ($approval->linkable_type === Document::class) {
|
||||
$approval->load([
|
||||
'linkable.template',
|
||||
'linkable.template.approvalLines',
|
||||
'linkable.data',
|
||||
'linkable.approvals.user:id,name',
|
||||
'linkable.attachments',
|
||||
]);
|
||||
}
|
||||
|
||||
return $approval;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -856,9 +834,6 @@ public function approve(int $id, ?string $comment = null): Approval
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
@@ -912,9 +887,6 @@ public function reject(int $id, string $comment): Approval
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
return $approval->fresh([
|
||||
'form:id,name,code,category',
|
||||
'drafter:id,name',
|
||||
@@ -954,9 +926,6 @@ public function cancel(int $id): Approval
|
||||
$approval->updated_by = $userId;
|
||||
$approval->save();
|
||||
|
||||
// Document 브릿지 동기화 (steps 삭제 전에 실행)
|
||||
$this->syncToLinkedDocument($approval);
|
||||
|
||||
// 결재 단계들 삭제
|
||||
$approval->steps()->delete();
|
||||
|
||||
@@ -967,57 +936,6 @@ public function cancel(int $id): Approval
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval → Document 브릿지 동기화
|
||||
* 결재 승인/반려/회수 시 연결된 Document의 상태와 결재란을 동기화
|
||||
*/
|
||||
private function syncToLinkedDocument(Approval $approval): void
|
||||
{
|
||||
if ($approval->linkable_type !== Document::class) {
|
||||
return;
|
||||
}
|
||||
|
||||
$document = Document::find($approval->linkable_id);
|
||||
if (! $document) {
|
||||
return;
|
||||
}
|
||||
|
||||
// approval_steps → document_approvals 동기화 (승인자 이름/시각 반영)
|
||||
foreach ($approval->steps as $step) {
|
||||
if ($step->status === ApprovalStep::STATUS_PENDING) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$docApproval = $document->approvals()
|
||||
->where('step', $step->step_order)
|
||||
->first();
|
||||
|
||||
if ($docApproval) {
|
||||
$docApproval->update([
|
||||
'status' => strtoupper($step->status),
|
||||
'acted_at' => $step->acted_at,
|
||||
'comment' => $step->comment,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Document 전체 상태 동기화
|
||||
$documentStatus = match ($approval->status) {
|
||||
Approval::STATUS_APPROVED => Document::STATUS_APPROVED,
|
||||
Approval::STATUS_REJECTED => Document::STATUS_REJECTED,
|
||||
Approval::STATUS_CANCELLED => Document::STATUS_CANCELLED,
|
||||
default => Document::STATUS_PENDING,
|
||||
};
|
||||
|
||||
$document->update([
|
||||
'status' => $documentStatus,
|
||||
'completed_at' => in_array($approval->status, [
|
||||
Approval::STATUS_APPROVED,
|
||||
Approval::STATUS_REJECTED,
|
||||
]) ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 열람 처리
|
||||
*/
|
||||
|
||||
@@ -86,8 +86,8 @@ public function summary(array $params = []): array
|
||||
|
||||
// is_active=true인 악성채권만 통계
|
||||
$query = BadDebt::query()
|
||||
->where('bad_debts.tenant_id', $tenantId)
|
||||
->where('bad_debts.is_active', true);
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true);
|
||||
|
||||
// 거래처 필터
|
||||
if (! empty($params['client_id'])) {
|
||||
@@ -110,9 +110,6 @@ public function summary(array $params = []): array
|
||||
->distinct('client_id')
|
||||
->count('client_id');
|
||||
|
||||
// per-card sub_label: 각 상태별 최다 금액 거래처명 + 건수
|
||||
$subLabels = $this->buildPerCardSubLabels($query);
|
||||
|
||||
return [
|
||||
'total_amount' => (float) $totalAmount,
|
||||
'collecting_amount' => (float) $collectingAmount,
|
||||
@@ -120,56 +117,9 @@ public function summary(array $params = []): array
|
||||
'recovered_amount' => (float) $recoveredAmount,
|
||||
'bad_debt_amount' => (float) $badDebtAmount,
|
||||
'client_count' => $clientCount,
|
||||
'sub_labels' => $subLabels,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드별 sub_label 생성 (최다 금액 거래처명 + 건수)
|
||||
*/
|
||||
private function buildPerCardSubLabels($baseQuery): array
|
||||
{
|
||||
$result = [];
|
||||
$statusScopes = [
|
||||
'dc1' => null, // 전체 (누적)
|
||||
'dc2' => 'collecting', // 추심중
|
||||
'dc3' => 'legalAction', // 법적조치
|
||||
'dc4' => 'recovered', // 회수완료
|
||||
];
|
||||
|
||||
foreach ($statusScopes as $cardId => $scope) {
|
||||
$q = clone $baseQuery;
|
||||
if ($scope) {
|
||||
$q = $q->$scope();
|
||||
}
|
||||
|
||||
$clientCount = (clone $q)->distinct('client_id')->count('client_id');
|
||||
|
||||
if ($clientCount <= 0) {
|
||||
$result[$cardId] = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$topClient = (clone $q)
|
||||
->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) {
|
||||
$result[$cardId] = $clientCount > 1
|
||||
? $topClient->name.' 외 '.($clientCount - 1).'건'
|
||||
: $topClient->name;
|
||||
} else {
|
||||
$result[$cardId] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 악성채권 상세 조회
|
||||
*/
|
||||
|
||||
@@ -48,16 +48,6 @@ public function index(array $params): LengthAwarePaginator
|
||||
$query->where('client_id', $params['client_id']);
|
||||
}
|
||||
|
||||
// 증권종류 필터
|
||||
if (! empty($params['instrument_type'])) {
|
||||
$query->where('instrument_type', $params['instrument_type']);
|
||||
}
|
||||
|
||||
// 매체 필터
|
||||
if (! empty($params['medium'])) {
|
||||
$query->where('medium', $params['medium']);
|
||||
}
|
||||
|
||||
// 전자어음 필터
|
||||
if (isset($params['is_electronic']) && $params['is_electronic'] !== '') {
|
||||
$query->where('is_electronic', (bool) $params['is_electronic']);
|
||||
@@ -123,23 +113,32 @@ public function store(array $data): Bill
|
||||
$bill->client_name = $data['client_name'] ?? null;
|
||||
$bill->amount = $data['amount'];
|
||||
$bill->issue_date = $data['issue_date'];
|
||||
$bill->maturity_date = $data['maturity_date'] ?? null;
|
||||
$bill->maturity_date = $data['maturity_date'];
|
||||
$bill->status = $data['status'] ?? 'stored';
|
||||
$bill->reason = $data['reason'] ?? null;
|
||||
$bill->installment_count = $data['installment_count'] ?? 0;
|
||||
$bill->note = $data['note'] ?? null;
|
||||
$bill->is_electronic = $data['is_electronic'] ?? false;
|
||||
$bill->bank_account_id = $data['bank_account_id'] ?? null;
|
||||
|
||||
// V8 확장 필드
|
||||
$this->assignV8Fields($bill, $data);
|
||||
|
||||
$bill->created_by = $userId;
|
||||
$bill->updated_by = $userId;
|
||||
$bill->save();
|
||||
|
||||
// 차수 관리 저장
|
||||
$this->syncInstallments($bill, $data['installments'] ?? [], $userId);
|
||||
if (! empty($data['installments'])) {
|
||||
foreach ($data['installments'] as $installment) {
|
||||
BillInstallment::create([
|
||||
'bill_id' => $bill->id,
|
||||
'installment_date' => $installment['date'],
|
||||
'amount' => $installment['amount'],
|
||||
'note' => $installment['note'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
// 차수 카운트 업데이트
|
||||
$bill->installment_count = count($data['installments']);
|
||||
$bill->save();
|
||||
}
|
||||
|
||||
return $bill->load(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']);
|
||||
});
|
||||
@@ -158,7 +157,6 @@ public function update(int $id, array $data): Bill
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 기존 필드
|
||||
if (isset($data['bill_number'])) {
|
||||
$bill->bill_number = $data['bill_number'];
|
||||
}
|
||||
@@ -177,7 +175,7 @@ public function update(int $id, array $data): Bill
|
||||
if (isset($data['issue_date'])) {
|
||||
$bill->issue_date = $data['issue_date'];
|
||||
}
|
||||
if (array_key_exists('maturity_date', $data)) {
|
||||
if (isset($data['maturity_date'])) {
|
||||
$bill->maturity_date = $data['maturity_date'];
|
||||
}
|
||||
if (isset($data['status'])) {
|
||||
@@ -196,15 +194,27 @@ public function update(int $id, array $data): Bill
|
||||
$bill->bank_account_id = $data['bank_account_id'];
|
||||
}
|
||||
|
||||
// V8 확장 필드
|
||||
$this->assignV8Fields($bill, $data);
|
||||
|
||||
$bill->updated_by = $userId;
|
||||
$bill->save();
|
||||
|
||||
// 차수 관리 업데이트 (전체 교체)
|
||||
if (isset($data['installments'])) {
|
||||
$this->syncInstallments($bill, $data['installments'], $userId);
|
||||
// 기존 차수 삭제
|
||||
$bill->installments()->delete();
|
||||
|
||||
// 새 차수 추가
|
||||
foreach ($data['installments'] as $installment) {
|
||||
BillInstallment::create([
|
||||
'bill_id' => $bill->id,
|
||||
'installment_date' => $installment['date'],
|
||||
'amount' => $installment['amount'],
|
||||
'note' => $installment['note'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
// 차수 카운트 업데이트
|
||||
$bill->installment_count = count($data['installments']);
|
||||
$bill->save();
|
||||
}
|
||||
|
||||
return $bill->fresh(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']);
|
||||
@@ -430,68 +440,6 @@ public function dashboardDetail(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* V8 확장 필드를 Bill 모델에 할당
|
||||
*/
|
||||
private function assignV8Fields(Bill $bill, array $data): void
|
||||
{
|
||||
$v8Fields = [
|
||||
'instrument_type', 'medium', 'bill_category',
|
||||
'electronic_bill_no', 'registration_org',
|
||||
'drawee', 'acceptance_status', 'acceptance_date',
|
||||
'acceptance_refusal_date', 'acceptance_refusal_reason',
|
||||
'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank',
|
||||
'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount',
|
||||
'endorsement_date', 'endorsee', 'endorsement_reason',
|
||||
'collection_bank', 'collection_request_date', 'collection_fee',
|
||||
'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount',
|
||||
'settlement_bank', 'payment_method', 'actual_payment_date',
|
||||
'payment_place', 'payment_place_detail',
|
||||
'renewal_date', 'renewal_new_bill_no', 'renewal_reason',
|
||||
'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason',
|
||||
'buyback_date', 'buyback_amount', 'buyback_bank',
|
||||
'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date',
|
||||
'recourse_notice_date', 'recourse_notice_deadline',
|
||||
'is_split',
|
||||
];
|
||||
|
||||
foreach ($v8Fields as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
$bill->{$field} = $data[$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 차수(이력) 동기화 — 기존 삭제 후 새로 생성
|
||||
*/
|
||||
private function syncInstallments(Bill $bill, array $installments, int $userId): void
|
||||
{
|
||||
if (empty($installments)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 차수 삭제
|
||||
$bill->installments()->delete();
|
||||
|
||||
// 새 차수 추가
|
||||
foreach ($installments as $installment) {
|
||||
BillInstallment::create([
|
||||
'bill_id' => $bill->id,
|
||||
'type' => $installment['type'] ?? 'other',
|
||||
'installment_date' => $installment['date'],
|
||||
'amount' => $installment['amount'],
|
||||
'counterparty' => $installment['counterparty'] ?? null,
|
||||
'note' => $installment['note'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
// 차수 카운트 업데이트
|
||||
$bill->installment_count = count($installments);
|
||||
$bill->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 어음번호 자동 생성
|
||||
*/
|
||||
|
||||
@@ -226,78 +226,6 @@ private function getLeaveSchedules(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 등록
|
||||
*/
|
||||
public function createSchedule(array $data): array
|
||||
{
|
||||
$schedule = Schedule::create([
|
||||
'tenant_id' => $this->tenantId(),
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'start_time' => $data['start_time'] ?? null,
|
||||
'end_time' => $data['end_time'] ?? null,
|
||||
'is_all_day' => $data['is_all_day'] ?? true,
|
||||
'type' => Schedule::TYPE_EVENT,
|
||||
'color' => $data['color'] ?? null,
|
||||
'is_active' => true,
|
||||
'created_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $schedule->id,
|
||||
'title' => $schedule->title,
|
||||
'start_date' => $schedule->start_date?->format('Y-m-d'),
|
||||
'end_date' => $schedule->end_date?->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 수정
|
||||
*/
|
||||
public function updateSchedule(int $id, array $data): array
|
||||
{
|
||||
$schedule = Schedule::where('tenant_id', $this->tenantId())
|
||||
->findOrFail($id);
|
||||
|
||||
$schedule->update([
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'start_date' => $data['start_date'],
|
||||
'end_date' => $data['end_date'],
|
||||
'start_time' => $data['start_time'] ?? null,
|
||||
'end_time' => $data['end_time'] ?? null,
|
||||
'is_all_day' => $data['is_all_day'] ?? true,
|
||||
'color' => $data['color'] ?? null,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $schedule->id,
|
||||
'title' => $schedule->title,
|
||||
'start_date' => $schedule->start_date?->format('Y-m-d'),
|
||||
'end_date' => $schedule->end_date?->format('Y-m-d'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 일정 삭제 (소프트 삭제)
|
||||
*/
|
||||
public function deleteSchedule(int $id): array
|
||||
{
|
||||
$schedule = Schedule::where('tenant_id', $this->tenantId())
|
||||
->findOrFail($id);
|
||||
|
||||
$schedule->update(['deleted_by' => $this->apiUserId()]);
|
||||
$schedule->delete();
|
||||
|
||||
return [
|
||||
'id' => $schedule->id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 범용 일정 조회 (본사 공통 + 테넌트 일정)
|
||||
*/
|
||||
|
||||
@@ -22,8 +22,6 @@ public function index(array $params)
|
||||
$q = trim((string) ($params['q'] ?? ''));
|
||||
$onlyActive = $params['only_active'] ?? null;
|
||||
$clientType = $params['client_type'] ?? null;
|
||||
$startDate = $params['start_date'] ?? null;
|
||||
$endDate = $params['end_date'] ?? null;
|
||||
|
||||
$query = Client::query()->where('tenant_id', $tenantId);
|
||||
|
||||
@@ -45,14 +43,6 @@ public function index(array $params)
|
||||
$query->whereIn('client_type', $types);
|
||||
}
|
||||
|
||||
// 등록일 기간 필터
|
||||
if ($startDate) {
|
||||
$query->whereDate('created_at', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$query->whereDate('created_at', '<=', $endDate);
|
||||
}
|
||||
|
||||
$query->orderBy('client_code')->orderBy('id');
|
||||
|
||||
$paginator = $query->paginate($size, ['*'], 'page', $page);
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
use App\Models\Tenants\BankAccount;
|
||||
use App\Models\Tenants\Bill;
|
||||
use App\Models\Tenants\Deposit;
|
||||
use App\Models\Tenants\ExpectedExpense;
|
||||
use App\Models\Tenants\Purchase;
|
||||
use App\Models\Tenants\Sale;
|
||||
use App\Models\Tenants\Withdrawal;
|
||||
use Carbon\Carbon;
|
||||
|
||||
@@ -158,11 +155,6 @@ public function summary(array $params): array
|
||||
: null;
|
||||
$operatingStability = $this->getOperatingStability($operatingMonths);
|
||||
|
||||
// 기획서 D1.7 자금현황 카드용 필드
|
||||
$receivableBalance = $this->calculateReceivableBalance($tenantId, $date);
|
||||
$payableBalance = $this->calculatePayableBalance($tenantId);
|
||||
$monthlyExpenseTotal = $this->calculateMonthlyExpenseTotal($tenantId, $date);
|
||||
|
||||
return [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'day_of_week' => $date->locale('ko')->dayName,
|
||||
@@ -175,138 +167,9 @@ public function summary(array $params): array
|
||||
'monthly_operating_expense' => $monthlyOperatingExpense,
|
||||
'operating_months' => $operatingMonths,
|
||||
'operating_stability' => $operatingStability,
|
||||
// 자금현황 카드용
|
||||
'receivable_balance' => $receivableBalance,
|
||||
'payable_balance' => $payableBalance,
|
||||
'monthly_expense_total' => $monthlyExpenseTotal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 내보내기용 데이터 조합
|
||||
* DailyReportExport가 기대하는 구조로 변환
|
||||
*/
|
||||
public function exportData(array $params): array
|
||||
{
|
||||
$date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today();
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
|
||||
// 화면과 동일한 계좌별 현황 데이터 재사용
|
||||
$dailyAccounts = $this->dailyAccounts($params);
|
||||
|
||||
// KRW 계좌 합산 (화면 합계와 동일)
|
||||
$carryover = 0;
|
||||
$totalIncome = 0;
|
||||
$totalExpense = 0;
|
||||
$totalBalance = 0;
|
||||
|
||||
$details = [];
|
||||
|
||||
foreach ($dailyAccounts as $account) {
|
||||
$carryover += $account['carryover'];
|
||||
$totalIncome += $account['income'];
|
||||
$totalExpense += $account['expense'];
|
||||
$totalBalance += $account['balance'];
|
||||
|
||||
// 계좌별 상세 내역
|
||||
if ($account['income'] > 0) {
|
||||
$details[] = [
|
||||
'type_label' => '입금',
|
||||
'client_name' => $account['category'],
|
||||
'account_code' => '-',
|
||||
'deposit_amount' => $account['income'],
|
||||
'withdrawal_amount' => 0,
|
||||
'description' => '',
|
||||
];
|
||||
}
|
||||
|
||||
if ($account['expense'] > 0) {
|
||||
$details[] = [
|
||||
'type_label' => '출금',
|
||||
'client_name' => $account['category'],
|
||||
'account_code' => '-',
|
||||
'deposit_amount' => 0,
|
||||
'withdrawal_amount' => $account['expense'],
|
||||
'description' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 어음 및 외상매출채권 현황
|
||||
$noteReceivables = $this->noteReceivables($params);
|
||||
|
||||
return [
|
||||
'date' => $dateStr,
|
||||
'previous_balance' => $carryover,
|
||||
'daily_deposit' => $totalIncome,
|
||||
'daily_withdrawal' => $totalExpense,
|
||||
'current_balance' => $totalBalance,
|
||||
'details' => $details,
|
||||
'note_receivables' => $noteReceivables,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 미수금 잔액 계산
|
||||
* = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지)
|
||||
* ReceivablesService.getTotalCarryForwardBalance() 동일 로직
|
||||
*/
|
||||
private function calculateReceivableBalance(int $tenantId, Carbon $date): float
|
||||
{
|
||||
$endDate = $date->format('Y-m-d');
|
||||
|
||||
$totalSales = Sale::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('sale_date', '<=', $endDate)
|
||||
->sum('total_amount');
|
||||
|
||||
$totalDeposits = Deposit::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('deposit_date', '<=', $endDate)
|
||||
->sum('amount');
|
||||
|
||||
$totalBills = Bill::where('tenant_id', $tenantId)
|
||||
->whereNotNull('client_id')
|
||||
->where('bill_type', 'received')
|
||||
->where('issue_date', '<=', $endDate)
|
||||
->sum('amount');
|
||||
|
||||
return (float) ($totalSales - $totalDeposits - $totalBills);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미지급금 잔액 계산
|
||||
* = 미지급 상태(pending, partial, overdue)인 ExpectedExpense 합계
|
||||
*/
|
||||
private function calculatePayableBalance(int $tenantId): float
|
||||
{
|
||||
return (float) ExpectedExpense::where('tenant_id', $tenantId)
|
||||
->whereIn('payment_status', ['pending', 'partial', 'overdue'])
|
||||
->sum('amount');
|
||||
}
|
||||
|
||||
/**
|
||||
* 당월 예상 지출 합계 계산
|
||||
* = 당월 매입(Purchase) + 당월 예상지출(ExpectedExpense)
|
||||
*/
|
||||
private function calculateMonthlyExpenseTotal(int $tenantId, Carbon $date): float
|
||||
{
|
||||
$startOfMonth = $date->copy()->startOfMonth()->format('Y-m-d');
|
||||
$endOfMonth = $date->copy()->endOfMonth()->format('Y-m-d');
|
||||
|
||||
// 당월 매입 합계
|
||||
$purchaseTotal = Purchase::where('tenant_id', $tenantId)
|
||||
->whereBetween('purchase_date', [$startOfMonth, $endOfMonth])
|
||||
->sum('total_amount');
|
||||
|
||||
// 당월 예상 지출 합계 (매입 외: 카드, 어음, 급여, 임대료 등)
|
||||
$expectedExpenseTotal = ExpectedExpense::where('tenant_id', $tenantId)
|
||||
->whereBetween('expected_payment_date', [$startOfMonth, $endOfMonth])
|
||||
->sum('amount');
|
||||
|
||||
return (float) ($purchaseTotal + $expectedExpenseTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직전 3개월 평균 월 운영비 계산
|
||||
*
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* CEO 대시보드 섹션별 요약 서비스
|
||||
*
|
||||
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
|
||||
* sam_stat 우선 조회 → fallback 원본 DB
|
||||
*/
|
||||
class DashboardCeoService extends Service
|
||||
{
|
||||
// ─── 1. 매출 현황 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 매출 현황 요약
|
||||
*/
|
||||
public function salesSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
$year = $now->year;
|
||||
$month = $now->month;
|
||||
$today = $now->format('Y-m-d');
|
||||
|
||||
// 누적 매출 (연초~오늘)
|
||||
$cumulativeSales = DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year)
|
||||
->where('sale_date', '<=', $today)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
// 당월 매출
|
||||
$monthlySales = DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year)
|
||||
->whereMonth('sale_date', $month)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
// 전년 동월 매출 (YoY)
|
||||
$lastYearMonthlySales = DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year - 1)
|
||||
->whereMonth('sale_date', $month)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
$yoyChange = $lastYearMonthlySales > 0
|
||||
? round((($monthlySales - $lastYearMonthlySales) / $lastYearMonthlySales) * 100, 1)
|
||||
: 0;
|
||||
|
||||
// 달성률 (당월 매출 / 전년 동월 매출 * 100)
|
||||
$achievementRate = $lastYearMonthlySales > 0
|
||||
? round(($monthlySales / $lastYearMonthlySales) * 100, 0)
|
||||
: 0;
|
||||
|
||||
// 월별 추이 (1~12월)
|
||||
$monthlyTrend = $this->getSalesMonthlyTrend($tenantId, $year);
|
||||
|
||||
// 거래처별 매출 (상위 5개)
|
||||
$clientSales = $this->getSalesClientRanking($tenantId, $year);
|
||||
|
||||
// 일별 매출 내역 (최근 10건)
|
||||
$dailyItems = $this->getSalesDailyItems($tenantId, $today);
|
||||
|
||||
// 일별 합계
|
||||
$dailyTotal = DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('sale_date', $today)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
return [
|
||||
'cumulative_sales' => (int) $cumulativeSales,
|
||||
'achievement_rate' => (int) $achievementRate,
|
||||
'yoy_change' => $yoyChange,
|
||||
'monthly_sales' => (int) $monthlySales,
|
||||
'monthly_trend' => $monthlyTrend,
|
||||
'client_sales' => $clientSales,
|
||||
'daily_items' => $dailyItems,
|
||||
'daily_total' => (int) $dailyTotal,
|
||||
];
|
||||
}
|
||||
|
||||
private function getSalesMonthlyTrend(int $tenantId, int $year): array
|
||||
{
|
||||
$monthlyData = DB::table('sales')
|
||||
->select(DB::raw('MONTH(sale_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(DB::raw('MONTH(sale_date)'))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$found = $monthlyData->firstWhere('month', $i);
|
||||
$result[] = [
|
||||
'month' => sprintf('%d-%02d', $year, $i),
|
||||
'label' => $i.'월',
|
||||
'amount' => $found ? (int) $found->amount : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getSalesClientRanking(int $tenantId, int $year): array
|
||||
{
|
||||
$clients = DB::table('sales as s')
|
||||
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
|
||||
->select('c.name', DB::raw('SUM(s.total_amount) as amount'))
|
||||
->where('s.tenant_id', $tenantId)
|
||||
->whereYear('s.sale_date', $year)
|
||||
->whereNull('s.deleted_at')
|
||||
->groupBy('s.client_id', 'c.name')
|
||||
->orderByDesc('amount')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return $clients->map(fn ($item) => [
|
||||
'name' => $item->name ?? '미지정',
|
||||
'amount' => (int) $item->amount,
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
private function getSalesDailyItems(int $tenantId, string $today): array
|
||||
{
|
||||
$items = DB::table('sales as s')
|
||||
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
|
||||
->select([
|
||||
's.sale_date as date',
|
||||
'c.name as client',
|
||||
's.description as item',
|
||||
's.total_amount as amount',
|
||||
's.status',
|
||||
's.deposit_id',
|
||||
])
|
||||
->where('s.tenant_id', $tenantId)
|
||||
->where('s.sale_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d'))
|
||||
->whereNull('s.deleted_at')
|
||||
->orderByDesc('s.sale_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $items->map(fn ($item) => [
|
||||
'date' => $item->date,
|
||||
'client' => $item->client ?? '미지정',
|
||||
'item' => $item->item ?? '-',
|
||||
'amount' => (int) $item->amount,
|
||||
'status' => $item->deposit_id ? 'deposited' : 'unpaid',
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
// ─── 2. 매입 현황 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 매입 현황 요약
|
||||
*/
|
||||
public function purchasesSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
$year = $now->year;
|
||||
$month = $now->month;
|
||||
$today = $now->format('Y-m-d');
|
||||
|
||||
// 누적 매입
|
||||
$cumulativePurchase = DB::table('purchases')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('purchase_date', $year)
|
||||
->where('purchase_date', '<=', $today)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
// 미결제 금액 (withdrawal_id가 없는 것)
|
||||
$unpaidAmount = DB::table('purchases')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('purchase_date', $year)
|
||||
->whereNull('withdrawal_id')
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
// 전년 동월 대비
|
||||
$thisMonthPurchase = DB::table('purchases')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('purchase_date', $year)
|
||||
->whereMonth('purchase_date', $month)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
$lastYearMonthPurchase = DB::table('purchases')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('purchase_date', $year - 1)
|
||||
->whereMonth('purchase_date', $month)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
$yoyChange = $lastYearMonthPurchase > 0
|
||||
? round((($thisMonthPurchase - $lastYearMonthPurchase) / $lastYearMonthPurchase) * 100, 1)
|
||||
: 0;
|
||||
|
||||
// 월별 추이
|
||||
$monthlyTrend = $this->getPurchaseMonthlyTrend($tenantId, $year);
|
||||
|
||||
// 자재 구성 비율 (purchase_type별)
|
||||
$materialRatio = $this->getPurchaseMaterialRatio($tenantId, $year);
|
||||
|
||||
// 일별 매입 내역
|
||||
$dailyItems = $this->getPurchaseDailyItems($tenantId, $today);
|
||||
|
||||
// 일별 합계
|
||||
$dailyTotal = DB::table('purchases')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('purchase_date', $today)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
|
||||
return [
|
||||
'cumulative_purchase' => (int) $cumulativePurchase,
|
||||
'unpaid_amount' => (int) $unpaidAmount,
|
||||
'yoy_change' => $yoyChange,
|
||||
'monthly_trend' => $monthlyTrend,
|
||||
'material_ratio' => $materialRatio,
|
||||
'daily_items' => $dailyItems,
|
||||
'daily_total' => (int) $dailyTotal,
|
||||
];
|
||||
}
|
||||
|
||||
private function getPurchaseMonthlyTrend(int $tenantId, int $year): array
|
||||
{
|
||||
$monthlyData = DB::table('purchases')
|
||||
->select(DB::raw('MONTH(purchase_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('purchase_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(DB::raw('MONTH(purchase_date)'))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$found = $monthlyData->firstWhere('month', $i);
|
||||
$result[] = [
|
||||
'month' => sprintf('%d-%02d', $year, $i),
|
||||
'label' => $i.'월',
|
||||
'amount' => $found ? (int) $found->amount : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getPurchaseMaterialRatio(int $tenantId, int $year): array
|
||||
{
|
||||
$colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
|
||||
$ratioData = DB::table('purchases')
|
||||
->select('purchase_type', DB::raw('SUM(total_amount) as value'))
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('purchase_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->groupBy('purchase_type')
|
||||
->orderByDesc('value')
|
||||
->limit(6)
|
||||
->get();
|
||||
|
||||
$total = $ratioData->sum('value');
|
||||
$idx = 0;
|
||||
|
||||
return $ratioData->map(function ($item) use ($total, $colors, &$idx) {
|
||||
$name = $this->getPurchaseTypeName($item->purchase_type);
|
||||
$result = [
|
||||
'name' => $name,
|
||||
'value' => (int) $item->value,
|
||||
'percentage' => $total > 0 ? round(($item->value / $total) * 100, 1) : 0,
|
||||
'color' => $colors[$idx % count($colors)],
|
||||
];
|
||||
$idx++;
|
||||
|
||||
return $result;
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
private function getPurchaseTypeName(?string $type): string
|
||||
{
|
||||
$map = [
|
||||
'원재료매입' => '원자재',
|
||||
'부재료매입' => '부자재',
|
||||
'소모품매입' => '소모품',
|
||||
'외주가공비' => '외주가공',
|
||||
'접대비' => '접대비',
|
||||
'복리후생비' => '복리후생',
|
||||
];
|
||||
|
||||
return $map[$type] ?? ($type ?? '기타');
|
||||
}
|
||||
|
||||
private function getPurchaseDailyItems(int $tenantId, string $today): array
|
||||
{
|
||||
$items = DB::table('purchases as p')
|
||||
->leftJoin('clients as c', 'p.client_id', '=', 'c.id')
|
||||
->select([
|
||||
'p.purchase_date as date',
|
||||
'c.name as supplier',
|
||||
'p.description as item',
|
||||
'p.total_amount as amount',
|
||||
'p.withdrawal_id',
|
||||
])
|
||||
->where('p.tenant_id', $tenantId)
|
||||
->where('p.purchase_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d'))
|
||||
->whereNull('p.deleted_at')
|
||||
->orderByDesc('p.purchase_date')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return $items->map(fn ($item) => [
|
||||
'date' => $item->date,
|
||||
'supplier' => $item->supplier ?? '미지정',
|
||||
'item' => $item->item ?? '-',
|
||||
'amount' => (int) $item->amount,
|
||||
'status' => $item->withdrawal_id ? 'paid' : 'unpaid',
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
// ─── 3. 생산 현황 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 생산 현황 요약
|
||||
*/
|
||||
public function productionSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$today = Carbon::now();
|
||||
$todayStr = $today->format('Y-m-d');
|
||||
|
||||
$dayOfWeekMap = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
|
||||
$dayOfWeek = $dayOfWeekMap[$today->dayOfWeek];
|
||||
|
||||
// 공정별 작업 현황
|
||||
$processes = $this->getProductionProcesses($tenantId, $todayStr);
|
||||
|
||||
// 출고 현황
|
||||
$shipment = $this->getShipmentSummary($tenantId, $todayStr);
|
||||
|
||||
return [
|
||||
'date' => $todayStr,
|
||||
'day_of_week' => $dayOfWeek,
|
||||
'processes' => $processes,
|
||||
'shipment' => $shipment,
|
||||
];
|
||||
}
|
||||
|
||||
private function getProductionProcesses(int $tenantId, string $today): array
|
||||
{
|
||||
// 공정별 작업 지시 집계
|
||||
$processData = DB::table('work_orders as wo')
|
||||
->leftJoin('processes as p', 'wo.process_id', '=', 'p.id')
|
||||
->select(
|
||||
'p.id as process_id',
|
||||
'p.process_name as process_name',
|
||||
DB::raw('COUNT(*) as total_work'),
|
||||
DB::raw("SUM(CASE WHEN wo.status = 'pending' OR wo.status = 'unassigned' OR wo.status = 'waiting' THEN 1 ELSE 0 END) as todo"),
|
||||
DB::raw("SUM(CASE WHEN wo.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress"),
|
||||
DB::raw("SUM(CASE WHEN wo.status = 'completed' OR wo.status = 'shipped' THEN 1 ELSE 0 END) as completed"),
|
||||
DB::raw("SUM(CASE WHEN wo.priority = 'urgent' THEN 1 ELSE 0 END) as urgent"),
|
||||
)
|
||||
->where('wo.tenant_id', $tenantId)
|
||||
->where('wo.scheduled_date', $today)
|
||||
->where('wo.is_active', true)
|
||||
->whereNull('wo.deleted_at')
|
||||
->whereNotNull('wo.process_id')
|
||||
->groupBy('p.id', 'p.process_name')
|
||||
->orderBy('p.process_name')
|
||||
->get();
|
||||
|
||||
return $processData->map(function ($process) use ($tenantId, $today) {
|
||||
$totalWork = (int) $process->total_work;
|
||||
$todo = (int) $process->todo;
|
||||
$inProgress = (int) $process->in_progress;
|
||||
$completed = (int) $process->completed;
|
||||
|
||||
// 작업 아이템 (최대 5건)
|
||||
$workItems = DB::table('work_orders as wo')
|
||||
->leftJoin('orders as o', 'wo.sales_order_id', '=', 'o.id')
|
||||
->leftJoin('clients as c', 'o.client_id', '=', 'c.id')
|
||||
->select([
|
||||
'wo.id',
|
||||
'wo.work_order_no as order_no',
|
||||
'c.name as client',
|
||||
'wo.project_name as product',
|
||||
'wo.status',
|
||||
])
|
||||
->where('wo.tenant_id', $tenantId)
|
||||
->where('wo.process_id', $process->process_id)
|
||||
->where('wo.scheduled_date', $today)
|
||||
->where('wo.is_active', true)
|
||||
->whereNull('wo.deleted_at')
|
||||
->orderByRaw("FIELD(wo.priority, 'urgent', 'normal', 'low')")
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// 작업자별 현황
|
||||
$workers = DB::table('work_order_assignees as woa')
|
||||
->join('work_orders as wo', 'woa.work_order_id', '=', 'wo.id')
|
||||
->leftJoin('users as u', 'woa.user_id', '=', 'u.id')
|
||||
->select(
|
||||
'u.name',
|
||||
DB::raw('COUNT(*) as assigned'),
|
||||
DB::raw("SUM(CASE WHEN wo.status IN ('completed', 'shipped') THEN 1 ELSE 0 END) as completed"),
|
||||
)
|
||||
->where('wo.tenant_id', $tenantId)
|
||||
->where('wo.process_id', $process->process_id)
|
||||
->where('wo.scheduled_date', $today)
|
||||
->where('wo.is_active', true)
|
||||
->whereNull('wo.deleted_at')
|
||||
->groupBy('woa.user_id', 'u.name')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'process_name' => $process->process_name ?? '미지정',
|
||||
'total_work' => $totalWork,
|
||||
'todo' => $todo,
|
||||
'in_progress' => $inProgress,
|
||||
'completed' => $completed,
|
||||
'urgent' => (int) $process->urgent,
|
||||
'sub_line' => 0,
|
||||
'regular' => max(0, $totalWork - (int) $process->urgent),
|
||||
'worker_count' => $workers->count(),
|
||||
'work_items' => $workItems->map(fn ($wi) => [
|
||||
'id' => 'wo_'.$wi->id,
|
||||
'order_no' => $wi->order_no ?? '-',
|
||||
'client' => $wi->client ?? '미지정',
|
||||
'product' => $wi->product ?? '-',
|
||||
'quantity' => 0,
|
||||
'status' => $this->mapWorkOrderStatus($wi->status),
|
||||
])->toArray(),
|
||||
'workers' => $workers->map(fn ($w) => [
|
||||
'name' => $w->name ?? '미지정',
|
||||
'assigned' => (int) $w->assigned,
|
||||
'completed' => (int) $w->completed,
|
||||
'rate' => $w->assigned > 0 ? round(($w->completed / $w->assigned) * 100, 0) : 0,
|
||||
])->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
private function mapWorkOrderStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'completed', 'shipped' => 'completed',
|
||||
'in_progress' => 'in_progress',
|
||||
default => 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
private function getShipmentSummary(int $tenantId, string $today): array
|
||||
{
|
||||
$thisMonth = Carbon::parse($today);
|
||||
$monthStart = $thisMonth->copy()->startOfMonth()->format('Y-m-d');
|
||||
$monthEnd = $thisMonth->copy()->endOfMonth()->format('Y-m-d');
|
||||
|
||||
// 예정 출고
|
||||
$expected = DB::table('shipments')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('scheduled_date', [$monthStart, $monthEnd])
|
||||
->whereIn('status', ['scheduled', 'ready'])
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount')
|
||||
->first();
|
||||
|
||||
// 실제 출고
|
||||
$actual = DB::table('shipments')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereBetween('scheduled_date', [$monthStart, $monthEnd])
|
||||
->whereIn('status', ['shipping', 'completed'])
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'expected_amount' => (int) ($expected->amount ?? 0),
|
||||
'expected_count' => (int) ($expected->count ?? 0),
|
||||
'actual_amount' => (int) ($actual->amount ?? 0),
|
||||
'actual_count' => (int) ($actual->count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 4. 미출고 내역 ──────────────────────────────
|
||||
|
||||
/**
|
||||
* 미출고 내역 요약
|
||||
*/
|
||||
public function unshippedSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$today = Carbon::now()->format('Y-m-d');
|
||||
|
||||
$items = DB::table('shipments as s')
|
||||
->leftJoin('orders as o', 's.order_id', '=', 'o.id')
|
||||
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
|
||||
->select([
|
||||
's.id',
|
||||
's.lot_no as port_no',
|
||||
's.site_name',
|
||||
'c.name as order_client',
|
||||
's.scheduled_date as due_date',
|
||||
])
|
||||
->where('s.tenant_id', $tenantId)
|
||||
->whereIn('s.status', ['scheduled', 'ready'])
|
||||
->whereNull('s.deleted_at')
|
||||
->orderBy('s.scheduled_date')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
$result = $items->map(function ($item) use ($today) {
|
||||
$dueDate = Carbon::parse($item->due_date);
|
||||
$daysLeft = Carbon::parse($today)->diffInDays($dueDate, false);
|
||||
|
||||
return [
|
||||
'id' => 'us_'.$item->id,
|
||||
'port_no' => $item->port_no ?? '-',
|
||||
'site_name' => $item->site_name ?? '-',
|
||||
'order_client' => $item->order_client ?? '미지정',
|
||||
'due_date' => $item->due_date,
|
||||
'days_left' => (int) $daysLeft,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'items' => $result,
|
||||
'total_count' => count($result),
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 5. 시공 현황 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 시공 현황 요약
|
||||
*/
|
||||
public function constructionSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
$monthStart = $now->copy()->startOfMonth()->format('Y-m-d');
|
||||
$monthEnd = $now->copy()->endOfMonth()->format('Y-m-d');
|
||||
|
||||
// 이번 달 시공 건수
|
||||
$thisMonthCount = DB::table('contracts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where(function ($q) use ($monthStart, $monthEnd) {
|
||||
$q->whereBetween('contract_start_date', [$monthStart, $monthEnd])
|
||||
->orWhereBetween('contract_end_date', [$monthStart, $monthEnd])
|
||||
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
|
||||
$q2->where('contract_start_date', '<=', $monthStart)
|
||||
->where('contract_end_date', '>=', $monthEnd);
|
||||
});
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// 완료 건수
|
||||
$completedCount = DB::table('contracts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', 'completed')
|
||||
->where(function ($q) use ($monthStart, $monthEnd) {
|
||||
$q->whereBetween('contract_end_date', [$monthStart, $monthEnd]);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// 시공 아이템 목록
|
||||
$items = DB::table('contracts as ct')
|
||||
->leftJoin('users as u', 'ct.construction_pm_id', '=', 'u.id')
|
||||
->select([
|
||||
'ct.id',
|
||||
'ct.project_name as site_name',
|
||||
'ct.partner_name as client',
|
||||
'ct.contract_start_date as start_date',
|
||||
'ct.contract_end_date as end_date',
|
||||
'ct.status',
|
||||
'ct.stage',
|
||||
])
|
||||
->where('ct.tenant_id', $tenantId)
|
||||
->where('ct.is_active', true)
|
||||
->whereNull('ct.deleted_at')
|
||||
->where(function ($q) use ($monthStart, $monthEnd) {
|
||||
$q->whereBetween('ct.contract_start_date', [$monthStart, $monthEnd])
|
||||
->orWhereBetween('ct.contract_end_date', [$monthStart, $monthEnd])
|
||||
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
|
||||
$q2->where('ct.contract_start_date', '<=', $monthStart)
|
||||
->where('ct.contract_end_date', '>=', $monthEnd);
|
||||
});
|
||||
})
|
||||
->orderBy('ct.contract_start_date')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$today = $now->format('Y-m-d');
|
||||
|
||||
return [
|
||||
'this_month' => $thisMonthCount,
|
||||
'completed' => $completedCount,
|
||||
'items' => $items->map(function ($item) use ($today) {
|
||||
$progress = $this->calculateContractProgress($item, $today);
|
||||
|
||||
return [
|
||||
'id' => 'c_'.$item->id,
|
||||
'site_name' => $item->site_name ?? '-',
|
||||
'client' => $item->client ?? '미지정',
|
||||
'start_date' => $item->start_date,
|
||||
'end_date' => $item->end_date,
|
||||
'progress' => $progress,
|
||||
'status' => $this->mapContractStatus($item->status, $item->start_date, $today),
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateContractProgress(object $contract, string $today): int
|
||||
{
|
||||
if ($contract->status === 'completed') {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$start = Carbon::parse($contract->start_date);
|
||||
$end = Carbon::parse($contract->end_date);
|
||||
$now = Carbon::parse($today);
|
||||
|
||||
if ($now->lt($start)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalDays = $start->diffInDays($end);
|
||||
if ($totalDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$elapsedDays = $start->diffInDays($now);
|
||||
$progress = min(99, round(($elapsedDays / $totalDays) * 100));
|
||||
|
||||
return (int) $progress;
|
||||
}
|
||||
|
||||
private function mapContractStatus(string $status, ?string $startDate, string $today): string
|
||||
{
|
||||
if ($status === 'completed') {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
if ($startDate && Carbon::parse($startDate)->gt(Carbon::parse($today))) {
|
||||
return 'scheduled';
|
||||
}
|
||||
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
// ─── 6. 근태 현황 ───────────────────────────────
|
||||
|
||||
/**
|
||||
* 근태 현황 요약
|
||||
*/
|
||||
public function attendanceSummary(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$today = Carbon::now()->format('Y-m-d');
|
||||
|
||||
// 오늘 근태 기록
|
||||
$attendances = DB::table('attendances as a')
|
||||
->leftJoin('users as u', 'a.user_id', '=', 'u.id')
|
||||
->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) {
|
||||
$join->on('tup.user_id', '=', 'u.id')
|
||||
->where('tup.tenant_id', '=', $tenantId);
|
||||
})
|
||||
->leftJoin('departments as d', 'tup.department_id', '=', 'd.id')
|
||||
->select([
|
||||
'a.id',
|
||||
'a.status',
|
||||
'u.name',
|
||||
'd.name as department',
|
||||
'tup.position_key as position',
|
||||
])
|
||||
->where('a.tenant_id', $tenantId)
|
||||
->where('a.base_date', $today)
|
||||
->whereNull('a.deleted_at')
|
||||
->get();
|
||||
|
||||
$present = 0;
|
||||
$onLeave = 0;
|
||||
$late = 0;
|
||||
$absent = 0;
|
||||
|
||||
$employees = $attendances->map(function ($att) use (&$present, &$onLeave, &$late, &$absent) {
|
||||
$mappedStatus = $this->mapAttendanceStatus($att->status);
|
||||
|
||||
match ($mappedStatus) {
|
||||
'present' => $present++,
|
||||
'on_leave' => $onLeave++,
|
||||
'late' => $late++,
|
||||
'absent' => $absent++,
|
||||
default => null,
|
||||
};
|
||||
|
||||
return [
|
||||
'id' => 'emp_'.$att->id,
|
||||
'department' => $att->department ?? '-',
|
||||
'position' => $att->position ?? '-',
|
||||
'name' => $att->name ?? '-',
|
||||
'status' => $mappedStatus,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return [
|
||||
'present' => $present,
|
||||
'on_leave' => $onLeave,
|
||||
'late' => $late,
|
||||
'absent' => $absent,
|
||||
'employees' => $employees,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapAttendanceStatus(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'onTime', 'normal', 'overtime', 'earlyLeave' => 'present',
|
||||
'late', 'lateEarlyLeave' => 'late',
|
||||
'vacation', 'halfDayVacation', 'sickLeave' => 'on_leave',
|
||||
'absent', 'noRecord' => 'absent',
|
||||
default => 'present',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,6 @@
|
||||
use App\Models\Documents\DocumentAttachment;
|
||||
use App\Models\Documents\DocumentData;
|
||||
use App\Models\Documents\DocumentTemplate;
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Tenants\ApprovalForm;
|
||||
use App\Models\Tenants\ApprovalLine;
|
||||
use App\Models\Tenants\ApprovalStep;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
@@ -122,7 +118,6 @@ public function create(array $data): Document
|
||||
'status' => Document::STATUS_DRAFT,
|
||||
'linkable_type' => $data['linkable_type'] ?? null,
|
||||
'linkable_id' => $data['linkable_id'] ?? null,
|
||||
'rendered_html' => $data['rendered_html'] ?? null,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
@@ -171,16 +166,12 @@ public function update(int $id, array $data): Document
|
||||
}
|
||||
|
||||
// 기본 정보 수정
|
||||
$updateFields = [
|
||||
$document->fill([
|
||||
'title' => $data['title'] ?? $document->title,
|
||||
'linkable_type' => $data['linkable_type'] ?? $document->linkable_type,
|
||||
'linkable_id' => $data['linkable_id'] ?? $document->linkable_id,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
if (isset($data['rendered_html'])) {
|
||||
$updateFields['rendered_html'] = $data['rendered_html'];
|
||||
}
|
||||
$document->fill($updateFields);
|
||||
]);
|
||||
|
||||
// 반려 상태에서 수정 시 DRAFT로 변경
|
||||
if ($document->status === Document::STATUS_REJECTED) {
|
||||
@@ -284,9 +275,6 @@ public function submit(int $id): Document
|
||||
$document->updated_by = $userId;
|
||||
$document->save();
|
||||
|
||||
// Approval 시스템 브릿지: 결재함(/approval/inbox)에 표시되도록 Approval 자동 생성
|
||||
$this->createApprovalBridge($document);
|
||||
|
||||
return $document->fresh([
|
||||
'template:id,name,category',
|
||||
'approvals.user:id,name',
|
||||
@@ -295,78 +283,6 @@ public function submit(int $id): Document
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Document → Approval 브릿지 생성
|
||||
* Document 상신 시 Approval 레코드를 자동 생성하여 /approval/inbox에 표시
|
||||
*/
|
||||
private function createApprovalBridge(Document $document): void
|
||||
{
|
||||
$form = ApprovalForm::where('code', 'document')
|
||||
->where('tenant_id', $document->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $form) {
|
||||
return; // 문서 결재 양식 미등록 시 스킵 (기존 동작 유지)
|
||||
}
|
||||
|
||||
// 기존 브릿지가 있으면 스킵 (재상신 방지)
|
||||
$existingApproval = Approval::where('linkable_type', Document::class)
|
||||
->where('linkable_id', $document->id)
|
||||
->whereNotIn('status', [Approval::STATUS_CANCELLED])
|
||||
->first();
|
||||
|
||||
if ($existingApproval) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 문서번호 생성 (Approval 체계)
|
||||
$today = now()->format('Ymd');
|
||||
$lastNumber = Approval::where('tenant_id', $document->tenant_id)
|
||||
->where('document_number', 'like', "AP-{$today}-%")
|
||||
->orderByDesc('document_number')
|
||||
->value('document_number');
|
||||
|
||||
$seq = 1;
|
||||
if ($lastNumber && preg_match('/AP-\d{8}-(\d{4})/', $lastNumber, $matches)) {
|
||||
$seq = (int) $matches[1] + 1;
|
||||
}
|
||||
$documentNumber = sprintf('AP-%s-%04d', $today, $seq);
|
||||
|
||||
$approval = Approval::create([
|
||||
'tenant_id' => $document->tenant_id,
|
||||
'document_number' => $documentNumber,
|
||||
'form_id' => $form->id,
|
||||
'title' => $document->title,
|
||||
'content' => [
|
||||
'document_id' => $document->id,
|
||||
'template_id' => $document->template_id,
|
||||
'document_no' => $document->document_no,
|
||||
],
|
||||
'status' => Approval::STATUS_PENDING,
|
||||
'drafter_id' => $document->created_by,
|
||||
'drafted_at' => now(),
|
||||
'current_step' => 1,
|
||||
'linkable_type' => Document::class,
|
||||
'linkable_id' => $document->id,
|
||||
'created_by' => $document->updated_by ?? $document->created_by,
|
||||
]);
|
||||
|
||||
// document_approvals → approval_steps 변환
|
||||
$docApprovals = $document->approvals()
|
||||
->orderBy('step')
|
||||
->get();
|
||||
|
||||
foreach ($docApprovals as $docApproval) {
|
||||
ApprovalStep::create([
|
||||
'approval_id' => $approval->id,
|
||||
'step_order' => $docApproval->step,
|
||||
'step_type' => ApprovalLine::STEP_TYPE_APPROVAL,
|
||||
'approver_id' => $docApproval->user_id,
|
||||
'status' => ApprovalStep::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인
|
||||
*/
|
||||
@@ -663,32 +579,20 @@ public function fqcStatus(int $orderId, int $templateId): array
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$order = \App\Models\Orders\Order::where('tenant_id', $tenantId)
|
||||
->with(['rootNodes.items' => fn ($q) => $q->orderBy('sort_order')])
|
||||
->with('items')
|
||||
->findOrFail($orderId);
|
||||
|
||||
// 개소별 대표 OrderItem ID 수집
|
||||
$representativeItemIds = $order->rootNodes
|
||||
->map(fn ($node) => $node->items->first()?->id)
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
// 해당 대표 품목의 FQC 문서 조회
|
||||
// 해당 수주의 FQC 문서 조회
|
||||
$documents = Document::where('tenant_id', $tenantId)
|
||||
->where('template_id', $templateId)
|
||||
->where('linkable_type', \App\Models\Orders\OrderItem::class)
|
||||
->whereIn('linkable_id', $representativeItemIds)
|
||||
->whereIn('linkable_id', $order->items->pluck('id'))
|
||||
->with('data')
|
||||
->get()
|
||||
->keyBy('linkable_id');
|
||||
|
||||
// 개소(root node)별 진행현황
|
||||
$items = $order->rootNodes->map(function ($node) use ($documents) {
|
||||
$representativeItem = $node->items->first();
|
||||
if (! $representativeItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$doc = $documents->get($representativeItem->id);
|
||||
$items = $order->items->map(function ($orderItem) use ($documents) {
|
||||
$doc = $documents->get($orderItem->id);
|
||||
|
||||
// 종합판정 값 추출
|
||||
$judgement = null;
|
||||
@@ -698,17 +602,17 @@ public function fqcStatus(int $orderId, int $templateId): array
|
||||
}
|
||||
|
||||
return [
|
||||
'order_item_id' => $representativeItem->id,
|
||||
'floor_code' => $representativeItem->floor_code,
|
||||
'symbol_code' => $representativeItem->symbol_code,
|
||||
'specification' => $representativeItem->specification,
|
||||
'item_name' => $representativeItem->item_name,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'floor_code' => $orderItem->floor_code,
|
||||
'symbol_code' => $orderItem->symbol_code,
|
||||
'specification' => $orderItem->specification,
|
||||
'item_name' => $orderItem->item_name,
|
||||
'document_id' => $doc?->id,
|
||||
'document_no' => $doc?->document_no,
|
||||
'status' => $doc?->status ?? 'NONE',
|
||||
'judgement' => $judgement,
|
||||
];
|
||||
})->filter()->values();
|
||||
});
|
||||
|
||||
// 통계
|
||||
$total = $items->count();
|
||||
@@ -730,28 +634,6 @@ public function fqcStatus(int $orderId, int $templateId): array
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Snapshot (Lazy Snapshot)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* rendered_html만 업데이트 (상태 무관, canEdit 체크 없음)
|
||||
* Lazy Snapshot: 조회 시 rendered_html이 없으면 프론트에서 캡처 후 저장
|
||||
*/
|
||||
public function patchSnapshot(int $id, string $renderedHtml): Document
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$document = Document::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
$document->rendered_html = $renderedHtml;
|
||||
$document->save();
|
||||
|
||||
return $document;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Resolve/Upsert (React 연동용)
|
||||
// =========================================================================
|
||||
@@ -891,34 +773,24 @@ public function upsert(array $data): Document
|
||||
|
||||
if ($existingDocument) {
|
||||
// UPDATE: 기존 update 로직 재사용
|
||||
$updatePayload = [
|
||||
return $this->update($existingDocument->id, [
|
||||
'title' => $data['title'] ?? $existingDocument->title,
|
||||
'linkable_type' => 'item',
|
||||
'linkable_id' => $itemId,
|
||||
'data' => $data['data'] ?? [],
|
||||
'attachments' => $data['attachments'] ?? [],
|
||||
];
|
||||
if (isset($data['rendered_html'])) {
|
||||
$updatePayload['rendered_html'] = $data['rendered_html'];
|
||||
}
|
||||
|
||||
return $this->update($existingDocument->id, $updatePayload);
|
||||
]);
|
||||
}
|
||||
|
||||
// CREATE: 기존 create 로직 재사용
|
||||
$createPayload = [
|
||||
return $this->create([
|
||||
'template_id' => $templateId,
|
||||
'title' => $data['title'] ?? '',
|
||||
'linkable_type' => 'item',
|
||||
'linkable_id' => $itemId,
|
||||
'data' => $data['data'] ?? [],
|
||||
'attachments' => $data['attachments'] ?? [],
|
||||
];
|
||||
if (isset($data['rendered_html'])) {
|
||||
$createPayload['rendered_html'] = $data['rendered_html'];
|
||||
}
|
||||
|
||||
return $this->create($createPayload);
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,35 +6,29 @@
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 접대비 현황 서비스 (D1.7 리스크 감지형)
|
||||
* 접대비 현황 서비스
|
||||
*
|
||||
* CEO 대시보드용 접대비 리스크 데이터를 제공합니다.
|
||||
* 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비
|
||||
* CEO 대시보드용 접대비 데이터를 제공합니다.
|
||||
*/
|
||||
class EntertainmentService extends Service
|
||||
{
|
||||
// 고액 결제 기준 (1회 50만원 초과)
|
||||
private const HIGH_AMOUNT_THRESHOLD = 500000;
|
||||
// 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%)
|
||||
private const DEFAULT_LIMIT_RATE = 0.003;
|
||||
|
||||
// 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등)
|
||||
private const PROHIBITED_MCC_CODES = [
|
||||
'5813', // 음주업소
|
||||
'7011', // 숙박업
|
||||
'5944', // 귀금속
|
||||
'7941', // 레저/스포츠
|
||||
'7992', // 골프장
|
||||
'7273', // 데이트서비스
|
||||
'5932', // 골동품
|
||||
// 기업 규모별 기본 한도 (연간)
|
||||
private const COMPANY_TYPE_LIMITS = [
|
||||
'large' => 36000000, // 대기업: 연 3,600만원
|
||||
'medium' => 36000000, // 중견기업: 연 3,600만원
|
||||
'small' => 24000000, // 중소기업: 연 2,400만원
|
||||
];
|
||||
|
||||
// 심야 시간대 (22시 ~ 06시)
|
||||
private const LATE_NIGHT_START = 22;
|
||||
|
||||
private const LATE_NIGHT_END = 6;
|
||||
|
||||
/**
|
||||
* 접대비 리스크 현황 요약 조회 (D1.7)
|
||||
* 접대비 현황 요약 조회
|
||||
*
|
||||
* @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly)
|
||||
* @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
* @return array{cards: array, check_points: array}
|
||||
*/
|
||||
public function getSummary(
|
||||
@@ -46,58 +40,73 @@ public function getSummary(
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
// 기본값 설정
|
||||
$year = $year ?? $now->year;
|
||||
$limitType = $limitType ?? 'quarterly';
|
||||
$companyType = $companyType ?? 'medium';
|
||||
$quarter = $quarter ?? $now->quarter;
|
||||
|
||||
// 기간 범위 계산
|
||||
if ($limitType === 'annual') {
|
||||
$startDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
$periodLabel = "{$year}년";
|
||||
} else {
|
||||
$startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
$periodLabel = "{$quarter}사분기";
|
||||
}
|
||||
|
||||
// 리스크 감지 쿼리
|
||||
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate);
|
||||
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate);
|
||||
$highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate);
|
||||
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate);
|
||||
// 연간 시작일 (매출 계산용)
|
||||
$yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
|
||||
// 매출액 조회 (연간)
|
||||
$annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate);
|
||||
|
||||
// 접대비 한도 계산
|
||||
$annualLimit = $this->calculateLimit($annualSales, $companyType);
|
||||
$periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4);
|
||||
|
||||
// 접대비 사용액 조회
|
||||
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
|
||||
// 잔여 한도
|
||||
$remainingLimit = max(0, $periodLimit - $usedAmount);
|
||||
|
||||
// 카드 데이터 구성
|
||||
$cards = [
|
||||
[
|
||||
'id' => 'et_weekend',
|
||||
'label' => '주말/심야',
|
||||
'amount' => (int) $weekendLateNight['total'],
|
||||
'subLabel' => "{$weekendLateNight['count']}건",
|
||||
'id' => 'et_sales',
|
||||
'label' => '매출',
|
||||
'amount' => (int) $annualSales,
|
||||
],
|
||||
[
|
||||
'id' => 'et_prohibited',
|
||||
'label' => '기피업종',
|
||||
'amount' => (int) $prohibitedBiz['total'],
|
||||
'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}건" : '0건',
|
||||
'id' => 'et_limit',
|
||||
'label' => "{{$periodLabel}} 접대비 총 한도",
|
||||
'amount' => (int) $periodLimit,
|
||||
],
|
||||
[
|
||||
'id' => 'et_high_amount',
|
||||
'label' => '고액 결제',
|
||||
'amount' => (int) $highAmount['total'],
|
||||
'subLabel' => "{$highAmount['count']}건",
|
||||
'id' => 'et_remaining',
|
||||
'label' => "{{$periodLabel}} 접대비 잔여한도",
|
||||
'amount' => (int) $remainingLimit,
|
||||
],
|
||||
[
|
||||
'id' => 'et_no_receipt',
|
||||
'label' => '증빙 미비',
|
||||
'amount' => (int) $missingReceipt['total'],
|
||||
'subLabel' => "{$missingReceipt['count']}건",
|
||||
'id' => 'et_used',
|
||||
'label' => "{{$periodLabel}} 접대비 사용금액",
|
||||
'amount' => (int) $usedAmount,
|
||||
],
|
||||
];
|
||||
|
||||
// 체크포인트 생성
|
||||
$checkPoints = $this->generateRiskCheckPoints(
|
||||
$weekendLateNight,
|
||||
$prohibitedBiz,
|
||||
$highAmount,
|
||||
$missingReceipt
|
||||
$checkPoints = $this->generateCheckPoints(
|
||||
$periodLabel,
|
||||
$periodLimit,
|
||||
$usedAmount,
|
||||
$remainingLimit,
|
||||
$tenantId,
|
||||
$startDate,
|
||||
$endDate
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -107,222 +116,34 @@ public function getSummary(
|
||||
}
|
||||
|
||||
/**
|
||||
* 주말/심야 사용 리스크 조회
|
||||
* expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시
|
||||
* 연간 매출액 조회
|
||||
*/
|
||||
private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float
|
||||
{
|
||||
// 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK)
|
||||
$weekendResult = DB::table('expense_accounts')
|
||||
// orders 테이블에서 확정된 수주 합계 조회
|
||||
$amount = DB::table('orders')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->where('status_code', 'confirmed')
|
||||
->whereBetween('received_at', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)')
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
->sum('total_amount');
|
||||
|
||||
// 심야 사용 (barobill 카드 거래 내역에서 시간 확인)
|
||||
$lateNightResult = DB::table('expense_accounts as ea')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지)
|
||||
->whereNotNull('bct.use_time')
|
||||
->where(function ($q) {
|
||||
$q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= ?', [self::LATE_NIGHT_START])
|
||||
->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]);
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0);
|
||||
$totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0);
|
||||
|
||||
return ['count' => $totalCount, 'total' => $totalAmount];
|
||||
return $amount ?: 30530000000; // 임시 기본값 (305억)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기피업종 사용 리스크 조회
|
||||
* barobill의 merchant_biz_type가 MCC 코드 매칭
|
||||
* 접대비 한도 계산
|
||||
*/
|
||||
private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
private function calculateLimit(float $annualSales, string $companyType): float
|
||||
{
|
||||
$result = DB::table('expense_accounts as ea')
|
||||
->join('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
|
||||
->first();
|
||||
// 기본 한도 (기업 규모별)
|
||||
$baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium'];
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
// 매출 기반 한도 (0.3%)
|
||||
$salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE;
|
||||
|
||||
/**
|
||||
* 고액 결제 리스크 조회
|
||||
* 1회 50만원 초과 결제
|
||||
*/
|
||||
private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 증빙 미비 리스크 조회
|
||||
* receipt_no가 NULL 또는 빈 값
|
||||
*/
|
||||
private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('receipt_no')
|
||||
->orWhere('receipt_no', '');
|
||||
})
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 접대비 상세 정보 조회 (모달용)
|
||||
*
|
||||
* @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium)
|
||||
* @param int|null $year 연도 (기본: 현재 연도)
|
||||
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
|
||||
*/
|
||||
public function getDetail(
|
||||
?string $companyType = 'medium',
|
||||
?int $year = null,
|
||||
?int $quarter = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null
|
||||
): array {
|
||||
$tenantId = $this->tenantId();
|
||||
$now = Carbon::now();
|
||||
|
||||
$year = $year ?? $now->year;
|
||||
$companyType = $companyType ?? 'medium';
|
||||
$quarter = $quarter ?? $now->quarter;
|
||||
|
||||
// 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간)
|
||||
$annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
|
||||
$annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
|
||||
|
||||
// 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본)
|
||||
if ($startDate && $endDate) {
|
||||
$filterStartDate = $startDate;
|
||||
$filterEndDate = $endDate;
|
||||
} else {
|
||||
$filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$filterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
}
|
||||
|
||||
// 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만)
|
||||
$baseLimit = $companyType === 'large' ? 12000000 : 36000000;
|
||||
|
||||
// 수입금액 조회 (sales 테이블)
|
||||
$revenue = $this->getAnnualRevenue($tenantId, $year);
|
||||
|
||||
// 수입금액별 추가한도 계산
|
||||
$revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue);
|
||||
|
||||
// 연간 총 한도
|
||||
$annualLimit = $baseLimit + $revenueAdditional;
|
||||
$quarterlyLimit = $annualLimit / 4;
|
||||
|
||||
// 연간/분기 사용액 조회
|
||||
$annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate);
|
||||
$quarterlyUsed = $this->getUsedAmount($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 잔여/초과 계산
|
||||
$annualRemaining = max(0, $annualLimit - $annualUsed);
|
||||
$annualExceeded = max(0, $annualUsed - $annualLimit);
|
||||
|
||||
// 1. 요약 데이터
|
||||
$summary = [
|
||||
'annual_limit' => (int) $annualLimit,
|
||||
'annual_remaining' => (int) $annualRemaining,
|
||||
'annual_used' => (int) $annualUsed,
|
||||
'annual_exceeded' => (int) $annualExceeded,
|
||||
];
|
||||
|
||||
// 2. 리스크 검토 카드 (날짜 필터 적용)
|
||||
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
$riskReview = [
|
||||
['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']],
|
||||
['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']],
|
||||
['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']],
|
||||
['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']],
|
||||
];
|
||||
|
||||
// 3. 월별 사용 추이
|
||||
$monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year);
|
||||
|
||||
// 4. 사용자별 분포 (날짜 필터 적용)
|
||||
$userDistribution = $this->getUserDistribution($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 5. 거래 내역 (날짜 필터 적용)
|
||||
$transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate);
|
||||
|
||||
// 6. 손금한도 계산 정보
|
||||
$calculation = [
|
||||
'company_type' => $companyType,
|
||||
'base_limit' => (int) $baseLimit,
|
||||
'revenue' => (int) $revenue,
|
||||
'revenue_additional' => (int) $revenueAdditional,
|
||||
'annual_limit' => (int) $annualLimit,
|
||||
];
|
||||
|
||||
// 7. 분기별 현황
|
||||
$quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit);
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'risk_review' => $riskReview,
|
||||
'monthly_usage' => $monthlyUsage,
|
||||
'user_distribution' => $userDistribution,
|
||||
'transactions' => $transactions,
|
||||
'calculation' => $calculation,
|
||||
'quarterly' => $quarterly,
|
||||
];
|
||||
// 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화)
|
||||
return $baseLimit + $salesBasedLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,300 +151,106 @@ public function getDetail(
|
||||
*/
|
||||
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
|
||||
{
|
||||
return DB::table('expense_accounts')
|
||||
// TODO: 실제 접대비 계정과목에서 조회
|
||||
// expense_accounts 또는 card_transactions에서 접대비 항목 합계
|
||||
$amount = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('deleted_at')
|
||||
->sum('amount') ?: 0;
|
||||
->sum('amount');
|
||||
|
||||
return $amount ?: 10000000; // 임시 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* 연간 수입금액(매출) 조회
|
||||
* 거래처 누락 건수 조회
|
||||
*/
|
||||
private function getAnnualRevenue(int $tenantId, int $year): float
|
||||
private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
return DB::table('sales')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereYear('sale_date', $year)
|
||||
->whereNull('deleted_at')
|
||||
->sum('total_amount') ?: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수입금액별 추가한도 계산 (세법 기준)
|
||||
* 100억 이하: 수입금액 × 0.2%
|
||||
* 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1%
|
||||
* 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03%
|
||||
*/
|
||||
private function calculateRevenueAdditionalLimit(float $revenue): float
|
||||
{
|
||||
$b10 = 10000000000; // 100억
|
||||
$b50 = 50000000000; // 500억
|
||||
|
||||
if ($revenue <= $b10) {
|
||||
return $revenue * 0.002;
|
||||
} elseif ($revenue <= $b50) {
|
||||
return 20000000 + ($revenue - $b10) * 0.001;
|
||||
} else {
|
||||
return 60000000 + ($revenue - $b50) * 0.0003;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 사용 추이 조회
|
||||
*/
|
||||
private function getMonthlyUsageTrend(int $tenantId, int $year): array
|
||||
{
|
||||
$monthlyData = DB::table('expense_accounts')
|
||||
->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount'))
|
||||
// TODO: 거래처 정보 누락 건수 조회
|
||||
$result = DB::table('expense_accounts')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', 'entertainment')
|
||||
->whereYear('expense_date', $year)
|
||||
->whereBetween('expense_date', [$startDate, $endDate])
|
||||
->whereNull('vendor_id')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(DB::raw('MONTH(expense_date)'))
|
||||
->orderBy('month')
|
||||
->get();
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
|
||||
->first();
|
||||
|
||||
$result = [];
|
||||
for ($i = 1; $i <= 12; $i++) {
|
||||
$found = $monthlyData->firstWhere('month', $i);
|
||||
$result[] = [
|
||||
'month' => $i,
|
||||
'label' => $i . '월',
|
||||
'amount' => $found ? (int) $found->amount : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
return [
|
||||
'count' => $result->count ?? 0,
|
||||
'total' => $result->total ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자별 분포 조회
|
||||
* 체크포인트 생성
|
||||
*/
|
||||
private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C'];
|
||||
|
||||
$distribution = DB::table('expense_accounts as ea')
|
||||
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
||||
->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount'))
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->groupBy('ea.created_by', 'u.name')
|
||||
->orderByDesc('amount')
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
$total = $distribution->sum('amount');
|
||||
$result = [];
|
||||
$idx = 0;
|
||||
|
||||
foreach ($distribution as $item) {
|
||||
$result[] = [
|
||||
'user_name' => $item->user_name ?? '사용자',
|
||||
'amount' => (int) $item->amount,
|
||||
'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0,
|
||||
'color' => $colors[$idx % count($colors)],
|
||||
];
|
||||
$idx++;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 내역 조회
|
||||
*/
|
||||
private function getTransactions(int $tenantId, string $startDate, string $endDate): array
|
||||
{
|
||||
$transactions = DB::table('expense_accounts as ea')
|
||||
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
|
||||
->leftJoin('barobill_card_transactions as bct', function ($join) {
|
||||
$join->on('ea.receipt_no', '=', 'bct.approval_num')
|
||||
->on('ea.tenant_id', '=', 'bct.tenant_id');
|
||||
})
|
||||
->select([
|
||||
'ea.id',
|
||||
'ea.card_no',
|
||||
'u.name as user_name',
|
||||
'ea.expense_date',
|
||||
'ea.vendor_name',
|
||||
'ea.amount',
|
||||
'ea.receipt_no',
|
||||
'bct.use_time',
|
||||
'bct.merchant_biz_type',
|
||||
])
|
||||
->where('ea.tenant_id', $tenantId)
|
||||
->where('ea.account_type', 'entertainment')
|
||||
->whereBetween('ea.expense_date', [$startDate, $endDate])
|
||||
->whereNull('ea.deleted_at')
|
||||
->orderByDesc('ea.expense_date')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($transactions as $t) {
|
||||
$riskType = $this->detectTransactionRiskType($t);
|
||||
|
||||
$result[] = [
|
||||
'id' => $t->id,
|
||||
'card_name' => $t->card_no ? '카드 *' . substr($t->card_no, -4) : '카드명',
|
||||
'user_name' => $t->user_name ?? '사용자',
|
||||
'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'),
|
||||
'vendor_name' => $t->vendor_name ?? '가맹점명',
|
||||
'amount' => (int) $t->amount,
|
||||
'risk_type' => $riskType,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래 건별 리스크 유형 감지
|
||||
*/
|
||||
private function detectTransactionRiskType(object $transaction): string
|
||||
{
|
||||
// 기피업종
|
||||
if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) {
|
||||
return '기피업종';
|
||||
}
|
||||
|
||||
// 고액 결제
|
||||
if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) {
|
||||
return '고액 결제';
|
||||
}
|
||||
|
||||
// 증빙 미비
|
||||
if (empty($transaction->receipt_no)) {
|
||||
return '증빙 미비';
|
||||
}
|
||||
|
||||
// 주말/심야 감지
|
||||
$expenseDate = Carbon::parse($transaction->expense_date);
|
||||
if ($expenseDate->isWeekend()) {
|
||||
return '주말/심야';
|
||||
}
|
||||
if ($transaction->use_time) {
|
||||
$hour = (int) substr($transaction->use_time, 0, 2);
|
||||
if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) {
|
||||
return '주말/심야';
|
||||
}
|
||||
}
|
||||
|
||||
return '정상';
|
||||
}
|
||||
|
||||
/**
|
||||
* 분기별 현황 조회
|
||||
*/
|
||||
private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array
|
||||
{
|
||||
$result = [];
|
||||
$previousRemaining = 0;
|
||||
|
||||
for ($q = 1; $q <= 4; $q++) {
|
||||
$startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d');
|
||||
$endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d');
|
||||
|
||||
$used = $this->getUsedAmount($tenantId, $startDate, $endDate);
|
||||
$carryover = $previousRemaining > 0 ? $previousRemaining : 0;
|
||||
$totalLimit = $quarterlyLimit + $carryover;
|
||||
$remaining = max(0, $totalLimit - $used);
|
||||
$exceeded = max(0, $used - $totalLimit);
|
||||
|
||||
$result[] = [
|
||||
'quarter' => $q,
|
||||
'limit' => (int) $quarterlyLimit,
|
||||
'carryover' => (int) $carryover,
|
||||
'used' => (int) $used,
|
||||
'remaining' => (int) $remaining,
|
||||
'exceeded' => (int) $exceeded,
|
||||
];
|
||||
|
||||
$previousRemaining = $remaining;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스크 감지 체크포인트 생성
|
||||
*/
|
||||
private function generateRiskCheckPoints(
|
||||
array $weekendLateNight,
|
||||
array $prohibitedBiz,
|
||||
array $highAmount,
|
||||
array $missingReceipt
|
||||
private function generateCheckPoints(
|
||||
string $periodLabel,
|
||||
float $limit,
|
||||
float $used,
|
||||
float $remaining,
|
||||
int $tenantId,
|
||||
string $startDate,
|
||||
string $endDate
|
||||
): array {
|
||||
$checkPoints = [];
|
||||
$totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count']
|
||||
+ $highAmount['count'] + $missingReceipt['count'];
|
||||
$usageRate = $limit > 0 ? ($used / $limit) * 100 : 0;
|
||||
$usedFormatted = number_format($used / 10000);
|
||||
$limitFormatted = number_format($limit / 10000);
|
||||
$remainingFormatted = number_format($remaining / 10000);
|
||||
|
||||
// 주말/심야
|
||||
if ($weekendLateNight['count'] > 0) {
|
||||
$amountFormatted = number_format($weekendLateNight['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_weekend',
|
||||
'type' => 'warning',
|
||||
'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.",
|
||||
'highlights' => [
|
||||
['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 기피업종
|
||||
if ($prohibitedBiz['count'] > 0) {
|
||||
$amountFormatted = number_format($prohibitedBiz['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_prohibited',
|
||||
'type' => 'error',
|
||||
'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
['text' => '접대비 불인정', 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 고액 결제
|
||||
if ($highAmount['count'] > 0) {
|
||||
$amountFormatted = number_format($highAmount['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_high',
|
||||
'type' => 'warning',
|
||||
'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 증빙 미비
|
||||
if ($missingReceipt['count'] > 0) {
|
||||
$amountFormatted = number_format($missingReceipt['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_receipt',
|
||||
'type' => 'error',
|
||||
'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 리스크 0건이면 정상 메시지
|
||||
if ($totalRiskCount === 0) {
|
||||
// 사용률에 따른 체크포인트
|
||||
if ($usageRate <= 75) {
|
||||
// 정상 운영
|
||||
$remainingRate = round(100 - $usageRate);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_normal',
|
||||
'type' => 'success',
|
||||
'message' => '접대비 사용 현황이 정상입니다.',
|
||||
'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.",
|
||||
'highlights' => [
|
||||
['text' => '정상', 'color' => 'green'],
|
||||
['text' => "{$usedFormatted}만원", 'color' => 'green'],
|
||||
['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'],
|
||||
],
|
||||
];
|
||||
} elseif ($usageRate <= 100) {
|
||||
// 주의 (85% 이상)
|
||||
$usageRateRounded = round($usageRate);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_warning',
|
||||
'type' => 'warning',
|
||||
'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.",
|
||||
'highlights' => [
|
||||
['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
// 한도 초과
|
||||
$overAmount = $used - $limit;
|
||||
$overFormatted = number_format($overAmount / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_over',
|
||||
'type' => 'error',
|
||||
'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.",
|
||||
'highlights' => [
|
||||
['text' => "{$overFormatted}만원 발생", 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 거래처 정보 누락 체크
|
||||
$missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate);
|
||||
if ($missingVendor['count'] > 0) {
|
||||
$missingTotal = number_format($missingVendor['total'] / 10000);
|
||||
$checkPoints[] = [
|
||||
'id' => 'et_cp_missing',
|
||||
'type' => 'error',
|
||||
'message' => "접대비 사용 중 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.",
|
||||
'highlights' => [
|
||||
['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'],
|
||||
['text' => '거래처 정보가 누락', 'color' => 'red'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -304,41 +304,34 @@ public function summary(array $params): array
|
||||
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
|
||||
*
|
||||
* @param string|null $transactionType 거래유형 필터 (purchase, card, bill, null=전체)
|
||||
* @param string|null $startDate 조회 시작일 (null이면 당월 1일)
|
||||
* @param string|null $endDate 조회 종료일 (null이면 당월 말일)
|
||||
* @param string|null $search 검색어 (거래처명, 적요)
|
||||
* @return array{
|
||||
* summary: array{
|
||||
* total_amount: float,
|
||||
* previous_month_amount: float,
|
||||
* change_rate: float,
|
||||
* remaining_balance: float,
|
||||
* item_count: int
|
||||
* },
|
||||
* monthly_trend: array,
|
||||
* vendor_distribution: array,
|
||||
* items: array,
|
||||
* footer_summary: array
|
||||
* }
|
||||
*/
|
||||
public function dashboardDetail(
|
||||
?string $transactionType = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
?string $search = null
|
||||
): array {
|
||||
public function dashboardDetail(?string $transactionType = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$currentMonthStart = now()->startOfMonth()->toDateString();
|
||||
$currentMonthEnd = now()->endOfMonth()->toDateString();
|
||||
$previousMonthStart = now()->subMonth()->startOfMonth()->toDateString();
|
||||
$previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString();
|
||||
|
||||
// 날짜 범위: 파라미터 우선, 없으면 당월 기본값
|
||||
$currentMonthStart = $startDate ?? now()->startOfMonth()->toDateString();
|
||||
$currentMonthEnd = $endDate ?? now()->endOfMonth()->toDateString();
|
||||
|
||||
// 전월 대비: 조회 기간과 동일한 길이의 이전 기간 계산
|
||||
$startCarbon = \Carbon\Carbon::parse($currentMonthStart);
|
||||
$endCarbon = \Carbon\Carbon::parse($currentMonthEnd);
|
||||
$daysDiff = $startCarbon->diffInDays($endCarbon) + 1;
|
||||
$previousMonthStart = $startCarbon->copy()->subDays($daysDiff)->toDateString();
|
||||
$previousMonthEnd = $startCarbon->copy()->subDay()->toDateString();
|
||||
|
||||
// 기본 쿼리 빌더 (transaction_type + search 필터 적용)
|
||||
$baseQuery = function () use ($tenantId, $transactionType, $search) {
|
||||
// 기본 쿼리 빌더 (transaction_type 필터 적용)
|
||||
$baseQuery = function () use ($tenantId, $transactionType) {
|
||||
$query = ExpectedExpense::query()->where('tenant_id', $tenantId);
|
||||
if ($transactionType) {
|
||||
$query->where('transaction_type', $transactionType);
|
||||
}
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('client_name', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
};
|
||||
@@ -368,10 +361,10 @@ public function dashboardDetail(
|
||||
// 2. 월별 추이 (최근 7개월)
|
||||
$monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType);
|
||||
|
||||
// 3. 거래처별 분포 (조회 기간, 상위 5개)
|
||||
// 3. 거래처별 분포 (당월, 상위 5개)
|
||||
$vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd);
|
||||
|
||||
// 4. 지출예상 목록 (조회 기간, 지급일 순)
|
||||
// 4. 지출예상 목록 (당월, 지급일 순)
|
||||
$itemsQuery = ExpectedExpense::query()
|
||||
->select([
|
||||
'expected_expenses.id',
|
||||
@@ -392,13 +385,6 @@ public function dashboardDetail(
|
||||
$itemsQuery->where('expected_expenses.transaction_type', $transactionType);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$itemsQuery->where(function ($q) use ($search) {
|
||||
$q->where('expected_expenses.client_name', 'like', "%{$search}%")
|
||||
->orWhere('expected_expenses.description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$items = $itemsQuery
|
||||
->orderBy('expected_expenses.expected_payment_date', 'asc')
|
||||
->get()
|
||||
|
||||
@@ -1,590 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\AccountCode;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Models\Tenants\JournalEntryLine;
|
||||
use App\Traits\SyncsExpenseAccounts;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class GeneralJournalEntryService extends Service
|
||||
{
|
||||
use SyncsExpenseAccounts;
|
||||
/**
|
||||
* 일반전표입력 통합 목록 (입금 + 출금 + 수기전표)
|
||||
* deposits/withdrawals는 계좌이체 건만, LEFT JOIN journal_entries로 분개 여부 표시
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$startDate = $params['start_date'] ?? null;
|
||||
$endDate = $params['end_date'] ?? null;
|
||||
$search = $params['search'] ?? null;
|
||||
$perPage = (int) ($params['per_page'] ?? 20);
|
||||
$page = (int) ($params['page'] ?? 1);
|
||||
|
||||
// 1) 입금(transfer) UNION 출금(transfer) UNION 수기전표
|
||||
$depositsQuery = DB::table('deposits')
|
||||
->leftJoin('journal_entries', function ($join) use ($tenantId) {
|
||||
$join->on('journal_entries.source_key', '=', DB::raw("CONCAT('deposit_', deposits.id)"))
|
||||
->where('journal_entries.tenant_id', $tenantId)
|
||||
->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
|
||||
->whereNull('journal_entries.deleted_at');
|
||||
})
|
||||
->where('deposits.tenant_id', $tenantId)
|
||||
->where('deposits.payment_method', 'transfer')
|
||||
->whereNull('deposits.deleted_at')
|
||||
->select([
|
||||
'deposits.id',
|
||||
'deposits.deposit_date as date',
|
||||
DB::raw("'deposit' as division"),
|
||||
'deposits.amount',
|
||||
'deposits.description',
|
||||
DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'),
|
||||
'deposits.amount as deposit_amount',
|
||||
DB::raw('0 as withdrawal_amount'),
|
||||
DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'),
|
||||
DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'),
|
||||
DB::raw("'linked' as source"),
|
||||
'deposits.created_at',
|
||||
'deposits.updated_at',
|
||||
DB::raw('journal_entries.id as journal_entry_id'),
|
||||
]);
|
||||
|
||||
$withdrawalsQuery = DB::table('withdrawals')
|
||||
->leftJoin('journal_entries', function ($join) use ($tenantId) {
|
||||
$join->on('journal_entries.source_key', '=', DB::raw("CONCAT('withdrawal_', withdrawals.id)"))
|
||||
->where('journal_entries.tenant_id', $tenantId)
|
||||
->where('journal_entries.source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
|
||||
->whereNull('journal_entries.deleted_at');
|
||||
})
|
||||
->where('withdrawals.tenant_id', $tenantId)
|
||||
->where('withdrawals.payment_method', 'transfer')
|
||||
->whereNull('withdrawals.deleted_at')
|
||||
->select([
|
||||
'withdrawals.id',
|
||||
'withdrawals.withdrawal_date as date',
|
||||
DB::raw("'withdrawal' as division"),
|
||||
'withdrawals.amount',
|
||||
'withdrawals.description',
|
||||
DB::raw('COALESCE(journal_entries.description, NULL) as journal_description'),
|
||||
DB::raw('0 as deposit_amount'),
|
||||
'withdrawals.amount as withdrawal_amount',
|
||||
DB::raw('COALESCE(journal_entries.total_debit, 0) as debit_amount'),
|
||||
DB::raw('COALESCE(journal_entries.total_credit, 0) as credit_amount'),
|
||||
DB::raw("'linked' as source"),
|
||||
'withdrawals.created_at',
|
||||
'withdrawals.updated_at',
|
||||
DB::raw('journal_entries.id as journal_entry_id'),
|
||||
]);
|
||||
|
||||
$manualQuery = DB::table('journal_entries')
|
||||
->where('journal_entries.tenant_id', $tenantId)
|
||||
->where('journal_entries.source_type', JournalEntry::SOURCE_MANUAL)
|
||||
->whereNull('journal_entries.deleted_at')
|
||||
->select([
|
||||
'journal_entries.id',
|
||||
'journal_entries.entry_date as date',
|
||||
DB::raw("'transfer' as division"),
|
||||
'journal_entries.total_debit as amount',
|
||||
'journal_entries.description',
|
||||
'journal_entries.description as journal_description',
|
||||
DB::raw('0 as deposit_amount'),
|
||||
DB::raw('0 as withdrawal_amount'),
|
||||
'journal_entries.total_debit as debit_amount',
|
||||
'journal_entries.total_credit as credit_amount',
|
||||
DB::raw("'manual' as source"),
|
||||
'journal_entries.created_at',
|
||||
'journal_entries.updated_at',
|
||||
DB::raw('journal_entries.id as journal_entry_id'),
|
||||
]);
|
||||
|
||||
// 날짜 필터
|
||||
if ($startDate) {
|
||||
$depositsQuery->where('deposits.deposit_date', '>=', $startDate);
|
||||
$withdrawalsQuery->where('withdrawals.withdrawal_date', '>=', $startDate);
|
||||
$manualQuery->where('journal_entries.entry_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$depositsQuery->where('deposits.deposit_date', '<=', $endDate);
|
||||
$withdrawalsQuery->where('withdrawals.withdrawal_date', '<=', $endDate);
|
||||
$manualQuery->where('journal_entries.entry_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if ($search) {
|
||||
$depositsQuery->where(function ($q) use ($search) {
|
||||
$q->where('deposits.description', 'like', "%{$search}%")
|
||||
->orWhere('deposits.client_name', 'like', "%{$search}%");
|
||||
});
|
||||
$withdrawalsQuery->where(function ($q) use ($search) {
|
||||
$q->where('withdrawals.description', 'like', "%{$search}%")
|
||||
->orWhere('withdrawals.client_name', 'like', "%{$search}%");
|
||||
});
|
||||
$manualQuery->where('journal_entries.description', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// UNION
|
||||
$unionQuery = $depositsQuery
|
||||
->unionAll($withdrawalsQuery)
|
||||
->unionAll($manualQuery);
|
||||
|
||||
// 전체 건수
|
||||
$totalCount = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
|
||||
->mergeBindings($unionQuery)
|
||||
->count();
|
||||
|
||||
// 날짜순 정렬 + 페이지네이션
|
||||
$items = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
|
||||
->mergeBindings($unionQuery)
|
||||
->orderBy('date', 'desc')
|
||||
->orderBy('created_at', 'desc')
|
||||
->offset(($page - 1) * $perPage)
|
||||
->limit($perPage)
|
||||
->get();
|
||||
|
||||
// 누적잔액 계산 (해당 기간 전체 기준)
|
||||
$allForBalance = DB::table(DB::raw("({$unionQuery->toSql()}) as union_table"))
|
||||
->mergeBindings($unionQuery)
|
||||
->orderBy('date', 'asc')
|
||||
->orderBy('created_at', 'asc')
|
||||
->get(['deposit_amount', 'withdrawal_amount']);
|
||||
|
||||
$runningBalance = 0;
|
||||
$balanceMap = [];
|
||||
foreach ($allForBalance as $idx => $row) {
|
||||
$runningBalance += (int) $row->deposit_amount - (int) $row->withdrawal_amount;
|
||||
$balanceMap[$idx] = $runningBalance;
|
||||
}
|
||||
|
||||
// 역순이므로 현재 페이지에 해당하는 잔액을 매핑
|
||||
$totalItems = count($allForBalance);
|
||||
$items = $items->map(function ($item, $index) use ($balanceMap, $totalItems, $page, $perPage) {
|
||||
// 역순 인덱스 → 정순 인덱스
|
||||
$reverseIdx = $totalItems - 1 - (($page - 1) * $perPage + $index);
|
||||
$item->balance = $reverseIdx >= 0 ? ($balanceMap[$reverseIdx] ?? 0) : 0;
|
||||
|
||||
return $item;
|
||||
});
|
||||
|
||||
return [
|
||||
'data' => $items->toArray(),
|
||||
'current_page' => $page,
|
||||
'last_page' => (int) ceil($totalCount / $perPage),
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 통계
|
||||
*/
|
||||
public function summary(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$startDate = $params['start_date'] ?? null;
|
||||
$endDate = $params['end_date'] ?? null;
|
||||
$search = $params['search'] ?? null;
|
||||
|
||||
// 입금 통계
|
||||
$depositQuery = DB::table('deposits')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('payment_method', 'transfer')
|
||||
->whereNull('deleted_at');
|
||||
|
||||
// 출금 통계
|
||||
$withdrawalQuery = DB::table('withdrawals')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('payment_method', 'transfer')
|
||||
->whereNull('deleted_at');
|
||||
|
||||
if ($startDate) {
|
||||
$depositQuery->where('deposit_date', '>=', $startDate);
|
||||
$withdrawalQuery->where('withdrawal_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$depositQuery->where('deposit_date', '<=', $endDate);
|
||||
$withdrawalQuery->where('withdrawal_date', '<=', $endDate);
|
||||
}
|
||||
if ($search) {
|
||||
$depositQuery->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%");
|
||||
});
|
||||
$withdrawalQuery->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$depositCount = (clone $depositQuery)->count();
|
||||
$depositAmount = (int) (clone $depositQuery)->sum('amount');
|
||||
$withdrawalCount = (clone $withdrawalQuery)->count();
|
||||
$withdrawalAmount = (int) (clone $withdrawalQuery)->sum('amount');
|
||||
|
||||
// 분개 완료/미완료 건수 (journal_entries가 연결된 입출금 수)
|
||||
$journalCompleteCount = DB::table('journal_entries')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('source_type', JournalEntry::SOURCE_BANK_TRANSACTION)
|
||||
->whereNull('deleted_at')
|
||||
->when($startDate, fn ($q) => $q->where('entry_date', '>=', $startDate))
|
||||
->when($endDate, fn ($q) => $q->where('entry_date', '<=', $endDate))
|
||||
->count();
|
||||
|
||||
$totalCount = $depositCount + $withdrawalCount;
|
||||
$journalIncompleteCount = max(0, $totalCount - $journalCompleteCount);
|
||||
|
||||
return [
|
||||
'total_count' => $totalCount,
|
||||
'deposit_count' => $depositCount,
|
||||
'deposit_amount' => $depositAmount,
|
||||
'withdrawal_count' => $withdrawalCount,
|
||||
'withdrawal_amount' => $withdrawalAmount,
|
||||
'journal_complete_count' => $journalCompleteCount,
|
||||
'journal_incomplete_count' => $journalIncompleteCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 상세 조회 (분개 수정 모달용)
|
||||
*/
|
||||
public function show(int $id): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('lines')
|
||||
->findOrFail($id);
|
||||
|
||||
// source_type에 따라 원본 거래 정보 조회
|
||||
$sourceInfo = $this->getSourceInfo($entry);
|
||||
|
||||
return [
|
||||
'id' => $entry->id,
|
||||
'date' => $entry->entry_date->format('Y-m-d'),
|
||||
'division' => $sourceInfo['division'],
|
||||
'amount' => $sourceInfo['amount'],
|
||||
'description' => $sourceInfo['description'] ?? $entry->description,
|
||||
'bank_name' => $sourceInfo['bank_name'] ?? '',
|
||||
'account_number' => $sourceInfo['account_number'] ?? '',
|
||||
'journal_memo' => $entry->description,
|
||||
'rows' => $entry->lines->map(function ($line) {
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'side' => $line->dc_type,
|
||||
'account_subject_id' => $line->account_code,
|
||||
'account_subject_name' => $line->account_name,
|
||||
'vendor_id' => $line->trading_partner_id,
|
||||
'vendor_name' => $line->trading_partner_name ?? '',
|
||||
'debit_amount' => (int) $line->debit_amount,
|
||||
'credit_amount' => (int) $line->credit_amount,
|
||||
'memo' => $line->description ?? '',
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 수기전표 등록
|
||||
*/
|
||||
public function store(array $data): JournalEntry
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId) {
|
||||
// 차대 균형 검증
|
||||
$this->validateDebitCreditBalance($data['rows']);
|
||||
|
||||
// 전표번호 생성
|
||||
$entryNo = $this->generateEntryNo($tenantId, $data['journal_date']);
|
||||
|
||||
// 합계 계산
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
foreach ($data['rows'] as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
// 전표 생성
|
||||
$entry = new JournalEntry;
|
||||
$entry->tenant_id = $tenantId;
|
||||
$entry->entry_no = $entryNo;
|
||||
$entry->entry_date = $data['journal_date'];
|
||||
$entry->entry_type = JournalEntry::TYPE_GENERAL;
|
||||
$entry->description = $data['description'] ?? null;
|
||||
$entry->total_debit = $totalDebit;
|
||||
$entry->total_credit = $totalCredit;
|
||||
$entry->status = JournalEntry::STATUS_CONFIRMED;
|
||||
$entry->source_type = JournalEntry::SOURCE_MANUAL;
|
||||
$entry->source_key = null;
|
||||
$entry->save();
|
||||
|
||||
// 분개 행 생성
|
||||
$this->createLines($entry, $data['rows'], $tenantId);
|
||||
|
||||
// expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드)
|
||||
$this->syncExpenseAccounts($entry);
|
||||
|
||||
return $entry->load('lines');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 수정 (lines 전체 교체)
|
||||
*/
|
||||
public function updateJournal(int $id, array $data): JournalEntry
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($id, $data, $tenantId) {
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// 메모 업데이트
|
||||
if (array_key_exists('journal_memo', $data)) {
|
||||
$entry->description = $data['journal_memo'];
|
||||
}
|
||||
|
||||
// rows가 있으면 lines 교체
|
||||
if (isset($data['rows']) && ! empty($data['rows'])) {
|
||||
$this->validateDebitCreditBalance($data['rows']);
|
||||
|
||||
// 기존 lines 삭제
|
||||
JournalEntryLine::query()
|
||||
->where('journal_entry_id', $entry->id)
|
||||
->delete();
|
||||
|
||||
// 새 lines 생성
|
||||
$this->createLines($entry, $data['rows'], $tenantId);
|
||||
|
||||
// 합계 재계산
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
foreach ($data['rows'] as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$entry->total_debit = $totalDebit;
|
||||
$entry->total_credit = $totalCredit;
|
||||
}
|
||||
|
||||
$entry->save();
|
||||
|
||||
// expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드)
|
||||
$this->syncExpenseAccounts($entry);
|
||||
|
||||
return $entry->load('lines');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표 삭제 (soft delete, lines는 FK CASCADE)
|
||||
*/
|
||||
public function destroyJournal(int $id): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($id, $tenantId) {
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->findOrFail($id);
|
||||
|
||||
// expense_accounts 정리 (복리후생비/접대비 → CEO 대시보드)
|
||||
$this->cleanupExpenseAccounts($tenantId, $entry->id);
|
||||
|
||||
// lines 먼저 삭제 (soft delete가 아니므로 물리 삭제)
|
||||
JournalEntryLine::query()
|
||||
->where('journal_entry_id', $entry->id)
|
||||
->delete();
|
||||
|
||||
$entry->delete(); // soft delete
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표번호 생성: JE-YYYYMMDD-NNN (동시성 안전)
|
||||
*/
|
||||
private function generateEntryNo(int $tenantId, string $date): string
|
||||
{
|
||||
$dateStr = str_replace('-', '', substr($date, 0, 10));
|
||||
$prefix = "JE-{$dateStr}-";
|
||||
|
||||
// SELECT ... FOR UPDATE 락으로 동시성 안전 보장
|
||||
$lastEntry = DB::table('journal_entries')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('entry_no', 'like', "{$prefix}%")
|
||||
->lockForUpdate()
|
||||
->orderBy('entry_no', 'desc')
|
||||
->first(['entry_no']);
|
||||
|
||||
if ($lastEntry) {
|
||||
$lastSeq = (int) substr($lastEntry->entry_no, -3);
|
||||
$nextSeq = $lastSeq + 1;
|
||||
} else {
|
||||
$nextSeq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 차대 균형 검증
|
||||
*/
|
||||
private function validateDebitCreditBalance(array $rows): void
|
||||
{
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
if ($totalDebit !== $totalCredit) {
|
||||
throw new BadRequestHttpException(__('error.journal_entry.debit_credit_mismatch'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분개 행 생성
|
||||
*/
|
||||
private function createLines(JournalEntry $entry, array $rows, int $tenantId): void
|
||||
{
|
||||
foreach ($rows as $index => $row) {
|
||||
$accountCode = $row['account_subject_id'] ?? '';
|
||||
$accountName = $this->resolveAccountName($tenantId, $accountCode);
|
||||
$vendorName = $this->resolveVendorName($row['vendor_id'] ?? null);
|
||||
|
||||
$line = new JournalEntryLine;
|
||||
$line->tenant_id = $tenantId;
|
||||
$line->journal_entry_id = $entry->id;
|
||||
$line->line_no = $index + 1;
|
||||
$line->dc_type = $row['side'];
|
||||
$line->account_code = $accountCode;
|
||||
$line->account_name = $accountName;
|
||||
$line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null;
|
||||
$line->trading_partner_name = $vendorName;
|
||||
$line->debit_amount = (int) ($row['debit_amount'] ?? 0);
|
||||
$line->credit_amount = (int) ($row['credit_amount'] ?? 0);
|
||||
$line->description = $row['memo'] ?? null;
|
||||
$line->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 코드 → 이름 조회
|
||||
*/
|
||||
private function resolveAccountName(int $tenantId, string $code): string
|
||||
{
|
||||
if (empty($code)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$account = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $code)
|
||||
->first(['name']);
|
||||
|
||||
return $account ? $account->name : $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 ID → 이름 조회
|
||||
*/
|
||||
private function resolveVendorName(?int $vendorId): string
|
||||
{
|
||||
if (! $vendorId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$vendor = DB::table('clients')
|
||||
->where('id', $vendorId)
|
||||
->first(['name']);
|
||||
|
||||
return $vendor ? $vendor->name : '';
|
||||
}
|
||||
|
||||
// syncExpenseAccounts, cleanupExpenseAccounts, getExpenseAccountType
|
||||
// → SyncsExpenseAccounts 트레이트로 이관
|
||||
|
||||
/**
|
||||
* 원본 거래 정보 조회 (입금/출금)
|
||||
*/
|
||||
private function getSourceInfo(JournalEntry $entry): array
|
||||
{
|
||||
if ($entry->source_type === JournalEntry::SOURCE_MANUAL) {
|
||||
return [
|
||||
'division' => 'transfer',
|
||||
'amount' => $entry->total_debit,
|
||||
'description' => $entry->description,
|
||||
'bank_name' => '',
|
||||
'account_number' => '',
|
||||
];
|
||||
}
|
||||
|
||||
// bank_transaction → deposit_123 / withdrawal_456
|
||||
if ($entry->source_key && str_starts_with($entry->source_key, 'deposit_')) {
|
||||
$sourceId = (int) str_replace('deposit_', '', $entry->source_key);
|
||||
$deposit = DB::table('deposits')
|
||||
->leftJoin('bank_accounts', 'deposits.bank_account_id', '=', 'bank_accounts.id')
|
||||
->where('deposits.id', $sourceId)
|
||||
->first([
|
||||
'deposits.amount',
|
||||
'deposits.description',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_number',
|
||||
]);
|
||||
|
||||
if ($deposit) {
|
||||
return [
|
||||
'division' => 'deposit',
|
||||
'amount' => (int) $deposit->amount,
|
||||
'description' => $deposit->description,
|
||||
'bank_name' => $deposit->bank_name ?? '',
|
||||
'account_number' => $deposit->account_number ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($entry->source_key && str_starts_with($entry->source_key, 'withdrawal_')) {
|
||||
$sourceId = (int) str_replace('withdrawal_', '', $entry->source_key);
|
||||
$withdrawal = DB::table('withdrawals')
|
||||
->leftJoin('bank_accounts', 'withdrawals.bank_account_id', '=', 'bank_accounts.id')
|
||||
->where('withdrawals.id', $sourceId)
|
||||
->first([
|
||||
'withdrawals.amount',
|
||||
'withdrawals.description',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_number',
|
||||
]);
|
||||
|
||||
if ($withdrawal) {
|
||||
return [
|
||||
'division' => 'withdrawal',
|
||||
'amount' => (int) $withdrawal->amount,
|
||||
'description' => $withdrawal->description,
|
||||
'bank_name' => $withdrawal->bank_name ?? '',
|
||||
'account_number' => $withdrawal->account_number ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'division' => 'transfer',
|
||||
'amount' => $entry->total_debit,
|
||||
'description' => $entry->description,
|
||||
'bank_name' => '',
|
||||
'account_number' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public function index(array $params)
|
||||
|
||||
$query = Inspection::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no']);
|
||||
->with(['inspector:id,name', 'item:id,item_name']);
|
||||
|
||||
// 검색어 (검사번호, LOT번호)
|
||||
if ($q !== '') {
|
||||
@@ -126,7 +126,7 @@ public function show(int $id)
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$inspection = Inspection::where('tenant_id', $tenantId)
|
||||
->with(['inspector:id,name', 'item:id,item_name', 'workOrder:id,work_order_no'])
|
||||
->with(['inspector:id,name', 'item:id,item_name'])
|
||||
->find($id);
|
||||
|
||||
if (! $inspection) {
|
||||
@@ -183,7 +183,6 @@ public function store(array $data)
|
||||
'inspection_type' => $data['inspection_type'],
|
||||
'request_date' => $data['request_date'] ?? now()->toDateString(),
|
||||
'lot_no' => $data['lot_no'],
|
||||
'work_order_id' => $data['work_order_id'] ?? null,
|
||||
'inspector_id' => $data['inspector_id'] ?? null,
|
||||
'meta' => $meta,
|
||||
'items' => $items,
|
||||
@@ -201,7 +200,7 @@ public function store(array $data)
|
||||
$inspection->toArray()
|
||||
);
|
||||
|
||||
return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no']));
|
||||
return $this->transformToFrontend($inspection->load(['inspector:id,name']));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,7 +277,7 @@ public function update(int $id, array $data)
|
||||
$inspection->fresh()->toArray()
|
||||
);
|
||||
|
||||
return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no']));
|
||||
return $this->transformToFrontend($inspection->load(['inspector:id,name']));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -361,83 +360,10 @@ public function complete(int $id, array $data)
|
||||
$inspection->fresh()->toArray()
|
||||
);
|
||||
|
||||
return $this->transformToFrontend($inspection->load(['inspector:id,name', 'workOrder:id,work_order_no']));
|
||||
return $this->transformToFrontend($inspection->load(['inspector:id,name']));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 캘린더 스케줄 조회
|
||||
*/
|
||||
public function calendar(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$year = (int) ($params['year'] ?? now()->year);
|
||||
$month = (int) ($params['month'] ?? now()->month);
|
||||
|
||||
// 해당 월의 시작일/종료일
|
||||
$startDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$query = Inspection::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where(function ($q) use ($startDate, $endDate) {
|
||||
$q->whereBetween('request_date', [$startDate, $endDate])
|
||||
->orWhereBetween('inspection_date', [$startDate, $endDate]);
|
||||
})
|
||||
->with(['inspector:id,name', 'item:id,item_name']);
|
||||
|
||||
// 검사자 필터
|
||||
if (! empty($params['inspector'])) {
|
||||
$query->whereHas('inspector', function ($q) use ($params) {
|
||||
$q->where('name', $params['inspector']);
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (! empty($params['status'])) {
|
||||
$status = $params['status'] === 'reception' ? self::mapStatusFromFrontend('reception') : $params['status'];
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
return $query->orderBy('request_date')
|
||||
->get()
|
||||
->map(fn (Inspection $item) => [
|
||||
'id' => $item->id,
|
||||
'start_date' => $item->request_date?->format('Y-m-d'),
|
||||
'end_date' => $item->inspection_date?->format('Y-m-d') ?? $item->request_date?->format('Y-m-d'),
|
||||
'inspector' => $item->inspector?->name ?? '',
|
||||
'site_name' => $item->item?->item_name ?? ($item->meta['process_name'] ?? $item->inspection_no),
|
||||
'status' => self::mapStatusToFrontend($item->status),
|
||||
])
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태를 프론트엔드 형식으로 매핑
|
||||
*/
|
||||
private static function mapStatusToFrontend(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
Inspection::STATUS_WAITING => 'reception',
|
||||
Inspection::STATUS_IN_PROGRESS => 'in_progress',
|
||||
Inspection::STATUS_COMPLETED => 'completed',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 프론트엔드 상태를 DB 상태로 매핑
|
||||
*/
|
||||
private static function mapStatusFromFrontend(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'reception' => Inspection::STATUS_WAITING,
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 데이터를 프론트엔드 형식으로 변환
|
||||
*/
|
||||
@@ -454,8 +380,6 @@ private function transformToFrontend(Inspection $inspection): array
|
||||
'inspection_date' => $inspection->inspection_date?->format('Y-m-d'),
|
||||
'item_name' => $inspection->item?->item_name ?? ($meta['item_name'] ?? null),
|
||||
'lot_no' => $inspection->lot_no,
|
||||
'work_order_id' => $inspection->work_order_id,
|
||||
'work_order_no' => $inspection->workOrder?->work_order_no,
|
||||
'process_name' => $meta['process_name'] ?? null,
|
||||
'quantity' => $meta['quantity'] ?? null,
|
||||
'unit' => $meta['unit'] ?? null,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\AccountCode;
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Models\Tenants\JournalEntryLine;
|
||||
use App\Traits\SyncsExpenseAccounts;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 세금계산서/카드거래 등 외부 소스의 분개 통합 관리 서비스
|
||||
*
|
||||
* journal_entries + journal_entry_lines에 저장하고
|
||||
* 복리후생비/접대비는 expense_accounts에 동기화 (CEO 대시보드)
|
||||
*/
|
||||
class JournalSyncService extends Service
|
||||
{
|
||||
use SyncsExpenseAccounts;
|
||||
|
||||
/**
|
||||
* 소스에 대한 분개 저장 (생성 또는 교체)
|
||||
*
|
||||
* @param string $sourceType JournalEntry::SOURCE_TAX_INVOICE 등
|
||||
* @param string $sourceKey 'tax_invoice_123' 등
|
||||
* @param string $entryDate 전표일자 (Y-m-d)
|
||||
* @param string|null $description 적요
|
||||
* @param array $rows 분개 행 [{side, account_code, account_name?, debit_amount, credit_amount, vendor_id?, vendor_name?, memo?}]
|
||||
*/
|
||||
public function saveForSource(
|
||||
string $sourceType,
|
||||
string $sourceKey,
|
||||
string $entryDate,
|
||||
?string $description,
|
||||
array $rows,
|
||||
): JournalEntry {
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($sourceType, $sourceKey, $entryDate, $description, $rows, $tenantId) {
|
||||
// 기존 전표가 있으면 삭제 후 재생성 (교체 방식)
|
||||
$existing = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('source_type', $sourceType)
|
||||
->where('source_key', $sourceKey)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->cleanupExpenseAccounts($tenantId, $existing->id);
|
||||
JournalEntryLine::where('journal_entry_id', $existing->id)->delete();
|
||||
$existing->forceDelete();
|
||||
}
|
||||
|
||||
// 합계 계산
|
||||
$totalDebit = 0;
|
||||
$totalCredit = 0;
|
||||
foreach ($rows as $row) {
|
||||
$totalDebit += (int) ($row['debit_amount'] ?? 0);
|
||||
$totalCredit += (int) ($row['credit_amount'] ?? 0);
|
||||
}
|
||||
|
||||
// 전표번호 생성
|
||||
$entryNo = $this->generateEntryNo($tenantId, $entryDate);
|
||||
|
||||
// 전표 생성
|
||||
$entry = new JournalEntry;
|
||||
$entry->tenant_id = $tenantId;
|
||||
$entry->entry_no = $entryNo;
|
||||
$entry->entry_date = $entryDate;
|
||||
$entry->entry_type = JournalEntry::TYPE_GENERAL;
|
||||
$entry->description = $description;
|
||||
$entry->total_debit = $totalDebit;
|
||||
$entry->total_credit = $totalCredit;
|
||||
$entry->status = JournalEntry::STATUS_CONFIRMED;
|
||||
$entry->source_type = $sourceType;
|
||||
$entry->source_key = $sourceKey;
|
||||
$entry->save();
|
||||
|
||||
// 분개 행 생성
|
||||
foreach ($rows as $index => $row) {
|
||||
$accountCode = $row['account_code'] ?? '';
|
||||
$accountName = $row['account_name'] ?? $this->resolveAccountName($tenantId, $accountCode);
|
||||
|
||||
$line = new JournalEntryLine;
|
||||
$line->tenant_id = $tenantId;
|
||||
$line->journal_entry_id = $entry->id;
|
||||
$line->line_no = $index + 1;
|
||||
$line->dc_type = $row['side'];
|
||||
$line->account_code = $accountCode;
|
||||
$line->account_name = $accountName;
|
||||
$line->trading_partner_id = ! empty($row['vendor_id']) ? (int) $row['vendor_id'] : null;
|
||||
$line->trading_partner_name = $row['vendor_name'] ?? '';
|
||||
$line->debit_amount = (int) ($row['debit_amount'] ?? 0);
|
||||
$line->credit_amount = (int) ($row['credit_amount'] ?? 0);
|
||||
$line->description = $row['memo'] ?? null;
|
||||
$line->save();
|
||||
}
|
||||
|
||||
// expense_accounts 동기화 (복리후생비/접대비 → CEO 대시보드)
|
||||
$this->syncExpenseAccounts($entry);
|
||||
|
||||
return $entry->load('lines');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스에 대한 분개 조회
|
||||
*/
|
||||
public function getForSource(string $sourceType, string $sourceKey): ?array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('source_type', $sourceType)
|
||||
->where('source_key', $sourceKey)
|
||||
->whereNull('deleted_at')
|
||||
->with('lines')
|
||||
->first();
|
||||
|
||||
if (! $entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $entry->id,
|
||||
'entry_no' => $entry->entry_no,
|
||||
'entry_date' => $entry->entry_date->format('Y-m-d'),
|
||||
'description' => $entry->description,
|
||||
'total_debit' => $entry->total_debit,
|
||||
'total_credit' => $entry->total_credit,
|
||||
'rows' => $entry->lines->map(function ($line) {
|
||||
return [
|
||||
'id' => $line->id,
|
||||
'side' => $line->dc_type,
|
||||
'account_code' => $line->account_code,
|
||||
'account_name' => $line->account_name,
|
||||
'vendor_id' => $line->trading_partner_id,
|
||||
'vendor_name' => $line->trading_partner_name ?? '',
|
||||
'debit_amount' => (int) $line->debit_amount,
|
||||
'credit_amount' => (int) $line->credit_amount,
|
||||
'memo' => $line->description ?? '',
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스에 대한 분개 삭제
|
||||
*/
|
||||
public function deleteForSource(string $sourceType, string $sourceKey): bool
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
return DB::transaction(function () use ($sourceType, $sourceKey, $tenantId) {
|
||||
$entry = JournalEntry::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('source_type', $sourceType)
|
||||
->where('source_key', $sourceKey)
|
||||
->first();
|
||||
|
||||
if (! $entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->cleanupExpenseAccounts($tenantId, $entry->id);
|
||||
JournalEntryLine::where('journal_entry_id', $entry->id)->delete();
|
||||
$entry->delete(); // soft delete
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 전표번호 생성: JE-YYYYMMDD-NNN
|
||||
*/
|
||||
private function generateEntryNo(int $tenantId, string $date): string
|
||||
{
|
||||
$dateStr = str_replace('-', '', substr($date, 0, 10));
|
||||
$prefix = "JE-{$dateStr}-";
|
||||
|
||||
$lastEntry = DB::table('journal_entries')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('entry_no', 'like', "{$prefix}%")
|
||||
->lockForUpdate()
|
||||
->orderBy('entry_no', 'desc')
|
||||
->first(['entry_no']);
|
||||
|
||||
if ($lastEntry) {
|
||||
$lastSeq = (int) substr($lastEntry->entry_no, -3);
|
||||
$nextSeq = $lastSeq + 1;
|
||||
} else {
|
||||
$nextSeq = 1;
|
||||
}
|
||||
|
||||
return $prefix . str_pad($nextSeq, 3, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정과목 코드 → 이름 조회
|
||||
*/
|
||||
private function resolveAccountName(int $tenantId, string $code): string
|
||||
{
|
||||
if (empty($code)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$account = AccountCode::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $code)
|
||||
->first(['name']);
|
||||
|
||||
return $account ? $account->name : $code;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\ExpenseAccount;
|
||||
use App\Models\Tenants\Loan;
|
||||
use App\Models\Tenants\Withdrawal;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -26,11 +25,6 @@ public function index(array $params): LengthAwarePaginator
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['user:id,name,email', 'creator:id,name']);
|
||||
|
||||
// 카테고리 필터
|
||||
if (! empty($params['category'])) {
|
||||
$query->where('category', $params['category']);
|
||||
}
|
||||
|
||||
// 사용자 필터
|
||||
if (! empty($params['user_id'])) {
|
||||
$query->where('user_id', $params['user_id']);
|
||||
@@ -90,7 +84,7 @@ public function show(int $id): Loan
|
||||
/**
|
||||
* 가지급금 요약 (특정 사용자 또는 전체)
|
||||
*/
|
||||
public function summary(?int $userId = null, ?string $category = null): array
|
||||
public function summary(?int $userId = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
@@ -101,14 +95,7 @@ public function summary(?int $userId = null, ?string $category = null): array
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($category) {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
|
||||
// 상품권 카테고리: holding/used/disposed 상태별 집계 추가
|
||||
$isGiftCertificate = $category === Loan::CATEGORY_GIFT_CERTIFICATE;
|
||||
|
||||
$selectRaw = '
|
||||
$stats = $query->selectRaw('
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as settled_count,
|
||||
@@ -116,27 +103,10 @@ public function summary(?int $userId = null, ?string $category = null): array
|
||||
SUM(amount) as total_amount,
|
||||
SUM(COALESCE(settlement_amount, 0)) as total_settled,
|
||||
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
|
||||
';
|
||||
$bindings = [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL];
|
||||
', [Loan::STATUS_OUTSTANDING, Loan::STATUS_SETTLED, Loan::STATUS_PARTIAL])
|
||||
->first();
|
||||
|
||||
if ($isGiftCertificate) {
|
||||
$selectRaw .= ',
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as holding_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as holding_amount,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as used_count,
|
||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) as used_amount,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as disposed_count
|
||||
';
|
||||
$bindings = array_merge($bindings, [
|
||||
Loan::STATUS_HOLDING, Loan::STATUS_HOLDING,
|
||||
Loan::STATUS_USED, Loan::STATUS_USED,
|
||||
Loan::STATUS_DISPOSED,
|
||||
]);
|
||||
}
|
||||
|
||||
$stats = $query->selectRaw($selectRaw, $bindings)->first();
|
||||
|
||||
$result = [
|
||||
return [
|
||||
'total_count' => (int) $stats->total_count,
|
||||
'outstanding_count' => (int) $stats->outstanding_count,
|
||||
'settled_count' => (int) $stats->settled_count,
|
||||
@@ -145,27 +115,6 @@ public function summary(?int $userId = null, ?string $category = null): array
|
||||
'total_settled' => (float) $stats->total_settled,
|
||||
'total_outstanding' => (float) $stats->total_outstanding,
|
||||
];
|
||||
|
||||
if ($isGiftCertificate) {
|
||||
$result['holding_count'] = (int) $stats->holding_count;
|
||||
$result['holding_amount'] = (float) $stats->holding_amount;
|
||||
$result['used_count'] = (int) $stats->used_count;
|
||||
$result['used_amount'] = (float) $stats->used_amount;
|
||||
$result['disposed_count'] = (int) $stats->disposed_count;
|
||||
|
||||
// 접대비 해당 집계 (expense_accounts 테이블에서 조회)
|
||||
$entertainmentStats = ExpenseAccount::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('account_type', ExpenseAccount::TYPE_ENTERTAINMENT)
|
||||
->where('sub_type', ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE)
|
||||
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as amount')
|
||||
->first();
|
||||
|
||||
$result['entertainment_count'] = (int) ($entertainmentStats->count ?? 0);
|
||||
$result['entertainment_amount'] = (float) ($entertainmentStats->amount ?? 0);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -195,34 +144,17 @@ public function store(array $data): Loan
|
||||
$withdrawalId = $withdrawal->id;
|
||||
}
|
||||
|
||||
// 상품권: user_id 미지정 시 현재 사용자로 대체
|
||||
$loanUserId = $data['user_id'] ?? $userId;
|
||||
|
||||
// 상태 결정: 상품권은 holding, 그 외는 outstanding
|
||||
$category = $data['category'] ?? null;
|
||||
$status = $data['status']
|
||||
?? ($category === Loan::CATEGORY_GIFT_CERTIFICATE ? Loan::STATUS_HOLDING : Loan::STATUS_OUTSTANDING);
|
||||
|
||||
$loan = Loan::create([
|
||||
return Loan::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $loanUserId,
|
||||
'user_id' => $data['user_id'],
|
||||
'loan_date' => $data['loan_date'],
|
||||
'amount' => $data['amount'],
|
||||
'purpose' => $data['purpose'] ?? null,
|
||||
'status' => $status,
|
||||
'category' => $category,
|
||||
'metadata' => $data['metadata'] ?? null,
|
||||
'status' => Loan::STATUS_OUTSTANDING,
|
||||
'withdrawal_id' => $withdrawalId,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 상품권 → 접대비 자동 연동
|
||||
if ($category === Loan::CATEGORY_GIFT_CERTIFICATE) {
|
||||
$this->syncGiftCertificateExpense($loan);
|
||||
}
|
||||
|
||||
return $loan;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,83 +186,20 @@ public function update(int $id, array $data): Loan
|
||||
}
|
||||
}
|
||||
|
||||
$fillData = [
|
||||
$loan->fill([
|
||||
'user_id' => $data['user_id'] ?? $loan->user_id,
|
||||
'loan_date' => $data['loan_date'] ?? $loan->loan_date,
|
||||
'amount' => $data['amount'] ?? $loan->amount,
|
||||
'purpose' => $data['purpose'] ?? $loan->purpose,
|
||||
'withdrawal_id' => $data['withdrawal_id'] ?? $loan->withdrawal_id,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if (isset($data['category'])) {
|
||||
$fillData['category'] = $data['category'];
|
||||
}
|
||||
if (array_key_exists('metadata', $data)) {
|
||||
$fillData['metadata'] = $data['metadata'];
|
||||
}
|
||||
if (isset($data['status'])) {
|
||||
$fillData['status'] = $data['status'];
|
||||
}
|
||||
if (array_key_exists('settlement_date', $data)) {
|
||||
$fillData['settlement_date'] = $data['settlement_date'];
|
||||
}
|
||||
|
||||
$loan->fill($fillData);
|
||||
]);
|
||||
|
||||
$loan->save();
|
||||
|
||||
// 상품권 → 접대비 자동 연동
|
||||
if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) {
|
||||
$this->syncGiftCertificateExpense($loan);
|
||||
}
|
||||
|
||||
return $loan->fresh(['user:id,name,email', 'creator:id,name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 상품권 → 접대비 자동 연동
|
||||
*
|
||||
* 상태가 'used' + entertainment_expense='applicable' → expense_accounts에 INSERT
|
||||
* 그 외 → 기존 연결된 expense_accounts 삭제
|
||||
*/
|
||||
private function syncGiftCertificateExpense(Loan $loan): void
|
||||
{
|
||||
$metadata = $loan->metadata ?? [];
|
||||
$isEntertainment = ($loan->status === Loan::STATUS_USED)
|
||||
&& ($metadata['entertainment_expense'] ?? '') === 'applicable';
|
||||
|
||||
if ($isEntertainment) {
|
||||
// upsert: loan_id 기준으로 있으면 업데이트, 없으면 생성
|
||||
ExpenseAccount::query()
|
||||
->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $loan->tenant_id,
|
||||
'loan_id' => $loan->id,
|
||||
],
|
||||
[
|
||||
'account_type' => ExpenseAccount::TYPE_ENTERTAINMENT,
|
||||
'sub_type' => ExpenseAccount::SUB_TYPE_GIFT_CERTIFICATE,
|
||||
'expense_date' => $loan->settlement_date ?? $loan->loan_date,
|
||||
'amount' => $loan->amount,
|
||||
'description' => ($metadata['cert_name'] ?? '상품권') . ' 접대비 전환',
|
||||
'receipt_no' => $metadata['serial_number'] ?? null,
|
||||
'vendor_name' => $metadata['vendor_name'] ?? null,
|
||||
'vendor_id' => ! empty($metadata['vendor_id']) ? (int) $metadata['vendor_id'] : null,
|
||||
'payment_method' => ExpenseAccount::PAYMENT_CASH,
|
||||
'created_by' => $loan->updated_by ?? $loan->created_by,
|
||||
'updated_by' => $loan->updated_by ?? $loan->created_by,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// 접대비 해당이 아니면 연결된 레코드 삭제
|
||||
ExpenseAccount::query()
|
||||
->where('tenant_id', $loan->tenant_id)
|
||||
->where('loan_id', $loan->id)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 가지급금 삭제
|
||||
*/
|
||||
@@ -347,14 +216,6 @@ public function destroy(int $id): bool
|
||||
throw new BadRequestHttpException(__('error.loan.not_deletable'));
|
||||
}
|
||||
|
||||
// 상품권 연결 접대비 레코드도 삭제
|
||||
if ($loan->category === Loan::CATEGORY_GIFT_CERTIFICATE) {
|
||||
ExpenseAccount::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('loan_id', $loan->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$loan->deleted_by = $userId;
|
||||
$loan->save();
|
||||
$loan->delete();
|
||||
@@ -504,8 +365,7 @@ public function calculateInterest(int $year, ?int $userId = null): array
|
||||
/**
|
||||
* 가지급금 대시보드 데이터
|
||||
*
|
||||
* CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공
|
||||
* D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류)
|
||||
* CEO 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공
|
||||
*
|
||||
* @return array{
|
||||
* summary: array{
|
||||
@@ -513,79 +373,38 @@ public function calculateInterest(int $year, ?int $userId = null): array
|
||||
* recognized_interest: float,
|
||||
* outstanding_count: int
|
||||
* },
|
||||
* category_breakdown: array<string, array{
|
||||
* outstanding_amount: float,
|
||||
* total_count: int,
|
||||
* unverified_count: int
|
||||
* }>,
|
||||
* loans: array
|
||||
* }
|
||||
*/
|
||||
public function dashboard(?string $startDate = null, ?string $endDate = null): array
|
||||
public function dashboard(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$currentYear = now()->year;
|
||||
|
||||
// 날짜 필터 조건 클로저
|
||||
$applyDateFilter = function ($query) use ($startDate, $endDate) {
|
||||
if ($startDate) {
|
||||
$query->where('loan_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$query->where('loan_date', '<=', $endDate);
|
||||
}
|
||||
return $query;
|
||||
};
|
||||
// 1. Summary 데이터
|
||||
$summaryData = $this->summary();
|
||||
|
||||
// 상품권 중 used/disposed 제외 조건 (접대비로 전환됨)
|
||||
$excludeUsedGiftCert = function ($query) {
|
||||
$query->whereNot(function ($q) {
|
||||
$q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE)
|
||||
->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]);
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Summary 데이터 (날짜 필터 적용)
|
||||
$summaryQuery = Loan::query()->where('tenant_id', $tenantId);
|
||||
$applyDateFilter($summaryQuery);
|
||||
$excludeUsedGiftCert($summaryQuery);
|
||||
|
||||
$stats = $summaryQuery->selectRaw('
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as outstanding_count,
|
||||
SUM(amount) as total_amount,
|
||||
SUM(amount - COALESCE(settlement_amount, 0)) as total_outstanding
|
||||
', [Loan::STATUS_OUTSTANDING])
|
||||
->first();
|
||||
|
||||
// 2. 인정이자 계산 (현재 연도 기준, 날짜 필터 무관)
|
||||
// 2. 인정이자 계산 (현재 연도 기준)
|
||||
$interestData = $this->calculateInterest($currentYear);
|
||||
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
|
||||
|
||||
// 3. 카테고리별 집계 (날짜 필터 적용)
|
||||
$categoryBreakdown = $this->getCategoryBreakdown($tenantId, $startDate, $endDate);
|
||||
|
||||
// 4. 가지급금 목록 (미정산 우선, 날짜 필터 적용, used/disposed 상품권 제외)
|
||||
$loansQuery = Loan::query()
|
||||
// 3. 가지급금 목록 (최근 10건, 미정산 우선)
|
||||
$loans = Loan::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['user:id,name,email', 'withdrawal']);
|
||||
$applyDateFilter($loansQuery);
|
||||
$excludeUsedGiftCert($loansQuery);
|
||||
|
||||
$loans = $loansQuery
|
||||
->with(['user:id,name,email', 'withdrawal'])
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 WHEN status = ? THEN 1 ELSE 2 END', [
|
||||
Loan::STATUS_OUTSTANDING,
|
||||
Loan::STATUS_PARTIAL,
|
||||
])
|
||||
->orderByDesc('loan_date')
|
||||
->limit(50)
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($loan) {
|
||||
return [
|
||||
'id' => $loan->id,
|
||||
'loan_date' => $loan->loan_date->format('Y-m-d'),
|
||||
'user_name' => $loan->user?->name ?? '미지정',
|
||||
'category' => $loan->category_label,
|
||||
'category' => $loan->withdrawal_id ? '카드' : '계좌',
|
||||
'amount' => (float) $loan->amount,
|
||||
'status' => $loan->status,
|
||||
'content' => $loan->purpose ?? '',
|
||||
@@ -595,70 +414,14 @@ public function dashboard(?string $startDate = null, ?string $endDate = null): a
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'total_outstanding' => (float) ($stats->total_outstanding ?? 0),
|
||||
'total_outstanding' => (float) $summaryData['total_outstanding'],
|
||||
'recognized_interest' => (float) $recognizedInterest,
|
||||
'outstanding_count' => (int) ($stats->outstanding_count ?? 0),
|
||||
'outstanding_count' => (int) $summaryData['outstanding_count'],
|
||||
],
|
||||
'category_breakdown' => $categoryBreakdown,
|
||||
'loans' => $loans,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 가지급금 집계
|
||||
*
|
||||
* @return array<string, array{outstanding_amount: float, total_count: int, unverified_count: int}>
|
||||
*/
|
||||
private function getCategoryBreakdown(int $tenantId, ?string $startDate = null, ?string $endDate = null): array
|
||||
{
|
||||
// 기본값: 4개 카테고리 모두 0으로 초기화
|
||||
$breakdown = [];
|
||||
foreach (Loan::CATEGORIES as $category) {
|
||||
$breakdown[$category] = [
|
||||
'outstanding_amount' => 0.0,
|
||||
'total_count' => 0,
|
||||
'unverified_count' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 카테고리별 집계 (날짜 필터 적용)
|
||||
// 상품권 중 used/disposed는 접대비로 전환되므로 가지급금 집계에서 제외
|
||||
$query = Loan::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNot(function ($q) {
|
||||
$q->where('category', Loan::CATEGORY_GIFT_CERTIFICATE)
|
||||
->whereIn('status', [Loan::STATUS_USED, Loan::STATUS_DISPOSED]);
|
||||
});
|
||||
|
||||
if ($startDate) {
|
||||
$query->where('loan_date', '>=', $startDate);
|
||||
}
|
||||
if ($endDate) {
|
||||
$query->where('loan_date', '<=', $endDate);
|
||||
}
|
||||
|
||||
// NOTE: SQL alias를 'cat_outstanding'으로 사용 — Loan 모델의
|
||||
// getOutstandingAmountAttribute() accessor와 이름 충돌 방지
|
||||
$stats = $query
|
||||
->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as cat_outstanding')
|
||||
->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count')
|
||||
->groupBy('category')
|
||||
->get();
|
||||
|
||||
foreach ($stats as $stat) {
|
||||
$cat = $stat->category ?? Loan::CATEGORY_CARD;
|
||||
if (isset($breakdown[$cat])) {
|
||||
$breakdown[$cat] = [
|
||||
'outstanding_amount' => (float) $stat->cat_outstanding,
|
||||
'total_count' => (int) $stat->total_count,
|
||||
'unverified_count' => (int) $stat->unverified_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금 시뮬레이션 데이터
|
||||
*
|
||||
|
||||
@@ -1325,13 +1325,9 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
// 작업지시번호 생성
|
||||
$workOrderNo = $this->generateWorkOrderNo($tenantId);
|
||||
|
||||
// 공정 옵션 초기화 (보조 공정 플래그 포함)
|
||||
// 절곡 공정이면 bending_info 자동 생성
|
||||
$workOrderOptions = null;
|
||||
if ($processId) {
|
||||
$process = \App\Models\Process::find($processId);
|
||||
if ($process && ! empty($process->options['is_auxiliary'])) {
|
||||
$workOrderOptions = ['is_auxiliary' => true];
|
||||
}
|
||||
// 이 작업지시에 포함되는 노드 ID만 추출
|
||||
$nodeIds = collect($items)
|
||||
->pluck('order_node_id')
|
||||
@@ -1342,7 +1338,7 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
|
||||
$buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
|
||||
if ($buildResult) {
|
||||
$workOrderOptions = array_merge($workOrderOptions ?? [], ['bending_info' => $buildResult['bending_info']]);
|
||||
$workOrderOptions = ['bending_info' => $buildResult['bending_info']];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,8 +1410,6 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
$woItemOptions = array_filter([
|
||||
'floor' => $orderItem->floor_code,
|
||||
'code' => $orderItem->symbol_code,
|
||||
'product_code' => ! empty($nodeOptions['product_code']) ? $nodeOptions['product_code'] : null,
|
||||
'product_name' => ! empty($nodeOptions['product_name']) ? $nodeOptions['product_name'] : null,
|
||||
'width' => $woWidth,
|
||||
'height' => $woHeight,
|
||||
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Qualitys\PerformanceReport;
|
||||
use App\Services\Audit\AuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
class PerformanceReportService extends Service
|
||||
{
|
||||
private const AUDIT_TARGET = 'performance_report';
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly QualityDocumentService $qualityDocumentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 목록 조회
|
||||
*/
|
||||
public function index(array $params)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$perPage = (int) ($params['per_page'] ?? 20);
|
||||
$q = trim((string) ($params['q'] ?? ''));
|
||||
$year = $params['year'] ?? null;
|
||||
$quarter = $params['quarter'] ?? null;
|
||||
$confirmStatus = $params['confirm_status'] ?? null;
|
||||
|
||||
$query = PerformanceReport::query()
|
||||
->where('performance_reports.tenant_id', $tenantId)
|
||||
->with(['qualityDocument.client', 'qualityDocument.locations', 'confirmer:id,name']);
|
||||
|
||||
if ($q !== '') {
|
||||
$query->whereHas('qualityDocument', function ($qq) use ($q) {
|
||||
$qq->where('quality_doc_number', 'like', "%{$q}%")
|
||||
->orWhere('site_name', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($year !== null) {
|
||||
$query->where('year', $year);
|
||||
}
|
||||
if ($quarter !== null) {
|
||||
$query->where('quarter', $quarter);
|
||||
}
|
||||
if ($confirmStatus !== null) {
|
||||
$query->where('confirmation_status', $confirmStatus);
|
||||
}
|
||||
|
||||
$query->orderByDesc('performance_reports.id');
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
$transformedData = $paginated->getCollection()->map(fn ($report) => $this->transformToFrontend($report));
|
||||
|
||||
return [
|
||||
'items' => $transformedData,
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'total' => $paginated->total(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function stats(array $params = []): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = PerformanceReport::where('performance_reports.tenant_id', $tenantId);
|
||||
|
||||
if (! empty($params['year'])) {
|
||||
$query->where('performance_reports.year', $params['year']);
|
||||
}
|
||||
if (! empty($params['quarter'])) {
|
||||
$query->where('performance_reports.quarter', $params['quarter']);
|
||||
}
|
||||
|
||||
$counts = (clone $query)
|
||||
->select('confirmation_status', DB::raw('count(*) as count'))
|
||||
->groupBy('confirmation_status')
|
||||
->pluck('count', 'confirmation_status')
|
||||
->toArray();
|
||||
|
||||
$totalLocations = (clone $query)
|
||||
->join('quality_documents', 'quality_documents.id', '=', 'performance_reports.quality_document_id')
|
||||
->join('quality_document_locations', 'quality_document_locations.quality_document_id', '=', 'quality_documents.id')
|
||||
->count('quality_document_locations.id');
|
||||
|
||||
return [
|
||||
'total_count' => array_sum($counts),
|
||||
'confirmed_count' => $counts[PerformanceReport::STATUS_CONFIRMED] ?? 0,
|
||||
'unconfirmed_count' => $counts[PerformanceReport::STATUS_UNCONFIRMED] ?? 0,
|
||||
'total_locations' => $totalLocations,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 확정
|
||||
*/
|
||||
public function confirm(array $ids)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($ids, $tenantId, $userId) {
|
||||
$reports = PerformanceReport::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->with(['qualityDocument'])
|
||||
->get();
|
||||
|
||||
$errors = [];
|
||||
foreach ($reports as $report) {
|
||||
if ($report->isConfirmed() || $report->isReported()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 필수정보 검증
|
||||
$requiredInfo = $this->qualityDocumentService->calculateRequiredInfo($report->qualityDocument);
|
||||
if ($requiredInfo !== '완료') {
|
||||
$errors[] = [
|
||||
'id' => $report->id,
|
||||
'quality_doc_number' => $report->qualityDocument->quality_doc_number,
|
||||
'reason' => $requiredInfo,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$report->update([
|
||||
'confirmation_status' => PerformanceReport::STATUS_CONFIRMED,
|
||||
'confirmed_date' => now()->toDateString(),
|
||||
'confirmed_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
throw new BadRequestHttpException(json_encode([
|
||||
'message' => __('error.quality.confirm_failed'),
|
||||
'errors' => $errors,
|
||||
]));
|
||||
}
|
||||
|
||||
return ['confirmed_count' => count($ids) - count($errors)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 확정 해제
|
||||
*/
|
||||
public function unconfirm(array $ids)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($ids, $tenantId, $userId) {
|
||||
PerformanceReport::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED)
|
||||
->update([
|
||||
'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED,
|
||||
'confirmed_date' => null,
|
||||
'confirmed_by' => null,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return ['unconfirmed_count' => count($ids)];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 메모 업데이트
|
||||
*/
|
||||
public function updateMemo(array $ids, string $memo)
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
PerformanceReport::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'memo' => $memo,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return ['updated_count' => count($ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 누락체크 (출고완료 but 제품검사 미등록)
|
||||
*/
|
||||
public function missing(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 품질관리서가 등록된 수주 ID
|
||||
$registeredOrderIds = DB::table('quality_document_orders')
|
||||
->join('quality_documents', 'quality_documents.id', '=', 'quality_document_orders.quality_document_id')
|
||||
->where('quality_documents.tenant_id', $tenantId)
|
||||
->pluck('quality_document_orders.order_id');
|
||||
|
||||
// 출고완료 상태이지만 품질관리서 미등록 수주
|
||||
$query = DB::table('orders')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotIn('id', $registeredOrderIds)
|
||||
->where('status_code', 'SHIPPED'); // TODO: 출고완료 상태 추가 시 상수 확인
|
||||
|
||||
if (! empty($params['year'])) {
|
||||
$query->whereYear('created_at', $params['year']);
|
||||
}
|
||||
if (! empty($params['quarter'])) {
|
||||
$quarter = (int) $params['quarter'];
|
||||
$startMonth = ($quarter - 1) * 3 + 1;
|
||||
$endMonth = $quarter * 3;
|
||||
$query->whereMonth('created_at', '>=', $startMonth)
|
||||
->whereMonth('created_at', '<=', $endMonth);
|
||||
}
|
||||
|
||||
return $query->orderByDesc('id')
|
||||
->limit(100)
|
||||
->get()
|
||||
->map(fn ($order) => [
|
||||
'id' => $order->id,
|
||||
'order_number' => $order->order_no ?? '',
|
||||
'site_name' => $order->site_name ?? '',
|
||||
'client' => '', // 별도 조인 필요
|
||||
'delivery_date' => $order->delivery_date ?? '',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* DB → 프론트엔드 변환
|
||||
*/
|
||||
private function transformToFrontend(PerformanceReport $report): array
|
||||
{
|
||||
$doc = $report->qualityDocument;
|
||||
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'quality_doc_number' => $doc?->quality_doc_number ?? '',
|
||||
'created_date' => $report->created_at?->format('Y-m-d') ?? '',
|
||||
'site_name' => $doc?->site_name ?? '',
|
||||
'client' => $doc?->client?->name ?? '',
|
||||
'location_count' => $doc?->locations?->count() ?? 0,
|
||||
'required_info' => $doc ? $this->qualityDocumentService->calculateRequiredInfo($doc) : '',
|
||||
'confirm_status' => $report->confirmation_status === PerformanceReport::STATUS_CONFIRMED ? 'confirmed' : 'unconfirmed',
|
||||
'confirm_date' => $report->confirmed_date?->format('Y-m-d'),
|
||||
'memo' => $report->memo ?? '',
|
||||
'year' => $report->year,
|
||||
'quarter' => $report->quarter,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -218,19 +218,17 @@ public function buildDynamicBomForItem(array $context, int $width, int $height,
|
||||
|
||||
// ─── 3. 셔터박스 세부품목 ───
|
||||
if ($boxSize) {
|
||||
$isStandard = $boxSize === '500*380';
|
||||
$dist = $this->shutterBoxDistribution($width);
|
||||
// 상부덮개(top_cover), 마구리(fin_cover)는 1219mm 기준으로 별도 생성 (아래 256행~)
|
||||
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner'];
|
||||
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'];
|
||||
|
||||
// 작업일지와 동일한 순서: 파트 → 길이
|
||||
foreach ($shutterPartTypes as $partType) {
|
||||
foreach ($dist as $length => $count) {
|
||||
$totalCount = $count * $qty;
|
||||
if ($totalCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prefix = $resolver->resolveShutterBoxPrefix($partType);
|
||||
foreach ($dist as $length => $count) {
|
||||
$totalCount = $count * $qty;
|
||||
if ($totalCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach ($shutterPartTypes as $partType) {
|
||||
$prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard);
|
||||
$itemCode = $resolver->buildItemCode($prefix, $length);
|
||||
if (! $itemCode) {
|
||||
continue;
|
||||
@@ -258,7 +256,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height,
|
||||
// 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위)
|
||||
$coverQty = (int) ceil($width / 1219) * $qty;
|
||||
if ($coverQty > 0) {
|
||||
$coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover');
|
||||
$coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard);
|
||||
$coverCode = $resolver->buildItemCode($coverPrefix, 1219);
|
||||
if ($coverCode) {
|
||||
$coverId = $resolver->resolveItemId($coverCode, $tenantId);
|
||||
@@ -280,7 +278,7 @@ public function buildDynamicBomForItem(array $context, int $width, int $height,
|
||||
// 마구리 수량: qty × 2
|
||||
$finQty = $qty * 2;
|
||||
if ($finQty > 0) {
|
||||
$finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover');
|
||||
$finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard);
|
||||
// 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용
|
||||
$finCode = $resolver->buildItemCode($finPrefix, 1219);
|
||||
if ($finCode) {
|
||||
|
||||
@@ -189,14 +189,16 @@ public function resolveBottomBarPrefix(string $partType, string $productCode, st
|
||||
/**
|
||||
* 셔터박스 세부품목의 prefix 결정
|
||||
*
|
||||
* CF/CL/CP/CB 품목은 모든 길이에 등록되어 있으므로 boxSize 무관하게 적용.
|
||||
* top_cover, fin_cover는 전용 품목 없이 XX(하부BASE/상부/마구리) 공용.
|
||||
*
|
||||
* @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'
|
||||
* @param bool $isStandardSize 500*380인지
|
||||
* @return string prefix
|
||||
*/
|
||||
public function resolveShutterBoxPrefix(string $partType): string
|
||||
public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string
|
||||
{
|
||||
if (! $isStandardSize) {
|
||||
return 'XX';
|
||||
}
|
||||
|
||||
return self::SHUTTER_STANDARD[$partType] ?? 'XX';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Orders\Order;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ProductionOrderService extends Service
|
||||
{
|
||||
/**
|
||||
* 생산지시 대상 상태 코드
|
||||
*/
|
||||
private const PRODUCTION_STATUSES = [
|
||||
Order::STATUS_IN_PROGRESS,
|
||||
Order::STATUS_IN_PRODUCTION,
|
||||
Order::STATUS_PRODUCED,
|
||||
Order::STATUS_SHIPPING,
|
||||
Order::STATUS_SHIPPED,
|
||||
];
|
||||
|
||||
/**
|
||||
* 생산지시 목록 조회
|
||||
*/
|
||||
public function index(array $params): LengthAwarePaginator
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = Order::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status_code', self::PRODUCTION_STATUSES)
|
||||
->with(['client', 'workOrders.process', 'workOrders.assignees.user'])
|
||||
->withCount([
|
||||
'workOrders' => fn ($q) => $q->whereNotNull('process_id')
|
||||
->where(fn ($q2) => $q2->whereNull('options->is_auxiliary')
|
||||
->orWhere('options->is_auxiliary', false)),
|
||||
'nodes',
|
||||
]);
|
||||
|
||||
// 검색어 필터
|
||||
if (! empty($params['search'])) {
|
||||
$search = $params['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('order_no', 'like', "%{$search}%")
|
||||
->orWhere('client_name', 'like', "%{$search}%")
|
||||
->orWhere('site_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 생산 상태 필터
|
||||
if (! empty($params['production_status'])) {
|
||||
switch ($params['production_status']) {
|
||||
case 'waiting':
|
||||
$query->where('status_code', Order::STATUS_IN_PROGRESS);
|
||||
break;
|
||||
case 'in_production':
|
||||
$query->where('status_code', Order::STATUS_IN_PRODUCTION);
|
||||
break;
|
||||
case 'completed':
|
||||
$query->whereIn('status_code', [
|
||||
Order::STATUS_PRODUCED,
|
||||
Order::STATUS_SHIPPING,
|
||||
Order::STATUS_SHIPPED,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 정렬
|
||||
$sortBy = $params['sort_by'] ?? 'created_at';
|
||||
$sortDir = $params['sort_dir'] ?? 'desc';
|
||||
$query->orderBy($sortBy, $sortDir);
|
||||
|
||||
// 페이지네이션
|
||||
$perPage = $params['per_page'] ?? 20;
|
||||
$result = $query->paginate($perPage);
|
||||
|
||||
// 가공 필드 추가
|
||||
$result->getCollection()->transform(function (Order $order) {
|
||||
$minCreatedAt = $order->workOrders->min('created_at');
|
||||
$order->production_ordered_at = $minCreatedAt
|
||||
? $minCreatedAt->format('Y-m-d')
|
||||
: null;
|
||||
|
||||
// 개소수 (order_nodes 수)
|
||||
$order->node_count = $order->nodes_count ?? 0;
|
||||
|
||||
// 주요 생산 공정 WO만 (구매품 + 보조 공정 제외)
|
||||
$productionWOs = $this->filterMainProductionWOs($order->workOrders);
|
||||
$order->work_order_progress = [
|
||||
'total' => $productionWOs->count(),
|
||||
'completed' => $productionWOs->where('status', 'completed')->count()
|
||||
+ $productionWOs->where('status', 'shipped')->count(),
|
||||
'in_progress' => $productionWOs->where('status', 'in_progress')->count(),
|
||||
];
|
||||
|
||||
// 프론트 탭용 production_status 매핑
|
||||
$order->production_status = $this->mapProductionStatus($order->status_code);
|
||||
|
||||
return $order;
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태별 통계
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$waiting = Order::where('tenant_id', $tenantId)
|
||||
->where('status_code', Order::STATUS_IN_PROGRESS)
|
||||
->count();
|
||||
|
||||
$inProduction = Order::where('tenant_id', $tenantId)
|
||||
->where('status_code', Order::STATUS_IN_PRODUCTION)
|
||||
->count();
|
||||
|
||||
$completed = Order::where('tenant_id', $tenantId)
|
||||
->whereIn('status_code', [
|
||||
Order::STATUS_PRODUCED,
|
||||
Order::STATUS_SHIPPING,
|
||||
Order::STATUS_SHIPPED,
|
||||
])
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $waiting + $inProduction + $completed,
|
||||
'waiting' => $waiting,
|
||||
'in_production' => $inProduction,
|
||||
'completed' => $completed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 상세 조회
|
||||
*/
|
||||
public function show(int $orderId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$order = Order::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('status_code', self::PRODUCTION_STATUSES)
|
||||
->with([
|
||||
'client',
|
||||
'workOrders.process',
|
||||
'workOrders.items',
|
||||
'workOrders.assignees.user',
|
||||
'nodes',
|
||||
])
|
||||
->withCount('nodes')
|
||||
->findOrFail($orderId);
|
||||
|
||||
// 생산지시일 (날짜만)
|
||||
$minCreatedAt = $order->workOrders->min('created_at');
|
||||
$order->production_ordered_at = $minCreatedAt
|
||||
? $minCreatedAt->format('Y-m-d')
|
||||
: null;
|
||||
$order->production_status = $this->mapProductionStatus($order->status_code);
|
||||
|
||||
// 주요 생산 공정 WO만 필터 (구매품 + 보조 공정 제외)
|
||||
$productionWorkOrders = $this->filterMainProductionWOs($order->workOrders);
|
||||
|
||||
// WorkOrder 진행 현황 (생산 공정 기준)
|
||||
$workOrderProgress = [
|
||||
'total' => $productionWorkOrders->count(),
|
||||
'completed' => $productionWorkOrders->where('status', 'completed')->count()
|
||||
+ $productionWorkOrders->where('status', 'shipped')->count(),
|
||||
'in_progress' => $productionWorkOrders->where('status', 'in_progress')->count(),
|
||||
];
|
||||
|
||||
// WorkOrder 목록 가공 (생산 공정만)
|
||||
$workOrders = $productionWorkOrders->values()->map(function ($wo) {
|
||||
return [
|
||||
'id' => $wo->id,
|
||||
'work_order_no' => $wo->work_order_no,
|
||||
'process_name' => $wo->process?->process_name ?? '',
|
||||
'quantity' => $wo->items->count(),
|
||||
'status' => $wo->status,
|
||||
'assignees' => $wo->assignees->map(fn ($a) => $a->user?->name ?? '')->filter()->values()->toArray(),
|
||||
];
|
||||
});
|
||||
|
||||
// BOM 데이터 (order_nodes에서 추출)
|
||||
$bomProcessGroups = $this->extractBomProcessGroups($order->nodes);
|
||||
|
||||
return [
|
||||
'order' => $order->makeHidden(['workOrders', 'nodes']),
|
||||
'production_ordered_at' => $order->production_ordered_at,
|
||||
'production_status' => $order->production_status,
|
||||
'node_count' => $order->nodes_count ?? 0,
|
||||
'work_order_progress' => $workOrderProgress,
|
||||
'work_orders' => $workOrders,
|
||||
'bom_process_groups' => $bomProcessGroups,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Order status_code → 프론트 production_status 매핑
|
||||
*/
|
||||
private function mapProductionStatus(string $statusCode): string
|
||||
{
|
||||
return match ($statusCode) {
|
||||
Order::STATUS_IN_PROGRESS => 'waiting',
|
||||
Order::STATUS_IN_PRODUCTION => 'in_production',
|
||||
default => 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* order_nodes에서 BOM 공정 분류 추출
|
||||
*
|
||||
* bom_result 구조: { items: [...], success, subtotals, ... }
|
||||
* 각 item: { item_id, item_code, item_name, process_group, specification, quantity, unit, ... }
|
||||
*/
|
||||
private function extractBomProcessGroups($nodes): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$bomResult = $node->options['bom_result'] ?? null;
|
||||
if (! $bomResult || ! is_array($bomResult)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// bom_result.items 배열에서 추출
|
||||
$items = $bomResult['items'] ?? [];
|
||||
if (! is_array($items)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nodeName = $node->name ?? '';
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$processGroup = $item['process_group'] ?? $item['category_group'] ?? '기타';
|
||||
|
||||
if (! isset($groups[$processGroup])) {
|
||||
$groups[$processGroup] = [
|
||||
'process_name' => $processGroup,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$processGroup]['items'][] = [
|
||||
'id' => $item['item_id'] ?? null,
|
||||
'item_code' => $item['item_code'] ?? '',
|
||||
'item_name' => $item['item_name'] ?? '',
|
||||
'spec' => $item['specification'] ?? '',
|
||||
'unit' => $item['unit'] ?? '',
|
||||
'quantity' => $item['quantity'] ?? 0,
|
||||
'unit_price' => $item['unit_price'] ?? 0,
|
||||
'total_price' => $item['total_price'] ?? 0,
|
||||
'node_name' => $nodeName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주요 생산 공정 WO만 필터 (구매품/서비스 + 보조 공정 제외)
|
||||
*
|
||||
* 제외 대상:
|
||||
* - process_id가 null인 WO (구매품/서비스)
|
||||
* - options.is_auxiliary가 true인 WO (재고생산 등 보조 공정)
|
||||
*/
|
||||
private function filterMainProductionWOs($workOrders): \Illuminate\Support\Collection
|
||||
{
|
||||
return $workOrders->filter(function ($wo) {
|
||||
if (empty($wo->process_id)) {
|
||||
return false;
|
||||
}
|
||||
$options = is_array($wo->options) ? $wo->options : (json_decode($wo->options, true) ?? []);
|
||||
|
||||
return empty($options['is_auxiliary']);
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user