merge: origin/develop 병합

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-30 11:55:50 +09:00
244 changed files with 14451 additions and 1699 deletions

View File

@@ -1,3 +1,49 @@
## 2026-01-29 (수) - 경동기업 견적 로직 Phase 4 완료
### 작업 목표
- 경동기업(tenant_id=287) 전용 견적 계산 로직 구현
- 5130 레거시 시스템의 BOM/견적 로직을 SAM에 이식
- 동적 BOM 계산: 모터, 제어기, 절곡품(10종), 부자재(3종)
### 생성된 파일
| 파일명 | 설명 |
|--------|------|
| `app/Models/Kyungdong/KdPriceTable.php` | 경동기업 전용 단가 테이블 모델 |
| `app/Services/Quote/Handlers/KyungdongFormulaHandler.php` | 경동기업 견적 계산 핸들러 |
| `database/migrations/2026_01_29_004736_create_kd_price_tables_table.php` | kd_price_tables 마이그레이션 |
| `database/seeders/Kyungdong/KdPriceTableSeeder.php` | 단가 데이터 시더 (47건) |
### 수정된 파일
| 파일명 | 설명 |
|--------|------|
| `app/Services/Quote/FormulaEvaluatorService.php` | tenant_id=287 라우팅 추가 |
### 구현된 기능
| 기능 | 설명 |
|------|------|
| 모터 용량 계산 | 제품타입 × 인치 × 중량 3차원 조건 |
| 브라켓 크기 결정 | 중량 기반 530*320, 600*350, 690*390 |
| 주자재 계산 | W × (H + 550) / 1,000,000 × 단가 |
| 절곡품 계산 (10종) | 케이스, 마구리, 가이드레일, 하장바, L바, 평철, 환봉 등 |
| 부자재 계산 (3종) | 감기샤프트, 각파이프, 앵글 |
### 테스트 결과
```
입력: W0=3000, H0=2500, 철재형, 5인치, KSS01 SUS
출력: 16개 항목, 합계 751,200원 ✅
```
### 검증 완료
- [x] Pint 코드 스타일 통과
- [x] 마이그레이션 실행 완료 (kd_price_tables)
- [x] 시더 실행 완료 (47건 단가 데이터)
- [x] tinker 테스트 통과 (16개 항목 정상 계산)
### 계획 문서
- `docs/plans/kd-quote-logic-plan.md` - Phase 0~4 완료 (100%)
---
## 2026-01-21 (화) - TodayIssue 헤더 알림 API (Phase 3 완료)
### 작업 목표

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-01-29 09:14:06
> **자동 생성**: 2026-01-29 19:41:35
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -194,6 +194,35 @@ ### model_versions
- **model()**: belongsTo → `models`
- **bomTemplates()**: hasMany → `bom_templates`
### documents
**모델**: `App\Models\Documents\Document`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **approvals()**: hasMany → `document_approvals`
- **data()**: hasMany → `document_data`
- **attachments()**: hasMany → `document_attachments`
- **linkable()**: morphTo → `(Polymorphic)`
### document_approvals
**모델**: `App\Models\Documents\DocumentApproval`
- **document()**: belongsTo → `documents`
- **user()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### document_attachments
**모델**: `App\Models\Documents\DocumentAttachment`
- **document()**: belongsTo → `documents`
- **file()**: belongsTo → `files`
- **creator()**: belongsTo → `users`
### document_datas
**모델**: `App\Models\Documents\DocumentData`
- **document()**: belongsTo → `documents`
### estimates
**모델**: `App\Models\Estimate\Estimate`
@@ -878,6 +907,7 @@ ### stocks
- **item()**: belongsTo → `items`
- **creator()**: belongsTo → `users`
- **lots()**: hasMany → `stock_lots`
- **transactions()**: hasMany → `stock_transactions`
### stock_lots
**모델**: `App\Models\Tenants\StockLot`
@@ -886,6 +916,13 @@ ### stock_lots
- **receiving()**: belongsTo → `receivings`
- **creator()**: belongsTo → `users`
### stock_transactions
**모델**: `App\Models\Tenants\StockTransaction`
- **stock()**: belongsTo → `stocks`
- **stockLot()**: belongsTo → `stock_lots`
- **creator()**: belongsTo → `users`
### subscriptions
**모델**: `App\Models\Tenants\Subscription`

View File

@@ -0,0 +1,640 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션
*
* 레거시 chandj DB의 BDmodels, price_motor, price_raw_materials,
* price_shaft, price_pipe, price_angle, price_smokeban 데이터를
* items + item_details + prices 통합 구조로 마이그레이션
*/
class MigrateBDModelsPrices extends Command
{
protected $signature = 'kd:migrate-prices
{--dry-run : 실제 DB 변경 없이 미리보기}
{--fresh : 기존 EST-* 항목 삭제 후 재생성}';
protected $description = '경동 견적 단가를 chandj 원본에서 items+item_details+prices로 마이그레이션';
private const TENANT_ID = 287;
private int $created = 0;
private int $updated = 0;
private int $skipped = 0;
private int $deleted = 0;
public function handle(): int
{
$dryRun = $this->option('dry-run');
$fresh = $this->option('fresh');
$this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ===');
$this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다');
$this->newLine();
DB::beginTransaction();
try {
// --fresh: 기존 EST-* 항목 삭제
if ($fresh) {
$this->cleanExistingEstItems($dryRun);
}
// 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철)
$this->migrateBDModels($dryRun);
// 2. price_motor (모터 + 제어기)
$this->migrateMotors($dryRun);
// 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등)
$this->migrateRawMaterials($dryRun);
// 4. price_shaft (감기샤프트)
$this->migrateShafts($dryRun);
// 5. price_pipe (각파이프)
$this->migratePipes($dryRun);
// 6. price_angle (앵글)
$this->migrateAngles($dryRun);
// 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완)
$this->migrateSmokeBan($dryRun);
if ($dryRun) {
DB::rollBack();
$this->warn('[DRY RUN] 롤백 완료');
} else {
DB::commit();
$this->info('커밋 완료');
}
$this->newLine();
$this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}");
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error("오류: {$e->getMessage()}");
$this->error($e->getTraceAsString());
return Command::FAILURE;
}
}
/**
* 기존 EST-* 항목 삭제 (--fresh 옵션)
*/
private function cleanExistingEstItems(bool $dryRun): void
{
$this->info('--- 기존 EST-* 항목 삭제 ---');
$items = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', 'LIKE', 'EST-%')
->whereNull('deleted_at')
->get(['id', 'code']);
foreach ($items as $item) {
$this->line(" [삭제] {$item->code}");
if (! $dryRun) {
DB::table('prices')->where('item_id', $item->id)->delete();
DB::table('item_details')->where('item_id', $item->id)->delete();
DB::table('items')->where('id', $item->id)->delete();
}
$this->deleted++;
}
}
/**
* chandj.BDmodels → items + item_details + prices
*/
private function migrateBDModels(bool $dryRun): void
{
$this->info('--- BDmodels (절곡품) ---');
$rows = DB::connection('chandj')->select("
SELECT model_name, seconditem, finishing_type, spec, unitprice, description
FROM BDmodels
WHERE is_deleted = 0
ORDER BY model_name, seconditem, finishing_type, spec
");
foreach ($rows as $row) {
$modelName = trim($row->model_name ?? '');
$secondItem = trim($row->seconditem ?? '');
$finishingType = trim($row->finishing_type ?? '');
$spec = trim($row->spec ?? '');
$unitPrice = (float) str_replace(',', '', $row->unitprice ?? '0');
// finishing_type 정규화: 'SUS마감' → 'SUS', 'EGI마감' → 'EGI'
$finishingType = str_replace('마감', '', $finishingType);
if (empty($secondItem) || $unitPrice <= 0) {
$this->skipped++;
continue;
}
$codeParts = ['BD', $secondItem];
if ($modelName) {
$codeParts[] = $modelName;
}
if ($finishingType) {
$codeParts[] = $finishingType;
}
if ($spec) {
$codeParts[] = $spec;
}
$code = implode('-', $codeParts);
$nameParts = [$secondItem];
if ($modelName) {
$nameParts[] = $modelName;
}
if ($finishingType) {
$nameParts[] = $finishingType;
}
if ($spec) {
$nameParts[] = $spec;
}
$name = implode(' ', $nameParts);
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'bdmodels',
partType: $secondItem,
specification: $spec ?: null,
attributes: array_filter([
'model_name' => $modelName ?: null,
'finishing_type' => $finishingType ?: null,
'bdmodel_source' => 'BDmodels',
'description' => $row->description ?: null,
]),
salesPrice: $unitPrice,
note: 'chandj.BDmodels',
dryRun: $dryRun
);
}
}
/**
* chandj.price_motor → 모터 + 제어기
*
* col1: 전압 (220, 380, 제어기, 방화, 방범)
* col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등)
* col13: 판매가
*/
private function migrateMotors(bool $dryRun): void
{
$this->info('--- price_motor (모터/제어기) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범
$name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
if (empty($name) || $price <= 0) {
$this->skipped++;
continue;
}
// 카테고리 분류
if (in_array($category, ['220', '380'])) {
$productCategory = 'motor';
$code = "EST-MOTOR-{$category}V-{$name}";
$displayName = "모터 {$name} ({$category}V)";
$partType = $name;
} elseif ($category === '제어기') {
$productCategory = 'controller';
$code = "EST-CTRL-{$name}";
$displayName = "제어기 {$name}";
$partType = $name;
} else {
// 방화, 방범 등
$productCategory = 'controller';
$code = "EST-CTRL-{$category}-{$name}";
$displayName = "{$category} {$name}";
$partType = "{$category} {$name}";
}
$this->upsertEstimateItem(
code: $code,
name: $displayName,
productCategory: $productCategory,
partType: $partType,
specification: null,
attributes: ['voltage' => $category, 'source' => 'price_motor'],
salesPrice: (float) $price,
note: 'chandj.price_motor',
dryRun: $dryRun
);
}
}
/**
* chandj.price_raw_materials → 원자재
*
* col1: 카테고리 (슬랫, 스크린)
* col2: 품명 (방화, 실리카, 화이바, 와이어 등)
* col13: 판매단가
*/
private function migrateRawMaterials(bool $dryRun): void
{
$this->info('--- price_raw_materials (원자재) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$category = trim($item['col1'] ?? '');
$name = trim($item['col2'] ?? '');
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
if (empty($name) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-RAW-{$category}-{$name}";
$displayName = "{$category} {$name}";
$this->upsertEstimateItem(
code: $code,
name: $displayName,
productCategory: 'raw_material',
partType: $name,
specification: $category,
attributes: ['category' => $category, 'source' => 'price_raw_materials'],
salesPrice: (float) $price,
note: 'chandj.price_raw_materials',
dryRun: $dryRun
);
}
}
/**
* chandj.price_shaft → 감기샤프트
*
* col4: 인치 (3, 4, 5, 6, 8, 10, 12)
* col10: 길이 (m)
* col19: 판매가
*/
private function migrateShafts(bool $dryRun): void
{
$this->info('--- price_shaft (감기샤프트) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$inch = trim($item['col4'] ?? '');
$lengthM = trim($item['col10'] ?? '');
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
if (empty($inch) || empty($lengthM) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-SHAFT-{$inch}-{$lengthM}";
$name = "감기샤프트 {$inch}인치 {$lengthM}m";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'shaft',
partType: $inch,
specification: $lengthM,
attributes: ['source' => 'price_shaft'],
salesPrice: (float) $price,
note: 'chandj.price_shaft',
dryRun: $dryRun
);
}
}
/**
* chandj.price_pipe → 각파이프
*
* col4: 두께 (1.4, 2)
* col2: 길이 (3,000 / 6,000)
* col8: 판매가
*/
private function migratePipes(bool $dryRun): void
{
$this->info('--- price_pipe (각파이프) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$thickness = trim($item['col4'] ?? '');
$length = (int) str_replace(',', '', $item['col2'] ?? '0');
$price = (int) str_replace(',', '', $item['col8'] ?? '0');
if (empty($thickness) || $length <= 0 || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-PIPE-{$thickness}-{$length}";
$name = "각파이프 {$thickness}T {$length}mm";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'pipe',
partType: $thickness,
specification: (string) $length,
attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'],
salesPrice: (float) $price,
note: 'chandj.price_pipe',
dryRun: $dryRun
);
}
}
/**
* chandj.price_angle → 앵글 (bracket + main 분리)
*
* bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등)
* - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가
*
* main angle (부자재용): col2가 숫자 (4 등)
* - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가
*/
private function migrateAngles(bool $dryRun): void
{
$this->info('--- price_angle (앵글) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$col2 = trim($item['col2'] ?? '');
$col3 = trim($item['col3'] ?? '');
$col4 = trim($item['col4'] ?? '');
$col10 = trim($item['col10'] ?? '');
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
if ($price <= 0) {
$this->skipped++;
continue;
}
// col2가 숫자이면 main angle, 텍스트이면 bracket angle
if (is_numeric($col2)) {
// Main angle (부자재용): col4=앵글3T, col10=2.5
if (empty($col4) || empty($col10)) {
$this->skipped++;
continue;
}
$code = "EST-ANGLE-MAIN-{$col4}-{$col10}";
$name = "앵글 {$col4} {$col10}m";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'angle_main',
partType: $col4,
specification: $col10,
attributes: ['source' => 'price_angle'],
salesPrice: (float) $price,
note: 'chandj.price_angle (main)',
dryRun: $dryRun
);
} else {
// Bracket angle (모터 받침용): col2=스크린용, col3=380*180
if (empty($col2)) {
$this->skipped++;
continue;
}
$code = "EST-ANGLE-BRACKET-{$col2}";
$name = "모터받침 앵글 {$col2}";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'angle_bracket',
partType: $col2,
specification: $col3 ?: null,
attributes: [
'angle_type' => $col4,
'source' => 'price_angle',
],
salesPrice: (float) $price,
note: 'chandj.price_angle (bracket)',
dryRun: $dryRun
);
}
}
}
/**
* chandj.price_smokeban → 연기차단재
*
* col2: 용도 (레일용, 케이스용)
* col11: 판매가
*/
private function migrateSmokeBan(bool $dryRun): void
{
$this->info('--- price_smokeban (연기차단재) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$usage = trim($item['col2'] ?? '');
$price = (int) str_replace(',', '', $item['col11'] ?? '0');
if (empty($usage) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-SMOKE-{$usage}";
$name = "연기차단재 {$usage}";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'smokeban',
partType: $usage,
specification: null,
attributes: ['source' => 'price_smokeban'],
salesPrice: (float) $price,
note: 'chandj.price_smokeban',
dryRun: $dryRun
);
}
}
/**
* 견적 품목 생성 또는 가격 업데이트
*/
private function upsertEstimateItem(
string $code,
string $name,
string $productCategory,
string $partType,
?string $specification,
array $attributes,
float $salesPrice,
string $note,
bool $dryRun
): void {
$existing = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', $code)
->whereNull('deleted_at')
->first();
if ($existing) {
// 가격 업데이트
$currentPrice = DB::table('prices')
->where('item_id', $existing->id)
->where('status', 'active')
->orderByDesc('id')
->value('sales_price');
if ((float) $currentPrice === $salesPrice) {
$this->skipped++;
return;
}
$this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . "" . number_format($salesPrice));
if (! $dryRun) {
// 기존 가격 비활성화
DB::table('prices')
->where('item_id', $existing->id)
->where('status', 'active')
->update(['status' => 'inactive', 'updated_at' => now()]);
// 새 가격 추가
DB::table('prices')->insert([
'tenant_id' => self::TENANT_ID,
'item_type_code' => 'PT',
'item_id' => $existing->id,
'sales_price' => $salesPrice,
'effective_from' => now()->toDateString(),
'status' => 'active',
'note' => $note,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->updated++;
return;
}
// 신규 생성
$this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice));
if ($dryRun) {
$this->created++;
return;
}
$now = now();
$itemId = DB::table('items')->insertGetId([
'tenant_id' => self::TENANT_ID,
'item_type' => 'PT',
'code' => $code,
'name' => $name,
'unit' => 'EA',
'attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('item_details')->insert([
'item_id' => $itemId,
'product_category' => $productCategory,
'part_type' => $partType,
'specification' => $specification,
'item_name' => $name,
'is_purchasable' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('prices')->insert([
'tenant_id' => self::TENANT_ID,
'item_type_code' => 'PT',
'item_id' => $itemId,
'sales_price' => $salesPrice,
'effective_from' => $now->toDateString(),
'status' => 'active',
'note' => $note,
'created_at' => $now,
'updated_at' => $now,
]);
$this->created++;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\StatAggregatorService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class StatAggregateDailyCommand extends Command
{
protected $signature = 'stat:aggregate-daily
{--date= : 집계 대상 날짜 (YYYY-MM-DD, 기본: 전일)}
{--domain= : 특정 도메인만 집계 (sales, finance, production)}
{--tenant= : 특정 테넌트만 집계}';
protected $description = '일간 통계 집계 (sam_stat DB)';
public function handle(StatAggregatorService $aggregator): int
{
$date = $this->option('date')
? Carbon::parse($this->option('date'))
: Carbon::yesterday();
$domain = $this->option('domain');
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$this->info("📊 일간 통계 집계 시작: {$date->format('Y-m-d')}");
if ($domain) {
$this->info(" 도메인 필터: {$domain}");
}
if ($tenantId) {
$this->info(" 테넌트 필터: {$tenantId}");
}
try {
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
$this->info('✅ 일간 집계 완료:');
$this->info(" - 처리 테넌트: {$result['tenants_processed']}");
$this->info(" - 처리 도메인: {$result['domains_processed']}");
$this->info(" - 소요 시간: {$result['duration_ms']}ms");
if (! empty($result['errors'])) {
$this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
return self::FAILURE;
}
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error("❌ 집계 실패: {$e->getMessage()}");
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\StatAggregatorService;
use Carbon\Carbon;
use Illuminate\Console\Command;
class StatAggregateMonthlyCommand extends Command
{
protected $signature = 'stat:aggregate-monthly
{--year= : 집계 대상 연도 (기본: 전월 기준)}
{--month= : 집계 대상 월 (기본: 전월)}
{--domain= : 특정 도메인만 집계}
{--tenant= : 특정 테넌트만 집계}';
protected $description = '월간 통계 집계 (sam_stat DB)';
public function handle(StatAggregatorService $aggregator): int
{
$lastMonth = Carbon::now()->subMonth();
$year = $this->option('year') ? (int) $this->option('year') : $lastMonth->year;
$month = $this->option('month') ? (int) $this->option('month') : $lastMonth->month;
$domain = $this->option('domain');
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$this->info("📊 월간 통계 집계 시작: {$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
try {
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
$this->info('✅ 월간 집계 완료:');
$this->info(" - 처리 테넌트: {$result['tenants_processed']}");
$this->info(" - 처리 도메인: {$result['domains_processed']}");
$this->info(" - 소요 시간: {$result['duration_ms']}ms");
if (! empty($result['errors'])) {
$this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
return self::FAILURE;
}
return self::SUCCESS;
} catch (\Throwable $e) {
$this->error("❌ 집계 실패: {$e->getMessage()}");
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\DimensionSyncService;
use App\Services\Stats\StatAggregatorService;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Console\Command;
class StatBackfillCommand extends Command
{
protected $signature = 'stat:backfill
{--from= : 시작 날짜 (YYYY-MM-DD, 필수)}
{--to= : 종료 날짜 (YYYY-MM-DD, 기본: 어제)}
{--domain= : 특정 도메인만 집계}
{--tenant= : 특정 테넌트만 집계}
{--skip-monthly : 월간 집계 건너뛰기}
{--skip-dimensions : 차원 동기화 건너뛰기}';
protected $description = '과거 데이터 일괄 통계 집계 (백필)';
public function handle(StatAggregatorService $aggregator, DimensionSyncService $dimensionSync): int
{
$from = $this->option('from');
if (! $from) {
$this->error('--from 옵션은 필수입니다. 예: --from=2024-01-01');
return self::FAILURE;
}
$startDate = Carbon::parse($from);
$endDate = $this->option('to')
? Carbon::parse($this->option('to'))
: Carbon::yesterday();
$domain = $this->option('domain');
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$totalDays = $startDate->diffInDays($endDate) + 1;
$this->info("📊 백필 시작: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
if ($domain) {
$this->info(" 도메인 필터: {$domain}");
}
if ($tenantId) {
$this->info(" 테넌트 필터: {$tenantId}");
}
$totalErrors = [];
$totalDomainsProcessed = 0;
$startTime = microtime(true);
// 1. 차원 테이블 동기화 (최초 1회)
if (! $this->option('skip-dimensions')) {
$this->info('');
$this->info('🔄 차원 테이블 동기화...');
try {
$tenants = $this->getTargetTenants($tenantId);
foreach ($tenants as $tenant) {
$clients = $dimensionSync->syncClients($tenant->id);
$products = $dimensionSync->syncProducts($tenant->id);
$this->line(" tenant={$tenant->id}: 고객 {$clients}건, 제품 {$products}");
}
} catch (\Throwable $e) {
$this->warn(" 차원 동기화 실패: {$e->getMessage()}");
}
}
// 2. 일간 집계
$this->info('');
$this->info('📅 일간 집계 시작...');
$bar = $this->output->createProgressBar($totalDays);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %message%');
$bar->setMessage('');
$period = CarbonPeriod::create($startDate, $endDate);
foreach ($period as $date) {
$bar->setMessage($date->format('Y-m-d'));
try {
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
$totalDomainsProcessed += $result['domains_processed'];
if (! empty($result['errors'])) {
$totalErrors = array_merge($totalErrors, $result['errors']);
}
} catch (\Throwable $e) {
$totalErrors[] = "daily {$date->format('Y-m-d')}: {$e->getMessage()}";
}
$bar->advance();
}
$bar->finish();
$this->newLine();
// 3. 월간 집계
if (! $this->option('skip-monthly')) {
$this->info('');
$this->info('📆 월간 집계 시작...');
$months = $this->getMonthRange($startDate, $endDate);
$monthBar = $this->output->createProgressBar(count($months));
foreach ($months as [$year, $month]) {
$monthBar->setMessage("{$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
try {
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
$totalDomainsProcessed += $result['domains_processed'];
if (! empty($result['errors'])) {
$totalErrors = array_merge($totalErrors, $result['errors']);
}
} catch (\Throwable $e) {
$totalErrors[] = "monthly {$year}-{$month}: {$e->getMessage()}";
}
$monthBar->advance();
}
$monthBar->finish();
$this->newLine();
}
$durationSec = round(microtime(true) - $startTime, 1);
$this->info('');
$this->info('✅ 백필 완료:');
$this->info(" - 기간: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
$this->info(" - 처리 도메인-테넌트: {$totalDomainsProcessed}");
$this->info(" - 소요 시간: {$durationSec}");
if (! empty($totalErrors)) {
$this->warn(' - 에러: '.count($totalErrors).'건');
foreach (array_slice($totalErrors, 0, 20) as $error) {
$this->error(" - {$error}");
}
if (count($totalErrors) > 20) {
$this->warn(' ... 외 '.(count($totalErrors) - 20).'건');
}
return self::FAILURE;
}
return self::SUCCESS;
}
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
{
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
if ($tenantId) {
$query->where('id', $tenantId);
}
return $query->get();
}
private function getMonthRange(Carbon $start, Carbon $end): array
{
$months = [];
$current = $start->copy()->startOfMonth();
$endMonth = $end->copy()->startOfMonth();
while ($current->lte($endMonth)) {
$months[] = [$current->year, $current->month];
$current->addMonth();
}
return $months;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\KpiAlertService;
use Illuminate\Console\Command;
class StatCheckKpiAlertsCommand extends Command
{
protected $signature = 'stat:check-kpi-alerts';
protected $description = 'KPI 목표 대비 실적을 체크하고 미달 시 알림을 생성합니다';
public function handle(KpiAlertService $service): int
{
$this->info('KPI 알림 체크 시작...');
$result = $service->checkKpiAlerts();
$this->info("알림 생성: {$result['alerts_created']}");
if (! empty($result['errors'])) {
$this->warn('오류 발생:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->info('KPI 알림 체크 완료.');
return empty($result['errors']) ? self::SUCCESS : self::FAILURE;
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Console\Commands;
use App\Models\Stats\Daily\StatFinanceDaily;
use App\Models\Stats\Daily\StatSalesDaily;
use App\Models\Stats\Daily\StatSystemDaily;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class StatVerifyCommand extends Command
{
protected $signature = 'stat:verify
{--date= : 검증 날짜 (YYYY-MM-DD, 기본: 어제)}
{--tenant= : 특정 테넌트만 검증}
{--domain= : 특정 도메인만 검증 (sales,finance,system)}
{--fix : 불일치 시 자동 재집계}';
protected $description = '원본 DB와 sam_stat 통계 정합성 교차 검증';
private int $totalChecks = 0;
private int $passedChecks = 0;
private int $failedChecks = 0;
private array $mismatches = [];
public function handle(): int
{
$date = $this->option('date')
? Carbon::parse($this->option('date'))
: Carbon::yesterday();
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
$domain = $this->option('domain');
$dateStr = $date->format('Y-m-d');
$this->info("🔍 정합성 검증: {$dateStr}");
$tenants = $this->getTargetTenants($tenantId);
$domains = $domain
? [$domain]
: ['sales', 'finance', 'system'];
foreach ($tenants as $tenant) {
$this->info('');
$this->info("── tenant={$tenant->id} ──");
foreach ($domains as $d) {
match ($d) {
'sales' => $this->verifySales($tenant->id, $dateStr),
'finance' => $this->verifyFinance($tenant->id, $dateStr),
'system' => $this->verifySystem($tenant->id, $dateStr),
default => $this->warn(" 미지원 도메인: {$d}"),
};
}
}
$this->printSummary();
if ($this->failedChecks > 0 && $this->option('fix')) {
$this->info('');
$this->info('🔧 불일치 항목 재집계...');
$this->reAggregate($date, $tenantId, $domains);
}
return $this->failedChecks > 0 ? self::FAILURE : self::SUCCESS;
}
private function verifySales(int $tenantId, string $dateStr): void
{
$this->line(' [sales]');
$originOrderCount = DB::connection('mysql')
->table('orders')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->whereNull('deleted_at')
->count();
$originSalesAmount = (float) DB::connection('mysql')
->table('sales')
->where('tenant_id', $tenantId)
->where('sale_date', $dateStr)
->whereNull('deleted_at')
->sum('supply_amount');
$stat = StatSalesDaily::where('tenant_id', $tenantId)
->where('stat_date', $dateStr)
->first();
$this->check('수주건수', $originOrderCount, $stat?->order_count ?? 0, $tenantId, 'sales');
$this->check('매출금액', $originSalesAmount, (float) ($stat?->sales_amount ?? 0), $tenantId, 'sales');
}
private function verifyFinance(int $tenantId, string $dateStr): void
{
$this->line(' [finance]');
$originDepositAmount = (float) DB::connection('mysql')
->table('deposits')
->where('tenant_id', $tenantId)
->where('deposit_date', $dateStr)
->whereNull('deleted_at')
->sum('amount');
$originWithdrawalAmount = (float) DB::connection('mysql')
->table('withdrawals')
->where('tenant_id', $tenantId)
->where('withdrawal_date', $dateStr)
->whereNull('deleted_at')
->sum('amount');
$stat = StatFinanceDaily::where('tenant_id', $tenantId)
->where('stat_date', $dateStr)
->first();
$this->check('입금액', $originDepositAmount, (float) ($stat?->deposit_amount ?? 0), $tenantId, 'finance');
$this->check('출금액', $originWithdrawalAmount, (float) ($stat?->withdrawal_amount ?? 0), $tenantId, 'finance');
}
private function verifySystem(int $tenantId, string $dateStr): void
{
$this->line(' [system]');
$originApiCount = DB::connection('mysql')
->table('api_request_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->count();
$originAuditCount = DB::connection('mysql')
->table('audit_logs')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->count();
$stat = StatSystemDaily::where('tenant_id', $tenantId)
->where('stat_date', $dateStr)
->first();
$this->check('API요청수', $originApiCount, $stat?->api_request_count ?? 0, $tenantId, 'system');
$statAuditTotal = ($stat?->audit_create_count ?? 0)
+ ($stat?->audit_update_count ?? 0)
+ ($stat?->audit_delete_count ?? 0);
$this->check('감사로그수', $originAuditCount, $statAuditTotal, $tenantId, 'system');
}
private function check(string $label, float|int $expected, float|int $actual, int $tenantId, string $domain): void
{
$this->totalChecks++;
$tolerance = is_float($expected) ? 0.01 : 0;
$match = abs($expected - $actual) <= $tolerance;
if ($match) {
$this->passedChecks++;
$this->line("{$label}: {$actual}");
} else {
$this->failedChecks++;
$this->error("{$label}: 원본={$expected} / 통계={$actual} (차이=".($actual - $expected).')');
$this->mismatches[] = compact('tenantId', 'domain', 'label', 'expected', 'actual');
}
}
private function printSummary(): void
{
$this->info('');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info("📊 검증 결과: {$this->totalChecks}건 검사, ✅ {$this->passedChecks}건 일치, ❌ {$this->failedChecks}건 불일치");
if ($this->failedChecks > 0) {
$this->warn('');
$this->warn('불일치 목록:');
foreach ($this->mismatches as $m) {
$this->warn(" - tenant={$m['tenantId']} [{$m['domain']}] {$m['label']}: 원본={$m['expected']} / 통계={$m['actual']}");
}
}
}
private function reAggregate(Carbon $date, ?int $tenantId, array $domains): void
{
$aggregator = app(\App\Services\Stats\StatAggregatorService::class);
foreach ($domains as $d) {
$result = $aggregator->aggregateDaily($date, $d, $tenantId);
$this->line(" {$d}: 재집계 완료 ({$result['domains_processed']}건)");
if (! empty($result['errors'])) {
foreach ($result['errors'] as $error) {
$this->error(" {$error}");
}
}
}
$this->info('✅ 재집계 완료. stat:verify를 다시 실행하여 확인하세요.');
}
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
{
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
if ($tenantId) {
$query->where('id', $tenantId);
}
return $query->get();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api\v1;
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;

View File

@@ -106,6 +106,22 @@ public function tenantBoards()
}, __('message.fetched'));
}
/**
* 게시판 상세 조회 (코드 기반)
*/
public function showByCode(string $code)
{
return ApiResponse::handle(function () use ($code) {
$board = $this->boardService->getBoardByCode($code);
if (! $board) {
abort(404, __('error.board.not_found'));
}
return $board;
}, __('message.fetched'));
}
/**
* 게시판 필드 목록 조회
*/

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\V1\Documents;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Document\IndexRequest;
use App\Http\Requests\Document\StoreRequest;
use App\Http\Requests\Document\UpdateRequest;
use App\Services\DocumentService;
use Illuminate\Http\JsonResponse;
class DocumentController extends Controller
{
public function __construct(private DocumentService $service) {}
/**
* 문서 목록 조회
* GET /v1/documents
*/
public function index(IndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->list($request->validated());
}, __('message.fetched'));
}
/**
* 문서 상세 조회
* GET /v1/documents/{id}
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 문서 생성
* POST /v1/documents
*/
public function store(StoreRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->create($request->validated());
}, __('message.created'));
}
/**
* 문서 수정
* PATCH /v1/documents/{id}
*/
public function update(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 문서 삭제
* DELETE /v1/documents/{id}
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroy($id);
}, __('message.deleted'));
}
// =========================================================================
// 결재 관련 메서드 (보류 - 기존 시스템 연동 필요)
// =========================================================================
// public function submit(int $id): JsonResponse
// public function approve(int $id, ApproveRequest $request): JsonResponse
// public function reject(int $id, RejectRequest $request): JsonResponse
// public function cancel(int $id): JsonResponse
}

View File

@@ -30,6 +30,7 @@ public function index(Request $request)
'item_category' => $request->input('item_category'),
'group_id' => $request->input('group_id'),
'active' => $request->input('is_active') ?? $request->input('active'),
'has_bom' => $request->input('has_bom'),
];
return $this->service->index($params);

View File

@@ -81,19 +81,8 @@ public function store(QuoteStoreRequest $request)
*/
public function update(QuoteUpdateRequest $request, int $id)
{
$validated = $request->validated();
// 🔍 디버깅: 요청 데이터 확인
\Log::info('🔍 [QuoteController::update] 요청 수신', [
'id' => $id,
'raw_options_keys' => $request->input('options') ? array_keys($request->input('options')) : null,
'raw_options_detail_items_count' => $request->input('options.detail_items') ? count($request->input('options.detail_items')) : 0,
'validated_options_keys' => isset($validated['options']) ? array_keys($validated['options']) : null,
'validated_options_detail_items_count' => isset($validated['options']['detail_items']) ? count($validated['options']['detail_items']) : 0,
]);
return ApiResponse::handle(function () use ($validated, $id) {
return $this->quoteService->update($id, $validated);
return ApiResponse::handle(function () use ($request, $id) {
return $this->quoteService->update($id, $request->validated());
}, __('message.quote.updated'));
}
@@ -270,4 +259,22 @@ public function sendHistory(int $id)
return $this->documentService->getSendHistory($id);
}, __('message.fetched'));
}
/**
* 품목 단가 조회
*
* 품목 코드 배열을 받아 단가를 조회합니다.
* 수동 품목 추가 시 단가를 조회하여 견적금액에 반영합니다.
*/
public function getItemPrices(\Illuminate\Http\Request $request)
{
$request->validate([
'item_codes' => 'required|array|min:1',
'item_codes.*' => 'required|string',
]);
return ApiResponse::handle(function () use ($request) {
return $this->calculationService->getItemPrices($request->input('item_codes'));
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Stat\StatAlertRequest;
use App\Http\Requests\V1\Stat\StatDailyRequest;
use App\Http\Requests\V1\Stat\StatMonthlyRequest;
use App\Services\Stats\StatQueryService;
use Illuminate\Http\JsonResponse;
class StatController extends Controller
{
public function __construct(
private readonly StatQueryService $statQueryService
) {}
/**
* 대시보드 요약 통계 (sam_stat 기반)
*/
public function summary(): JsonResponse
{
$data = $this->statQueryService->getDashboardSummary();
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 일간 통계 조회
*/
public function daily(StatDailyRequest $request): JsonResponse
{
$data = $this->statQueryService->getDailyStat(
$request->validated('domain'),
$request->validated()
);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 월간 통계 조회
*/
public function monthly(StatMonthlyRequest $request): JsonResponse
{
$data = $this->statQueryService->getMonthlyStat(
$request->validated('domain'),
$request->validated()
);
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
/**
* 알림 목록 조회
*/
public function alerts(StatAlertRequest $request): JsonResponse
{
$data = $this->statQueryService->getAlerts($request->validated());
return ApiResponse::handle(['data' => $data], __('message.fetched'));
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Api\v1;
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpFoundation\Response;
/**
* API 버전 관리 미들웨어
*
* - 헤더 Accept-Version으로 버전 선택 (기본: v1)
* - 요청된 버전의 라우트가 없으면 하위 버전으로 fallback
* - 응답 헤더에 실제 사용된 버전 표시
*/
class ApiVersionMiddleware
{
/**
* 지원하는 API 버전 목록 (우선순위 순)
*/
protected array $supportedVersions = ['v2', 'v1'];
/**
* 기본 버전
*/
protected string $defaultVersion = 'v1';
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// 1. 요청된 버전 확인 (헤더 > 쿼리 파라미터 > 기본값)
$requestedVersion = $this->getRequestedVersion($request);
// 2. 실제 사용할 버전 결정 (fallback 적용)
$actualVersion = $this->resolveVersion($request, $requestedVersion);
// 3. 요청에 버전 정보 저장 (컨트롤러에서 사용 가능)
$request->attributes->set('api_version', $actualVersion);
$request->attributes->set('api_version_requested', $requestedVersion);
$request->attributes->set('api_version_fallback', $actualVersion !== $requestedVersion);
// 4. 요청 처리
$response = $next($request);
// 5. 응답 헤더에 버전 정보 추가
$response->headers->set('X-API-Version', $actualVersion);
if ($actualVersion !== $requestedVersion) {
$response->headers->set('X-API-Version-Fallback', 'true');
$response->headers->set('X-API-Version-Requested', $requestedVersion);
}
return $response;
}
/**
* 요청에서 버전 정보 추출
*/
protected function getRequestedVersion(Request $request): string
{
// 1. Accept-Version 헤더 (권장)
$version = $request->header('Accept-Version');
if ($version && $this->isValidVersion($version)) {
return $version;
}
// 2. X-API-Version 헤더 (대안)
$version = $request->header('X-API-Version');
if ($version && $this->isValidVersion($version)) {
return $version;
}
// 3. 쿼리 파라미터 (테스트용)
$version = $request->query('api_version');
if ($version && $this->isValidVersion($version)) {
return $version;
}
return $this->defaultVersion;
}
/**
* 유효한 버전인지 확인
*/
protected function isValidVersion(string $version): bool
{
return in_array($version, $this->supportedVersions, true);
}
/**
* 실제 사용할 버전 결정 (fallback 로직)
*/
protected function resolveVersion(Request $request, string $requestedVersion): string
{
// 요청된 버전부터 하위 버전까지 순차 확인
$startIndex = array_search($requestedVersion, $this->supportedVersions, true);
if ($startIndex === false) {
return $this->defaultVersion;
}
// 요청된 버전부터 하위 버전까지 체크
for ($i = $startIndex; $i < count($this->supportedVersions); $i++) {
$version = $this->supportedVersions[$i];
if ($this->versionRouteExists($request, $version)) {
return $version;
}
}
// 모든 버전에서 라우트를 찾지 못하면 기본값 반환
return $this->defaultVersion;
}
/**
* 해당 버전의 라우트가 존재하는지 확인
*/
protected function versionRouteExists(Request $request, string $version): bool
{
$path = $request->path();
// URL에서 버전 부분 교체
// /api/v1/users → /api/v2/users
$versionedPath = preg_replace('/^api\/v\d+/', "api/{$version}", $path);
// 해당 경로의 라우트가 존재하는지 확인
$routes = Route::getRoutes();
foreach ($routes as $route) {
$routeUri = $route->uri();
// 정확히 일치하거나 파라미터 패턴 매칭
if ($this->matchesRoute($versionedPath, $routeUri, $request->method())) {
return true;
}
}
return false;
}
/**
* 경로가 라우트와 일치하는지 확인
*/
protected function matchesRoute(string $path, string $routeUri, string $method): bool
{
// 라우트 URI의 파라미터를 정규식으로 변환
// {id} → [^/]+
$pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $routeUri);
$pattern = '#^'.$pattern.'$#';
return (bool) preg_match($pattern, $path);
}
/**
* 지원 버전 목록 반환 (외부에서 사용)
*/
public function getSupportedVersions(): array
{
return $this->supportedVersions;
}
/**
* 기본 버전 반환
*/
public function getDefaultVersion(): string
{
return $this->defaultVersion;
}
}

View File

@@ -96,6 +96,7 @@ public function rules(): array
// 기타
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'is_overdue' => 'nullable|boolean',
];
}
}

View File

@@ -96,6 +96,7 @@ public function rules(): array
// 기타
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'is_overdue' => 'nullable|boolean',
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\Document;
use Illuminate\Foundation\Http\FormRequest;
class IndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$statuses = implode(',', [
Document::STATUS_DRAFT,
Document::STATUS_PENDING,
Document::STATUS_APPROVED,
Document::STATUS_REJECTED,
Document::STATUS_CANCELLED,
]);
return [
'status' => "nullable|string|in:{$statuses}",
'template_id' => 'nullable|integer',
'search' => 'nullable|string|max:100',
'from_date' => 'nullable|date',
'to_date' => 'nullable|date|after_or_equal:from_date',
'sort_by' => 'nullable|string|in:created_at,document_no,title,status,submitted_at,completed_at',
'sort_dir' => 'nullable|string|in:asc,desc',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\DocumentAttachment;
use Illuminate\Foundation\Http\FormRequest;
class StoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
return [
// 기본 정보
'template_id' => 'required|integer|exists:document_templates,id',
'title' => 'required|string|max:255',
'linkable_type' => 'nullable|string|max:100',
'linkable_id' => 'nullable|integer',
// 결재선 (보류 상태이지만 구조는 유지)
'approvers' => 'nullable|array',
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer|min:0',
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
'attachments.*.description' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'template_id.required' => __('validation.required', ['attribute' => '템플릿']),
'template_id.exists' => __('validation.exists', ['attribute' => '템플릿']),
'title.required' => __('validation.required', ['attribute' => '제목']),
'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]),
'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\Document;
use App\Models\Documents\DocumentAttachment;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$attachmentTypes = implode(',', DocumentAttachment::TYPES);
return [
// 기본 정보
'title' => 'nullable|string|max:255',
'linkable_type' => 'nullable|string|max:100',
'linkable_id' => 'nullable|integer',
// 결재선 (보류 상태이지만 구조는 유지)
'approvers' => 'nullable|array',
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',
'data.*.column_id' => 'nullable|integer',
'data.*.row_index' => 'nullable|integer|min:0',
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',
'attachments.*.attachment_type' => "nullable|string|in:{$attachmentTypes}",
'attachments.*.description' => 'nullable|string|max:255',
];
}
public function messages(): array
{
return [
'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 255]),
'approvers.*.user_id.exists' => __('validation.exists', ['attribute' => '결재자']),
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
];
}
}

View File

@@ -71,6 +71,7 @@ public function rules(): array
'labor_cost' => 'nullable|numeric|min:0',
'install_cost' => 'nullable|numeric|min:0',
'discount_rate' => 'nullable|numeric|min:0|max:100',
'discount_amount' => 'nullable|numeric|min:0',
'total_amount' => 'nullable|numeric|min:0',
// 기타 정보

View File

@@ -69,6 +69,7 @@ public function rules(): array
'labor_cost' => 'nullable|numeric|min:0',
'install_cost' => 'nullable|numeric|min:0',
'discount_rate' => 'nullable|numeric|min:0|max:100',
'discount_amount' => 'nullable|numeric|min:0',
'total_amount' => 'nullable|numeric|min:0',
// 기타 정보

View File

@@ -21,7 +21,7 @@ public function rules(): array
'item_name' => ['required', 'string', 'max:200'],
'specification' => ['nullable', 'string', 'max:200'],
'supplier' => ['required', 'string', 'max:100'],
'order_qty' => ['required', 'numeric', 'min:0'],
'order_qty' => ['nullable', 'numeric', 'min:0'],
'order_unit' => ['nullable', 'string', 'max:20'],
'due_date' => ['nullable', 'date'],
'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],

View File

@@ -23,8 +23,11 @@ public function rules(): array
'order_qty' => ['sometimes', 'numeric', 'min:0'],
'order_unit' => ['nullable', 'string', 'max:20'],
'due_date' => ['nullable', 'date'],
'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],
'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,completed'],
'remark' => ['nullable', 'string', 'max:1000'],
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
'receiving_date' => ['nullable', 'date'],
'lot_no' => ['nullable', 'string', 'max:50'],
];
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\V1\Stat;
use Illuminate\Foundation\Http\FormRequest;
class StatAlertRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'limit' => 'nullable|integer|min:1|max:100',
'unread_only' => 'nullable|boolean',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\V1\Stat;
use Illuminate\Foundation\Http\FormRequest;
class StatDailyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'domain' => 'required|string|in:sales,finance,production,inventory,quote,hr,system',
'start_date' => 'required|date|date_format:Y-m-d',
'end_date' => 'required|date|date_format:Y-m-d|after_or_equal:start_date',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\V1\Stat;
use Illuminate\Foundation\Http\FormRequest;
class StatMonthlyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'domain' => 'required|string|in:sales,finance,production,project',
'year' => 'required|integer|min:2020|max:2099',
'month' => 'nullable|integer|min:1|max:12',
];
}
}

View File

@@ -15,6 +15,7 @@ public function rules(): array
{
return [
'use_gps' => ['sometimes', 'boolean'],
'use_auto' => ['sometimes', 'boolean'],
'allowed_radius' => ['sometimes', 'integer', 'min:10', 'max:10000'],
'hq_address' => ['nullable', 'string', 'max:255'],
'hq_latitude' => ['nullable', 'numeric', 'between:-90,90'],

View File

@@ -4,6 +4,7 @@
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\Relations\BelongsTo;
@@ -12,7 +13,7 @@
class BadDebt extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -5,6 +5,7 @@
use App\Models\Members\User;
use App\Models\Orders\Client;
use App\Models\Quote\Quote;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -13,7 +14,7 @@
class Bidding extends Model
{
use BelongsToTenant, HasFactory, SoftDeletes;
use Auditable, BelongsToTenant, HasFactory, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Models\Tenants\Tenant;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -15,7 +16,7 @@
*/
class CategoryGroup extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'category_groups';

View File

@@ -2,6 +2,7 @@
namespace App\Models\Commons;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class Category extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'parent_id', 'code_group', 'code', 'name',

View File

@@ -2,6 +2,7 @@
namespace App\Models\Commons;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class CategoryField extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'category_fields';

View File

@@ -2,13 +2,14 @@
namespace App\Models\Commons;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
class CategoryLog extends Model
{
use BelongsToTenant, ModelTrait;
use Auditable, BelongsToTenant, ModelTrait;
protected $table = 'category_logs';

View File

@@ -2,13 +2,14 @@
namespace App\Models\Commons;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
class CategoryTemplate extends Model
{
use BelongsToTenant, ModelTrait;
use Auditable, BelongsToTenant, ModelTrait;
protected $table = 'category_templates';

View File

@@ -2,6 +2,7 @@
namespace App\Models\Commons;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class Classification extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Commons;
use App\Models\Scopes\TenantScope;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -15,7 +16,7 @@
*/
class Menu extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'parent_id', 'global_menu_id', 'name', 'url', 'is_active', 'sort_order',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Construction;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -42,7 +43,7 @@
*/
class Contract extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'contracts';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Construction;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -49,7 +50,7 @@
*/
class HandoverReport extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'handover_reports';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Construction;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -25,7 +26,7 @@
*/
class HandoverReportItem extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'handover_report_items';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Construction;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -24,7 +25,7 @@
*/
class HandoverReportManager extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'handover_report_managers';

View File

@@ -4,6 +4,7 @@
use App\Models\Members\User;
use App\Models\Site;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -38,7 +39,7 @@
*/
class StructureReview extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'structure_reviews';

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Models\Documents;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
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;
/**
* 문서 모델
*
* @property int $id
* @property int $tenant_id
* @property int $template_id
* @property string $document_no 문서번호
* @property string $title 문서 제목
* @property string $status 상태 (DRAFT/PENDING/APPROVED/REJECTED/CANCELLED)
* @property string|null $linkable_type 연결 모델 타입
* @property int|null $linkable_id 연결 모델 ID
* @property \Carbon\Carbon|null $submitted_at 결재 요청일
* @property \Carbon\Carbon|null $completed_at 결재 완료일
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property \Carbon\Carbon|null $deleted_at
*/
class Document extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'documents';
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_DRAFT = 'DRAFT';
public const STATUS_PENDING = 'PENDING';
public const STATUS_APPROVED = 'APPROVED';
public const STATUS_REJECTED = 'REJECTED';
public const STATUS_CANCELLED = 'CANCELLED';
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_CANCELLED,
];
// =========================================================================
// Fillable & Casts
// =========================================================================
protected $fillable = [
'tenant_id',
'template_id',
'document_no',
'title',
'status',
'linkable_type',
'linkable_id',
'submitted_at',
'completed_at',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'submitted_at' => 'datetime',
'completed_at' => 'datetime',
];
protected $attributes = [
'status' => self::STATUS_DRAFT,
];
// =========================================================================
// Relationships
// =========================================================================
/**
* 템플릿
*/
public function template(): BelongsTo
{
return $this->belongsTo(\App\Models\DocumentTemplate::class, 'template_id');
}
/**
* 결재 목록
*/
public function approvals(): HasMany
{
return $this->hasMany(DocumentApproval::class)->orderBy('step');
}
/**
* 문서 데이터
*/
public function data(): HasMany
{
return $this->hasMany(DocumentData::class);
}
/**
* 첨부파일
*/
public function attachments(): HasMany
{
return $this->hasMany(DocumentAttachment::class);
}
/**
* 연결된 엔티티 (다형성)
*/
public function linkable(): MorphTo
{
return $this->morphTo();
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// Scopes
// =========================================================================
/**
* 상태로 필터링
*/
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
/**
* 임시저장 문서
*/
public function scopeDraft($query)
{
return $query->where('status', self::STATUS_DRAFT);
}
/**
* 결재 대기 문서
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 승인된 문서
*/
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 편집 가능 여부
*/
public function canEdit(): bool
{
return $this->status === self::STATUS_DRAFT
|| $this->status === self::STATUS_REJECTED;
}
/**
* 결재 요청 가능 여부
*/
public function canSubmit(): bool
{
return $this->status === self::STATUS_DRAFT
|| $this->status === self::STATUS_REJECTED;
}
/**
* 결재 처리 가능 여부
*/
public function canApprove(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 취소 가능 여부
*/
public function canCancel(): bool
{
return in_array($this->status, [
self::STATUS_DRAFT,
self::STATUS_PENDING,
]);
}
/**
* 현재 결재 단계 가져오기
*/
public function getCurrentApprovalStep(): ?DocumentApproval
{
return $this->approvals()
->where('status', DocumentApproval::STATUS_PENDING)
->orderBy('step')
->first();
}
/**
* 특정 사용자의 결재 차례 확인
*/
public function isUserTurn(int $userId): bool
{
$currentStep = $this->getCurrentApprovalStep();
return $currentStep && $currentStep->user_id === $userId;
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Models\Documents;
use App\Models\Members\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 문서 결재 모델
*
* @property int $id
* @property int $document_id
* @property int $user_id
* @property int $step 결재 순서
* @property string $role 역할 (작성/검토/승인)
* @property string $status 상태 (PENDING/APPROVED/REJECTED)
* @property string|null $comment 결재 의견
* @property \Carbon\Carbon|null $acted_at 결재 처리일
* @property int|null $created_by
* @property int|null $updated_by
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class DocumentApproval extends Model
{
protected $table = 'document_approvals';
// =========================================================================
// 상태 상수
// =========================================================================
public const STATUS_PENDING = 'PENDING';
public const STATUS_APPROVED = 'APPROVED';
public const STATUS_REJECTED = 'REJECTED';
public const STATUSES = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_REJECTED,
];
// =========================================================================
// 역할 상수
// =========================================================================
public const ROLE_WRITER = '작성';
public const ROLE_REVIEWER = '검토';
public const ROLE_APPROVER = '승인';
// =========================================================================
// Fillable & Casts
// =========================================================================
protected $fillable = [
'document_id',
'user_id',
'step',
'role',
'status',
'comment',
'acted_at',
'created_by',
'updated_by',
];
protected $casts = [
'step' => 'integer',
'acted_at' => 'datetime',
];
protected $attributes = [
'step' => 1,
'status' => self::STATUS_PENDING,
];
// =========================================================================
// Relationships
// =========================================================================
/**
* 문서
*/
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
/**
* 결재자
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// Scopes
// =========================================================================
/**
* 대기 중인 결재
*/
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
/**
* 승인된 결재
*/
public function scopeApproved($query)
{
return $query->where('status', self::STATUS_APPROVED);
}
/**
* 반려된 결재
*/
public function scopeRejected($query)
{
return $query->where('status', self::STATUS_REJECTED);
}
/**
* 특정 사용자의 결재
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 대기 상태 여부
*/
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 승인 상태 여부
*/
public function isApproved(): bool
{
return $this->status === self::STATUS_APPROVED;
}
/**
* 반려 상태 여부
*/
public function isRejected(): bool
{
return $this->status === self::STATUS_REJECTED;
}
/**
* 결재 처리 완료 여부
*/
public function isProcessed(): bool
{
return ! $this->isPending();
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Models\Documents;
use App\Models\Commons\File;
use App\Models\Members\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 문서 첨부파일 모델
*
* @property int $id
* @property int $document_id
* @property int $file_id
* @property string $attachment_type 첨부 유형 (general, signature, image 등)
* @property string|null $description 설명
* @property int|null $created_by
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class DocumentAttachment extends Model
{
protected $table = 'document_attachments';
// =========================================================================
// 첨부 유형 상수
// =========================================================================
public const TYPE_GENERAL = 'general';
public const TYPE_SIGNATURE = 'signature';
public const TYPE_IMAGE = 'image';
public const TYPE_REFERENCE = 'reference';
public const TYPES = [
self::TYPE_GENERAL,
self::TYPE_SIGNATURE,
self::TYPE_IMAGE,
self::TYPE_REFERENCE,
];
// =========================================================================
// Fillable & Casts
// =========================================================================
protected $fillable = [
'document_id',
'file_id',
'attachment_type',
'description',
'created_by',
];
protected $attributes = [
'attachment_type' => self::TYPE_GENERAL,
];
// =========================================================================
// Relationships
// =========================================================================
/**
* 문서
*/
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
/**
* 파일
*/
public function file(): BelongsTo
{
return $this->belongsTo(File::class);
}
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// =========================================================================
// Scopes
// =========================================================================
/**
* 특정 유형의 첨부파일
*/
public function scopeOfType($query, string $type)
{
return $query->where('attachment_type', $type);
}
/**
* 일반 첨부파일
*/
public function scopeGeneral($query)
{
return $query->where('attachment_type', self::TYPE_GENERAL);
}
/**
* 서명 첨부파일
*/
public function scopeSignatures($query)
{
return $query->where('attachment_type', self::TYPE_SIGNATURE);
}
/**
* 이미지 첨부파일
*/
public function scopeImages($query)
{
return $query->where('attachment_type', self::TYPE_IMAGE);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 서명 첨부파일 여부
*/
public function isSignature(): bool
{
return $this->attachment_type === self::TYPE_SIGNATURE;
}
/**
* 이미지 첨부파일 여부
*/
public function isImage(): bool
{
return $this->attachment_type === self::TYPE_IMAGE;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Models\Documents;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 문서 데이터 모델 (EAV 패턴)
*
* @property int $id
* @property int $document_id
* @property int|null $section_id 섹션 ID
* @property int|null $column_id 컬럼 ID
* @property int $row_index 행 인덱스
* @property string $field_key 필드 키
* @property string|null $field_value 필드 값
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class DocumentData extends Model
{
protected $table = 'document_data';
// =========================================================================
// Fillable & Casts
// =========================================================================
protected $fillable = [
'document_id',
'section_id',
'column_id',
'row_index',
'field_key',
'field_value',
];
protected $casts = [
'section_id' => 'integer',
'column_id' => 'integer',
'row_index' => 'integer',
];
protected $attributes = [
'row_index' => 0,
];
// =========================================================================
// Relationships
// =========================================================================
/**
* 문서
*/
public function document(): BelongsTo
{
return $this->belongsTo(Document::class);
}
// =========================================================================
// Scopes
// =========================================================================
/**
* 특정 섹션의 데이터
*/
public function scopeForSection($query, int $sectionId)
{
return $query->where('section_id', $sectionId);
}
/**
* 특정 필드 키의 데이터
*/
public function scopeForField($query, string $fieldKey)
{
return $query->where('field_key', $fieldKey);
}
/**
* 특정 행의 데이터
*/
public function scopeForRow($query, int $rowIndex)
{
return $query->where('row_index', $rowIndex);
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* 값을 특정 타입으로 캐스팅
*/
public function getTypedValue(string $type = 'string'): mixed
{
return match ($type) {
'integer', 'int' => (int) $this->field_value,
'float', 'double' => (float) $this->field_value,
'boolean', 'bool' => filter_var($this->field_value, FILTER_VALIDATE_BOOLEAN),
'array', 'json' => json_decode($this->field_value, true),
default => $this->field_value,
};
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models\Estimate;
use App\Models\Commons\Category;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@
class Estimate extends Model
{
use BelongsToTenant, HasFactory, SoftDeletes;
use Auditable, BelongsToTenant, HasFactory, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\Estimate;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,7 +11,7 @@
class EstimateItem extends Model
{
use BelongsToTenant, HasFactory, SoftDeletes;
use Auditable, BelongsToTenant, HasFactory, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class CustomTab extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -17,7 +18,7 @@
*/
class EntityRelationship extends Model
{
use BelongsToTenant, ModelTrait;
use Auditable, BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class ItemBomItem extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class ItemField extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class ItemPage extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class ItemSection extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,13 +2,14 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
class TabColumn extends Model
{
use BelongsToTenant, ModelTrait;
use Auditable, BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\ItemMaster;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class UnitOption extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -5,6 +5,7 @@
use App\Models\Commons\Category;
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -18,7 +19,7 @@
*/
class Item extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Items;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -30,7 +31,7 @@
*/
class ItemReceipt extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'item_receipts';

View File

@@ -0,0 +1,341 @@
<?php
namespace App\Models\Kyungdong;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 경동기업 전용 단가 테이블 모델
*
* 5130 레거시 price_* 테이블 데이터 조회용
*
* @property int $id
* @property int $tenant_id
* @property string $table_type
* @property string|null $item_code
* @property string|null $item_name
* @property string|null $category
* @property string|null $spec1
* @property string|null $spec2
* @property string|null $spec3
* @property float $unit_price
* @property string $unit
* @property array|null $raw_data
* @property bool $is_active
*/
class KdPriceTable extends Model
{
// 테이블 유형 상수
public const TYPE_MOTOR = 'motor';
public const TYPE_SHAFT = 'shaft';
public const TYPE_PIPE = 'pipe';
public const TYPE_ANGLE = 'angle';
public const TYPE_RAW_MATERIAL = 'raw_material';
public const TYPE_BDMODELS = 'bdmodels';
// 경동기업 테넌트 ID
public const TENANT_ID = 287;
protected $table = 'kd_price_tables';
protected $fillable = [
'tenant_id',
'table_type',
'item_code',
'item_name',
'category',
'spec1',
'spec2',
'spec3',
'unit_price',
'unit',
'raw_data',
'is_active',
];
protected $casts = [
'unit_price' => 'decimal:2',
'raw_data' => 'array',
'is_active' => 'boolean',
];
// =========================================================================
// Scopes
// =========================================================================
/**
* 테이블 유형으로 필터링
*/
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('table_type', $type);
}
/**
* 활성 데이터만
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 모터 단가 조회
*/
public function scopeMotor(Builder $query): Builder
{
return $query->ofType(self::TYPE_MOTOR)->active();
}
/**
* 샤프트 단가 조회
*/
public function scopeShaft(Builder $query): Builder
{
return $query->ofType(self::TYPE_SHAFT)->active();
}
/**
* 파이프 단가 조회
*/
public function scopePipeType(Builder $query): Builder
{
return $query->ofType(self::TYPE_PIPE)->active();
}
/**
* 앵글 단가 조회
*/
public function scopeAngle(Builder $query): Builder
{
return $query->ofType(self::TYPE_ANGLE)->active();
}
// =========================================================================
// Static Query Methods
// =========================================================================
/**
* 모터 단가 조회
*
* @param string $motorCapacity 모터 용량 (150K, 300K, 400K, 500K, 600K, 800K, 1000K)
*/
public static function getMotorPrice(string $motorCapacity): float
{
$record = self::motor()
->where('category', $motorCapacity)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 제어기 단가 조회
*
* @param string $controllerType 제어기 타입 (매립형, 노출형, 뒷박스)
*/
public static function getControllerPrice(string $controllerType): float
{
$record = self::motor()
->where('category', $controllerType)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 샤프트 단가 조회
*
* @param string $size 사이즈 (3, 4, 5인치)
* @param float $length 길이 (m 단위)
*/
public static function getShaftPrice(string $size, float $length): float
{
// 길이를 소수점 1자리 문자열로 변환 (DB 저장 형식: '3.0', '4.0')
$lengthStr = number_format($length, 1, '.', '');
$record = self::shaft()
->where('spec1', $size)
->where('spec2', $lengthStr)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 파이프 단가 조회
*
* @param string $thickness 두께 (1.4 등)
* @param int $length 길이 (3000, 6000)
*/
public static function getPipePrice(string $thickness, int $length): float
{
$record = self::pipeType()
->where('spec1', $thickness)
->where('spec2', (string) $length)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 앵글 단가 조회
*
* @param string $type 타입 (스크린용, 철재용)
* @param string $bracketSize 브라켓크기 (530*320, 600*350, 690*390)
* @param string $angleType 앵글타입 (앵글3T, 앵글4T)
*/
public static function getAnglePrice(string $type, string $bracketSize, string $angleType): float
{
$record = self::angle()
->where('category', $type)
->where('spec1', $bracketSize)
->where('spec2', $angleType)
->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 원자재 단가 조회
*
* @param string $materialName 원자재명 (실리카, 스크린 등)
*/
public static function getRawMaterialPrice(string $materialName): float
{
$record = self::ofType(self::TYPE_RAW_MATERIAL)
->active()
->where('item_name', $materialName)
->first();
return (float) ($record?->unit_price ?? 0);
}
// =========================================================================
// BDmodels 단가 조회 (절곡품)
// =========================================================================
/**
* BDmodels 스코프
*/
public function scopeBdmodels(Builder $query): Builder
{
return $query->ofType(self::TYPE_BDMODELS)->active();
}
/**
* BDmodels 단가 조회 (케이스, 가이드레일, 하단마감재 등)
*
* @param string $secondItem 부품분류 (케이스, 가이드레일, 하단마감재, L-BAR 등)
* @param string|null $modelName 모델코드 (KSS01, KWS01 등)
* @param string|null $finishingType 마감재질 (SUS, EGI)
* @param string|null $spec 규격 (120*70, 650*550 등)
*/
public static function getBDModelPrice(
string $secondItem,
?string $modelName = null,
?string $finishingType = null,
?string $spec = null
): float {
$query = self::bdmodels()->where('category', $secondItem);
if ($modelName) {
$query->where('item_code', $modelName);
}
if ($finishingType) {
$query->where('spec1', $finishingType);
}
if ($spec) {
$query->where('spec2', $spec);
}
$record = $query->first();
return (float) ($record?->unit_price ?? 0);
}
/**
* 케이스 단가 조회
*
* @param string $spec 케이스 규격 (500*380, 650*550 등)
*/
public static function getCasePrice(string $spec): float
{
return self::getBDModelPrice('케이스', null, null, $spec);
}
/**
* 가이드레일 단가 조회
*
* @param string $modelName 모델코드 (KSS01 등)
* @param string $finishingType 마감재질 (SUS, EGI)
* @param string $spec 규격 (120*70, 120*100)
*/
public static function getGuideRailPrice(string $modelName, string $finishingType, string $spec): float
{
return self::getBDModelPrice('가이드레일', $modelName, $finishingType, $spec);
}
/**
* 하단마감재(하장바) 단가 조회
*
* @param string $modelName 모델코드
* @param string $finishingType 마감재질
*/
public static function getBottomBarPrice(string $modelName, string $finishingType): float
{
return self::getBDModelPrice('하단마감재', $modelName, $finishingType);
}
/**
* L-BAR 단가 조회
*
* @param string $modelName 모델코드
*/
public static function getLBarPrice(string $modelName): float
{
return self::getBDModelPrice('L-BAR', $modelName);
}
/**
* 보강평철 단가 조회
*/
public static function getFlatBarPrice(): float
{
return self::getBDModelPrice('보강평철');
}
/**
* 케이스 마구리 단가 조회
*
* @param string $spec 규격
*/
public static function getCaseCapPrice(string $spec): float
{
return self::getBDModelPrice('마구리', null, null, $spec);
}
/**
* 케이스용 연기차단재 단가 조회
*/
public static function getCaseSmokeBlockPrice(): float
{
return self::getBDModelPrice('케이스용 연기차단재');
}
/**
* 가이드레일용 연기차단재 단가 조회
*/
public static function getRailSmokeBlockPrice(): float
{
return self::getBDModelPrice('가이드레일용 연기차단재');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class Labor extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'labors';

View File

@@ -6,6 +6,7 @@
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Models\Qualitys\Lot;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -16,7 +17,7 @@
*/
class Material extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -4,6 +4,7 @@
use App\Models\Permissions\Role;
use App\Models\Tenants\Tenant;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -13,7 +14,7 @@
*/
class UserRole extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $fillable = [
'user_id', 'tenant_id', 'role_id', 'assigned_at',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Members;
use App\Models\Tenants\Tenant;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -13,7 +14,7 @@
*/
class UserTenant extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'user_id', 'tenant_id', 'is_active', 'is_default', 'joined_at', 'left_at',

View File

@@ -2,13 +2,14 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationSetting extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -9,7 +10,7 @@
class NotificationSettingGroup extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $fillable = [
'tenant_id',

View File

@@ -2,13 +2,14 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationSettingGroupState extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $fillable = [
'tenant_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Orders;
use App\Models\BadDebts\BadDebt;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -10,7 +11,7 @@
class Client extends Model
{
use BelongsToTenant, ModelTrait;
use Auditable, BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models\Orders;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class ClientGroup extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -8,6 +8,7 @@
use App\Models\Quote\Quote;
use App\Models\Tenants\Sale;
use App\Models\Tenants\Shipment;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -21,7 +22,7 @@
*/
class Order extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
// 상태 코드
public const STATUS_DRAFT = 'DRAFT';
@@ -149,6 +150,7 @@ class Order extends Model
*/
protected $appends = [
'delivery_method_label',
'shipping_cost_label',
];
/**
@@ -255,6 +257,16 @@ public function getDeliveryMethodLabelAttribute(): string
return CommonCode::getLabel('delivery_method', $this->delivery_method_code);
}
/**
* 운임비용 라벨 (common_codes 테이블에서 조회)
*/
public function getShippingCostLabelAttribute(): string
{
$shippingCostCode = $this->options['shipping_cost_code'] ?? null;
return $shippingCostCode ? CommonCode::getLabel('shipping_cost', $shippingCostCode) : '';
}
/**
* 수주확정 시 매출 생성 여부
*/

View File

@@ -5,6 +5,7 @@
use App\Models\Items\Item;
use App\Models\Quote\Quote;
use App\Models\Quote\QuoteItem;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -18,7 +19,7 @@
*/
class OrderItem extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'order_items';

View File

@@ -6,6 +6,7 @@
use App\Models\Members\User;
use App\Models\Members\UserRole;
use App\Models\Tenants\Tenant;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -15,7 +16,7 @@
*/
class Role extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -4,6 +4,7 @@
use App\Models\Members\User;
use App\Models\Tenants\Department;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@
class Popup extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,7 +13,7 @@
class Process extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
use HasFactory;
use ModelTrait;
use SoftDeletes;

View File

@@ -7,6 +7,7 @@
use App\Models\Process;
use App\Models\Tenants\Department;
use App\Models\Tenants\Shipment;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -22,7 +23,7 @@
*/
class WorkOrder extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'work_orders';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -15,7 +16,7 @@
*/
class WorkOrderAssignee extends Model
{
use BelongsToTenant, ModelTrait;
use Auditable, BelongsToTenant, ModelTrait;
protected $table = 'work_order_assignees';

View File

@@ -2,6 +2,7 @@
namespace App\Models\Production;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,7 +14,7 @@
*/
class WorkOrderBendingDetail extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'work_order_bending_details';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -14,7 +15,7 @@
*/
class WorkOrderIssue extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'work_order_issues';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Items\Item;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -12,7 +13,7 @@
*/
class WorkOrderItem extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'work_order_items';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Production;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -16,7 +17,7 @@
*/
class WorkResult extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'work_results';

View File

@@ -2,6 +2,7 @@
namespace App\Models\Products;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@
*/
class CommonCode extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'common_codes';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Products;
use App\Models\Orders\ClientGroup;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@
class Price extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'prices';

View File

@@ -3,13 +3,14 @@
namespace App\Models\Products;
use App\Models\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PriceRevision extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $table = 'price_revisions';

View File

@@ -5,6 +5,7 @@
use App\Models\Commons\Category;
use App\Models\Commons\File;
use App\Models\Commons\Tag;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@
class Product extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id', 'code', 'name', 'unit', 'category_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Products;
use App\Models\Materials\Material;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
@@ -10,7 +11,7 @@
class ProductComponent extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'product_components';

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -9,7 +10,7 @@
class PushDeviceToken extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
use SoftDeletes;
protected $fillable = [

View File

@@ -2,13 +2,14 @@
namespace App\Models;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PushNotificationSetting extends Model
{
use BelongsToTenant;
use Auditable, BelongsToTenant;
protected $fillable = [
'tenant_id',

View File

@@ -4,6 +4,7 @@
use App\Models\Items\Item;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
@@ -32,7 +33,7 @@
*/
class Inspection extends Model
{
use BelongsToTenant, SoftDeletes;
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'inspections';

View File

@@ -7,6 +7,7 @@
use App\Models\Orders\Client;
use App\Models\Orders\Order;
use App\Models\Tenants\SiteBriefing;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -16,7 +17,7 @@
class Quote extends Model
{
use BelongsToTenant, HasFactory, SoftDeletes;
use Auditable, BelongsToTenant, HasFactory, SoftDeletes;
protected $fillable = [
'tenant_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Quote;
use App\Models\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Builder;
@@ -29,7 +30,7 @@
*/
class QuoteFormula extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'quote_formulas';

View File

@@ -3,6 +3,7 @@
namespace App\Models\Quote;
use App\Models\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Builder;
@@ -26,7 +27,7 @@
*/
class QuoteFormulaCategory extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'quote_formula_categories';

View File

@@ -2,6 +2,7 @@
namespace App\Models\Quote;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -9,7 +10,7 @@
class QuoteItem extends Model
{
use BelongsToTenant, HasFactory;
use Auditable, BelongsToTenant, HasFactory;
protected $fillable = [
'quote_id',

View File

@@ -3,6 +3,7 @@
namespace App\Models\Quote;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,7 +11,7 @@
class QuoteRevision extends Model
{
use BelongsToTenant, HasFactory;
use Auditable, BelongsToTenant, HasFactory;
const UPDATED_AT = null;

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models\Stats;
use Illuminate\Database\Eloquent\Model;
abstract class BaseStatModel extends Model
{
protected $connection = 'sam_stat';
protected $guarded = ['id'];
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatFinanceDaily extends BaseStatModel
{
protected $table = 'stat_finance_daily';
protected $casts = [
'stat_date' => 'date',
'deposit_amount' => 'decimal:2',
'withdrawal_amount' => 'decimal:2',
'net_cashflow' => 'decimal:2',
'purchase_amount' => 'decimal:2',
'purchase_tax_amount' => 'decimal:2',
'receivable_balance' => 'decimal:2',
'payable_balance' => 'decimal:2',
'overdue_receivable' => 'decimal:2',
'bill_issued_amount' => 'decimal:2',
'bill_matured_amount' => 'decimal:2',
'card_transaction_amount' => 'decimal:2',
'bank_balance_total' => 'decimal:2',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatHrAttendanceDaily extends BaseStatModel
{
protected $table = 'stat_hr_attendance_daily';
protected $casts = [
'stat_date' => 'date',
'attendance_rate' => 'decimal:2',
'overtime_hours' => 'decimal:2',
'total_labor_cost' => 'decimal:2',
];
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatInventoryDaily extends BaseStatModel
{
protected $table = 'stat_inventory_daily';
protected $casts = [
'stat_date' => 'date',
'total_stock_qty' => 'decimal:2',
'total_stock_value' => 'decimal:2',
'receipt_qty' => 'decimal:2',
'receipt_amount' => 'decimal:2',
'issue_qty' => 'decimal:2',
'issue_amount' => 'decimal:2',
'inspection_pass_rate' => 'decimal:2',
'turnover_rate' => 'decimal:2',
];
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatProductionDaily extends BaseStatModel
{
protected $table = 'stat_production_daily';
protected $casts = [
'stat_date' => 'date',
'production_qty' => 'decimal:2',
'defect_qty' => 'decimal:2',
'defect_rate' => 'decimal:2',
'planned_hours' => 'decimal:2',
'actual_hours' => 'decimal:2',
'efficiency_rate' => 'decimal:2',
'delivery_rate' => 'decimal:2',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatQuotePipelineDaily extends BaseStatModel
{
protected $table = 'stat_quote_pipeline_daily';
protected $casts = [
'stat_date' => 'date',
'quote_amount' => 'decimal:2',
'quote_conversion_rate' => 'decimal:2',
'prospect_amount' => 'decimal:2',
'bidding_amount' => 'decimal:2',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatSalesDaily extends BaseStatModel
{
protected $table = 'stat_sales_daily';
protected $casts = [
'stat_date' => 'date',
'order_amount' => 'decimal:2',
'sales_amount' => 'decimal:2',
'sales_tax_amount' => 'decimal:2',
'shipment_amount' => 'decimal:2',
];
}

Some files were not shown because too many files have changed in this diff Show More