Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86429ba02e | ||
|
|
2c98648fad | ||
|
|
78ec646b42 | ||
|
|
dcf97b468d | ||
|
|
848711583a | ||
|
|
b8ede70121 | ||
|
|
14274be999 | ||
|
|
696de2e046 | ||
|
|
3c8d2bd9f4 | ||
|
|
40149afe1d | ||
|
|
3b919079df | ||
|
|
fb32932ca7 | ||
|
|
8dd12d991c | ||
|
|
0ca3242e83 | ||
| 5448f0e57d | |||
|
|
4e19e3ed67 | ||
|
|
7b93fd7f68 | ||
|
|
e371b7c9ab | ||
|
|
f231c60d3d | ||
|
|
6c208cfb2c | ||
|
|
c4b4be495a | ||
|
|
7eab19508a | ||
|
|
100a78b6e5 | ||
|
|
f662a389f7 | ||
|
|
8222ba667f | ||
| 047524c19f | |||
| bd500a87bd | |||
| c46b950fde | |||
|
|
7ff9206f93 | ||
| 28ae481a7d | |||
| b6d2b9942e | |||
| 4208ca3010 | |||
| 95371fd841 | |||
| 1df34b2fa9 | |||
| 3d12687a2d | |||
| 5e4cbc7742 | |||
| 4dd38ab14d | |||
| f9cd219f67 | |||
|
|
091719e81b | ||
|
|
b06438cc52 | ||
|
|
1e5cd70081 | ||
|
|
b21c9de6eb | ||
|
|
91567c54bd | ||
|
|
88eb507426 | ||
|
|
0bf56931fa | ||
|
|
c55a4a42e6 | ||
|
|
428b2e2a12 | ||
|
|
64877869e6 | ||
|
|
92efe2e83b | ||
|
|
78eb9363f4 | ||
|
|
c611f551a6 | ||
|
|
48889a7250 | ||
|
|
18f39433ae | ||
|
|
3785d87df4 | ||
|
|
d9075e5da5 | ||
| 1d71b588cb | |||
|
|
521229adcf | ||
|
|
5ce2d2fcbf | ||
|
|
5f5b5db59f | ||
|
|
814b965748 | ||
|
|
c55380f1d2 | ||
|
|
4870b7e6eb | ||
|
|
88ef6a8490 | ||
|
|
2fd122feba | ||
|
|
5a0deddb58 | ||
| d68fd56232 | |||
|
|
d8abc57271 | ||
|
|
d7dd6cdbc5 | ||
|
|
2bb3a2872a | ||
|
|
6df1da9e42 | ||
|
|
93e94901b7 | ||
|
|
7028e27517 | ||
|
|
1d2876d90c | ||
|
|
bfb821698a | ||
|
|
b80f4a0392 | ||
|
|
2ed90dc6db | ||
|
|
347d351d9d | ||
|
|
87a8930c00 | ||
|
|
bbcb0205fe | ||
| 9bf0cc8df2 | |||
|
|
255fad99e7 | ||
|
|
08b07c724a | ||
|
|
91cdfe9917 | ||
|
|
10c09b9fea | ||
|
|
04bb990045 | ||
| 7543054df3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -113,7 +113,6 @@ desktop.ini
|
||||
*.mp3
|
||||
*.wav
|
||||
*.ogg
|
||||
!public/sounds/*.wav
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 논리적 데이터베이스 관계 문서
|
||||
|
||||
> **자동 생성**: 2026-03-19 16:09:46
|
||||
> **자동 생성**: 2026-03-12 13:58:25
|
||||
> **소스**: Eloquent 모델 관계 분석
|
||||
|
||||
## 📊 모델별 관계 현황
|
||||
@@ -374,8 +374,6 @@ ### esign_signers
|
||||
### equipments
|
||||
**모델**: `App\Models\Equipment\Equipment`
|
||||
|
||||
- **manager()**: belongsTo → `users`
|
||||
- **subManager()**: belongsTo → `users`
|
||||
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
|
||||
- **inspections()**: hasMany → `equipment_inspections`
|
||||
- **repairs()**: hasMany → `equipment_repairs`
|
||||
@@ -386,7 +384,6 @@ ### equipment_inspections
|
||||
**모델**: `App\Models\Equipment\EquipmentInspection`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
- **inspector()**: belongsTo → `users`
|
||||
- **details()**: hasMany → `equipment_inspection_details`
|
||||
|
||||
### equipment_inspection_details
|
||||
@@ -410,7 +407,6 @@ ### equipment_repairs
|
||||
**모델**: `App\Models\Equipment\EquipmentRepair`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
- **repairer()**: belongsTo → `users`
|
||||
|
||||
### estimates
|
||||
**모델**: `App\Models\Estimate\Estimate`
|
||||
@@ -433,12 +429,6 @@ ### file_share_links
|
||||
- **file()**: belongsTo → `files`
|
||||
- **tenant()**: belongsTo → `tenants`
|
||||
|
||||
### corporate_vehicles
|
||||
**모델**: `App\Models\Tenants\CorporateVehicle`
|
||||
|
||||
- **logs()**: hasMany → `vehicle_logs`
|
||||
- **maintenances()**: hasMany → `vehicle_maintenances`
|
||||
|
||||
### folders
|
||||
**모델**: `App\Models\Folder`
|
||||
|
||||
@@ -723,11 +713,6 @@ ### process_steps
|
||||
|
||||
- **process()**: belongsTo → `processes`
|
||||
|
||||
### bending_item_mappings
|
||||
**모델**: `App\Models\Production\BendingItemMapping`
|
||||
|
||||
- **item()**: belongsTo → `items`
|
||||
|
||||
### work_orders
|
||||
**모델**: `App\Models\Production\WorkOrder`
|
||||
|
||||
@@ -913,7 +898,6 @@ ### quality_documents
|
||||
- **documentOrders()**: hasMany → `quality_document_orders`
|
||||
- **locations()**: hasMany → `quality_document_locations`
|
||||
- **performanceReport()**: hasOne → `performance_reports`
|
||||
- **file()**: hasOne → `files`
|
||||
|
||||
### quality_document_locations
|
||||
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
|
||||
@@ -1248,7 +1232,6 @@ ### shipments
|
||||
|
||||
- **order()**: belongsTo → `orders`
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **client()**: belongsTo → `clients`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **items()**: hasMany → `shipment_items`
|
||||
@@ -1259,7 +1242,6 @@ ### shipment_items
|
||||
|
||||
- **shipment()**: belongsTo → `shipments`
|
||||
- **stockLot()**: belongsTo → `stock_lots`
|
||||
- **orderItem()**: belongsTo → `order_items`
|
||||
|
||||
### shipment_vehicle_dispatchs
|
||||
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
|
||||
@@ -1371,16 +1353,6 @@ ### today_issues
|
||||
- **reader()**: belongsTo → `users`
|
||||
- **targetUser()**: belongsTo → `users`
|
||||
|
||||
### vehicle_logs
|
||||
**모델**: `App\Models\Tenants\VehicleLog`
|
||||
|
||||
- **vehicle()**: belongsTo → `corporate_vehicles`
|
||||
|
||||
### vehicle_maintenances
|
||||
**모델**: `App\Models\Tenants\VehicleMaintenance`
|
||||
|
||||
- **vehicle()**: belongsTo → `corporate_vehicles`
|
||||
|
||||
### withdrawals
|
||||
**모델**: `App\Models\Tenants\Withdrawal`
|
||||
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
// bending_data는 bending_items.bending_data JSON 컬럼에 저장
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 클린 재이관: bending_items/bending_data 전체 삭제 → chandj.bending 직접 이관
|
||||
* 기존 R2 파일도 삭제 처리
|
||||
*
|
||||
* 실행: php artisan bending:clean-reimport [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
class BendingCleanReimport extends Command
|
||||
{
|
||||
protected $signature = 'bending:clean-reimport
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 실행하지 않고 미리보기만}
|
||||
{--legacy-img-path=/tmp/bending_img : 레거시 이미지 경로}';
|
||||
|
||||
protected $description = 'bending_items 클린 재이관 (chandj.bending 직접)';
|
||||
|
||||
private int $tenantId;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$legacyImgPath = $this->option('legacy-img-path');
|
||||
|
||||
// 1. 현재 상태
|
||||
$biCount = BendingItem::where('tenant_id', $this->tenantId)->count();
|
||||
$bdCount = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('bending_data')->count();
|
||||
$fileCount = File::where('field_key', 'bending_diagram')
|
||||
->where(function ($q) {
|
||||
$q->where('document_type', 'bending_item')
|
||||
->orWhere('document_type', '1');
|
||||
})->count();
|
||||
|
||||
$this->info("현재: bending_items={$biCount}, bending_data={$bdCount}, files={$fileCount}");
|
||||
|
||||
// chandj 유효 건수
|
||||
$chandjRows = DB::connection('chandj')->table('bending')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('is_deleted')->orWhere('is_deleted', 0);
|
||||
})
|
||||
->orderBy('num')
|
||||
->get();
|
||||
|
||||
$this->info("chandj 이관 대상: {$chandjRows->count()}건");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->preview($chandjRows);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (! $this->confirm("기존 데이터 전체 삭제 후 chandj에서 재이관합니다. 계속?")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($chandjRows) {
|
||||
// 2. 기존 파일 DB 레코드만 삭제 (R2 파일은 유지)
|
||||
$this->deleteFileRecords();
|
||||
|
||||
// 3. 기존 데이터 삭제
|
||||
BendingItem::where('tenant_id', $this->tenantId)->forceDelete();
|
||||
$this->info("기존 데이터 삭제 완료");
|
||||
|
||||
// 4. chandj에서 직접 이관
|
||||
$success = 0;
|
||||
$bdTotal = 0;
|
||||
|
||||
foreach ($chandjRows as $row) {
|
||||
try {
|
||||
$bi = $this->importItem($row);
|
||||
$bd = $this->importBendingData($bi, $row);
|
||||
$bdTotal += $bd;
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("이관 완료: {$success}/{$chandjRows->count()}건, 전개도 {$bdTotal}행");
|
||||
});
|
||||
|
||||
// 5. 이미지 이관
|
||||
$this->importImages($legacyImgPath);
|
||||
|
||||
// 6. 최종 검증
|
||||
$this->verify();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function importItem(object $row): BendingItem
|
||||
{
|
||||
$code = $this->generateCode($row);
|
||||
|
||||
$bi = BendingItem::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'code' => $code,
|
||||
'legacy_code' => "CHANDJ-{$row->num}",
|
||||
'legacy_bending_id' => $row->num,
|
||||
'item_name' => $row->itemName ?: "부품#{$row->num}",
|
||||
'item_sep' => $this->clean($row->item_sep),
|
||||
'item_bending' => $this->clean($row->item_bending),
|
||||
'material' => $this->clean($row->material),
|
||||
'item_spec' => $this->clean($row->item_spec),
|
||||
'model_name' => $this->clean($row->model_name ?? null),
|
||||
'model_UA' => $this->clean($row->model_UA ?? null),
|
||||
'rail_width' => $this->toNum($row->rail_width ?? null),
|
||||
'exit_direction' => $this->clean($row->exit_direction ?? null),
|
||||
'box_width' => $this->toNum($row->box_width ?? null),
|
||||
'box_height' => $this->toNum($row->box_height ?? null),
|
||||
'front_bottom' => $this->toNum($row->front_bottom_width ?? null),
|
||||
'options' => $this->buildOptions($row),
|
||||
'is_active' => true,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ #{$row->num} → {$bi->id} ({$row->itemName}) [{$code}]");
|
||||
|
||||
return $bi;
|
||||
}
|
||||
|
||||
private function importBendingData(BendingItem $bi, object $row): int
|
||||
{
|
||||
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
|
||||
if (empty($inputs)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
|
||||
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
|
||||
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
|
||||
$angles = json_decode($row->AList ?? '[]', true) ?: [];
|
||||
|
||||
$data = [];
|
||||
$count = count($inputs);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$data[] = [
|
||||
'no' => $i + 1,
|
||||
'input' => (float) ($inputs[$i] ?? 0),
|
||||
'rate' => (string) ($rates[$i] ?? ''),
|
||||
'sum' => (float) ($sums[$i] ?? 0),
|
||||
'color' => (bool) ($colors[$i] ?? false),
|
||||
'aAngle' => (bool) ($angles[$i] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
$bi->update(['bending_data' => $data]);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function deleteFileRecords(): void
|
||||
{
|
||||
$count = File::where('field_key', 'bending_diagram')
|
||||
->where('document_type', 'bending_item')
|
||||
->forceDelete();
|
||||
|
||||
$this->info("파일 레코드 삭제: {$count}건 (R2 파일은 유지)");
|
||||
}
|
||||
|
||||
private function importImages(string $legacyImgPath): void
|
||||
{
|
||||
$chandjMap = DB::connection('chandj')->table('bending')
|
||||
->whereNotNull('imgdata')
|
||||
->where('imgdata', '!=', '')
|
||||
->pluck('imgdata', 'num');
|
||||
|
||||
$items = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->get();
|
||||
|
||||
$uploaded = 0;
|
||||
$notFound = 0;
|
||||
|
||||
foreach ($items as $bi) {
|
||||
$imgFile = $chandjMap[$bi->legacy_bending_id] ?? null;
|
||||
if (! $imgFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = "{$legacyImgPath}/{$imgFile}";
|
||||
if (! file_exists($filePath)) {
|
||||
$notFound++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$extension = pathinfo($imgFile, PATHINFO_EXTENSION);
|
||||
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$directory = sprintf('%d/bending/%s/%s', $this->tenantId, date('Y'), date('m'));
|
||||
$r2Path = $directory . '/' . $storedName;
|
||||
|
||||
Storage::disk('r2')->put($r2Path, file_get_contents($filePath));
|
||||
|
||||
File::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'display_name' => $imgFile,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $r2Path,
|
||||
'file_size' => filesize($filePath),
|
||||
'mime_type' => mime_content_type($filePath),
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'bending_diagram',
|
||||
'document_id' => $bi->id,
|
||||
'document_type' => 'bending_item',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$uploaded++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" ⚠️ 이미지 업로드 실패: #{$bi->legacy_bending_id} — {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("이미지 업로드: {$uploaded}건" . ($notFound > 0 ? " (파일없음 {$notFound}건)" : ''));
|
||||
}
|
||||
|
||||
private function generateCode(object $row): string
|
||||
{
|
||||
$bending = $row->item_bending ?? '';
|
||||
$sep = $row->item_sep ?? '';
|
||||
$material = $row->material ?? '';
|
||||
$name = $row->itemName ?? '';
|
||||
|
||||
$prodCode = match (true) {
|
||||
$bending === '케이스' => 'C',
|
||||
$bending === '하단마감재' && str_contains($sep, '철재') => 'T',
|
||||
$bending === '하단마감재' => 'B',
|
||||
$bending === '가이드레일' => 'R',
|
||||
$bending === '마구리' => 'X',
|
||||
$bending === 'L-BAR' => 'L',
|
||||
$bending === '연기차단재' => 'G',
|
||||
default => 'Z',
|
||||
};
|
||||
|
||||
$specCode = match (true) {
|
||||
str_contains($name, '전면') => 'F',
|
||||
str_contains($name, '린텔') => 'L',
|
||||
str_contains($name, '점검') => 'P',
|
||||
str_contains($name, '후면') => 'B',
|
||||
str_contains($name, '상부') || str_contains($name, '덮개') => 'X',
|
||||
str_contains($name, '본체') => 'M',
|
||||
str_contains($name, 'C형') || str_contains($name, '-C') => 'C',
|
||||
str_contains($name, 'D형') || str_contains($name, '-D') => 'D',
|
||||
str_contains($name, '마감') && str_contains($material, 'SUS') => 'S',
|
||||
str_contains($name, '하장바') && str_contains($material, 'SUS') => 'S',
|
||||
str_contains($name, '하장바') && str_contains($material, 'EGI') => 'E',
|
||||
str_contains($name, '보강') => 'H',
|
||||
str_contains($name, '절단') => 'T',
|
||||
str_contains($name, '비인정') => 'N',
|
||||
str_contains($name, '밑면') => 'P',
|
||||
str_contains($material, 'SUS') => 'S',
|
||||
str_contains($material, 'EGI') => 'E',
|
||||
default => 'Z',
|
||||
};
|
||||
|
||||
$date = $row->registration_date ?? now()->format('Y-m-d');
|
||||
$dateCode = date('ymd', strtotime($date));
|
||||
|
||||
$base = "{$prodCode}{$specCode}{$dateCode}";
|
||||
|
||||
// 중복 방지 일련번호
|
||||
$seq = 1;
|
||||
while (BendingItem::where('tenant_id', $this->tenantId)
|
||||
->where('code', $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT))
|
||||
->exists()) {
|
||||
$seq++;
|
||||
}
|
||||
|
||||
return $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function buildOptions(object $row): ?array
|
||||
{
|
||||
$opts = [];
|
||||
if (! empty($row->memo)) $opts['memo'] = $row->memo;
|
||||
if (! empty($row->author)) $opts['author'] = $row->author;
|
||||
if (! empty($row->search_keyword)) $opts['search_keyword'] = $row->search_keyword;
|
||||
if (! empty($row->registration_date)) $opts['registration_date'] = (string) $row->registration_date;
|
||||
|
||||
return empty($opts) ? null : $opts;
|
||||
}
|
||||
|
||||
private function verify(): void
|
||||
{
|
||||
$bi = BendingItem::where('tenant_id', $this->tenantId)->count();
|
||||
$bd = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('bending_data')->count();
|
||||
$mapped = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->distinct('legacy_bending_id')
|
||||
->count('legacy_bending_id');
|
||||
$files = File::where('field_key', 'bending_diagram')->count();
|
||||
|
||||
$this->newLine();
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
$this->info("📊 최종 결과");
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
$this->info(" bending_items: {$bi}건");
|
||||
$this->info(" bending_data: {$bd}행");
|
||||
$this->info(" chandj 매핑: {$mapped}건");
|
||||
$this->info(" 파일: {$files}건 (이미지 재업로드 필요)");
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
}
|
||||
|
||||
private function preview($rows): void
|
||||
{
|
||||
$grouped = $rows->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류'));
|
||||
$this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values());
|
||||
}
|
||||
|
||||
private function clean(?string $v): ?string
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
|
||||
}
|
||||
|
||||
private function toNum(mixed $v): ?float
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* BD-* 품목의 options 속성 보강
|
||||
*
|
||||
* 1단계: BD-PREFIX-LEN 패턴(112건)에서 prefix/length 자동 추출
|
||||
* 2단계: BD-한글 패턴(58건)에 item_sep/item_bending 등 분류 속성 추가
|
||||
*
|
||||
* 실행: php artisan bending:fill-options [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
#[AsCommand(name: 'bending:fill-options', description: 'BD-* 품목 options 속성 보강 (prefix/length + 분류 속성)')]
|
||||
class BendingFillOptions extends Command
|
||||
{
|
||||
protected $signature = 'bending:fill-options
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}';
|
||||
|
||||
// PREFIX → 분류 속성 매핑
|
||||
private const PREFIX_META = [
|
||||
// 가이드레일 (벽면)
|
||||
'RS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'],
|
||||
'RE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'],
|
||||
'RM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'],
|
||||
'RC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'],
|
||||
'RD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'],
|
||||
'RT' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'],
|
||||
// 가이드레일 (측면)
|
||||
'SS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'],
|
||||
'SE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'],
|
||||
'SM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'],
|
||||
'SC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'],
|
||||
'SD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'],
|
||||
'ST' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'],
|
||||
'SU' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재2', 'material' => 'SUS 1.2T'],
|
||||
// 하단마감재
|
||||
'BE' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'EGI 1.55T'],
|
||||
'BS' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'SUS 1.5T'],
|
||||
'TS' => ['item_sep' => '철재', 'item_bending' => '하단마감재', 'item_name' => '하단마감재(철재)', 'material' => 'SUS 1.2T'],
|
||||
'LA' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR', 'item_name' => 'L-Bar', 'material' => 'EGI 1.55T'],
|
||||
'HH' => ['item_sep' => '스크린', 'item_bending' => '보강평철', 'item_name' => '보강평철', 'material' => 'EGI 1.55T'],
|
||||
// 셔터박스
|
||||
'CF' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '전면부', 'material' => 'EGI 1.55T'],
|
||||
'CL' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '린텔부', 'material' => 'EGI 1.55T'],
|
||||
'CP' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '점검구', 'material' => 'EGI 1.55T'],
|
||||
'CB' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '후면코너부', 'material' => 'EGI 1.55T'],
|
||||
// 연기차단재
|
||||
'GI' => ['item_sep' => '스크린', 'item_bending' => '연기차단재', 'item_name' => '연기차단재', 'material' => '화이바원단'],
|
||||
// 공용
|
||||
'XX' => ['item_sep' => '스크린', 'item_bending' => '공용', 'item_name' => '하부BASE/상부덮개/마구리', 'material' => 'EGI 1.55T'],
|
||||
'YY' => ['item_sep' => '스크린', 'item_bending' => '별도마감', 'item_name' => '별도SUS마감', 'material' => 'SUS 1.2T'],
|
||||
];
|
||||
|
||||
// 한글 패턴 → 분류 매핑
|
||||
private const KOREAN_PATTERN_META = [
|
||||
'BD-가이드레일' => ['item_sep' => null, 'item_bending' => '가이드레일'],
|
||||
'BD-케이스' => ['item_sep' => null, 'item_bending' => '케이스'],
|
||||
'BD-마구리' => ['item_sep' => null, 'item_bending' => '마구리'],
|
||||
'BD-하단마감재' => ['item_sep' => null, 'item_bending' => '하단마감재'],
|
||||
'BD-L-BAR' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR'],
|
||||
'BD-보강평철' => ['item_sep' => '스크린', 'item_bending' => '보강평철'],
|
||||
];
|
||||
|
||||
private const LENGTH_MAP = [
|
||||
'02' => 200, '12' => 1219, '24' => 2438, '30' => 3000,
|
||||
'35' => 3500, '40' => 4000, '41' => 4150, '42' => 4200,
|
||||
'43' => 4300, '53' => 3000, '54' => 4000, '83' => 3000, '84' => 4000,
|
||||
];
|
||||
|
||||
private array $stats = [
|
||||
'total' => 0,
|
||||
'prefix_len_filled' => 0,
|
||||
'korean_filled' => 0,
|
||||
'already_complete' => 0,
|
||||
'unknown_pattern' => 0,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== BD-* 품목 options 보강 ===');
|
||||
$this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
|
||||
$this->newLine();
|
||||
|
||||
// BD-* 전체 품목 조회
|
||||
$items = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', 'like', 'BD-%')
|
||||
->whereNull('deleted_at')
|
||||
->select('id', 'code', 'name', 'options')
|
||||
->orderBy('code')
|
||||
->get();
|
||||
|
||||
$this->stats['total'] = $items->count();
|
||||
$this->info("BD-* 품목: {$items->count()}건");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$options = json_decode($item->options ?? '{}', true) ?: [];
|
||||
$code = $item->code;
|
||||
$newOptions = $this->resolveOptions($code, $item->name, $options);
|
||||
|
||||
if ($newOptions === null) {
|
||||
$this->stats['unknown_pattern']++;
|
||||
$this->warn(" ❓ 미인식 패턴: {$code}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 변경 필요 여부 확인
|
||||
$merged = array_merge($options, $newOptions);
|
||||
if ($merged == $options) {
|
||||
$this->stats['already_complete']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$encoded = json_encode($merged, JSON_UNESCAPED_UNICODE);
|
||||
if ($encoded === false) {
|
||||
$this->error(" ❌ JSON 인코딩 실패: {$code} — ".json_last_error_msg());
|
||||
|
||||
continue;
|
||||
}
|
||||
DB::table('items')
|
||||
->where('id', $item->id)
|
||||
->update([
|
||||
'options' => $encoded,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$pattern = $this->detectPattern($code);
|
||||
if ($pattern === 'prefix_len') {
|
||||
$this->stats['prefix_len_filled']++;
|
||||
} else {
|
||||
$this->stats['korean_filled']++;
|
||||
}
|
||||
|
||||
$this->line(" ✅ {$code}: +".implode(', ', array_keys($newOptions)));
|
||||
}
|
||||
|
||||
$this->showStats($dryRun);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드에서 options 속성 추출
|
||||
*/
|
||||
private function resolveOptions(string $code, string $name, array $existing): ?array
|
||||
{
|
||||
$new = [];
|
||||
|
||||
// item_category 보장
|
||||
if (empty($existing['item_category'])) {
|
||||
// item_category는 items 테이블 컬럼이므로 여기서는 skip
|
||||
}
|
||||
|
||||
// 패턴 A: BD-PREFIX-LEN (예: BD-RS-30)
|
||||
if (preg_match('/^BD-([A-Z]{2})-(\d{2})$/', $code, $m)) {
|
||||
$prefix = $m[1];
|
||||
$lengthCode = $m[2];
|
||||
|
||||
// prefix/length 기본값
|
||||
if (empty($existing['prefix'])) {
|
||||
$new['prefix'] = $prefix;
|
||||
}
|
||||
if (empty($existing['length_code'])) {
|
||||
$new['length_code'] = $lengthCode;
|
||||
}
|
||||
if (empty($existing['length_mm']) && isset(self::LENGTH_MAP[$lengthCode])) {
|
||||
$new['length_mm'] = self::LENGTH_MAP[$lengthCode];
|
||||
}
|
||||
|
||||
// PREFIX 기반 분류 속성
|
||||
$meta = self::PREFIX_META[$prefix] ?? null;
|
||||
if ($meta) {
|
||||
foreach ($meta as $key => $value) {
|
||||
if (empty($existing[$key])) {
|
||||
$new[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
// 특수 코드 (패턴 미준수)
|
||||
$specialCodes = [
|
||||
'BD-가이드레일용 연기차단재' => ['item_bending' => '연기차단재'],
|
||||
'BD-케이스용 연기차단재' => ['item_bending' => '연기차단재'],
|
||||
];
|
||||
if (isset($specialCodes[$code])) {
|
||||
foreach ($specialCodes[$code] as $key => $value) {
|
||||
if (empty($existing[$key])) {
|
||||
$new[$key] = $value;
|
||||
}
|
||||
}
|
||||
if (empty($existing['item_name'])) {
|
||||
$new['item_name'] = $name;
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
// 패턴 B~G: 한글 패턴
|
||||
foreach (self::KOREAN_PATTERN_META as $patternPrefix => $meta) {
|
||||
// 정확한 접두사+구분자 매칭 (BD-케이스-xxx는 O, BD-케이스용xxx는 X)
|
||||
if ($code === $patternPrefix || str_starts_with($code, $patternPrefix.'-')) {
|
||||
// 분류 속성
|
||||
foreach ($meta as $key => $value) {
|
||||
if ($value !== null && empty($existing[$key])) {
|
||||
$new[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 한글 패턴별 추가 파싱
|
||||
$this->parseKoreanPattern($code, $patternPrefix, $existing, $new);
|
||||
|
||||
// item_name 폴백: options에 없으면 items.name 사용
|
||||
if (empty($existing['item_name']) && empty($new['item_name'])) {
|
||||
$new['item_name'] = $name;
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
}
|
||||
|
||||
// 패턴 C: BD-LEGACY-NUM → chandj.bending에서 직접 조회
|
||||
if (preg_match('/^BD-LEGACY-(\d+)$/', $code, $m)) {
|
||||
$chandjNum = (int) $m[1];
|
||||
$chandjRow = DB::connection('chandj')->table('bending')
|
||||
->where('num', $chandjNum)
|
||||
->first();
|
||||
|
||||
if ($chandjRow) {
|
||||
$fields = [
|
||||
'item_name' => $chandjRow->itemName ?? $chandjRow->item_name ?? null,
|
||||
'item_sep' => $chandjRow->item_sep ?? null,
|
||||
'item_bending' => $chandjRow->item_bending ?? null,
|
||||
'material' => $chandjRow->material ?? null,
|
||||
'item_spec' => $chandjRow->item_spec ?? null,
|
||||
'model_name' => $chandjRow->model_name ?? null,
|
||||
'model_UA' => $chandjRow->model_UA ?? null,
|
||||
'rail_width' => $chandjRow->rail_width ?? null,
|
||||
'search_keyword' => $chandjRow->search_keyword ?? null,
|
||||
'legacy_bending_num' => $chandjNum,
|
||||
];
|
||||
foreach ($fields as $key => $value) {
|
||||
if (! empty($value) && empty($existing[$key])) {
|
||||
$new[$key] = $value;
|
||||
}
|
||||
}
|
||||
// item_name 폴백: chandj에도 없으면 items.name 사용
|
||||
if (empty($new['item_name']) && empty($existing['item_name'])) {
|
||||
$new['item_name'] = $name;
|
||||
}
|
||||
} else {
|
||||
// chandj에 없으면 items.name으로 폴백
|
||||
if (empty($existing['item_name'])) {
|
||||
$new['item_name'] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 패턴에서 모델/재질/규격 추출
|
||||
*/
|
||||
private function parseKoreanPattern(string $code, string $patternPrefix, array $existing, array &$new): void
|
||||
{
|
||||
$suffix = substr($code, strlen($patternPrefix) + 1); // "-" 제거
|
||||
$parts = explode('-', $suffix);
|
||||
|
||||
switch ($patternPrefix) {
|
||||
case 'BD-가이드레일':
|
||||
// BD-가이드레일-KSS01-SUS-120*70
|
||||
if (count($parts) >= 3) {
|
||||
if (empty($existing['model_name'])) {
|
||||
$new['model_name'] = $parts[0];
|
||||
}
|
||||
if (empty($existing['material'])) {
|
||||
$material = $parts[1];
|
||||
$new['material'] = str_contains($material, 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T';
|
||||
}
|
||||
if (empty($existing['item_spec'])) {
|
||||
$new['item_spec'] = $parts[2];
|
||||
}
|
||||
// item_sep 추론 (KTE → 철재)
|
||||
if (empty($existing['item_sep'])) {
|
||||
$new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BD-하단마감재':
|
||||
// BD-하단마감재-KSS01-SUS-60*40
|
||||
if (count($parts) >= 3) {
|
||||
if (empty($existing['model_name'])) {
|
||||
$new['model_name'] = $parts[0];
|
||||
}
|
||||
if (empty($existing['material'])) {
|
||||
$material = $parts[1];
|
||||
$new['material'] = str_contains($material, 'SUS') ? 'SUS 1.5T' : 'EGI 1.55T';
|
||||
}
|
||||
if (empty($existing['item_spec'])) {
|
||||
$new['item_spec'] = $parts[2];
|
||||
}
|
||||
if (empty($existing['item_sep'])) {
|
||||
$new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BD-케이스':
|
||||
// BD-케이스-650*550
|
||||
if (count($parts) >= 1 && ! empty($parts[0])) {
|
||||
if (empty($existing['item_spec'])) {
|
||||
$new['item_spec'] = $parts[0];
|
||||
}
|
||||
// 케이스는 대부분 철재
|
||||
if (empty($existing['item_sep'])) {
|
||||
$new['item_sep'] = '철재';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BD-마구리':
|
||||
// BD-마구리-655*505
|
||||
if (count($parts) >= 1 && ! empty($parts[0])) {
|
||||
if (empty($existing['item_spec'])) {
|
||||
$new['item_spec'] = $parts[0];
|
||||
}
|
||||
if (empty($existing['item_sep'])) {
|
||||
$new['item_sep'] = '철재';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BD-L-BAR':
|
||||
// BD-L-BAR-KSS01-17*60
|
||||
if (count($parts) >= 2) {
|
||||
if (empty($existing['model_name'])) {
|
||||
$new['model_name'] = $parts[0];
|
||||
}
|
||||
if (empty($existing['item_spec'])) {
|
||||
$new['item_spec'] = $parts[1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'BD-보강평철':
|
||||
// BD-보강평철-50
|
||||
if (count($parts) >= 1 && ! empty($parts[0])) {
|
||||
if (empty($existing['item_spec'])) {
|
||||
$new['item_spec'] = $parts[0];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function detectPattern(string $code): string
|
||||
{
|
||||
return preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code) ? 'prefix_len' : 'korean';
|
||||
}
|
||||
|
||||
private function showStats(bool $dryRun): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : ''));
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info(" 전체 BD-* 품목: {$this->stats['total']}건");
|
||||
$this->info(" PREFIX-LEN 업데이트: {$this->stats['prefix_len_filled']}건");
|
||||
$this->info(" 한글 패턴 업데이트: {$this->stats['korean_filled']}건");
|
||||
$this->info(" 이미 완료: {$this->stats['already_complete']}건");
|
||||
if ($this->stats['unknown_pattern'] > 0) {
|
||||
$this->warn(" 미인식 패턴: {$this->stats['unknown_pattern']}건");
|
||||
}
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 모델(GUIDERAIL/SHUTTERBOX/BOTTOMBAR) components에 sam_item_id 일괄 채우기
|
||||
* legacy_bending_num → SAM BENDING item ID 매핑
|
||||
*/
|
||||
#[AsCommand(name: 'bending:fill-sam-item-ids', description: '모델 components의 sam_item_id 일괄 매핑')]
|
||||
class BendingFillSamItemIds extends Command
|
||||
{
|
||||
protected $signature = 'bending:fill-sam-item-ids
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== sam_item_id 일괄 매핑 ===');
|
||||
$this->info('Mode: ' . ($dryRun ? 'DRY-RUN' : 'LIVE'));
|
||||
|
||||
// 1. legacy_bending_num → SAM item ID 매핑 테이블 구축
|
||||
$bendingItems = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('item_category', 'BENDING')
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$legacyMap = [];
|
||||
foreach ($bendingItems as $item) {
|
||||
$legacyNum = $item->getOption('legacy_bending_num');
|
||||
if ($legacyNum !== null) {
|
||||
$legacyMap[(string) $legacyNum] = $item->id;
|
||||
}
|
||||
}
|
||||
$this->info("BENDING items: {$bendingItems->count()}건, legacy_bending_num 매핑: " . count($legacyMap) . '건');
|
||||
|
||||
// 2. 모델 items의 components 순회
|
||||
$models = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('item_category', ['GUIDERAIL_MODEL', 'SHUTTERBOX_MODEL', 'BOTTOMBAR_MODEL'])
|
||||
->whereNull('deleted_at')
|
||||
->get();
|
||||
|
||||
$this->info("모델: {$models->count()}건");
|
||||
|
||||
$updated = 0;
|
||||
$mapped = 0;
|
||||
$notFound = 0;
|
||||
|
||||
foreach ($models as $model) {
|
||||
$components = $model->getOption('components', []);
|
||||
if (empty($components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
foreach ($components as &$comp) {
|
||||
// 이미 sam_item_id가 있으면 스킵
|
||||
if (! empty($comp['sam_item_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacyNum = $comp['legacy_bending_num'] ?? null;
|
||||
if ($legacyNum === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$samId = $legacyMap[(string) $legacyNum] ?? null;
|
||||
if ($samId) {
|
||||
$comp['sam_item_id'] = $samId;
|
||||
$changed = true;
|
||||
$mapped++;
|
||||
} else {
|
||||
$notFound++;
|
||||
$this->warn(" [{$model->id}] legacy_bending_num={$legacyNum} → SAM ID 없음 ({$comp['itemName']})");
|
||||
}
|
||||
}
|
||||
unset($comp);
|
||||
|
||||
if ($changed && ! $dryRun) {
|
||||
$model->setOption('components', $components);
|
||||
$model->save();
|
||||
$updated++;
|
||||
} elseif ($changed) {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('');
|
||||
$this->info("결과: 모델 {$updated}건 업데이트, 컴포넌트 {$mapped}건 매핑, {$notFound}건 미매핑");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 레거시 이미지 → R2 업로드 + bending_items 연결
|
||||
*
|
||||
* 실행: php artisan bending:import-images [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
class BendingImportImages extends Command
|
||||
{
|
||||
protected $signature = 'bending:import-images
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 미리보기}
|
||||
{--legacy-path=/home/kkk/sam/5130/bending/img : 레거시 이미지 경로}';
|
||||
|
||||
protected $description = '레거시 절곡품 이미지 → R2 업로드 + bending_items 연결';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$legacyPath = $this->option('legacy-path');
|
||||
|
||||
$items = BendingItem::where('tenant_id', $tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->get();
|
||||
|
||||
$chandjMap = DB::connection('chandj')->table('bending')
|
||||
->whereNotNull('imgdata')
|
||||
->where('imgdata', '!=', '')
|
||||
->pluck('imgdata', 'num');
|
||||
|
||||
$this->info("bending_items: {$items->count()}건 / chandj imgdata: {$chandjMap->count()}건");
|
||||
|
||||
$uploaded = 0;
|
||||
$skipped = 0;
|
||||
$notFound = 0;
|
||||
$errors = 0;
|
||||
|
||||
foreach ($items as $bi) {
|
||||
$imgFile = $chandjMap[$bi->legacy_bending_id] ?? null;
|
||||
if (! $imgFile) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$filePath = "{$legacyPath}/{$imgFile}";
|
||||
if (! file_exists($filePath)) {
|
||||
$this->warn(" ⚠️ 파일 없음: {$imgFile} (#{$bi->legacy_bending_id})");
|
||||
$notFound++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = File::where('document_type', 'bending_item')
|
||||
->where('document_id', $bi->id)
|
||||
->where('field_key', 'bending_diagram')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY] #{$bi->legacy_bending_id} → {$bi->id} ({$bi->item_name}) ← {$imgFile}");
|
||||
$uploaded++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$extension = pathinfo($imgFile, PATHINFO_EXTENSION);
|
||||
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$directory = sprintf('%d/bending/%s/%s', $tenantId, date('Y'), date('m'));
|
||||
$r2Path = $directory . '/' . $storedName;
|
||||
|
||||
Storage::disk('r2')->put($r2Path, file_get_contents($filePath));
|
||||
|
||||
File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $imgFile,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $r2Path,
|
||||
'file_size' => filesize($filePath),
|
||||
'mime_type' => mime_content_type($filePath),
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'bending_diagram',
|
||||
'document_id' => $bi->id,
|
||||
'document_type' => 'bending_item',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ #{$bi->legacy_bending_id} → {$bi->id} ({$bi->item_name}) ← {$imgFile}");
|
||||
$uploaded++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(" ❌ #{$bi->legacy_bending_id}: {$e->getMessage()}");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("완료: 업로드 {$uploaded}, 스킵 {$skipped}, 파일없음 {$notFound}, 오류 {$errors}");
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 3단계: chandj.bending → SAM items.options 전개도(bendingData) + 속성 임포트
|
||||
*
|
||||
* chandj bending 265건 → SAM items (item_category=BENDING) 170건
|
||||
*
|
||||
* 매핑 방식:
|
||||
* A) 한글 패턴 (58건): code 파싱으로 item_spec/material 매칭
|
||||
* B) PREFIX-LEN (112건): PREFIX → 부품 유형 → chandj item_bending+itemName+material 매칭
|
||||
*
|
||||
* 실행: php artisan bending:import-legacy [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
#[AsCommand(name: 'bending:import-legacy', description: 'chandj 레거시 전개도(bendingData) + 속성 → SAM items.options 임포트')]
|
||||
class BendingImportLegacy extends Command
|
||||
{
|
||||
protected $signature = 'bending:import-legacy
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}
|
||||
{--force : 기존 bendingData 덮어쓰기}';
|
||||
|
||||
// PREFIX → chandj 매칭 조건 (item_bending + itemName 패턴 + material)
|
||||
private const PREFIX_TO_CHANDJ = [
|
||||
// 가이드레일 (벽면) — item_spec=120*70 (KSS01/02 기준)
|
||||
'RS' => ['item_bending' => '가이드레일', 'itemName_like' => '%마감%', 'material' => 'SUS 1.2T', 'item_spec' => '120*70'],
|
||||
'RE' => ['item_bending' => '가이드레일', 'itemName_like' => '%마감%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
|
||||
'RM' => ['item_bending' => '가이드레일', 'itemName_like' => '%본체%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
|
||||
'RC' => ['item_bending' => '가이드레일', 'itemName_like' => '%C형%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
|
||||
'RD' => ['item_bending' => '가이드레일', 'itemName_like' => '%벽면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'],
|
||||
'RT' => ['item_bending' => '가이드레일', 'itemName_like' => '%본체%', 'material' => 'EGI 1.55T', 'item_spec' => '130*75'],
|
||||
// 가이드레일 (측면) — 벽면과 같은 전개도
|
||||
'SS' => ['same_as' => 'RS'],
|
||||
'SU' => ['same_as' => 'RS'],
|
||||
'SM' => ['same_as' => 'RM'],
|
||||
'SC' => ['same_as' => 'RC'],
|
||||
'SD' => ['item_bending' => '가이드레일', 'itemName_like' => '%측면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*120'],
|
||||
'ST' => ['same_as' => 'RT'],
|
||||
'SE' => ['same_as' => 'RE'],
|
||||
// 하단마감재
|
||||
'BS' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%SUS%', 'item_sep' => '스크린'],
|
||||
'BE' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%EGI%', 'item_sep' => '스크린'],
|
||||
'TS' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%SUS%', 'item_sep' => '철재'],
|
||||
'LA' => ['item_bending' => 'L-BAR', 'itemName_like' => '%L-BAR%', 'material' => 'EGI 1.55T'],
|
||||
'HH' => ['item_bending' => '하단마감재', 'itemName_like' => '%보강평철%', 'material_like' => '%EGI%'],
|
||||
// 케이스 — spec 없이 itemName으로 구분
|
||||
'CF' => ['item_bending' => '케이스', 'itemName_like' => '%전면%', 'item_sep' => '스크린'],
|
||||
'CL' => ['item_bending' => '케이스', 'itemName_like' => '%린텔%', 'item_sep' => '스크린'],
|
||||
'CP' => ['item_bending' => '케이스', 'itemName_like' => '%점검%', 'item_sep' => '스크린'],
|
||||
'CB' => ['item_bending' => '케이스', 'itemName_like' => '%후면%', 'item_sep' => '스크린'],
|
||||
// 연기차단재
|
||||
'GI' => ['item_bending' => '연기차단재', 'itemName_like' => '%연기%'],
|
||||
// 공용
|
||||
'XX' => null, // 여러 부품이 섞여 있어 자동 매핑 불가
|
||||
'YY' => null, // 별도 마감 — 자동 매핑 불가
|
||||
];
|
||||
|
||||
private array $stats = [
|
||||
'total_sam' => 0,
|
||||
'matched' => 0,
|
||||
'updated' => 0,
|
||||
'already_has' => 0,
|
||||
'no_match' => 0,
|
||||
'no_bending_data' => 0,
|
||||
];
|
||||
|
||||
private array $unmatchedItems = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$force = $this->option('force');
|
||||
|
||||
$this->info('=== 3단계: chandj 전개도 → SAM options 임포트 ===');
|
||||
$this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE').($force ? ' (FORCE)' : ''));
|
||||
$this->newLine();
|
||||
|
||||
// 1. chandj bending 전체 로드
|
||||
$chandjRows = DB::connection('chandj')->table('bending')
|
||||
->whereNull('is_deleted')
|
||||
->get();
|
||||
$this->info("chandj bending 활성: {$chandjRows->count()}건");
|
||||
|
||||
// 2. SAM BENDING items 전체 로드
|
||||
$samItems = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('item_category', 'BENDING')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('code')
|
||||
->get(['id', 'code', 'name', 'options']);
|
||||
|
||||
$this->stats['total_sam'] = $samItems->count();
|
||||
$this->info("SAM BENDING items: {$samItems->count()}건");
|
||||
$this->newLine();
|
||||
|
||||
// 3. 매칭 + 임포트
|
||||
foreach ($samItems as $item) {
|
||||
$options = json_decode($item->options ?? '{}', true) ?: [];
|
||||
|
||||
// 이미 bendingData가 있으면 skip (--force 아닌 경우)
|
||||
if (! empty($options['bendingData']) && ! $force) {
|
||||
$this->stats['already_has']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// chandj 매칭
|
||||
$chandjRow = $this->findChandjMatch($item->code, $options, $chandjRows);
|
||||
|
||||
if (! $chandjRow) {
|
||||
$this->stats['no_match']++;
|
||||
$this->unmatchedItems[] = $item->code;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// bendingData 변환
|
||||
$bendingData = $this->convertBendingData($chandjRow);
|
||||
|
||||
if (empty($bendingData)) {
|
||||
$this->stats['no_bending_data']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// options 업데이트
|
||||
$updates = ['bendingData' => $bendingData];
|
||||
|
||||
// 추가 속성 (비어있으면 채우기)
|
||||
$optionalFields = [
|
||||
'memo' => $chandjRow->memo,
|
||||
'author' => $chandjRow->author,
|
||||
'search_keyword' => $chandjRow->search_keyword,
|
||||
'registration_date' => $chandjRow->registration_date,
|
||||
'model_UA' => $chandjRow->model_UA,
|
||||
'exit_direction' => $chandjRow->exit_direction,
|
||||
'front_bottom_width' => $chandjRow->front_bottom_width,
|
||||
'rail_width' => $chandjRow->rail_width,
|
||||
'box_width' => $chandjRow->box_width,
|
||||
'box_height' => $chandjRow->box_height,
|
||||
'item_spec' => $chandjRow->item_spec,
|
||||
'legacy_bending_num' => $chandjRow->num,
|
||||
];
|
||||
|
||||
foreach ($optionalFields as $key => $value) {
|
||||
if (! empty($value) && empty($options[$key])) {
|
||||
$updates[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = array_merge($options, $updates);
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::table('items')->where('id', $item->id)->update([
|
||||
'options' => json_encode($merged, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->stats['matched']++;
|
||||
$this->stats['updated']++;
|
||||
$colCount = count($bendingData);
|
||||
$this->line(" ✅ {$item->code} ← chandj#{$chandjRow->num} (전개도 {$colCount}열, +".implode(',', array_keys($updates)).')');
|
||||
}
|
||||
|
||||
$this->showStats($dryRun);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* SAM item code → chandj bending 매칭
|
||||
*/
|
||||
private function findChandjMatch(string $code, array $options, $chandjRows): ?object
|
||||
{
|
||||
// A) 한글 패턴 — code에서 속성 추출하여 매칭
|
||||
if (! preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code)) {
|
||||
return $this->matchKoreanPattern($code, $chandjRows);
|
||||
}
|
||||
|
||||
// B) PREFIX-LEN — PREFIX로 chandj 조건 결정
|
||||
preg_match('/^BD-([A-Z]{2})-\d{2}$/', $code, $m);
|
||||
$prefix = $m[1];
|
||||
|
||||
$mapping = self::PREFIX_TO_CHANDJ[$prefix] ?? null;
|
||||
if (! $mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// same_as 참조
|
||||
if (isset($mapping['same_as'])) {
|
||||
$mapping = self::PREFIX_TO_CHANDJ[$mapping['same_as']] ?? null;
|
||||
if (! $mapping) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->queryChangj($chandjRows, $mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글 패턴 매칭
|
||||
*/
|
||||
private function matchKoreanPattern(string $code, $chandjRows): ?object
|
||||
{
|
||||
// BD-가이드레일-KSS01-SUS-120*70
|
||||
if (preg_match('/^BD-가이드레일-(\w+)-(\w+)-(.+)$/', $code, $m)) {
|
||||
$material = str_contains($m[2], 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T';
|
||||
|
||||
return $this->queryChangj($chandjRows, [
|
||||
'item_bending' => '가이드레일',
|
||||
'material' => $material,
|
||||
'item_spec' => $m[3],
|
||||
]);
|
||||
}
|
||||
|
||||
// BD-하단마감재-KSS01-SUS-60*40
|
||||
if (preg_match('/^BD-하단마감재-(\w+)-(\w+)-(.+)$/', $code, $m)) {
|
||||
$material = str_contains($m[2], 'SUS') ? 'SUS' : 'EGI';
|
||||
|
||||
return $this->queryChangj($chandjRows, [
|
||||
'item_bending' => '하단마감재',
|
||||
'material_like' => "%{$material}%",
|
||||
'item_spec_like' => "%{$m[3]}%",
|
||||
]);
|
||||
}
|
||||
|
||||
// BD-케이스-650*550 → chandj에서 itemName에 "650*550" 포함된 전면판 매칭
|
||||
if (preg_match('/^BD-케이스-(\d+)\*(\d+)$/', $code, $m)) {
|
||||
$spec = $m[1].'*'.$m[2];
|
||||
|
||||
return $chandjRows->first(function ($r) use ($spec) {
|
||||
return (str_contains($r->itemName, $spec) || $r->item_spec === $spec)
|
||||
&& str_contains($r->itemName, '전면');
|
||||
});
|
||||
}
|
||||
|
||||
// BD-마구리-655*505
|
||||
if (preg_match('/^BD-마구리-(.+)$/', $code, $m)) {
|
||||
return $chandjRows->first(fn ($r) => $r->item_bending === '마구리' && $r->item_spec === $m[1]);
|
||||
}
|
||||
|
||||
// BD-L-BAR-KSS01-17*60
|
||||
if (preg_match('/^BD-L-BAR-\w+-(.+)$/', $code, $m)) {
|
||||
return $chandjRows->first(fn ($r) => $r->item_bending === 'L-BAR' && $r->item_spec === $m[1]);
|
||||
}
|
||||
|
||||
// BD-보강평철-50
|
||||
if (preg_match('/^BD-보강평철-(.+)$/', $code, $m)) {
|
||||
return $chandjRows->first(fn ($r) => str_contains($r->itemName, '보강평철') && $r->item_spec === $m[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj 컬렉션에서 조건으로 검색
|
||||
*/
|
||||
private function queryChangj($rows, array $cond): ?object
|
||||
{
|
||||
return $rows->first(function ($r) use ($cond) {
|
||||
if (isset($cond['item_sep']) && $r->item_sep !== $cond['item_sep']) {
|
||||
return false;
|
||||
}
|
||||
if (isset($cond['item_bending']) && $r->item_bending !== $cond['item_bending']) {
|
||||
return false;
|
||||
}
|
||||
if (isset($cond['material']) && $r->material !== $cond['material']) {
|
||||
return false;
|
||||
}
|
||||
if (isset($cond['material_like']) && ! str_contains($r->material, str_replace('%', '', $cond['material_like']))) {
|
||||
return false;
|
||||
}
|
||||
if (isset($cond['itemName_like']) && ! str_contains($r->itemName, str_replace('%', '', $cond['itemName_like']))) {
|
||||
return false;
|
||||
}
|
||||
if (isset($cond['item_spec']) && $r->item_spec !== $cond['item_spec']) {
|
||||
return false;
|
||||
}
|
||||
if (isset($cond['item_spec_like']) && ! str_contains($r->item_spec ?? '', str_replace('%', '', $cond['item_spec_like']))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* chandj bending row → bendingData JSON 배열 변환
|
||||
*
|
||||
* 레거시: inputList=["10","11","110"], bendingrateList=["","-1",""], sumList=["10","21","131"], colorList=[false,false,true], AList=[false,false,false]
|
||||
* SAM: [{"no":1,"input":10,"rate":"","sum":10,"color":false,"aAngle":false}, ...]
|
||||
*/
|
||||
private function convertBendingData(object $row): array
|
||||
{
|
||||
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
|
||||
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
|
||||
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
|
||||
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
|
||||
$angles = json_decode($row->AList ?? '[]', true) ?: [];
|
||||
|
||||
if (empty($inputs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = [];
|
||||
$count = count($inputs);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$data[] = [
|
||||
'no' => $i + 1,
|
||||
'input' => (float) ($inputs[$i] ?? 0),
|
||||
'rate' => (string) ($rates[$i] ?? ''),
|
||||
'sum' => (float) ($sums[$i] ?? 0),
|
||||
'color' => (bool) ($colors[$i] ?? false),
|
||||
'aAngle' => (bool) ($angles[$i] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function showStats(bool $dryRun): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : ''));
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
$this->info(" SAM BENDING 전체: {$this->stats['total_sam']}건");
|
||||
$this->info(" 매칭 성공 (업데이트): {$this->stats['updated']}건");
|
||||
$this->info(" 이미 bendingData 있음 (skip): {$this->stats['already_has']}건");
|
||||
$this->info(" 매칭 실패: {$this->stats['no_match']}건");
|
||||
if ($this->stats['no_bending_data'] > 0) {
|
||||
$this->warn(" 전개도 데이터 없음: {$this->stats['no_bending_data']}건");
|
||||
}
|
||||
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
if (! empty($this->unmatchedItems)) {
|
||||
$this->newLine();
|
||||
$this->warn('⚠️ 매칭 실패 항목:');
|
||||
foreach ($this->unmatchedItems as $code) {
|
||||
$this->line(" - {$code}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->newLine();
|
||||
$this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
use App\Models\BendingDataRow;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* chandj.bending 누락분 → bending_items + bending_data 직접 이관
|
||||
*
|
||||
* 실행: php artisan bending:import-missing [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
class BendingImportMissing extends Command
|
||||
{
|
||||
protected $signature = 'bending:import-missing
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 실행하지 않고 미리보기만}';
|
||||
|
||||
protected $description = 'chandj.bending 누락분 → bending_items 직접 이관';
|
||||
|
||||
private int $tenantId;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$existingNums = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->pluck('legacy_bending_id')
|
||||
->toArray();
|
||||
|
||||
$missing = DB::connection('chandj')->table('bending')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('is_deleted')->orWhere('is_deleted', 0);
|
||||
})
|
||||
->whereNotIn('num', $existingNums)
|
||||
->orderBy('num')
|
||||
->get();
|
||||
|
||||
$this->info("누락분: {$missing->count()}건 (이미 매핑: " . count($existingNums) . "건)");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->preview($missing);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
$bdCount = 0;
|
||||
$errors = 0;
|
||||
|
||||
DB::transaction(function () use ($missing, &$success, &$bdCount, &$errors) {
|
||||
foreach ($missing as $row) {
|
||||
try {
|
||||
$bi = $this->importItem($row);
|
||||
$bd = $this->importBendingData($bi, $row);
|
||||
$bdCount += $bd;
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("완료: {$success}건 이관, {$bdCount}건 전개도, {$errors}건 오류");
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function importItem(object $row): BendingItem
|
||||
{
|
||||
$code = $this->generateCode($row);
|
||||
|
||||
$bi = BendingItem::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'code' => $code,
|
||||
'legacy_code' => "CHANDJ-{$row->num}",
|
||||
'legacy_bending_id' => $row->num,
|
||||
'item_name' => $row->itemName ?: "부품#{$row->num}",
|
||||
'item_sep' => $this->clean($row->item_sep),
|
||||
'item_bending' => $this->clean($row->item_bending),
|
||||
'material' => $this->clean($row->material),
|
||||
'item_spec' => $this->clean($row->item_spec),
|
||||
'model_name' => $this->clean($row->model_name ?? null),
|
||||
'model_UA' => $this->clean($row->model_UA ?? null),
|
||||
'rail_width' => $this->toNum($row->rail_width ?? null),
|
||||
'exit_direction' => $this->clean($row->exit_direction ?? null),
|
||||
'box_width' => $this->toNum($row->box_width ?? null),
|
||||
'box_height' => $this->toNum($row->box_height ?? null),
|
||||
'front_bottom' => $this->toNum($row->front_bottom_width ?? null),
|
||||
'options' => $this->buildOptions($row),
|
||||
'is_active' => true,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ #{$row->num} → {$bi->id} ({$row->itemName}) [{$code}]");
|
||||
|
||||
return $bi;
|
||||
}
|
||||
|
||||
private function importBendingData(BendingItem $bi, object $row): int
|
||||
{
|
||||
$inputs = json_decode($row->inputList ?? '[]', true) ?: [];
|
||||
if (empty($inputs)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rates = json_decode($row->bendingrateList ?? '[]', true) ?: [];
|
||||
$sums = json_decode($row->sumList ?? '[]', true) ?: [];
|
||||
$colors = json_decode($row->colorList ?? '[]', true) ?: [];
|
||||
$angles = json_decode($row->AList ?? '[]', true) ?: [];
|
||||
|
||||
$count = count($inputs);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$input = (float) ($inputs[$i] ?? 0);
|
||||
$rate = (string) ($rates[$i] ?? '');
|
||||
$afterRate = ($rate !== '') ? $input + (float) $rate : $input;
|
||||
|
||||
BendingDataRow::create([
|
||||
'bending_item_id' => $bi->id,
|
||||
'sort_order' => $i + 1,
|
||||
'input' => $input,
|
||||
'rate' => $rate !== '' ? $rate : null,
|
||||
'after_rate' => $afterRate,
|
||||
'sum' => (float) ($sums[$i] ?? 0),
|
||||
'color' => (bool) ($colors[$i] ?? false),
|
||||
'a_angle' => (bool) ($angles[$i] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function generateCode(object $row): string
|
||||
{
|
||||
$bending = $row->item_bending ?? '';
|
||||
$sep = $row->item_sep ?? '';
|
||||
$material = $row->material ?? '';
|
||||
$name = $row->itemName ?? '';
|
||||
|
||||
$prodCode = match (true) {
|
||||
$bending === '케이스' => 'C',
|
||||
$bending === '하단마감재' && str_contains($sep, '철재') => 'T',
|
||||
$bending === '하단마감재' => 'B',
|
||||
$bending === '가이드레일' && str_contains($sep, '철재') => 'R',
|
||||
$bending === '가이드레일' => 'R',
|
||||
$bending === '마구리' => 'X',
|
||||
$bending === 'L-BAR' => 'L',
|
||||
$bending === '연기차단재' => 'G',
|
||||
default => 'Z',
|
||||
};
|
||||
|
||||
$specCode = match (true) {
|
||||
str_contains($name, '전면') => 'F',
|
||||
str_contains($name, '린텔') => 'L',
|
||||
str_contains($name, '점검') => 'P',
|
||||
str_contains($name, '후면') => 'B',
|
||||
str_contains($name, '상부') || str_contains($name, '덮개') => 'X',
|
||||
str_contains($name, '본체') => 'M',
|
||||
str_contains($name, 'C형') || str_contains($name, '-C') => 'C',
|
||||
str_contains($name, 'D형') || str_contains($name, '-D') => 'D',
|
||||
str_contains($name, '마감') && str_contains($material, 'SUS') => 'S',
|
||||
str_contains($material, 'SUS') => 'S',
|
||||
str_contains($material, 'EGI') => 'E',
|
||||
default => 'Z',
|
||||
};
|
||||
|
||||
$date = $row->registration_date ?? now()->format('Y-m-d');
|
||||
$dateCode = date('ymd', strtotime($date));
|
||||
|
||||
$base = "{$prodCode}{$specCode}{$dateCode}";
|
||||
|
||||
// 중복 방지 일련번호
|
||||
$seq = 1;
|
||||
while (BendingItem::where('tenant_id', $this->tenantId)
|
||||
->where('code', $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT))
|
||||
->whereNull('length_code')
|
||||
->exists()) {
|
||||
$seq++;
|
||||
}
|
||||
|
||||
return $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function buildOptions(object $row): ?array
|
||||
{
|
||||
$opts = [];
|
||||
if (! empty($row->memo)) $opts['memo'] = $row->memo;
|
||||
if (! empty($row->author)) $opts['author'] = $row->author;
|
||||
if (! empty($row->search_keyword)) $opts['search_keyword'] = $row->search_keyword;
|
||||
if (! empty($row->registration_date)) $opts['registration_date'] = (string) $row->registration_date;
|
||||
|
||||
return empty($opts) ? null : $opts;
|
||||
}
|
||||
|
||||
private function preview($missing): void
|
||||
{
|
||||
$grouped = $missing->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류'));
|
||||
$this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values());
|
||||
|
||||
$this->newLine();
|
||||
$headers = ['num', 'itemName', 'item_sep', 'item_bending', 'material', 'has_bd'];
|
||||
$rows = $missing->take(15)->map(fn ($r) => [
|
||||
$r->num,
|
||||
mb_substr($r->itemName ?? '', 0, 25),
|
||||
$r->item_sep ?? '-',
|
||||
$r->item_bending ?? '-',
|
||||
mb_substr($r->material ?? '-', 0, 12),
|
||||
! empty(json_decode($r->inputList ?? '[]', true)) ? '✅' : '❌',
|
||||
]);
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
|
||||
private function clean(?string $v): ?string
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
|
||||
}
|
||||
|
||||
private function toNum(mixed $v): ?float
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
use App\Models\BendingModel;
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 모델 component별 이미지 복사 (기초관리 원본 → 독립 복사본)
|
||||
*
|
||||
* component.source_num → bending_items.legacy_bending_id → 원본 이미지
|
||||
* → R2에 복사 → 새 file 레코드 → component.image_file_id 업데이트
|
||||
*
|
||||
* 실행: php artisan bending:model-copy-images [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
class BendingModelCopyImages extends Command
|
||||
{
|
||||
protected $signature = 'bending:model-copy-images
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 미리보기}';
|
||||
|
||||
protected $description = '모델 component별 이미지를 기초관리에서 복사';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
// bending_items의 legacy_bending_id → 이미지 파일 매핑
|
||||
$itemImageMap = [];
|
||||
$items = BendingItem::where('tenant_id', $tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->get();
|
||||
|
||||
foreach ($items as $bi) {
|
||||
$file = File::where('document_type', 'bending_item')
|
||||
->where('document_id', $bi->id)
|
||||
->where('field_key', 'bending_diagram')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($file) {
|
||||
$itemImageMap[$bi->legacy_bending_id] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("기초관리 이미지 매핑: " . count($itemImageMap) . "건");
|
||||
|
||||
$models = BendingModel::where('tenant_id', $tenantId)
|
||||
->whereNotNull('components')
|
||||
->get();
|
||||
|
||||
$copied = 0;
|
||||
$skipped = 0;
|
||||
$noSource = 0;
|
||||
|
||||
foreach ($models as $model) {
|
||||
$components = $model->components;
|
||||
if (empty($components)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$updated = false;
|
||||
foreach ($components as $idx => &$comp) {
|
||||
// 이미 image_file_id가 있으면 skip
|
||||
if (! empty($comp['image_file_id'])) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// source_num으로 기초관리 이미지 찾기
|
||||
$sourceNum = $comp['num'] ?? $comp['source_num'] ?? null;
|
||||
if (! $sourceNum) {
|
||||
$noSource++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sourceFile = $itemImageMap[(int) $sourceNum] ?? null;
|
||||
if (! $sourceFile || ! $sourceFile->file_path) {
|
||||
$noSource++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [DRY] model#{$model->id} comp[{$idx}] ← bending#{$sourceNum} file#{$sourceFile->id}");
|
||||
$copied++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// R2에서 파일 복사
|
||||
try {
|
||||
$newFile = $this->copyFile($sourceFile, $model->id, $tenantId);
|
||||
$comp['image_file_id'] = $newFile->id;
|
||||
$updated = true;
|
||||
$copied++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" ⚠️ 복사 실패: model#{$model->id} comp[{$idx}] — {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
unset($comp);
|
||||
|
||||
if ($updated && ! $dryRun) {
|
||||
$model->components = $components;
|
||||
$model->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("완료: 복사 {$copied}건, 스킵 {$skipped}건, 소스없음 {$noSource}건");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function copyFile(File $source, int $modelId, int $tenantId): File
|
||||
{
|
||||
$extension = pathinfo($source->stored_name, PATHINFO_EXTENSION);
|
||||
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$directory = sprintf('%d/bending/model-parts/%s/%s', $tenantId, date('Y'), date('m'));
|
||||
$newPath = $directory . '/' . $storedName;
|
||||
|
||||
// R2 파일 복사
|
||||
$content = Storage::disk('r2')->get($source->file_path);
|
||||
Storage::disk('r2')->put($newPath, $content);
|
||||
|
||||
// 새 파일 레코드 생성
|
||||
return File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $source->display_name,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $newPath,
|
||||
'file_size' => $source->file_size,
|
||||
'mime_type' => $source->mime_type,
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'component_image',
|
||||
'document_id' => $modelId,
|
||||
'document_type' => 'bending_model',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
use App\Models\BendingModel;
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* chandj guiderail/bottombar/shutterbox → bending_models 직접 이관
|
||||
* + 기존 assembly_image 파일 매핑 보존
|
||||
* + component별 이미지 복사 (기초관리 원본 → 독립 복사본)
|
||||
*
|
||||
* 실행: php artisan bending:model-import [--dry-run] [--tenant_id=287]
|
||||
*/
|
||||
class BendingModelImport extends Command
|
||||
{
|
||||
protected $signature = 'bending:model-import
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 미리보기}
|
||||
{--legacy-path=/tmp/legacy_5130 : 레거시 5130 경로}';
|
||||
|
||||
protected $description = 'chandj 절곡품 모델 3종 → bending_models 이관 (이미지 포함)';
|
||||
|
||||
private int $tenantId;
|
||||
private string $legacyPath;
|
||||
private array $itemImageMap = [];
|
||||
private array $itemIdMap = [];
|
||||
private array $modelImageMap = []; // "type:model_name:finishing_type" → image path
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$this->legacyPath = $this->option('legacy-path');
|
||||
|
||||
// 기초관리 이미지 매핑 + 모델 JSON 이미지 로드
|
||||
$this->buildItemImageMap();
|
||||
$this->loadModelImageJsons();
|
||||
|
||||
// 기존 데이터 삭제 (assembly_image 파일 매핑 보존)
|
||||
$existing = BendingModel::where('tenant_id', $this->tenantId)->count();
|
||||
$oldFileMap = [];
|
||||
if ($existing > 0 && ! $dryRun) {
|
||||
$oldFileMap = $this->buildOldFileMap();
|
||||
|
||||
// component_image 삭제 (재생성할 거니까)
|
||||
File::where('document_type', 'bending_model')
|
||||
->where('field_key', 'component_image')
|
||||
->whereNull('deleted_at')
|
||||
->forceDelete();
|
||||
|
||||
BendingModel::where('tenant_id', $this->tenantId)->forceDelete();
|
||||
$this->info("기존 bending_models {$existing}건 삭제 (component_image 재생성)");
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
|
||||
// 1. guiderail
|
||||
$guiderails = DB::connection('chandj')->table('guiderail')
|
||||
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
|
||||
->orderBy('num')->get();
|
||||
$this->info("\n=== 가이드레일: {$guiderails->count()}건 ===");
|
||||
foreach ($guiderails as $row) {
|
||||
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; }
|
||||
$this->importModel($row, BendingModel::TYPE_GUIDERAIL, "GR-{$row->num}", $this->buildGuiderailData($row));
|
||||
$total++;
|
||||
}
|
||||
|
||||
// 2. shutterbox
|
||||
$shutterboxes = DB::connection('chandj')->table('shutterbox')
|
||||
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
|
||||
->orderBy('num')->get();
|
||||
$this->info("\n=== 케이스: {$shutterboxes->count()}건 ===");
|
||||
foreach ($shutterboxes as $row) {
|
||||
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->exit_direction}"); continue; }
|
||||
$this->importModel($row, BendingModel::TYPE_SHUTTERBOX, "SB-{$row->num}", $this->buildShutterboxData($row));
|
||||
$total++;
|
||||
}
|
||||
|
||||
// 3. bottombar
|
||||
$bottombars = DB::connection('chandj')->table('bottombar')
|
||||
->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); })
|
||||
->orderBy('num')->get();
|
||||
$this->info("\n=== 하단마감재: {$bottombars->count()}건 ===");
|
||||
foreach ($bottombars as $row) {
|
||||
if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; }
|
||||
$this->importModel($row, BendingModel::TYPE_BOTTOMBAR, "BB-{$row->num}", $this->buildBottombarData($row));
|
||||
$total++;
|
||||
}
|
||||
|
||||
// assembly_image 파일 매핑 업데이트
|
||||
if (! $dryRun && ! empty($oldFileMap)) {
|
||||
$this->remapAssemblyImages($oldFileMap);
|
||||
}
|
||||
|
||||
// 최종 결과
|
||||
$this->newLine();
|
||||
$final = BendingModel::where('tenant_id', $this->tenantId)->count();
|
||||
$assemblyFiles = File::where('document_type', 'bending_model')->where('field_key', 'assembly_image')->whereNull('deleted_at')->count();
|
||||
$compFiles = File::where('document_type', 'bending_model')->where('field_key', 'component_image')->whereNull('deleted_at')->count();
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
$this->info("모델: {$final}건 / 조립도: {$assemblyFiles}건 / 부품이미지: {$compFiles}건");
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function importModel(object $row, string $type, string $code, array $data): void
|
||||
{
|
||||
$components = json_decode($row->bending_components ?? '[]', true) ?: [];
|
||||
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
|
||||
|
||||
// component별 이미지 복사
|
||||
$components = $this->copyComponentImages($components);
|
||||
|
||||
$bm = BendingModel::create(array_merge($data, [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'model_type' => $type,
|
||||
'code' => $code,
|
||||
'legacy_num' => $row->num,
|
||||
'components' => $components,
|
||||
'material_summary' => $materialSummary,
|
||||
'registration_date' => $row->registration_date ?? null,
|
||||
'author' => $this->clean($row->author ?? null),
|
||||
'remark' => $this->clean($row->remark ?? null),
|
||||
'search_keyword' => $this->clean($row->search_keyword ?? null),
|
||||
'is_active' => true,
|
||||
'created_by' => 1,
|
||||
]));
|
||||
|
||||
// assembly_image 업로드 (JSON 파일에서)
|
||||
$this->uploadAssemblyImage($bm, $type, $data);
|
||||
|
||||
$compCount = count($components);
|
||||
$imgCount = collect($components)->whereNotNull('image_file_id')->count();
|
||||
$hasAssembly = File::where('document_type', 'bending_model')->where('document_id', $bm->id)->where('field_key', 'assembly_image')->exists();
|
||||
$this->line(" ✅ #{$row->num} → {$bm->id} ({$data['name']}) [부품:{$compCount} 이미지:{$imgCount} 조립도:" . ($hasAssembly ? 'Y' : 'N') . ']');
|
||||
}
|
||||
|
||||
private function copyComponentImages(array $components): array
|
||||
{
|
||||
foreach ($components as &$comp) {
|
||||
$sourceNum = $comp['num'] ?? $comp['source_num'] ?? null;
|
||||
if (! $sourceNum) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// sam_item_id 매핑 (원본수정 링크용)
|
||||
$samItemId = $this->itemIdMap[(int) $sourceNum] ?? null;
|
||||
if ($samItemId) {
|
||||
$comp['sam_item_id'] = $samItemId;
|
||||
}
|
||||
|
||||
$sourceFile = $this->itemImageMap[(int) $sourceNum] ?? null;
|
||||
if (! $sourceFile || ! $sourceFile->file_path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$extension = pathinfo($sourceFile->stored_name, PATHINFO_EXTENSION);
|
||||
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$directory = sprintf('%d/bending/model-parts/%s/%s', $this->tenantId, date('Y'), date('m'));
|
||||
$newPath = $directory . '/' . $storedName;
|
||||
|
||||
$content = Storage::disk('r2')->get($sourceFile->file_path);
|
||||
Storage::disk('r2')->put($newPath, $content);
|
||||
|
||||
$newFile = File::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'display_name' => $sourceFile->display_name,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $newPath,
|
||||
'file_size' => $sourceFile->file_size,
|
||||
'mime_type' => $sourceFile->mime_type,
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'component_image',
|
||||
'document_id' => 0, // 모델 생성 전이므로 임시, 나중에 update 불필요 (component JSON에 ID 저장)
|
||||
'document_type' => 'bending_model',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$comp['image_file_id'] = $newFile->id;
|
||||
} catch (\Throwable $e) {
|
||||
// 복사 실패 시 무시
|
||||
}
|
||||
}
|
||||
unset($comp);
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
private function buildItemImageMap(): void
|
||||
{
|
||||
$items = BendingItem::where('tenant_id', $this->tenantId)
|
||||
->whereNotNull('legacy_bending_id')
|
||||
->get();
|
||||
|
||||
foreach ($items as $bi) {
|
||||
$file = File::where('document_type', 'bending_item')
|
||||
->where('document_id', $bi->id)
|
||||
->where('field_key', 'bending_diagram')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
$this->itemIdMap[$bi->legacy_bending_id] = $bi->id;
|
||||
|
||||
if ($file) {
|
||||
$this->itemImageMap[$bi->legacy_bending_id] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("기초관리 매핑: " . count($this->itemIdMap) . "건 (이미지: " . count($this->itemImageMap) . "건)");
|
||||
}
|
||||
|
||||
private function buildOldFileMap(): array
|
||||
{
|
||||
return File::where('document_type', 'bending_model')
|
||||
->where('field_key', 'assembly_image')
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->mapWithKeys(function ($file) {
|
||||
$bm = BendingModel::find($file->document_id);
|
||||
return $bm ? [$bm->legacy_num => $file->document_id] : [];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
private function remapAssemblyImages(array $oldFileMap): void
|
||||
{
|
||||
$remapped = 0;
|
||||
$newModels = BendingModel::where('tenant_id', $this->tenantId)->get()->keyBy('legacy_num');
|
||||
|
||||
foreach ($oldFileMap as $legacyNum => $oldDocId) {
|
||||
$newBm = $newModels[$legacyNum] ?? null;
|
||||
if ($newBm && $oldDocId !== $newBm->id) {
|
||||
File::where('document_type', 'bending_model')
|
||||
->where('field_key', 'assembly_image')
|
||||
->where('document_id', $oldDocId)
|
||||
->whereNull('deleted_at')
|
||||
->update(['document_id' => $newBm->id]);
|
||||
$remapped++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("조립도 매핑 업데이트: {$remapped}건");
|
||||
}
|
||||
|
||||
private function loadModelImageJsons(): void
|
||||
{
|
||||
$jsonFiles = [
|
||||
'guiderail' => $this->legacyPath . '/guiderail/guiderail.json',
|
||||
'shutterbox' => $this->legacyPath . '/shutterbox/shutterbox.json',
|
||||
'bottombar' => $this->legacyPath . '/bottombar/bottombar.json',
|
||||
];
|
||||
|
||||
foreach ($jsonFiles as $type => $path) {
|
||||
if (! file_exists($path)) {
|
||||
continue;
|
||||
}
|
||||
$items = json_decode(file_get_contents($path), true) ?: [];
|
||||
foreach ($items as $item) {
|
||||
$key = $this->makeImageKey($type, $item);
|
||||
if ($key && ! empty($item['image'])) {
|
||||
$this->modelImageMap[$key] = $item['image'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("모델 이미지 매핑: " . count($this->modelImageMap) . "건");
|
||||
}
|
||||
|
||||
private function makeImageKey(string $type, array $item): ?string
|
||||
{
|
||||
if ($type === 'guiderail') {
|
||||
return "GR:{$item['model_name']}:{$item['check_type']}:{$item['finishing_type']}";
|
||||
}
|
||||
if ($type === 'shutterbox') {
|
||||
return "SB:{$item['exit_direction']}:{$item['box_width']}x{$item['box_height']}";
|
||||
}
|
||||
if ($type === 'bottombar') {
|
||||
return "BB:{$item['model_name']}:{$item['finishing_type']}";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function uploadAssemblyImage(BendingModel $bm, string $type, array $data): void
|
||||
{
|
||||
$key = match ($type) {
|
||||
BendingModel::TYPE_GUIDERAIL => "GR:{$data['model_name']}:{$data['check_type']}:{$data['finishing_type']}",
|
||||
BendingModel::TYPE_SHUTTERBOX => "SB:{$data['exit_direction']}:" . intval($data['box_width'] ?? 0) . 'x' . intval($data['box_height'] ?? 0),
|
||||
BendingModel::TYPE_BOTTOMBAR => "BB:{$data['model_name']}:{$data['finishing_type']}",
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $key) return;
|
||||
|
||||
$imagePath = $this->modelImageMap[$key] ?? null;
|
||||
if (! $imagePath) return;
|
||||
|
||||
// /bottombar/images/xxx.png → legacy-path/bottombar/images/xxx.png
|
||||
$localPath = $this->legacyPath . $imagePath;
|
||||
if (! file_exists($localPath)) return;
|
||||
|
||||
try {
|
||||
$extension = pathinfo($localPath, PATHINFO_EXTENSION);
|
||||
$storedName = bin2hex(random_bytes(8)) . '.' . $extension;
|
||||
$directory = sprintf('%d/bending/models/%s/%s', $this->tenantId, date('Y'), date('m'));
|
||||
$r2Path = $directory . '/' . $storedName;
|
||||
|
||||
Storage::disk('r2')->put($r2Path, file_get_contents($localPath));
|
||||
|
||||
File::create([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'display_name' => basename($imagePath),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $r2Path,
|
||||
'file_size' => filesize($localPath),
|
||||
'mime_type' => mime_content_type($localPath),
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'assembly_image',
|
||||
'document_id' => $bm->id,
|
||||
'document_type' => 'bending_model',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" ⚠️ 조립도 업로드 실패: {$bm->name} — {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 모델별 데이터 빌드 ──
|
||||
|
||||
private function buildGuiderailData(object $row): array
|
||||
{
|
||||
return [
|
||||
'name' => trim("{$row->model_name} {$row->firstitem} {$row->check_type} {$row->finishing_type}"),
|
||||
'model_name' => $this->clean($row->model_name),
|
||||
'model_UA' => $this->clean($row->model_UA),
|
||||
'item_sep' => $this->clean($row->firstitem),
|
||||
'finishing_type' => $this->clean($row->finishing_type),
|
||||
'check_type' => $this->clean($row->check_type),
|
||||
'rail_width' => $this->toNum($row->rail_width),
|
||||
'rail_length' => $this->toNum($row->rail_length),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildShutterboxData(object $row): array
|
||||
{
|
||||
return [
|
||||
'name' => trim("케이스 {$row->exit_direction} {$row->box_width}x{$row->box_height}"),
|
||||
'exit_direction' => $this->clean($row->exit_direction),
|
||||
'front_bottom_width' => $this->toNum($row->front_bottom_width ?? null),
|
||||
'rail_width' => $this->toNum($row->rail_width ?? null),
|
||||
'box_width' => $this->toNum($row->box_width),
|
||||
'box_height' => $this->toNum($row->box_height),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildBottombarData(object $row): array
|
||||
{
|
||||
return [
|
||||
'name' => trim("{$row->model_name} {$row->firstitem} {$row->finishing_type} {$row->bar_width}x{$row->bar_height}"),
|
||||
'model_name' => $this->clean($row->model_name),
|
||||
'model_UA' => $this->clean($row->model_UA),
|
||||
'item_sep' => $this->clean($row->firstitem),
|
||||
'finishing_type' => $this->clean($row->finishing_type),
|
||||
'bar_width' => $this->toNum($row->bar_width),
|
||||
'bar_height' => $this->toNum($row->bar_height),
|
||||
];
|
||||
}
|
||||
|
||||
private function clean(?string $v): ?string
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : trim($v);
|
||||
}
|
||||
|
||||
private function toNum(mixed $v): ?float
|
||||
{
|
||||
return ($v === null || $v === '' || $v === 'null') ? null : (float) $v;
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 레거시 guiderail.json 결합형태 이미지 → SAM 모델 연결
|
||||
*/
|
||||
#[AsCommand(name: 'bending-model:import-assembly-images', description: '결합형태 이미지 → R2 마이그레이션')]
|
||||
class BendingModelImportAssemblyImages extends Command
|
||||
{
|
||||
protected $signature = 'bending-model:import-assembly-images
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}
|
||||
{--source=https://5130.codebridge-x.com : 소스 URL}';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$sourceBase = rtrim($this->option('source'), '/');
|
||||
|
||||
$this->info('=== 결합형태 이미지 → R2 마이그레이션 ===');
|
||||
|
||||
// 3개 JSON 파일 순차 처리
|
||||
$jsonConfigs = [
|
||||
['file' => 'guiderail/guiderail.json', 'category' => 'GUIDERAIL_MODEL', 'imageBase' => ''],
|
||||
['file' => 'shutterbox/shutterbox.json', 'category' => 'SHUTTERBOX_MODEL', 'imageBase' => ''],
|
||||
['file' => 'bottombar/bottombar.json', 'category' => 'BOTTOMBAR_MODEL', 'imageBase' => ''],
|
||||
];
|
||||
|
||||
$uploaded = 0;
|
||||
$skipped = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($jsonConfigs as $jsonConfig) {
|
||||
$jsonPath = base_path('../5130/' . $jsonConfig['file']);
|
||||
if (! file_exists($jsonPath)) {
|
||||
$resp = Http::withoutVerifying()->get("{$sourceBase}/{$jsonConfig['file']}");
|
||||
$assemblyData = $resp->successful() ? $resp->json() : [];
|
||||
} else {
|
||||
$assemblyData = json_decode(file_get_contents($jsonPath), true) ?: [];
|
||||
}
|
||||
|
||||
$this->info("--- {$jsonConfig['category']} ({$jsonConfig['file']}): " . count($assemblyData) . '건 ---');
|
||||
|
||||
foreach ($assemblyData as $entry) {
|
||||
$imagePath = $entry['image'] ?? '';
|
||||
if (! $imagePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// SAM 코드 생성 (카테고리별)
|
||||
$code = $this->buildCode($entry, $jsonConfig['category']);
|
||||
if (! $code) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$samItem = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $code)
|
||||
->where('item_category', $jsonConfig['category'])
|
||||
->whereNull('deleted_at')
|
||||
->first(['id', 'code', 'options']);
|
||||
|
||||
if (! $samItem) {
|
||||
$this->warn(" ⚠️ {$code}: SAM 모델 없음");
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 이미지 있으면 스킵
|
||||
$existing = File::where('tenant_id', $tenantId)
|
||||
->where('document_id', $samItem->id)
|
||||
->where('field_key', 'assembly_image')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageUrl = "{$sourceBase}{$imagePath}";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" ✅ {$code} ← {$imagePath}");
|
||||
$uploaded++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withoutVerifying()->timeout(15)->get($imageUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$this->warn(" ❌ {$code}: HTTP {$response->status()}");
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $response->body();
|
||||
$ext = pathinfo($imagePath, PATHINFO_EXTENSION) ?: 'png';
|
||||
$storedName = bin2hex(random_bytes(8)).'.'.strtolower($ext);
|
||||
$directory = sprintf('%d/items/%s/%s', $tenantId, date('Y'), date('m'));
|
||||
$filePath = $directory.'/'.$storedName;
|
||||
|
||||
Storage::disk('r2')->put($filePath, $content);
|
||||
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => basename($imagePath),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($content),
|
||||
'mime_type' => $response->header('Content-Type', 'image/png'),
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'assembly_image',
|
||||
'document_id' => $samItem->id,
|
||||
'document_type' => '1',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ {$code} ← {$imagePath} → file_id={$file->id}");
|
||||
$uploaded++;
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ {$code}: {$e->getMessage()}");
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
} // end foreach jsonConfigs
|
||||
|
||||
$this->newLine();
|
||||
$this->info("업로드: {$uploaded}건 | 스킵: {$skipped}건 | 실패: {$failed}건");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function buildCode(array $entry, string $category): ?string
|
||||
{
|
||||
if ($category === 'GUIDERAIL_MODEL') {
|
||||
$modelName = $entry['model_name'] ?? '';
|
||||
$checkType = $entry['check_type'] ?? '';
|
||||
$finishType = $entry['finishing_type'] ?? '';
|
||||
if (! $modelName) {
|
||||
return null;
|
||||
}
|
||||
$finish = str_contains($finishType, 'SUS') ? 'SUS' : 'EGI';
|
||||
|
||||
return "GR-{$modelName}-{$checkType}-{$finish}";
|
||||
}
|
||||
|
||||
if ($category === 'SHUTTERBOX_MODEL') {
|
||||
$w = $entry['box_width'] ?? '';
|
||||
$h = $entry['box_height'] ?? '';
|
||||
$exit = $entry['exit_direction'] ?? '';
|
||||
$exitShort = match ($exit) {
|
||||
'양면 점검구' => '양면',
|
||||
'밑면 점검구' => '밑면',
|
||||
'후면 점검구' => '후면',
|
||||
default => $exit,
|
||||
};
|
||||
|
||||
return "SB-{$w}*{$h}-{$exitShort}";
|
||||
}
|
||||
|
||||
if ($category === 'BOTTOMBAR_MODEL') {
|
||||
$modelName = $entry['model_name'] ?? '';
|
||||
$finishType = $entry['finishing_type'] ?? '';
|
||||
if (! $modelName) {
|
||||
return null;
|
||||
}
|
||||
$finish = str_contains($finishType, 'SUS') ? 'SUS' : 'EGI';
|
||||
|
||||
return "BB-{$modelName}-{$finish}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 가이드레일/케이스/하단마감재 모델의 부품별 이미지 임포트
|
||||
*
|
||||
* chandj guiderail/shutterbox/bottombar components의 imgdata →
|
||||
* 5130.codebridge-x.com에서 다운로드 → R2 업로드 → components에 file_id 추가
|
||||
*/
|
||||
#[AsCommand(name: 'bending-model:import-images', description: '절곡품 모델 부품별 이미지 → R2 마이그레이션')]
|
||||
class BendingModelImportImages extends Command
|
||||
{
|
||||
protected $signature = 'bending-model:import-images
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}
|
||||
{--source=https://5130.codebridge-x.com/bending/img : 이미지 소스 URL}';
|
||||
|
||||
private int $uploaded = 0;
|
||||
|
||||
private int $skipped = 0;
|
||||
|
||||
private int $failed = 0;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$sourceBase = rtrim($this->option('source'), '/');
|
||||
|
||||
$this->info('=== 절곡품 모델 부품 이미지 → R2 마이그레이션 ===');
|
||||
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
|
||||
$this->newLine();
|
||||
|
||||
// chandj에서 원본 imgdata 조회
|
||||
$chandjTables = [
|
||||
'GUIDERAIL_MODEL' => 'guiderail',
|
||||
'SHUTTERBOX_MODEL' => 'shutterbox',
|
||||
'BOTTOMBAR_MODEL' => 'bottombar',
|
||||
];
|
||||
|
||||
foreach ($chandjTables as $category => $table) {
|
||||
$this->info("--- {$category} ({$table}) ---");
|
||||
|
||||
$chandjRows = DB::connection('chandj')->table($table)->whereNull('is_deleted')->get();
|
||||
$samItems = DB::table('items')->where('tenant_id', $tenantId)
|
||||
->where('item_category', $category)->whereNull('deleted_at')
|
||||
->get(['id', 'code', 'options']);
|
||||
|
||||
// legacy_num → chandj row 매핑
|
||||
$chandjMap = [];
|
||||
foreach ($chandjRows as $row) {
|
||||
$chandjMap[$row->num] = $row;
|
||||
}
|
||||
|
||||
foreach ($samItems as $samItem) {
|
||||
$opts = json_decode($samItem->options, true) ?? [];
|
||||
$legacyNum = $opts['legacy_num'] ?? $opts['legacy_guiderail_num'] ?? null;
|
||||
|
||||
if (! $legacyNum || ! isset($chandjMap[$legacyNum])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$chandjRow = $chandjMap[$legacyNum];
|
||||
$chandjComps = json_decode($chandjRow->bending_components ?? '[]', true) ?: [];
|
||||
$components = $opts['components'] ?? [];
|
||||
$updated = false;
|
||||
|
||||
foreach ($components as $idx => &$comp) {
|
||||
// chandj component에서 imgdata 찾기
|
||||
$chandjComp = $chandjComps[$idx] ?? null;
|
||||
$imgdata = $chandjComp['imgdata'] ?? null;
|
||||
|
||||
if (! $imgdata || ! empty($comp['image_file_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageUrl = "{$sourceBase}/{$imgdata}";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" ✅ {$samItem->code} #{$idx} ← {$imgdata}");
|
||||
$this->uploaded++;
|
||||
$updated = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withoutVerifying()->timeout(15)->get($imageUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$this->warn(" ❌ {$samItem->code} #{$idx}: HTTP {$response->status()}");
|
||||
$this->failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageContent = $response->body();
|
||||
$extension = pathinfo($imgdata, PATHINFO_EXTENSION) ?: 'png';
|
||||
$storedName = bin2hex(random_bytes(8)).'.'.strtolower($extension);
|
||||
$directory = sprintf('%d/items/%s/%s', $tenantId, date('Y'), date('m'));
|
||||
$filePath = $directory.'/'.$storedName;
|
||||
|
||||
Storage::disk('r2')->put($filePath, $imageContent);
|
||||
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $imgdata,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($imageContent),
|
||||
'mime_type' => $response->header('Content-Type', 'image/png'),
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'bending_component_image',
|
||||
'document_id' => $samItem->id,
|
||||
'document_type' => '1',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$comp['image_file_id'] = $file->id;
|
||||
$comp['imgdata'] = $imgdata;
|
||||
$updated = true;
|
||||
$this->uploaded++;
|
||||
$this->line(" ✅ {$samItem->code} #{$idx} {$comp['itemName']} ← {$imgdata} → file_id={$file->id}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ {$samItem->code} #{$idx}: {$e->getMessage()}");
|
||||
$this->failed++;
|
||||
}
|
||||
}
|
||||
unset($comp);
|
||||
|
||||
// components 업데이트
|
||||
if ($updated && ! $dryRun) {
|
||||
$opts['components'] = $components;
|
||||
DB::table('items')->where('id', $samItem->id)->update([
|
||||
'options' => json_encode($opts, JSON_UNESCAPED_UNICODE),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("업로드: {$this->uploaded}건 | 스킵: {$this->skipped}건 | 실패: {$this->failed}건");
|
||||
if ($dryRun) {
|
||||
$this->info('🔍 DRY-RUN 완료.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* chandj shutterbox(케이스) + bottombar(하단마감재) → SAM items 임포트
|
||||
*/
|
||||
#[AsCommand(name: 'bending-product:import-legacy', description: 'chandj 케이스/하단마감재 모델 → SAM items 임포트')]
|
||||
class BendingProductImportLegacy extends Command
|
||||
{
|
||||
protected $signature = 'bending-product:import-legacy
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== 케이스/하단마감재 모델 → SAM 임포트 ===');
|
||||
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
|
||||
$this->newLine();
|
||||
|
||||
// 케이스 (shutterbox)
|
||||
$this->info('--- 케이스 (shutterbox) ---');
|
||||
$cases = DB::connection('chandj')->table('shutterbox')->whereNull('is_deleted')->get();
|
||||
$this->info("chandj shutterbox: {$cases->count()}건");
|
||||
$caseCreated = $this->importItems($cases, 'SHUTTERBOX_MODEL', $tenantId, $dryRun);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// 하단마감재 (bottombar)
|
||||
$this->info('--- 하단마감재 (bottombar) ---');
|
||||
$bars = DB::connection('chandj')->table('bottombar')->whereNull('is_deleted')->get();
|
||||
$this->info("chandj bottombar: {$bars->count()}건");
|
||||
$barCreated = $this->importItems($bars, 'BOTTOMBAR_MODEL', $tenantId, $dryRun);
|
||||
|
||||
$this->newLine();
|
||||
$this->info("결과: 케이스 {$caseCreated}건 + 하단마감재 {$barCreated}건");
|
||||
if ($dryRun) {
|
||||
$this->info('🔍 DRY-RUN 완료.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function importItems($rows, string $category, int $tenantId, bool $dryRun): int
|
||||
{
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$code = $this->buildCode($row, $category);
|
||||
$name = $this->buildName($row, $category);
|
||||
|
||||
$existing = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $code)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$components = $this->convertComponents(json_decode($row->bending_components ?? '[]', true) ?: []);
|
||||
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
|
||||
|
||||
$options = $this->buildOptions($row, $category, $components, $materialSummary);
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::table('items')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'item_type' => 'PT',
|
||||
'item_category' => $category,
|
||||
'unit' => 'SET',
|
||||
'options' => json_encode($options, JSON_UNESCAPED_UNICODE),
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$created++;
|
||||
$this->line(" ✅ {$code} ({$name}) — 부품 ".count($components).'개');
|
||||
}
|
||||
|
||||
$this->info(" 생성: {$created}건 | 스킵: {$skipped}건");
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function buildCode(object $row, string $category): string
|
||||
{
|
||||
if ($category === 'SHUTTERBOX_MODEL') {
|
||||
$size = ($row->box_width ?? '').
|
||||
'*'.($row->box_height ?? '');
|
||||
$exit = match ($row->exit_direction ?? '') {
|
||||
'양면 점검구' => '양면',
|
||||
'밑면 점검구' => '밑면',
|
||||
'후면 점검구' => '후면',
|
||||
default => $row->exit_direction ?? '',
|
||||
};
|
||||
|
||||
return "SB-{$size}-{$exit}";
|
||||
}
|
||||
|
||||
// BOTTOMBAR_MODEL
|
||||
$model = $row->model_name ?? 'UNKNOWN';
|
||||
$finish = str_contains($row->finishing_type ?? '', 'SUS') ? 'SUS' : 'EGI';
|
||||
|
||||
return "BB-{$model}-{$finish}";
|
||||
}
|
||||
|
||||
private function buildName(object $row, string $category): string
|
||||
{
|
||||
if ($category === 'SHUTTERBOX_MODEL') {
|
||||
return "케이스 {$row->box_width}*{$row->box_height} {$row->exit_direction}";
|
||||
}
|
||||
|
||||
return "하단마감재 {$row->model_name} {$row->firstitem}";
|
||||
}
|
||||
|
||||
private function buildOptions(object $row, string $category, array $components, array $materialSummary): array
|
||||
{
|
||||
$base = [
|
||||
'author' => $row->author ?? null,
|
||||
'registration_date' => $row->registration_date ?? null,
|
||||
'search_keyword' => $row->search_keyword ?? null,
|
||||
'memo' => $row->remark ?? null,
|
||||
'components' => $components,
|
||||
'material_summary' => $materialSummary,
|
||||
'source' => 'chandj_'.(strtolower($category)),
|
||||
'legacy_num' => $row->num,
|
||||
];
|
||||
|
||||
if ($category === 'SHUTTERBOX_MODEL') {
|
||||
return array_merge($base, [
|
||||
'box_width' => (int) ($row->box_width ?? 0),
|
||||
'box_height' => (int) ($row->box_height ?? 0),
|
||||
'exit_direction' => $row->exit_direction ?? null,
|
||||
'front_bottom_width' => $row->front_bottom_width ?? null,
|
||||
'rail_width' => $row->rail_width ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// BOTTOMBAR_MODEL
|
||||
return array_merge($base, [
|
||||
'model_name' => $row->model_name ?? null,
|
||||
'item_sep' => $row->firstitem ?? null,
|
||||
'model_UA' => $row->model_UA ?? null,
|
||||
'finishing_type' => $row->finishing_type ?? null,
|
||||
'bar_width' => $row->bar_width ?? null,
|
||||
'bar_height' => $row->bar_height ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function convertComponents(array $legacyComps): array
|
||||
{
|
||||
return array_map(function ($c, $idx) {
|
||||
$inputs = $c['inputList'] ?? [];
|
||||
$rates = $c['bendingrateList'] ?? [];
|
||||
$sums = $c['sumList'] ?? [];
|
||||
$colors = $c['colorList'] ?? [];
|
||||
$angles = $c['AList'] ?? [];
|
||||
|
||||
$bendingData = [];
|
||||
for ($i = 0; $i < count($inputs); $i++) {
|
||||
$bendingData[] = [
|
||||
'no' => $i + 1,
|
||||
'input' => (float) ($inputs[$i] ?? 0),
|
||||
'rate' => (string) ($rates[$i] ?? ''),
|
||||
'sum' => (float) ($sums[$i] ?? 0),
|
||||
'color' => (bool) ($colors[$i] ?? false),
|
||||
'aAngle' => (bool) ($angles[$i] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
$lastSum = ! empty($sums) ? (float) end($sums) : ($c['widthsum'] ?? 0);
|
||||
|
||||
return [
|
||||
'orderNumber' => $idx + 1,
|
||||
'itemName' => $c['itemName'] ?? '',
|
||||
'material' => $c['material'] ?? '',
|
||||
'quantity' => (int) ($c['quantity'] ?? 1),
|
||||
'width_sum' => (float) $lastSum,
|
||||
'bendingData' => $bendingData,
|
||||
'legacy_bending_num' => $c['source_num'] ?? $c['num'] ?? null,
|
||||
];
|
||||
}, $legacyComps, array_keys($legacyComps));
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 만료 체크 및 알림 커맨드
|
||||
*
|
||||
* - 만료 임박 (7일 이내): 파트너에게 알림 로그
|
||||
* - 만료된 테넌트: 비활성 상태로 전환
|
||||
*
|
||||
* 기존 코드 영향 없음: DEMO_TRIAL 테넌트만 대상
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class CheckDemoExpiredCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:check-expired
|
||||
{--dry-run : 실제 변경 없이 대상만 표시}';
|
||||
|
||||
protected $description = '데모 체험 테넌트 만료 체크 및 비활성 처리';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// 1. 만료 임박 테넌트 (7일 이내)
|
||||
$expiringSoon = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->where('tenant_st_code', '!=', 'expired')
|
||||
->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '>', now())
|
||||
->where('demo_expires_at', '<=', now()->addDays(7))
|
||||
->get();
|
||||
|
||||
if ($expiringSoon->isNotEmpty()) {
|
||||
$this->info("만료 임박 테넌트: {$expiringSoon->count()}건");
|
||||
foreach ($expiringSoon as $tenant) {
|
||||
$daysLeft = (int) now()->diffInDays($tenant->demo_expires_at, false);
|
||||
$this->line(" - [{$tenant->id}] {$tenant->company_name} (D-{$daysLeft})");
|
||||
|
||||
Log::info('데모 체험 만료 임박', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'expires_at' => $tenant->demo_expires_at->toDateString(),
|
||||
'days_left' => $daysLeft,
|
||||
'partner_id' => $tenant->demo_source_partner_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이미 만료된 테넌트 → 상태 변경
|
||||
$expired = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->where('tenant_st_code', '!=', 'expired')
|
||||
->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '<', now())
|
||||
->get();
|
||||
|
||||
if ($expired->isEmpty()) {
|
||||
$this->info('만료 처리 대상 없음');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("만료 처리 대상: {$expired->count()}건");
|
||||
|
||||
foreach ($expired as $tenant) {
|
||||
$this->line(" - [{$tenant->id}] {$tenant->company_name} (만료: {$tenant->demo_expires_at->toDateString()})");
|
||||
|
||||
if (! $this->option('dry-run')) {
|
||||
$tenant->forceFill(['tenant_st_code' => 'expired']);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('데모 체험 만료 처리', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'partner_id' => $tenant->demo_source_partner_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('(dry-run 모드 — 실제 변경 없음)');
|
||||
} else {
|
||||
$this->info(" {$expired->count()}건 만료 처리 완료");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 비활성 알림 커맨드
|
||||
*
|
||||
* - 7일 이상 활동 없는 데모 테넌트 탐지
|
||||
* - 파트너에게 후속 조치 알림 로그
|
||||
*
|
||||
* 기존 코드 영향 없음: DEMO 테넌트만 대상
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class CheckDemoInactiveCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:check-inactive
|
||||
{--days=7 : 비활성 기준 일수}';
|
||||
|
||||
protected $description = '데모 테넌트 비활성 알림 (활동 없는 테넌트 탐지)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$thresholdDays = (int) $this->option('days');
|
||||
|
||||
$demos = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES)
|
||||
->where('tenant_st_code', '!=', 'expired')
|
||||
->get();
|
||||
|
||||
if ($demos->isEmpty()) {
|
||||
$this->info('활성 데모 테넌트 없음');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$inactiveCount = 0;
|
||||
|
||||
foreach ($demos as $tenant) {
|
||||
$lastActivity = $this->getLastActivity($tenant->id);
|
||||
|
||||
if (! $lastActivity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$daysSince = (int) now()->diffInDays($lastActivity);
|
||||
|
||||
if ($daysSince < $thresholdDays) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$inactiveCount++;
|
||||
$this->line(" - [{$tenant->id}] {$tenant->company_name} ({$daysSince}일 비활성)");
|
||||
|
||||
Log::warning('데모 테넌트 비활성 알림', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'tenant_type' => $tenant->tenant_type,
|
||||
'days_inactive' => $daysSince,
|
||||
'last_activity' => $lastActivity->toDateString(),
|
||||
'partner_id' => $tenant->demo_source_partner_id,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inactiveCount === 0) {
|
||||
$this->info("비활성 테넌트 없음 (기준: {$thresholdDays}일)");
|
||||
} else {
|
||||
$this->info("비활성 테넌트: {$inactiveCount}건 (기준: {$thresholdDays}일)");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getLastActivity(int $tenantId): ?\Carbon\Carbon
|
||||
{
|
||||
$tables = ['orders', 'quotes', 'items', 'clients'];
|
||||
$latest = null;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = DB::table($table)
|
||||
->where('tenant_id', $tenantId)
|
||||
->max('updated_at');
|
||||
|
||||
if ($date) {
|
||||
$parsed = \Carbon\Carbon::parse($date);
|
||||
if (! $latest || $parsed->gt($latest)) {
|
||||
$latest = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $latest;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\AsCommand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* chandj.guiderail → SAM items (item_category=GUIDERAIL_MODEL) 임포트
|
||||
*/
|
||||
#[AsCommand(name: 'guiderail:import-legacy', description: 'chandj 가이드레일 모델 → SAM items 임포트')]
|
||||
class GuiderailImportLegacy extends Command
|
||||
{
|
||||
protected $signature = 'guiderail:import-legacy
|
||||
{--tenant_id=287 : Target tenant ID}
|
||||
{--dry-run : 실제 저장 없이 미리보기}';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== chandj guiderail → SAM 임포트 ===');
|
||||
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
|
||||
|
||||
$rows = DB::connection('chandj')->table('guiderail')->whereNull('is_deleted')->get();
|
||||
$this->info("chandj guiderail: {$rows->count()}건");
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$finish = str_contains($row->finishing_type ?? '', 'SUS') ? 'SUS' : 'EGI';
|
||||
$code = 'GR-'.($row->model_name ?? 'UNKNOWN').'-'.($row->check_type ?? '').'-'.$finish;
|
||||
$name = implode(' ', array_filter([$row->model_name, $row->check_type, $row->finishing_type]));
|
||||
|
||||
// 중복 확인
|
||||
$existing = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $code)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// components 변환
|
||||
$legacyComps = json_decode($row->bending_components ?? '[]', true) ?: [];
|
||||
$components = array_map(fn ($c) => $this->convertComponent($c), $legacyComps);
|
||||
|
||||
$materialSummary = json_decode($row->material_summary ?? '{}', true) ?: [];
|
||||
|
||||
$options = [
|
||||
'model_name' => $row->model_name,
|
||||
'check_type' => $row->check_type,
|
||||
'rail_width' => (int) $row->rail_width,
|
||||
'rail_length' => (int) $row->rail_length,
|
||||
'finishing_type' => $row->finishing_type,
|
||||
'item_sep' => $row->firstitem,
|
||||
'model_UA' => $row->model_UA,
|
||||
'search_keyword' => $row->search_keyword,
|
||||
'author' => $row->author,
|
||||
'registration_date' => $row->registration_date,
|
||||
'memo' => $row->remark,
|
||||
'components' => $components,
|
||||
'material_summary' => $materialSummary,
|
||||
'source' => 'chandj_guiderail',
|
||||
'legacy_guiderail_num' => $row->num,
|
||||
];
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::table('items')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'item_type' => 'PT',
|
||||
'item_category' => 'GUIDERAIL_MODEL',
|
||||
'unit' => 'SET',
|
||||
'options' => json_encode($options, JSON_UNESCAPED_UNICODE),
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$created++;
|
||||
$this->line(" ✅ {$code} ({$name}) — {$row->firstitem}/{$row->model_UA} — 부품 ".count($components).'개');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("생성: {$created}건 | 스킵(중복): {$skipped}건");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('🔍 DRY-RUN 완료.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function convertComponent(array $c): array
|
||||
{
|
||||
$inputs = $c['inputList'] ?? [];
|
||||
$rates = $c['bendingrateList'] ?? [];
|
||||
$sums = $c['sumList'] ?? [];
|
||||
$colors = $c['colorList'] ?? [];
|
||||
$angles = $c['AList'] ?? [];
|
||||
|
||||
// bendingData 형식으로 변환
|
||||
$bendingData = [];
|
||||
for ($i = 0; $i < count($inputs); $i++) {
|
||||
$bendingData[] = [
|
||||
'no' => $i + 1,
|
||||
'input' => (float) ($inputs[$i] ?? 0),
|
||||
'rate' => (string) ($rates[$i] ?? ''),
|
||||
'sum' => (float) ($sums[$i] ?? 0),
|
||||
'color' => (bool) ($colors[$i] ?? false),
|
||||
'aAngle' => (bool) ($angles[$i] ?? false),
|
||||
];
|
||||
}
|
||||
|
||||
$lastSum = ! empty($sums) ? (float) end($sums) : 0;
|
||||
|
||||
return [
|
||||
'orderNumber' => $c['orderNumber'] ?? null,
|
||||
'itemName' => $c['itemName'] ?? '',
|
||||
'material' => $c['material'] ?? '',
|
||||
'quantity' => (int) ($c['quantity'] ?? 1),
|
||||
'width_sum' => $lastSum,
|
||||
'bendingData' => $bendingData,
|
||||
'legacy_bending_num' => $c['num'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BendingItem;
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* items(BENDING) + options JSON → bending_items + bending_data 이관
|
||||
*
|
||||
* 실행: php artisan bending:migrate-to-new-table
|
||||
* 롤백: php artisan bending:migrate-to-new-table --rollback
|
||||
*/
|
||||
class MigrateBendingItemsToNewTable extends Command
|
||||
{
|
||||
protected $signature = 'bending:migrate-to-new-table
|
||||
{--tenant_id=287 : 테넌트 ID}
|
||||
{--dry-run : 실행하지 않고 미리보기만}
|
||||
{--rollback : bending_items/bending_data 전체 삭제}';
|
||||
|
||||
protected $description = 'items(BENDING) → bending_items + bending_data 테이블 이관';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant_id');
|
||||
$dryRun = $this->option('dry-run');
|
||||
$rollback = $this->option('rollback');
|
||||
|
||||
if ($rollback) {
|
||||
return $this->rollback($tenantId);
|
||||
}
|
||||
|
||||
// 이미 이관된 데이터 확인
|
||||
$existingCount = BendingItem::where('tenant_id', $tenantId)->count();
|
||||
if ($existingCount > 0) {
|
||||
$this->warn("이미 bending_items에 {$existingCount}건 존재합니다.");
|
||||
if (! $this->confirm('기존 데이터 삭제 후 재이관하시겠습니까?')) {
|
||||
return 0;
|
||||
}
|
||||
$this->rollback($tenantId);
|
||||
}
|
||||
|
||||
// items(BENDING) 조회
|
||||
$items = Item::where('item_category', 'BENDING')
|
||||
->where('tenant_id', $tenantId)
|
||||
->get();
|
||||
|
||||
$this->info("이관 대상: {$items->count()}건");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->previewItems($items);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
$errors = 0;
|
||||
$bdCount = 0;
|
||||
|
||||
DB::transaction(function () use ($items, $tenantId, &$success, &$errors, &$bdCount) {
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$bi = $this->migrateItem($item, $tenantId);
|
||||
$bdRows = $this->migrateBendingData($bi, $item);
|
||||
$bdCount += $bdRows;
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(" ❌ {$item->code}: {$e->getMessage()}");
|
||||
$errors++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info("완료: {$success}건 이관, {$bdCount}건 전개도 행, {$errors}건 오류");
|
||||
|
||||
return $errors > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
private function migrateItem(Item $item, int $tenantId): BendingItem
|
||||
{
|
||||
$opts = $item->options ?? [];
|
||||
|
||||
// item_name: options.item_name → name 폴백
|
||||
$itemName = $opts['item_name'] ?? null;
|
||||
if (empty($itemName) || $itemName === 'null') {
|
||||
$itemName = $item->name;
|
||||
}
|
||||
|
||||
$bi = BendingItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $item->code,
|
||||
'legacy_code' => $item->code,
|
||||
'legacy_bending_id' => $opts['legacy_bending_num'] ?? null,
|
||||
// 정규 컬럼 (options에서 승격)
|
||||
'item_name' => $itemName,
|
||||
'item_sep' => $this->cleanNull($opts['item_sep'] ?? null),
|
||||
'item_bending' => $this->cleanNull($opts['item_bending'] ?? null),
|
||||
'material' => $this->cleanNull($opts['material'] ?? null),
|
||||
'item_spec' => $this->cleanNull($opts['item_spec'] ?? null),
|
||||
'model_name' => $this->cleanNull($opts['model_name'] ?? null),
|
||||
'model_UA' => $this->cleanNull($opts['model_UA'] ?? null),
|
||||
'rail_width' => $this->toDecimal($opts['rail_width'] ?? null),
|
||||
'exit_direction' => $this->cleanNull($opts['exit_direction'] ?? null),
|
||||
'box_width' => $this->toDecimal($opts['box_width'] ?? null),
|
||||
'box_height' => $this->toDecimal($opts['box_height'] ?? null),
|
||||
'front_bottom' => $this->toDecimal($opts['front_bottom_width'] ?? $opts['front_bottom'] ?? null),
|
||||
'inspection_door' => $this->cleanNull($opts['inspection_door'] ?? null),
|
||||
// 비정형 속성
|
||||
'options' => $this->buildMetaOptions($opts),
|
||||
'is_active' => $item->is_active,
|
||||
'created_by' => $item->created_by,
|
||||
'updated_by' => $item->updated_by,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ {$item->code} → bending_items#{$bi->id} ({$itemName})");
|
||||
|
||||
return $bi;
|
||||
}
|
||||
|
||||
private function migrateBendingData(BendingItem $bi, Item $item): int
|
||||
{
|
||||
$opts = $item->options ?? [];
|
||||
$bendingData = $opts['bendingData'] ?? [];
|
||||
|
||||
if (empty($bendingData) || ! is_array($bendingData)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// bending_items.bending_data JSON 컬럼에 저장
|
||||
$bi->update(['bending_data' => $bendingData]);
|
||||
|
||||
return count($bendingData);
|
||||
}
|
||||
|
||||
private function rollback(int $tenantId): int
|
||||
{
|
||||
$biCount = BendingItem::where('tenant_id', $tenantId)->count();
|
||||
BendingItem::where('tenant_id', $tenantId)->forceDelete();
|
||||
$this->info("롤백 완료: bending_items {$biCount}건 삭제");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function previewItems($items): void
|
||||
{
|
||||
$headers = ['code', 'name', 'item_name(opts)', 'item_sep', 'material', 'has_bd'];
|
||||
$rows = $items->take(20)->map(function ($item) {
|
||||
$opts = $item->options ?? [];
|
||||
return [
|
||||
$item->code,
|
||||
mb_substr($item->name, 0, 20),
|
||||
mb_substr($opts['item_name'] ?? '(NULL)', 0, 20),
|
||||
$opts['item_sep'] ?? '-',
|
||||
$opts['material'] ?? '-',
|
||||
! empty($opts['bendingData']) ? '✅' : '❌',
|
||||
];
|
||||
});
|
||||
$this->table($headers, $rows);
|
||||
|
||||
$nullNameCount = $items->filter(fn ($i) => empty(($i->options ?? [])['item_name']))->count();
|
||||
$hasBdCount = $items->filter(fn ($i) => ! empty(($i->options ?? [])['bendingData']))->count();
|
||||
$this->info("item_name NULL: {$nullNameCount}건 (name 필드로 폴백)");
|
||||
$this->info("bendingData 있음: {$hasBdCount}건");
|
||||
}
|
||||
|
||||
private function cleanNull(?string $value): ?string
|
||||
{
|
||||
if ($value === null || $value === 'null' || $value === '') {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function toDecimal(mixed $value): ?float
|
||||
{
|
||||
if ($value === null || $value === 'null' || $value === '') {
|
||||
return null;
|
||||
}
|
||||
return (float) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* options에 남길 비정형 속성만 추출
|
||||
*/
|
||||
private function buildMetaOptions(array $opts): ?array
|
||||
{
|
||||
$metaKeys = ['search_keyword', 'registration_date', 'author', 'memo', 'parent_num', 'modified_by'];
|
||||
$meta = [];
|
||||
foreach ($metaKeys as $key) {
|
||||
$val = $opts[$key] ?? null;
|
||||
if ($val !== null && $val !== 'null' && $val !== '') {
|
||||
$meta[$key] = $val;
|
||||
}
|
||||
}
|
||||
return empty($meta) ? null : $meta;
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 데모 쇼케이스 테넌트 데이터 리셋 커맨드
|
||||
*
|
||||
* 매일 자정에 쇼케이스 테넌트의 비즈니스 데이터를 삭제하고
|
||||
* 샘플 데이터를 다시 시드한다.
|
||||
*
|
||||
* 기존 코드 영향 없음: DEMO_SHOWCASE 테넌트만 대상
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class ResetDemoShowcaseCommand extends Command
|
||||
{
|
||||
protected $signature = 'demo:reset-showcase
|
||||
{--seed : 리셋 후 샘플 데이터 시드}
|
||||
{--dry-run : 실제 삭제 없이 대상만 표시}';
|
||||
|
||||
protected $description = '데모 쇼케이스 테넌트의 비즈니스 데이터를 리셋합니다';
|
||||
|
||||
/**
|
||||
* 리셋 대상 테이블 목록 (tenant_id 기반)
|
||||
* 순서 중요: FK 의존성 역순으로 삭제
|
||||
*/
|
||||
private const RESET_TABLES = [
|
||||
// 영업/주문
|
||||
'order_item_components',
|
||||
'order_items',
|
||||
'order_histories',
|
||||
'orders',
|
||||
'quotes',
|
||||
|
||||
// 생산
|
||||
'production_results',
|
||||
'production_plans',
|
||||
|
||||
// 자재/재고
|
||||
'material_inspection_items',
|
||||
'material_inspections',
|
||||
'material_receipts',
|
||||
'lot_sales',
|
||||
'lots',
|
||||
|
||||
// 마스터
|
||||
'price_histories',
|
||||
'product_components',
|
||||
'items',
|
||||
'clients',
|
||||
|
||||
// 파일 (데모 데이터 관련)
|
||||
// files는 morphable이므로 별도 처리 필요
|
||||
|
||||
// 조직
|
||||
'departments',
|
||||
|
||||
// 감사 로그 (데모 데이터)
|
||||
'audit_logs',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$showcases = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)
|
||||
->get();
|
||||
|
||||
if ($showcases->isEmpty()) {
|
||||
$this->info('데모 쇼케이스 테넌트가 없습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($showcases as $tenant) {
|
||||
$this->info("리셋 대상: [{$tenant->id}] {$tenant->company_name}");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->showStats($tenant);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->resetTenantData($tenant);
|
||||
|
||||
if ($this->option('seed')) {
|
||||
$this->seedSampleData($tenant);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function showStats(Tenant $tenant): void
|
||||
{
|
||||
foreach (self::RESET_TABLES as $table) {
|
||||
if (! \Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count = DB::table($table)->where('tenant_id', $tenant->id)->count();
|
||||
if ($count > 0) {
|
||||
$this->line(" - {$table}: {$count}건");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function resetTenantData(Tenant $tenant): void
|
||||
{
|
||||
$totalDeleted = 0;
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach (self::RESET_TABLES as $table) {
|
||||
if (! \Schema::hasTable($table)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deleted = DB::table($table)->where('tenant_id', $tenant->id)->delete();
|
||||
if ($deleted > 0) {
|
||||
$this->line(" 삭제: {$table} → {$deleted}건");
|
||||
$totalDeleted += $deleted;
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->info(" 총 {$totalDeleted}건 삭제 완료");
|
||||
|
||||
Log::info('데모 쇼케이스 리셋 완료', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'deleted_count' => $totalDeleted,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error(" 리셋 실패: {$e->getMessage()}");
|
||||
Log::error('데모 쇼케이스 리셋 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function seedSampleData(Tenant $tenant): void
|
||||
{
|
||||
$preset = $tenant->getDemoPreset() ?? 'manufacturing';
|
||||
$this->info(" 샘플 데이터 시드: {$preset}");
|
||||
|
||||
try {
|
||||
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
|
||||
$seeder->run($tenant->id);
|
||||
$this->info(' 샘플 데이터 시드 완료');
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" 시드 실패: {$e->getMessage()}");
|
||||
Log::error('데모 샘플 데이터 시드 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,6 @@ public function render($request, Throwable $exception)
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '입력값 검증 실패',
|
||||
'errors' => $exception->errors(),
|
||||
'error' => [
|
||||
'code' => 422,
|
||||
'details' => $exception->errors(),
|
||||
@@ -96,7 +95,7 @@ public function render($request, Throwable $exception)
|
||||
if ($exception instanceof BadRequestHttpException) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $exception->getMessage() ?: '잘못된 요청',
|
||||
'message' => '잘못된 요청',
|
||||
'data' => null,
|
||||
], 400);
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Tenants\AiPricingConfig;
|
||||
use App\Models\Tenants\AiTokenUsage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiTokenHelper
|
||||
{
|
||||
/**
|
||||
* Gemini API 응답에서 토큰 사용량 저장
|
||||
*/
|
||||
public static function saveGeminiUsage(array $apiResult, string $model, string $menuName): void
|
||||
{
|
||||
try {
|
||||
$usage = $apiResult['usageMetadata'] ?? null;
|
||||
if (! $usage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$promptTokens = $usage['promptTokenCount'] ?? 0;
|
||||
$completionTokens = $usage['candidatesTokenCount'] ?? 0;
|
||||
$totalTokens = $usage['totalTokenCount'] ?? 0;
|
||||
|
||||
$pricing = AiPricingConfig::getActivePricing('gemini');
|
||||
$inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000;
|
||||
$outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000;
|
||||
|
||||
self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('AI token usage save failed (Gemini)', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude API 응답에서 토큰 사용량 저장
|
||||
*/
|
||||
public static function saveClaudeUsage(array $apiResult, string $model, string $menuName): void
|
||||
{
|
||||
try {
|
||||
$usage = $apiResult['usage'] ?? null;
|
||||
if (! $usage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$promptTokens = $usage['input_tokens'] ?? 0;
|
||||
$completionTokens = $usage['output_tokens'] ?? 0;
|
||||
$totalTokens = $promptTokens + $completionTokens;
|
||||
|
||||
$pricing = AiPricingConfig::getActivePricing('claude');
|
||||
$inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.25 / 1_000_000;
|
||||
$outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 1.25 / 1_000_000;
|
||||
|
||||
self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('AI token usage save failed (Claude)', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare R2 Storage 업로드 사용량 저장
|
||||
* Class A (PUT/POST): $0.0045 / 1,000,000건
|
||||
* Storage: $0.015 / GB / 월
|
||||
*/
|
||||
public static function saveR2StorageUsage(string $menuName, int $fileSizeBytes): void
|
||||
{
|
||||
try {
|
||||
$pricing = AiPricingConfig::getActivePricing('cloudflare-r2');
|
||||
$unitPrice = $pricing ? (float) $pricing->unit_price : 0.0045;
|
||||
|
||||
$operationCost = $unitPrice / 1_000_000;
|
||||
$fileSizeGB = $fileSizeBytes / (1024 * 1024 * 1024);
|
||||
$storageCost = $fileSizeGB * 0.015;
|
||||
$costUsd = $operationCost + $storageCost;
|
||||
|
||||
self::save('cloudflare-r2', $menuName, $fileSizeBytes, 0, $fileSizeBytes, $costUsd / max($fileSizeBytes, 1), 0);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('AI token usage save failed (R2)', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speech-to-Text 사용량 저장
|
||||
* STT latest_long 모델: $0.009 / 15초
|
||||
*/
|
||||
public static function saveSttUsage(string $menuName, int $durationSeconds): void
|
||||
{
|
||||
try {
|
||||
$pricing = AiPricingConfig::getActivePricing('google-stt');
|
||||
$sttUnitPrice = $pricing ? (float) $pricing->unit_price : 0.009;
|
||||
$costUsd = ceil($durationSeconds / 15) * $sttUnitPrice;
|
||||
|
||||
self::save('google-speech-to-text', $menuName, $durationSeconds, 0, $durationSeconds, $costUsd / max($durationSeconds, 1), 0);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('AI token usage save failed (STT)', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 공통 저장 로직
|
||||
*/
|
||||
private static function save(
|
||||
string $model,
|
||||
string $menuName,
|
||||
int $promptTokens,
|
||||
int $completionTokens,
|
||||
int $totalTokens,
|
||||
float $inputPricePerToken,
|
||||
float $outputPricePerToken,
|
||||
): void {
|
||||
$costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken);
|
||||
$exchangeRate = AiPricingConfig::getExchangeRate();
|
||||
$costKrw = $costUsd * $exchangeRate;
|
||||
|
||||
$tenantId = app('tenant_id');
|
||||
$userId = app('api_user');
|
||||
|
||||
AiTokenUsage::create([
|
||||
'tenant_id' => $tenantId ?: 1,
|
||||
'model' => $model,
|
||||
'menu_name' => $menuName,
|
||||
'prompt_tokens' => $promptTokens,
|
||||
'completion_tokens' => $completionTokens,
|
||||
'total_tokens' => $totalTokens,
|
||||
'cost_usd' => $costUsd,
|
||||
'cost_krw' => $costKrw,
|
||||
'request_id' => Str::uuid()->toString(),
|
||||
'created_by' => $userId ?: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -146,22 +146,13 @@ public static function error(
|
||||
int $code = 400,
|
||||
array $error = []
|
||||
): JsonResponse {
|
||||
$errorBody = [
|
||||
'code' => $code,
|
||||
'details' => $error['details'] ?? null,
|
||||
];
|
||||
|
||||
// details, code 이외 추가 필드(expected_code 등)를 error 객체에 포함
|
||||
$reserved = ['details'];
|
||||
$extra = array_diff_key($error, array_flip($reserved));
|
||||
if ($extra) {
|
||||
$errorBody = array_merge($errorBody, $extra);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "[{$code}] {$message}",
|
||||
'error' => $errorBody,
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'details' => $error['details'] ?? null,
|
||||
],
|
||||
], $code);
|
||||
}
|
||||
|
||||
@@ -234,16 +225,8 @@ public static function handle(
|
||||
$message = (string) ($result['message'] ?? ($result['error'] ?? '서버 에러'));
|
||||
$details = $result['details'] ?? null;
|
||||
|
||||
// 에러 신호 배열의 추가 필드(expected_code 등)를 응답에 포함
|
||||
$reserved = ['error', 'code', 'message', 'details'];
|
||||
$extra = array_diff_key($result, array_flip($reserved));
|
||||
$errorData = ['details' => $details];
|
||||
if ($extra) {
|
||||
$errorData = array_merge($errorData, $extra);
|
||||
}
|
||||
|
||||
// 에러에도 쿼리 로그 포함되도록 error()가 처리하게 맡김
|
||||
return self::error($message, $code, $errorData);
|
||||
return self::error($message, $code, ['details' => $details]);
|
||||
}
|
||||
|
||||
// 표준 박스( ['data'=>..., 'query'=>..., 'statusCode'=>...] ) 하위호환
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* eval() 없이 산술 수식을 안전하게 계산하는 평가기
|
||||
*
|
||||
* Shunting-yard 알고리즘으로 중위 표기법 → 후위 표기법(RPN) 변환 후 계산
|
||||
* 지원: 숫자, +, -, *, /, %, (, ), 단항 마이너스
|
||||
*/
|
||||
class SafeMathEvaluator
|
||||
{
|
||||
private const OPERATORS = ['+', '-', '*', '/', '%'];
|
||||
|
||||
private const PRECEDENCE = [
|
||||
'+' => 2,
|
||||
'-' => 2,
|
||||
'*' => 3,
|
||||
'/' => 3,
|
||||
'%' => 3,
|
||||
'UNARY_MINUS' => 4,
|
||||
];
|
||||
|
||||
/**
|
||||
* 산술 수식을 계산하여 float 반환
|
||||
*/
|
||||
public static function calculate(string $expression): float
|
||||
{
|
||||
$expression = trim($expression);
|
||||
|
||||
if ($expression === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$tokens = self::tokenize($expression);
|
||||
|
||||
if (empty($tokens)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rpn = self::toRPN($tokens);
|
||||
|
||||
return self::evaluateRPN($rpn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비교식을 평가하여 bool 반환
|
||||
* 예: "3000 <= 6000", "100 == 100", "5 > 3 && 2 < 4"
|
||||
*/
|
||||
public static function compare(string $expression): bool
|
||||
{
|
||||
$expression = trim($expression);
|
||||
|
||||
// && 논리 AND 처리
|
||||
if (str_contains($expression, '&&')) {
|
||||
$parts = explode('&&', $expression);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (! self::compare(trim($part))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// || 논리 OR 처리
|
||||
if (str_contains($expression, '||')) {
|
||||
$parts = explode('||', $expression);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (self::compare(trim($part))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비교 연산자 추출 (2문자 먼저 검사)
|
||||
$operators = ['>=', '<=', '!=', '==', '>', '<'];
|
||||
|
||||
foreach ($operators as $op) {
|
||||
$pos = strpos($expression, $op);
|
||||
if ($pos !== false) {
|
||||
$left = self::calculate(substr($expression, 0, $pos));
|
||||
$right = self::calculate(substr($expression, $pos + strlen($op)));
|
||||
|
||||
return match ($op) {
|
||||
'>=' => $left >= $right,
|
||||
'<=' => $left <= $right,
|
||||
'!=' => $left != $right,
|
||||
'==' => $left == $right,
|
||||
'>' => $left > $right,
|
||||
'<' => $left < $right,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 비교 연산자가 없으면 수치를 boolean으로 평가
|
||||
return (bool) self::calculate($expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수식 문자열을 토큰 배열로 분리
|
||||
*/
|
||||
private static function tokenize(string $expression): array
|
||||
{
|
||||
$tokens = [];
|
||||
$len = strlen($expression);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $len) {
|
||||
$char = $expression[$i];
|
||||
|
||||
// 공백 건너뛰기
|
||||
if ($char === ' ' || $char === "\t") {
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 숫자 (정수, 소수)
|
||||
if (is_numeric($char) || ($char === '.' && $i + 1 < $len && is_numeric($expression[$i + 1]))) {
|
||||
$num = '';
|
||||
while ($i < $len && (is_numeric($expression[$i]) || $expression[$i] === '.')) {
|
||||
$num .= $expression[$i];
|
||||
$i++;
|
||||
}
|
||||
$tokens[] = ['type' => 'number', 'value' => (float) $num];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 괄호
|
||||
if ($char === '(') {
|
||||
$tokens[] = ['type' => 'lparen'];
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === ')') {
|
||||
$tokens[] = ['type' => 'rparen'];
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 연산자
|
||||
if (in_array($char, self::OPERATORS)) {
|
||||
// 단항 마이너스 판별: 맨 앞이거나, 앞이 연산자 또는 여는 괄호인 경우
|
||||
if ($char === '-') {
|
||||
$isUnary = empty($tokens)
|
||||
|| $tokens[count($tokens) - 1]['type'] === 'operator'
|
||||
|| $tokens[count($tokens) - 1]['type'] === 'lparen';
|
||||
|
||||
if ($isUnary) {
|
||||
$tokens[] = ['type' => 'operator', 'value' => 'UNARY_MINUS'];
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$tokens[] = ['type' => 'operator', 'value' => $char];
|
||||
$i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException("허용되지 않는 문자: '{$char}' (위치 {$i})");
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중위 표기법 토큰 → 후위 표기법(RPN) 변환 (Shunting-yard)
|
||||
*/
|
||||
private static function toRPN(array $tokens): array
|
||||
{
|
||||
$output = [];
|
||||
$operatorStack = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if ($token['type'] === 'number') {
|
||||
$output[] = $token;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($token['type'] === 'operator') {
|
||||
$op = $token['value'];
|
||||
$prec = self::PRECEDENCE[$op] ?? 0;
|
||||
|
||||
while (! empty($operatorStack)) {
|
||||
$top = end($operatorStack);
|
||||
if ($top['type'] === 'lparen') {
|
||||
break;
|
||||
}
|
||||
$topPrec = self::PRECEDENCE[$top['value']] ?? 0;
|
||||
|
||||
// 단항 연산자는 오른쪽 결합
|
||||
if ($op === 'UNARY_MINUS') {
|
||||
if ($topPrec > $prec) {
|
||||
$output[] = array_pop($operatorStack);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 이항 연산자는 왼쪽 결합 (같은 우선순위면 먼저 pop)
|
||||
if ($topPrec >= $prec) {
|
||||
$output[] = array_pop($operatorStack);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$operatorStack[] = $token;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($token['type'] === 'lparen') {
|
||||
$operatorStack[] = $token;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($token['type'] === 'rparen') {
|
||||
while (! empty($operatorStack) && end($operatorStack)['type'] !== 'lparen') {
|
||||
$output[] = array_pop($operatorStack);
|
||||
}
|
||||
if (empty($operatorStack)) {
|
||||
throw new InvalidArgumentException('괄호 불일치: 여는 괄호 없음');
|
||||
}
|
||||
array_pop($operatorStack); // 여는 괄호 제거
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
while (! empty($operatorStack)) {
|
||||
$top = array_pop($operatorStack);
|
||||
if ($top['type'] === 'lparen') {
|
||||
throw new InvalidArgumentException('괄호 불일치: 닫는 괄호 없음');
|
||||
}
|
||||
$output[] = $top;
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 후위 표기법(RPN) 계산
|
||||
*/
|
||||
private static function evaluateRPN(array $rpn): float
|
||||
{
|
||||
$stack = [];
|
||||
|
||||
foreach ($rpn as $token) {
|
||||
if ($token['type'] === 'number') {
|
||||
$stack[] = $token['value'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($token['type'] === 'operator') {
|
||||
$op = $token['value'];
|
||||
|
||||
// 단항 마이너스
|
||||
if ($op === 'UNARY_MINUS') {
|
||||
if (empty($stack)) {
|
||||
throw new InvalidArgumentException('수식 오류: 단항 마이너스 피연산자 없음');
|
||||
}
|
||||
$stack[] = -array_pop($stack);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이항 연산자
|
||||
if (count($stack) < 2) {
|
||||
throw new InvalidArgumentException('수식 오류: 피연산자 부족');
|
||||
}
|
||||
|
||||
$right = array_pop($stack);
|
||||
$left = array_pop($stack);
|
||||
|
||||
$stack[] = match ($op) {
|
||||
'+' => $left + $right,
|
||||
'-' => $left - $right,
|
||||
'*' => $left * $right,
|
||||
'/' => $right != 0 ? $left / $right : throw new InvalidArgumentException('0으로 나눌 수 없음'),
|
||||
'%' => $right != 0 ? fmod($left, $right) : throw new InvalidArgumentException('0으로 나눌 수 없음'),
|
||||
default => throw new InvalidArgumentException("알 수 없는 연산자: {$op}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (count($stack) !== 1) {
|
||||
throw new InvalidArgumentException('수식 오류: 결과가 하나가 아님');
|
||||
}
|
||||
|
||||
return (float) $stack[0];
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\AiTokenUsageListRequest;
|
||||
use App\Services\AiTokenUsageService;
|
||||
|
||||
class AiTokenUsageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiTokenUsageService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* AI 토큰 사용량 목록 + 통계
|
||||
*/
|
||||
public function index(AiTokenUsageListRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(fn () => $this->service->list($request->validated()));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 단가 설정 조회 (읽기 전용)
|
||||
*/
|
||||
public function pricing()
|
||||
{
|
||||
return ApiResponse::handle(fn () => $this->service->getPricing());
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,13 @@
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\BarobillBankTransaction;
|
||||
use App\Models\Barobill\BarobillCardTransaction;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Services\Barobill\BarobillSoapService;
|
||||
use App\Services\BarobillService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BarobillController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BarobillService $barobillService,
|
||||
private BarobillSoapService $soapService,
|
||||
private BarobillService $barobillService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -24,43 +19,17 @@ public function __construct(
|
||||
public function status()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$tenantId = app('tenant_id');
|
||||
$setting = $this->barobillService->getSetting();
|
||||
$member = BarobillMember::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
$accountCount = 0;
|
||||
$cardCount = 0;
|
||||
|
||||
if ($member) {
|
||||
$accountCount = BarobillBankTransaction::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->distinct('bank_account_num')
|
||||
->count('bank_account_num');
|
||||
|
||||
$cardCount = BarobillCardTransaction::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->distinct('card_num')
|
||||
->count('card_num');
|
||||
}
|
||||
|
||||
return [
|
||||
'bank_service_count' => $accountCount,
|
||||
'account_link_count' => $accountCount,
|
||||
'card_count' => $cardCount,
|
||||
'member' => $member ? [
|
||||
'barobill_id' => $member->barobill_id,
|
||||
'biz_no' => $member->formatted_biz_no,
|
||||
'corp_name' => $member->corp_name,
|
||||
'status' => $member->status,
|
||||
'server_mode' => $member->server_mode ?? 'test',
|
||||
] : ($setting ? [
|
||||
'bank_service_count' => 0,
|
||||
'account_link_count' => 0,
|
||||
'member' => $setting ? [
|
||||
'barobill_id' => $setting->barobill_id,
|
||||
'biz_no' => $setting->corp_num,
|
||||
'status' => $setting->isVerified() ? 'active' : 'inactive',
|
||||
'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production',
|
||||
] : null),
|
||||
] : null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Services\Barobill\BarobillBankSyncService;
|
||||
use App\Services\Barobill\BarobillCardSyncService;
|
||||
use App\Services\Barobill\BarobillSoapService;
|
||||
use App\Services\Barobill\HometaxSyncService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BarobillSyncController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BarobillSoapService $soapService,
|
||||
private BarobillBankSyncService $bankSyncService,
|
||||
private BarobillCardSyncService $cardSyncService,
|
||||
private HometaxSyncService $hometaxSyncService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 수동 은행 동기화
|
||||
*/
|
||||
public function syncBank(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'start_date' => 'nullable|date_format:Ymd',
|
||||
'end_date' => 'nullable|date_format:Ymd',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
$tenantId = app('tenant_id');
|
||||
$startDate = $data['start_date'] ?? Carbon::now()->subMonth()->format('Ymd');
|
||||
$endDate = $data['end_date'] ?? Carbon::now()->format('Ymd');
|
||||
|
||||
return $this->bankSyncService->syncIfNeeded($tenantId, $startDate, $endDate);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 카드 동기화
|
||||
*/
|
||||
public function syncCard(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'start_date' => 'nullable|date_format:Ymd',
|
||||
'end_date' => 'nullable|date_format:Ymd',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
$tenantId = app('tenant_id');
|
||||
$startDate = $data['start_date'] ?? Carbon::now()->subMonth()->format('Ymd');
|
||||
$endDate = $data['end_date'] ?? Carbon::now()->format('Ymd');
|
||||
|
||||
return $this->cardSyncService->syncCardTransactions($tenantId, $startDate, $endDate);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 홈택스 동기화
|
||||
*/
|
||||
public function syncHometax(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'invoices' => 'required|array',
|
||||
'invoices.*.ntsConfirmNum' => 'required|string',
|
||||
'invoice_type' => 'required|in:sales,purchase',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
return $this->hometaxSyncService->syncInvoices(
|
||||
$data['invoices'],
|
||||
$tenantId,
|
||||
$data['invoice_type']
|
||||
);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 등록계좌 목록 (SOAP 실시간)
|
||||
*/
|
||||
public function accounts()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$member = $this->getMember();
|
||||
if (! $member) {
|
||||
return ['accounts' => [], 'message' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
|
||||
return [
|
||||
'accounts' => $this->bankSyncService->getRegisteredAccounts($member),
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 등록카드 목록 (SOAP 실시간)
|
||||
*/
|
||||
public function cards()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$member = $this->getMember();
|
||||
if (! $member) {
|
||||
return ['cards' => [], 'message' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
|
||||
return [
|
||||
'cards' => $this->cardSyncService->getRegisteredCards($member),
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증서 상태 조회 (만료일, 유효성)
|
||||
*/
|
||||
public function certificate()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$member = $this->getMember();
|
||||
if (! $member) {
|
||||
return ['certificate' => null, 'message' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
$corpNum = $member->biz_no;
|
||||
|
||||
$valid = $this->soapService->checkCertificateValid($corpNum);
|
||||
$expireDate = $this->soapService->getCertificateExpireDate($corpNum);
|
||||
$registDate = $this->soapService->getCertificateRegistDate($corpNum);
|
||||
|
||||
return [
|
||||
'certificate' => [
|
||||
'is_valid' => $valid['success'] && ($valid['data'] ?? 0) >= 0,
|
||||
'expire_date' => $expireDate['success'] ? ($expireDate['data'] ?? null) : null,
|
||||
'regist_date' => $registDate['success'] ? ($registDate['data'] ?? null) : null,
|
||||
],
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 충전잔액 조회
|
||||
*/
|
||||
public function balance()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$member = $this->getMember();
|
||||
if (! $member) {
|
||||
return ['balance' => null, 'message' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
$result = $this->soapService->getBalanceCostAmount($member->biz_no);
|
||||
|
||||
return [
|
||||
'balance' => $result['success'] ? ($result['data'] ?? 0) : null,
|
||||
'success' => $result['success'],
|
||||
'error' => $result['error'] ?? null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 회원 등록 (SOAP RegistCorp)
|
||||
*/
|
||||
public function registerMember(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'biz_no' => 'required|string|size:10',
|
||||
'corp_name' => 'required|string',
|
||||
'ceo_name' => 'required|string',
|
||||
'biz_type' => 'nullable|string',
|
||||
'biz_class' => 'nullable|string',
|
||||
'addr' => 'nullable|string',
|
||||
'barobill_id' => 'required|string',
|
||||
'barobill_pwd' => 'required|string',
|
||||
'manager_name' => 'nullable|string',
|
||||
'manager_hp' => 'nullable|string',
|
||||
'manager_email' => 'nullable|email',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
$tenantId = app('tenant_id');
|
||||
$this->soapService->initForMember(
|
||||
BarobillMember::withoutGlobalScopes()->where('tenant_id', $tenantId)->first()
|
||||
?? new BarobillMember(['server_mode' => 'test'])
|
||||
);
|
||||
|
||||
$result = $this->soapService->registCorp($data);
|
||||
|
||||
if ($result['success']) {
|
||||
BarobillMember::withoutGlobalScopes()->updateOrCreate(
|
||||
['tenant_id' => $tenantId],
|
||||
[
|
||||
'biz_no' => $data['biz_no'],
|
||||
'corp_name' => $data['corp_name'],
|
||||
'ceo_name' => $data['ceo_name'],
|
||||
'biz_type' => $data['biz_type'] ?? null,
|
||||
'biz_class' => $data['biz_class'] ?? null,
|
||||
'addr' => $data['addr'] ?? null,
|
||||
'barobill_id' => $data['barobill_id'],
|
||||
'barobill_pwd' => $data['barobill_pwd'],
|
||||
'manager_name' => $data['manager_name'] ?? null,
|
||||
'manager_hp' => $data['manager_hp'] ?? null,
|
||||
'manager_email' => $data['manager_email'] ?? null,
|
||||
'status' => 'active',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 회원 수정 (SOAP UpdateCorpInfo)
|
||||
*/
|
||||
public function updateMember(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'corp_name' => 'required|string',
|
||||
'ceo_name' => 'required|string',
|
||||
'biz_type' => 'nullable|string',
|
||||
'biz_class' => 'nullable|string',
|
||||
'addr' => 'nullable|string',
|
||||
'manager_name' => 'nullable|string',
|
||||
'manager_hp' => 'nullable|string',
|
||||
'manager_email' => 'nullable|email',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($data) {
|
||||
$member = $this->getMember();
|
||||
if (! $member) {
|
||||
return ['error' => 'NO_MEMBER', 'code' => 404, 'message' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
|
||||
$data['biz_no'] = $member->biz_no;
|
||||
$result = $this->soapService->updateCorpInfo($data);
|
||||
|
||||
if ($result['success']) {
|
||||
$member->update([
|
||||
'corp_name' => $data['corp_name'],
|
||||
'ceo_name' => $data['ceo_name'],
|
||||
'biz_type' => $data['biz_type'] ?? $member->biz_type,
|
||||
'biz_class' => $data['biz_class'] ?? $member->biz_class,
|
||||
'addr' => $data['addr'] ?? $member->addr,
|
||||
'manager_name' => $data['manager_name'] ?? $member->manager_name,
|
||||
'manager_hp' => $data['manager_hp'] ?? $member->manager_hp,
|
||||
'manager_email' => $data['manager_email'] ?? $member->manager_email,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 회원 상태 (SOAP GetCorpState)
|
||||
*/
|
||||
public function memberStatus()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$member = $this->getMember();
|
||||
if (! $member) {
|
||||
return ['status' => null, 'message' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
$result = $this->soapService->getCorpState($member->biz_no);
|
||||
|
||||
return [
|
||||
'member' => [
|
||||
'biz_no' => $member->formatted_biz_no,
|
||||
'corp_name' => $member->corp_name,
|
||||
'status' => $member->status,
|
||||
'server_mode' => $member->server_mode,
|
||||
],
|
||||
'barobill_state' => $result['success'] ? $result['data'] : null,
|
||||
'error' => $result['error'] ?? null,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 테넌트의 바로빌 회원 조회
|
||||
*/
|
||||
private function getMember(): ?BarobillMember
|
||||
{
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
return BarobillMember::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenants\Receiving;
|
||||
use App\Services\BendingCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BendingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BendingCodeService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 절곡품 코드맵 조회 (캐스케이딩 드롭다운용)
|
||||
*/
|
||||
public function codeMap(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->getCodeMap();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운 선택 → 품목 매핑 조회
|
||||
*/
|
||||
public function resolveItem(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$prodCode = $request->query('prod');
|
||||
$specCode = $request->query('spec');
|
||||
$lengthCode = $request->query('length');
|
||||
|
||||
if (! $prodCode || ! $specCode || ! $lengthCode) {
|
||||
return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod, spec, length 파라미터가 필요합니다.'];
|
||||
}
|
||||
|
||||
$expectedCode = "BD-{$prodCode}{$specCode}-{$lengthCode}";
|
||||
$item = $this->service->resolveItem($prodCode, $specCode, $lengthCode);
|
||||
|
||||
if (! $item) {
|
||||
return [
|
||||
'error' => 'NOT_MAPPED',
|
||||
'code' => 404,
|
||||
'message' => '해당 조합에 매핑된 품목이 없습니다.',
|
||||
'expected_code' => $expectedCode,
|
||||
];
|
||||
}
|
||||
|
||||
$item['expected_code'] = $expectedCode;
|
||||
|
||||
return $item;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 원자재 LOT 목록 조회 (입고 + 수입검사 완료 기준)
|
||||
*
|
||||
* 재질(material) 키워드를 분해하여 유연 검색
|
||||
* 예: "EGI 1.55T" → "EGI" AND "1.55" 로 검색 (공백/T 무관)
|
||||
*/
|
||||
public function materialLots(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$material = $request->query('material');
|
||||
|
||||
$query = Receiving::whereIn('status', ['completed', 'inspection_completed'])
|
||||
->whereNotNull('lot_no')
|
||||
->where('lot_no', '!=', '');
|
||||
|
||||
// 재질 키워드 분해 검색 (공백/T 접미사 무관)
|
||||
if ($material) {
|
||||
// "EGI 1.55T" → ["EGI", "1.55"], "SUS 1.2T" → ["SUS", "1.2"]
|
||||
$keywords = preg_split('/[\s]+/', preg_replace('/T$/i', '', trim($material)));
|
||||
$keywords = array_filter($keywords);
|
||||
|
||||
$query->where(function ($q) use ($keywords) {
|
||||
foreach ($keywords as $kw) {
|
||||
$q->where(function ($sub) use ($kw) {
|
||||
$sub->where('item_name', 'LIKE', "%{$kw}%")
|
||||
->orWhere('specification', 'LIKE', "%{$kw}%");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query->select([
|
||||
'id',
|
||||
'lot_no',
|
||||
'supplier_lot',
|
||||
'item_name',
|
||||
'specification',
|
||||
'receiving_qty',
|
||||
'receiving_date',
|
||||
'supplier',
|
||||
'options',
|
||||
])
|
||||
->orderByDesc('receiving_date')
|
||||
->limit(50)
|
||||
->get();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* LOT 번호 생성 (일련번호 없음 — 같은 날 같은 조합은 동일 LOT)
|
||||
*/
|
||||
public function generateLotNumber(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$prodCode = $request->input('prod_code');
|
||||
$specCode = $request->input('spec_code');
|
||||
$lengthCode = $request->input('length_code');
|
||||
$regDate = $request->input('reg_date', now()->toDateString());
|
||||
|
||||
if (! $prodCode || ! $specCode || ! $lengthCode) {
|
||||
return ['error' => 'MISSING_PARAMS', 'code' => 400, 'message' => 'prod_code, spec_code, length_code가 필요합니다.'];
|
||||
}
|
||||
|
||||
$lotNumber = $this->service->generateLotNumber($prodCode, $specCode, $lengthCode, $regDate);
|
||||
$material = BendingCodeService::getMaterial($prodCode, $specCode);
|
||||
|
||||
return [
|
||||
'lot_number' => $lotNumber,
|
||||
'material' => $material,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\BendingItemIndexRequest;
|
||||
use App\Http\Requests\Api\V1\BendingItemStoreRequest;
|
||||
use App\Http\Requests\Api\V1\BendingItemUpdateRequest;
|
||||
use App\Http\Resources\Api\V1\BendingItemResource;
|
||||
use App\Services\BendingItemService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BendingItemController extends Controller
|
||||
{
|
||||
public function __construct(private BendingItemService $service) {}
|
||||
|
||||
/**
|
||||
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정
|
||||
*/
|
||||
private function ensureContext(Request $request): void
|
||||
{
|
||||
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
|
||||
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
|
||||
app()->instance('tenant_id', $tenantId);
|
||||
}
|
||||
if (! app()->bound('api_user') || ! app('api_user')) {
|
||||
// mng에서 Bearer 토큰 없이 호출 시 시스템 사용자(1)로 설정
|
||||
app()->instance('api_user', 1);
|
||||
}
|
||||
}
|
||||
|
||||
public function index(BendingItemIndexRequest $request): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$paginator = $this->service->list($request->validated());
|
||||
$paginator->getCollection()->transform(fn ($item) => (new BendingItemResource($item))->resolve());
|
||||
|
||||
return $paginator;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function filters(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->filters(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => new BendingItemResource($this->service->find($id)),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(BendingItemStoreRequest $request): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => new BendingItemResource($this->service->create($request->validated())),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(BendingItemUpdateRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => new BendingItemResource($this->service->update($id, $request->validated())),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->delete($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,6 @@ public function index(Request $request)
|
||||
}, __('message.client.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 간단 목록 (id, name만 반환) - vendors 엔드포인트용
|
||||
*/
|
||||
public function vendors(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->vendors($request->all());
|
||||
}, __('message.client.fetched'));
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\CondolenceExpense\StoreCondolenceExpenseRequest;
|
||||
use App\Http\Requests\V1\CondolenceExpense\UpdateCondolenceExpenseRequest;
|
||||
use App\Services\CondolenceExpenseService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CondolenceExpenseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CondolenceExpenseService $service
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 경조사비 목록
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$params = $request->only([
|
||||
'year',
|
||||
'category',
|
||||
'search',
|
||||
'sort_by',
|
||||
'sort_order',
|
||||
'per_page',
|
||||
'page',
|
||||
]);
|
||||
|
||||
$expenses = $this->service->index($params);
|
||||
|
||||
return ApiResponse::success($expenses, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경조사비 통계
|
||||
*/
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$params = $request->only(['year', 'category']);
|
||||
$summary = $this->service->summary($params);
|
||||
|
||||
return ApiResponse::success($summary, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경조사비 상세
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$expense = $this->service->show($id);
|
||||
|
||||
return ApiResponse::success($expense, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경조사비 등록
|
||||
*/
|
||||
public function store(StoreCondolenceExpenseRequest $request)
|
||||
{
|
||||
$expense = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($expense, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경조사비 수정
|
||||
*/
|
||||
public function update(int $id, UpdateCondolenceExpenseRequest $request)
|
||||
{
|
||||
$expense = $this->service->update($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($expense, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 경조사비 삭제
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$this->service->destroy($id);
|
||||
|
||||
return ApiResponse::success(null, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Demo\DemoAnalyticsService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 분석 API 컨트롤러
|
||||
*
|
||||
* 전환율, 파트너 성과, 활동 현황 등 데모 분석 엔드포인트
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private DemoAnalyticsService $service) {}
|
||||
|
||||
/**
|
||||
* 대시보드 요약
|
||||
*/
|
||||
public function summary()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->summary();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 전환율 퍼널 분석
|
||||
*/
|
||||
public function conversionFunnel(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->conversionFunnel($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트너별 성과 분석
|
||||
*/
|
||||
public function partnerPerformance(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->partnerPerformance($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트 활동 현황
|
||||
*/
|
||||
public function activityReport(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->activityReport($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Demo\DemoTenantStoreRequest;
|
||||
use App\Services\Demo\DemoTenantService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 관리 API 컨트롤러
|
||||
*
|
||||
* 파트너가 고객 체험 테넌트를 생성/관리하는 엔드포인트
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoTenantController extends Controller
|
||||
{
|
||||
public function __construct(private DemoTenantService $service) {}
|
||||
|
||||
/**
|
||||
* 내가 생성한 데모 테넌트 목록
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.demo_tenant.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트 상세 조회
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.demo_tenant.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 체험 테넌트 생성 (Tier 3)
|
||||
*/
|
||||
public function store(DemoTenantStoreRequest $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->createTrialFromApi($request->validated());
|
||||
}, __('message.demo_tenant.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 데이터 리셋
|
||||
*/
|
||||
public function reset(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->resetFromApi($id);
|
||||
}, __('message.demo_tenant.reset'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 체험 기간 연장
|
||||
*/
|
||||
public function extend(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$days = (int) $request->input('days', 30);
|
||||
|
||||
return $this->service->extendFromApi($id, $days);
|
||||
}, __('message.demo_tenant.extended'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 → 정식 전환
|
||||
*/
|
||||
public function convert(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->convertFromApi($id);
|
||||
}, __('message.demo_tenant.converted'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 현황 통계
|
||||
*/
|
||||
public function stats()
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->stats();
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
@@ -9,24 +9,9 @@
|
||||
use App\Http\Requests\Api\V1\ShareLinkRequest;
|
||||
use App\Services\FileStorageService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FileStorageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 (MNG 프록시 호출용)
|
||||
*/
|
||||
private function ensureContext(Request $request): void
|
||||
{
|
||||
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
|
||||
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
|
||||
app()->instance('tenant_id', $tenantId);
|
||||
}
|
||||
if (! app()->bound('api_user') || ! app('api_user')) {
|
||||
app()->instance('api_user', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to temp
|
||||
*/
|
||||
@@ -100,10 +85,8 @@ public function trash()
|
||||
/**
|
||||
* Download file (attachment)
|
||||
*/
|
||||
public function download(int $id, Request $request)
|
||||
public function download(int $id)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
@@ -113,53 +96,14 @@ public function download(int $id, Request $request)
|
||||
/**
|
||||
* View file inline (이미지/PDF 브라우저에서 바로 표시)
|
||||
*/
|
||||
public function view(int $id, Request $request)
|
||||
public function view(int $id)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
return $file->download(inline: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* R2 Presigned URL 발급 (30분 유효)
|
||||
*/
|
||||
public function presignedUrl(int $id, Request $request)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
if (! $file->file_path) {
|
||||
abort(404, 'File not found');
|
||||
}
|
||||
|
||||
$url = Storage::disk('r2')->temporaryUrl(
|
||||
$file->file_path,
|
||||
now()->addMinutes(30)
|
||||
);
|
||||
|
||||
return ApiResponse::handle(fn () => ['url' => $url]);
|
||||
}
|
||||
|
||||
/**
|
||||
* R2 Presigned URL 발급 (file_path 기반, 30분 유효)
|
||||
*/
|
||||
public function presignedUrlByPath(Request $request)
|
||||
{
|
||||
$path = $request->input('path');
|
||||
if (! $path) {
|
||||
abort(400, 'path is required');
|
||||
}
|
||||
|
||||
$url = Storage::disk('r2')->temporaryUrl($path, now()->addMinutes(30));
|
||||
|
||||
return ApiResponse::handle(fn () => ['url' => $url]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete file
|
||||
*/
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Api\V1\GuiderailModelResource;
|
||||
use App\Services\GuiderailModelService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GuiderailModelController extends Controller
|
||||
{
|
||||
public function __construct(private GuiderailModelService $service) {}
|
||||
|
||||
private function ensureContext(Request $request): void
|
||||
{
|
||||
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
|
||||
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
|
||||
app()->instance('tenant_id', $tenantId);
|
||||
}
|
||||
if (! app()->bound('api_user') || ! app('api_user')) {
|
||||
app()->instance('api_user', 1);
|
||||
}
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = $request->only(['item_category', 'item_sep', 'model_UA', 'check_type', 'model_name', 'exit_direction', 'search', 'page', 'size']);
|
||||
$paginator = $this->service->list($params);
|
||||
$paginator->getCollection()->transform(fn ($item) => (new GuiderailModelResource($item))->resolve());
|
||||
|
||||
return $paginator;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function filters(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->filters(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => new GuiderailModelResource($this->service->find($id)),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => new GuiderailModelResource($this->service->create($request->all())),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => new GuiderailModelResource($this->service->update($id, $request->all())),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->delete($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -446,13 +446,12 @@ private function expandBomItems(array $bom): array
|
||||
'child_item_type' => $childItem?->item_type,
|
||||
'unit' => $childItem?->unit,
|
||||
'quantity' => $entry['quantity'] ?? 1,
|
||||
'category' => $entry['category'] ?? null,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 트리 구조 빌드 (재귀, category 필드가 있으면 3단계 그룹화)
|
||||
* BOM 트리 구조 빌드 (재귀)
|
||||
*/
|
||||
private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): array
|
||||
{
|
||||
@@ -479,49 +478,16 @@ private function buildBomTree(Item $item, int $maxDepth, int $currentDepth): arr
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// category 필드가 있으면 카테고리별 그룹 노드 생성 (3단계)
|
||||
$hasCategory = collect($bom)->contains(fn ($b) => ! empty($b['category']));
|
||||
|
||||
if ($hasCategory) {
|
||||
$grouped = [];
|
||||
foreach ($bom as $entry) {
|
||||
$cat = $entry['category'] ?? '기타';
|
||||
$grouped[$cat][] = $entry;
|
||||
}
|
||||
$childItemId = $entry['child_item_id'] ?? null;
|
||||
$childItem = $childItems[$childItemId] ?? null;
|
||||
|
||||
foreach ($grouped as $catName => $catEntries) {
|
||||
$catChildren = [];
|
||||
foreach ($catEntries as $entry) {
|
||||
$childItem = $childItems[$entry['child_item_id'] ?? null] ?? null;
|
||||
if ($childItem) {
|
||||
$childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 2);
|
||||
$childTree['quantity'] = $entry['quantity'] ?? 1;
|
||||
$catChildren[] = $childTree;
|
||||
}
|
||||
}
|
||||
if (! empty($catChildren)) {
|
||||
$result['children'][] = [
|
||||
'id' => 0,
|
||||
'code' => '',
|
||||
'name' => $catName,
|
||||
'item_type' => 'CAT',
|
||||
'unit' => '',
|
||||
'depth' => $currentDepth + 1,
|
||||
'count' => count($catChildren),
|
||||
'children' => $catChildren,
|
||||
];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
foreach ($bom as $entry) {
|
||||
$childItem = $childItems[$entry['child_item_id'] ?? null] ?? null;
|
||||
if ($childItem) {
|
||||
$childTree = $this->buildBomTree($childItem, $maxDepth, $currentDepth + 1);
|
||||
$childTree['quantity'] = $entry['quantity'] ?? 1;
|
||||
$result['children'][] = $childTree;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,11 @@ public function index(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$params = [
|
||||
'size' => $request->input('size') ?? $request->input('per_page', 20),
|
||||
'size' => $request->input('size', 20),
|
||||
'q' => $request->input('q') ?? $request->input('search'),
|
||||
'category_id' => $request->input('category_id'),
|
||||
'item_type' => $request->input('type') ?? $request->input('item_type') ?? $request->input('itemType'),
|
||||
'item_type' => $request->input('type') ?? $request->input('item_type'),
|
||||
'item_category' => $request->input('item_category'),
|
||||
'bom_category' => $request->input('bom_category'),
|
||||
'group_id' => $request->input('group_id'),
|
||||
'active' => $request->input('is_active') ?? $request->input('active'),
|
||||
'has_bom' => $request->input('has_bom'),
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Item\ItemFileUploadRequest;
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\BendingItem;
|
||||
use App\Models\BendingModel;
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -28,20 +26,6 @@ class ItemsFileController extends Controller
|
||||
*/
|
||||
private const ITEM_GROUP_ID = '1';
|
||||
|
||||
/**
|
||||
* Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 (MNG 프록시 호출용)
|
||||
*/
|
||||
private function ensureContext(Request $request): void
|
||||
{
|
||||
if (! app()->bound('tenant_id') || ! app('tenant_id')) {
|
||||
$tenantId = (int) ($request->header('X-TENANT-ID') ?: 287);
|
||||
app()->instance('tenant_id', $tenantId);
|
||||
}
|
||||
if (! app()->bound('api_user') || ! app('api_user')) {
|
||||
app()->instance('api_user', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 목록 조회
|
||||
*
|
||||
@@ -49,19 +33,17 @@ private function ensureContext(Request $request): void
|
||||
*/
|
||||
public function index(int $id, Request $request)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$tenantId = app('tenant_id');
|
||||
$fieldKey = $request->input('field_key');
|
||||
|
||||
// 품목 존재 확인
|
||||
$owner = $this->getItemById($id, $tenantId);
|
||||
$docType = $this->getDocumentType($owner);
|
||||
$this->getItemById($id, $tenantId);
|
||||
|
||||
// 파일 조회
|
||||
$query = File::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('document_type', $docType)
|
||||
->where('document_type', self::ITEM_GROUP_ID)
|
||||
->where('document_id', $id);
|
||||
|
||||
// 특정 field_key만 조회
|
||||
@@ -87,7 +69,6 @@ public function index(int $id, Request $request)
|
||||
*/
|
||||
public function upload(int $id, ItemFileUploadRequest $request)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$tenantId = app('tenant_id');
|
||||
$userId = auth()->id() ?? app('api_user');
|
||||
@@ -97,8 +78,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
$existingFileId = $validated['file_id'] ?? null;
|
||||
|
||||
// 품목 존재 확인
|
||||
$owner = $this->getItemById($id, $tenantId);
|
||||
$docType = $this->getDocumentType($owner);
|
||||
$this->getItemById($id, $tenantId);
|
||||
|
||||
$replaced = false;
|
||||
|
||||
@@ -106,7 +86,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
if ($existingFileId) {
|
||||
$existingFile = File::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('document_type', $docType)
|
||||
->where('document_type', self::ITEM_GROUP_ID)
|
||||
->where('document_id', $id)
|
||||
->where('id', $existingFileId)
|
||||
->first();
|
||||
@@ -146,7 +126,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
'file_type' => $fileType, // 파일 형식 (image, document, excel, archive)
|
||||
'field_key' => $fieldKey, // 비즈니스 용도 (drawing, certificate 등)
|
||||
'document_id' => $id,
|
||||
'document_type' => $docType,
|
||||
'document_type' => self::ITEM_GROUP_ID, // group_id
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => $userId,
|
||||
'created_by' => $userId,
|
||||
@@ -172,20 +152,18 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
*/
|
||||
public function delete(int $id, mixed $fileId, Request $request)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
$fileId = (int) $fileId;
|
||||
|
||||
return ApiResponse::handle(function () use ($id, $fileId) {
|
||||
$tenantId = app('tenant_id');
|
||||
|
||||
// 품목 존재 확인
|
||||
$owner = $this->getItemById($id, $tenantId);
|
||||
$docType = $this->getDocumentType($owner);
|
||||
$this->getItemById($id, $tenantId);
|
||||
|
||||
// 파일 조회
|
||||
$file = File::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('document_type', $docType)
|
||||
->where('document_type', self::ITEM_GROUP_ID)
|
||||
->where('document_id', $id)
|
||||
->where('id', $fileId)
|
||||
->first();
|
||||
@@ -205,51 +183,19 @@ public function delete(int $id, mixed $fileId, Request $request)
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 품목 조회 (items → bending_items 폴백)
|
||||
* ID로 품목 조회 (통합 items 테이블)
|
||||
*/
|
||||
private function getItemById(int $id, int $tenantId): Item|BendingItem|BendingModel
|
||||
private function getItemById(int $id, int $tenantId): Item
|
||||
{
|
||||
$item = Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
|
||||
if ($item) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
// bending_items 폴백
|
||||
$bendingItem = BendingItem::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
|
||||
if ($bendingItem) {
|
||||
return $bendingItem;
|
||||
}
|
||||
|
||||
// bending_models 폴백
|
||||
$bendingModel = BendingModel::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->find($id);
|
||||
|
||||
if ($bendingModel) {
|
||||
return $bendingModel;
|
||||
}
|
||||
|
||||
if (! $item) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목 유형에 따른 document_type 반환
|
||||
*/
|
||||
private function getDocumentType(Item|BendingItem|BendingModel $item): string
|
||||
{
|
||||
if ($item instanceof BendingItem) {
|
||||
return 'bending_item';
|
||||
}
|
||||
if ($item instanceof BendingModel) {
|
||||
return 'bending_model';
|
||||
}
|
||||
return self::ITEM_GROUP_ID;
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Material\StoreNonconformingReportRequest;
|
||||
use App\Http\Requests\Material\UpdateNonconformingReportRequest;
|
||||
use App\Services\NonconformingReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NonconformingReportController extends Controller
|
||||
{
|
||||
public function __construct(private NonconformingReportService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->index($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
return $this->service->show($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function store(StoreNonconformingReportRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->store($request->validated());
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
public function update(UpdateNonconformingReportRequest $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->update($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->destroy($id);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
|
||||
public function changeStatus(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate(['status' => 'required|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED']);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->changeStatus($id, $request->input('status'));
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
public function submitApproval(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'nullable|string|max:200',
|
||||
'form_id' => 'nullable|integer',
|
||||
'steps' => 'required|array|min:1',
|
||||
'steps.*.approver_id' => 'required|integer',
|
||||
'steps.*.step_type' => 'nullable|string|in:approval,agreement,reference',
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->submitForApproval($id, $request->all());
|
||||
}, __('message.created'));
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,10 @@ public function index(Request $request)
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function stats(Request $request)
|
||||
public function stats()
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->input('order_type'));
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->stats();
|
||||
}, __('message.order.fetched'));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,12 +56,4 @@ public function missing(Request $request)
|
||||
return $this->service->missing($request->all());
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function exportExcel(Request $request)
|
||||
{
|
||||
$year = (int) $request->input('year', now()->year);
|
||||
$quarter = (int) $request->input('quarter', ceil(now()->month / 3));
|
||||
|
||||
return $this->service->exportConfirmed($year, $quarter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,24 +124,4 @@ public function resultDocument(int $id)
|
||||
return $this->service->resultDocument($id);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
public function uploadFile(Request $request, int $id)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'max:51200'], // 50MB
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->uploadFile($id, $request->file('file'));
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
public function deleteFile(int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id) {
|
||||
$this->service->deleteFile($id);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\V1\Stock\StoreStockAdjustmentRequest;
|
||||
use App\Services\StockService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -72,32 +71,4 @@ public function statsByItemType(): JsonResponse
|
||||
|
||||
return ApiResponse::success($stats, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 조정 이력 조회
|
||||
*/
|
||||
public function adjustments(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$adjustments = $this->service->adjustments($id);
|
||||
|
||||
return ApiResponse::success($adjustments, __('message.fetched'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.stock.not_found'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 조정 등록
|
||||
*/
|
||||
public function storeAdjustment(int $id, StoreStockAdjustmentRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->service->createAdjustment($id, $request->validated());
|
||||
|
||||
return ApiResponse::success($result, __('message.created'));
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.stock.not_found'), 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,13 @@
|
||||
use App\Http\Requests\V1\Subscription\SubscriptionCancelRequest;
|
||||
use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest;
|
||||
use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest;
|
||||
use App\Services\ExportService;
|
||||
use App\Services\SubscriptionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly ExportService $exportService
|
||||
private readonly SubscriptionService $subscriptionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -121,12 +117,12 @@ public function usage(): JsonResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 내보내기 요청 (동기 처리)
|
||||
* 내보내기 요청
|
||||
*/
|
||||
public function export(ExportStoreRequest $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->subscriptionService->createExport($request->validated(), $this->exportService),
|
||||
fn () => $this->subscriptionService->createExport($request->validated()),
|
||||
__('message.export.requested')
|
||||
);
|
||||
}
|
||||
@@ -141,24 +137,4 @@ public function exportStatus(int $id): JsonResponse
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내보내기 파일 다운로드
|
||||
*/
|
||||
public function exportDownload(int $id): BinaryFileResponse
|
||||
{
|
||||
$export = $this->subscriptionService->getExport($id);
|
||||
|
||||
if (! $export->is_downloadable) {
|
||||
throw new NotFoundHttpException(__('error.export.not_found'));
|
||||
}
|
||||
|
||||
$filePath = storage_path('app/'.$export->file_path);
|
||||
|
||||
if (! file_exists($filePath)) {
|
||||
throw new NotFoundHttpException(__('error.export.not_found'));
|
||||
}
|
||||
|
||||
return response()->download($filePath, $export->file_name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,30 +13,14 @@
|
||||
use App\Models\Tenants\JournalEntry;
|
||||
use App\Services\JournalSyncService;
|
||||
use App\Services\TaxInvoiceService;
|
||||
use App\Services\TenantSettingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TaxInvoiceController extends Controller
|
||||
{
|
||||
private const SUPPLIER_GROUP = 'supplier';
|
||||
|
||||
private const SUPPLIER_KEYS = [
|
||||
'business_number',
|
||||
'company_name',
|
||||
'representative_name',
|
||||
'address',
|
||||
'business_type',
|
||||
'business_item',
|
||||
'contact_name',
|
||||
'contact_phone',
|
||||
'contact_email',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private TaxInvoiceService $taxInvoiceService,
|
||||
private JournalSyncService $journalSyncService,
|
||||
private TenantSettingService $tenantSettingService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -141,48 +125,6 @@ public function summary(TaxInvoiceSummaryRequest $request)
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 공급자 설정 (Supplier Settings)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 공급자 설정 조회
|
||||
*/
|
||||
public function getSupplierSettings(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
$settings = $this->tenantSettingService->getByGroup(self::SUPPLIER_GROUP);
|
||||
|
||||
$result = [];
|
||||
foreach (self::SUPPLIER_KEYS as $key) {
|
||||
$result[$key] = $settings[$key] ?? null;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 공급자 설정 저장
|
||||
*/
|
||||
public function saveSupplierSettings(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$data = $request->only(self::SUPPLIER_KEYS);
|
||||
|
||||
$settings = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, self::SUPPLIER_KEYS)) {
|
||||
$settings[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$this->tenantSettingService->setMany(self::SUPPLIER_GROUP, $settings);
|
||||
|
||||
return $settings;
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 분개 (Journal Entries)
|
||||
// =========================================================================
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Finance\StoreVehiclePhotoRequest;
|
||||
use App\Services\Finance\VehiclePhotoService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class VehiclePhotoController extends Controller
|
||||
{
|
||||
public function __construct(private readonly VehiclePhotoService $service) {}
|
||||
|
||||
public function index(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->index($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreVehiclePhotoRequest $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($id, $request->file('files')),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(int $id, int $fileId): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->destroy($id, $fileId),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,10 @@ public function resetInspection(Request $request): JsonResponse
|
||||
);
|
||||
}
|
||||
|
||||
public function templates(Request $request, int $id): JsonResponse
|
||||
public function templates(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->getTemplatesByEquipment($id, $request->input('cycle')),
|
||||
fn () => $this->service->getActiveCycles($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Vehicle;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Vehicle\CorporateVehicleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CorporateVehicleController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CorporateVehicleService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->index($request->only([
|
||||
'search', 'ownership_type', 'status', 'per_page',
|
||||
])),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->show($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($request->all()),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->update($id, $request->all()),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->destroy($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
|
||||
public function dropdown(): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->dropdown(),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Vehicle;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Vehicle\VehicleLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VehicleLogController extends Controller
|
||||
{
|
||||
public function __construct(private readonly VehicleLogService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->index($request->only([
|
||||
'search', 'vehicle_id', 'year', 'month', 'trip_type', 'per_page',
|
||||
])),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->show($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($request->all()),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->update($id, $request->all()),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->destroy($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->summary($request->only([
|
||||
'vehicle_id', 'year', 'month',
|
||||
])),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V1\Vehicle;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Vehicle\VehicleMaintenanceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VehicleMaintenanceController extends Controller
|
||||
{
|
||||
public function __construct(private readonly VehicleMaintenanceService $service) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->index($request->only([
|
||||
'search', 'vehicle_id', 'category', 'start_date', 'end_date', 'per_page',
|
||||
])),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->show($id),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->store($request->all()),
|
||||
__('message.created')
|
||||
);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->update($id, $request->all()),
|
||||
__('message.updated')
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->destroy($id),
|
||||
__('message.deleted')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,11 @@ public function handle(Request $request, Closure $next)
|
||||
'api/v1/refresh',
|
||||
'api/v1/debug-apikey',
|
||||
'api/v1/internal/*', // 내부 서버간 통신 (HMAC 인증 사용)
|
||||
'api-docs', // Swagger UI (정적)
|
||||
'api-docs', // Swagger UI
|
||||
'api-docs/*', // Swagger 하위 경로
|
||||
'docs', // L5-Swagger UI
|
||||
'docs/*', // L5-Swagger 하위 경로 (에셋 등)
|
||||
'docs/api-docs.json', // Swagger JSON (기본)
|
||||
'docs/api-docs-v1.json', // Swagger JSON (v1)
|
||||
'api/documentation/*', // L5-Swagger v1 문서
|
||||
'docs/api-docs.json', // Swagger JSON
|
||||
'up', // Health check
|
||||
];
|
||||
|
||||
@@ -91,7 +89,6 @@ public function handle(Request $request, Closure $next)
|
||||
|
||||
// Bearer 인증 (Sanctum)
|
||||
$user = [];
|
||||
$accessToken = null;
|
||||
if ($token = $request->bearerToken()) {
|
||||
$accessToken = PersonalAccessToken::findToken($token);
|
||||
if ($accessToken && $accessToken->tokenable instanceof User) {
|
||||
@@ -117,21 +114,6 @@ public function handle(Request $request, Closure $next)
|
||||
}
|
||||
}
|
||||
|
||||
// MNG 내부 통신: X-TENANT-ID 헤더로 테넌트 컨텍스트 설정
|
||||
$headerTenantId = $request->header('X-TENANT-ID');
|
||||
if ($headerTenantId && $validApiKey) {
|
||||
if ($accessToken && $accessToken->name === 'mng_session') {
|
||||
// Bearer 토큰(mng_session)이 있으면 테넌트 컨텍스트 재설정
|
||||
$overrideTenantId = (int) $headerTenantId;
|
||||
$request->attributes->set('tenant_id', $overrideTenantId);
|
||||
app()->instance('tenant_id', $overrideTenantId);
|
||||
} elseif (! app()->bound('tenant_id')) {
|
||||
// Bearer 토큰 없이 API Key + X-TENANT-ID만 있으면 tenant 컨텍스트만 설정
|
||||
$request->attributes->set('tenant_id', (int) $headerTenantId);
|
||||
app()->instance('tenant_id', (int) $headerTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
// 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
|
||||
$allowWithoutAuth = [
|
||||
'api/v1/login',
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 제한 미들웨어
|
||||
*
|
||||
* - DEMO_SHOWCASE: 모든 쓰기 작업 차단 (읽기 전용)
|
||||
* - DEMO_PARTNER / DEMO_TRIAL: 만료 체크 + 차단 기능 체크
|
||||
*
|
||||
* 기존 코드 영향 없음: 프로덕션 테넌트(STD/TPL/HQ)는 즉시 통과
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoLimitMiddleware
|
||||
{
|
||||
/**
|
||||
* 데모에서 차단하는 라우트 프리픽스 (외부 시스템 연동)
|
||||
*/
|
||||
private const BLOCKED_ROUTE_PREFIXES = [
|
||||
'api/v1/barobill',
|
||||
'api/v1/ecount',
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenantId = app('tenant_id');
|
||||
if (! $tenantId) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withoutGlobalScopes()->find($tenantId);
|
||||
if (! $tenant || ! $tenant->isDemoTenant()) {
|
||||
// 프로덕션 테넌트 → 즉시 통과 (기존 동작 유지)
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// 1. 만료 체크 (파트너 데모, 고객 체험)
|
||||
if ($tenant->isDemoExpired()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '체험 기간이 만료되었습니다. 정식 계약을 진행해 주세요.',
|
||||
'error_code' => 'DEMO_EXPIRED',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. 쇼케이스 → 읽기 전용 (GET, HEAD, OPTIONS만 허용)
|
||||
if ($tenant->isDemoShowcase() && ! $request->isMethodSafe()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '데모 환경에서는 조회만 가능합니다.',
|
||||
'error_code' => 'DEMO_READ_ONLY',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 3. 읽기전용 옵션이 설정된 데모 테넌트
|
||||
if ($tenant->isDemoReadOnly() && ! $request->isMethodSafe()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '데모 환경에서는 조회만 가능합니다.',
|
||||
'error_code' => 'DEMO_READ_ONLY',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 4. 차단 기능 체크 (바로빌, 이카운트 등 외부 연동)
|
||||
if ($this->isBlockedRoute($request)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '데모 환경에서 사용할 수 없는 기능입니다. 정식 계약 후 이용 가능합니다.',
|
||||
'error_code' => 'DEMO_FEATURE_BLOCKED',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function isBlockedRoute(Request $request): bool
|
||||
{
|
||||
$path = $request->path();
|
||||
foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BendingItemIndexRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'item_sep' => 'nullable|string|in:스크린,철재',
|
||||
'item_bending' => 'nullable|string',
|
||||
'material' => 'nullable|string',
|
||||
'model_UA' => 'nullable|string|in:인정,비인정',
|
||||
'model_name' => 'nullable|string',
|
||||
'legacy_bending_num' => 'nullable|integer',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'size' => 'nullable|integer|min:1|max:200',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BendingItemStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => [
|
||||
'required', 'string', 'max:50',
|
||||
\Illuminate\Validation\Rule::unique('bending_items', 'code')->where('tenant_id', request()->header('X-TENANT-ID', app()->bound('tenant_id') ? app('tenant_id') : 1)),
|
||||
],
|
||||
'item_name' => 'required|string|max:50',
|
||||
'item_sep' => 'required|in:스크린,철재',
|
||||
'item_bending' => 'required|string|max:50',
|
||||
'material' => 'required|string|max:50',
|
||||
'model_UA' => 'nullable|in:인정,비인정',
|
||||
'item_spec' => 'nullable|string|max:50',
|
||||
'model_name' => 'nullable|string|max:30',
|
||||
'search_keyword' => 'nullable|string|max:100',
|
||||
'rail_width' => 'nullable|integer',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
'author' => 'nullable|string|max:50',
|
||||
'registration_date' => 'nullable|date',
|
||||
// 케이스 전용
|
||||
'exit_direction' => 'nullable|string|max:30',
|
||||
'front_bottom_width' => 'nullable|integer',
|
||||
'box_width' => 'nullable|integer',
|
||||
'box_height' => 'nullable|integer',
|
||||
// 전개도
|
||||
'bendingData' => 'nullable|array',
|
||||
'bendingData.*.no' => 'required|integer',
|
||||
'bendingData.*.input' => 'required|numeric',
|
||||
'bendingData.*.rate' => 'nullable|string',
|
||||
'bendingData.*.sum' => 'required|numeric',
|
||||
'bendingData.*.color' => 'required|boolean',
|
||||
'bendingData.*.aAngle' => 'required|boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BendingItemUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => 'sometimes|string|max:100',
|
||||
'name' => 'sometimes|string|max:200',
|
||||
'item_name' => 'sometimes|string|max:50',
|
||||
'item_sep' => 'sometimes|in:스크린,철재',
|
||||
'item_bending' => 'sometimes|string|max:50',
|
||||
'material' => 'sometimes|string|max:50',
|
||||
'model_UA' => 'nullable|in:인정,비인정',
|
||||
'item_spec' => 'nullable|string|max:50',
|
||||
'model_name' => 'nullable|string|max:30',
|
||||
'search_keyword' => 'nullable|string|max:100',
|
||||
'rail_width' => 'nullable|integer',
|
||||
'memo' => 'nullable|string|max:500',
|
||||
'author' => 'nullable|string|max:50',
|
||||
'registration_date' => 'nullable|date',
|
||||
// 케이스 전용
|
||||
'exit_direction' => 'nullable|string|max:30',
|
||||
'front_bottom_width' => 'nullable|integer',
|
||||
'box_width' => 'nullable|integer',
|
||||
'box_height' => 'nullable|integer',
|
||||
// 전개도
|
||||
'bendingData' => 'nullable|array',
|
||||
'bendingData.*.no' => 'required|integer',
|
||||
'bendingData.*.input' => 'required|numeric',
|
||||
'bendingData.*.rate' => 'nullable|string',
|
||||
'bendingData.*.sum' => 'required|numeric',
|
||||
'bendingData.*.color' => 'required|boolean',
|
||||
'bendingData.*.aAngle' => 'required|boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Demo;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DemoTenantStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company_name' => 'required|string|max:100',
|
||||
'email' => 'required|email|max:255',
|
||||
'duration_days' => 'sometimes|integer|min:7|max:60',
|
||||
'preset' => 'sometimes|string|in:manufacturing',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'company_name.required' => '회사명은 필수입니다.',
|
||||
'email.required' => '이메일은 필수입니다.',
|
||||
'email.email' => '올바른 이메일 형식이 아닙니다.',
|
||||
'duration_days.min' => '체험 기간은 최소 7일입니다.',
|
||||
'duration_days.max' => '체험 기간은 최대 60일입니다.',
|
||||
'preset.in' => '유효하지 않은 프리셋입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ public function rules(): array
|
||||
'data.*.field_key' => 'required_with:data|string|max:100',
|
||||
'data.*.field_value' => 'nullable|string',
|
||||
|
||||
// HTML 스냅샷 (500KB 제한 — 초과 시 413 대신 422 반환)
|
||||
'rendered_html' => 'nullable|string|max:512000',
|
||||
// HTML 스냅샷
|
||||
'rendered_html' => 'nullable|string',
|
||||
|
||||
// 첨부파일
|
||||
'attachments' => 'nullable|array',
|
||||
@@ -49,7 +49,6 @@ public function messages(): array
|
||||
'item_id.required' => __('validation.required', ['attribute' => '품목 ID']),
|
||||
'data.*.field_key.required_with' => __('validation.required', ['attribute' => '필드 키']),
|
||||
'attachments.*.file_id.exists' => __('validation.exists', ['attribute' => '파일']),
|
||||
'rendered_html.max' => 'HTML 스냅샷이 너무 큽니다. (최대 500KB)',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreVehiclePhotoRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$vehicleId = $this->route('id');
|
||||
$currentCount = File::where('document_id', $vehicleId)
|
||||
->where('document_type', 'corporate_vehicle')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
$maxFiles = 10 - $currentCount;
|
||||
|
||||
return [
|
||||
'files' => ['required', 'array', 'min:1', "max:{$maxFiles}"],
|
||||
'files.*' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:jpg,jpeg,png,gif,bmp,webp',
|
||||
'max:10240', // 10MB
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'files' => '사진 파일',
|
||||
'files.*' => '사진 파일',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'files.required' => __('error.file.required'),
|
||||
'files.max' => __('error.vehicle.photo_limit_exceeded'),
|
||||
'files.*.mimes' => __('error.file.invalid_type'),
|
||||
'files.*.max' => __('error.file.size_exceeded'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public function rules(): array
|
||||
return [
|
||||
'group_id' => 'nullable|integer',
|
||||
'field_name' => 'required|string|max:255',
|
||||
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z0-9][a-zA-Z0-9_]*$/',
|
||||
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
|
||||
'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea',
|
||||
'is_required' => 'nullable|boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
|
||||
@@ -16,7 +16,7 @@ public function rules(): array
|
||||
return [
|
||||
'group_id' => 'nullable|integer|min:1', // 계층번호
|
||||
'field_name' => 'required|string|max:255',
|
||||
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z0-9][a-zA-Z0-9_]*$/',
|
||||
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
|
||||
'field_type' => 'required|in:textbox,number,dropdown,checkbox,date,textarea',
|
||||
'is_required' => 'nullable|boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
|
||||
@@ -15,7 +15,7 @@ public function rules(): array
|
||||
{
|
||||
return [
|
||||
'field_name' => 'sometimes|string|max:255',
|
||||
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z0-9][a-zA-Z0-9_]*$/',
|
||||
'field_key' => 'nullable|string|max:80|regex:/^[a-zA-Z][a-zA-Z0-9_]*$/',
|
||||
'field_type' => 'sometimes|in:textbox,number,dropdown,checkbox,date,textarea',
|
||||
'is_required' => 'nullable|boolean',
|
||||
'default_value' => 'nullable|string',
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Material;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreNonconformingReportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nc_type' => 'required|string|in:material,process,construction,other',
|
||||
'occurred_at' => 'required|date',
|
||||
'confirmed_at' => 'nullable|date',
|
||||
'site_name' => 'nullable|string|max:100',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'order_id' => 'nullable|integer|exists:orders,id',
|
||||
'item_id' => 'nullable|integer|exists:items,id',
|
||||
'defect_quantity' => 'nullable|numeric|min:0',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'defect_description' => 'nullable|string',
|
||||
'cause_analysis' => 'nullable|string',
|
||||
'corrective_action' => 'nullable|string',
|
||||
'action_completed_at' => 'nullable|date',
|
||||
'action_manager_id' => 'nullable|integer',
|
||||
'related_employee_id' => 'nullable|integer',
|
||||
'material_cost' => 'nullable|integer|min:0',
|
||||
'shipping_cost' => 'nullable|integer|min:0',
|
||||
'construction_cost' => 'nullable|integer|min:0',
|
||||
'other_cost' => 'nullable|integer|min:0',
|
||||
'remarks' => 'nullable|string',
|
||||
'drawing_location' => 'nullable|string|max:255',
|
||||
|
||||
// 자재 상세 내역
|
||||
'items' => 'nullable|array',
|
||||
'items.*.item_id' => 'nullable|integer',
|
||||
'items.*.item_name' => 'required_with:items|string|max:100',
|
||||
'items.*.specification' => 'nullable|string|max:100',
|
||||
'items.*.quantity' => 'nullable|numeric|min:0',
|
||||
'items.*.unit_price' => 'nullable|integer|min:0',
|
||||
'items.*.remarks' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'nc_type.required' => __('error.nonconforming.nc_type_required'),
|
||||
'nc_type.in' => __('error.nonconforming.nc_type_invalid'),
|
||||
'occurred_at.required' => __('error.nonconforming.occurred_at_required'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Material;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateNonconformingReportRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'nc_type' => 'sometimes|string|in:material,process,construction,other',
|
||||
'occurred_at' => 'sometimes|date',
|
||||
'confirmed_at' => 'nullable|date',
|
||||
'site_name' => 'nullable|string|max:100',
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'order_id' => 'nullable|integer|exists:orders,id',
|
||||
'item_id' => 'nullable|integer|exists:items,id',
|
||||
'defect_quantity' => 'nullable|numeric|min:0',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'defect_description' => 'nullable|string',
|
||||
'cause_analysis' => 'nullable|string',
|
||||
'corrective_action' => 'nullable|string',
|
||||
'action_completed_at' => 'nullable|date',
|
||||
'action_manager_id' => 'nullable|integer',
|
||||
'related_employee_id' => 'nullable|integer',
|
||||
'material_cost' => 'nullable|integer|min:0',
|
||||
'shipping_cost' => 'nullable|integer|min:0',
|
||||
'construction_cost' => 'nullable|integer|min:0',
|
||||
'other_cost' => 'nullable|integer|min:0',
|
||||
'remarks' => 'nullable|string',
|
||||
'drawing_location' => 'nullable|string|max:255',
|
||||
'status' => 'sometimes|string|in:RECEIVED,ANALYZING,RESOLVED,CLOSED',
|
||||
|
||||
// 자재 상세 내역
|
||||
'items' => 'nullable|array',
|
||||
'items.*.id' => 'nullable|integer',
|
||||
'items.*.item_id' => 'nullable|integer',
|
||||
'items.*.item_name' => 'required_with:items|string|max:100',
|
||||
'items.*.specification' => 'nullable|string|max:100',
|
||||
'items.*.quantity' => 'nullable|numeric|min:0',
|
||||
'items.*.unit_price' => 'nullable|integer|min:0',
|
||||
'items.*.remarks' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -25,93 +25,75 @@ public function rules(): array
|
||||
'notice.notice' => ['sometimes', 'array'],
|
||||
'notice.notice.enabled' => ['sometimes', 'boolean'],
|
||||
'notice.notice.email' => ['sometimes', 'boolean'],
|
||||
'notice.notice.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'notice.event' => ['sometimes', 'array'],
|
||||
'notice.event.enabled' => ['sometimes', 'boolean'],
|
||||
'notice.event.email' => ['sometimes', 'boolean'],
|
||||
'notice.event.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
|
||||
'schedule' => ['sometimes', 'array'],
|
||||
'schedule.enabled' => ['sometimes', 'boolean'],
|
||||
'schedule.vatReport' => ['sometimes', 'array'],
|
||||
'schedule.vatReport.enabled' => ['sometimes', 'boolean'],
|
||||
'schedule.vatReport.email' => ['sometimes', 'boolean'],
|
||||
'schedule.vatReport.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'schedule.incomeTaxReport' => ['sometimes', 'array'],
|
||||
'schedule.incomeTaxReport.enabled' => ['sometimes', 'boolean'],
|
||||
'schedule.incomeTaxReport.email' => ['sometimes', 'boolean'],
|
||||
'schedule.incomeTaxReport.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
|
||||
'vendor' => ['sometimes', 'array'],
|
||||
'vendor.enabled' => ['sometimes', 'boolean'],
|
||||
'vendor.newVendor' => ['sometimes', 'array'],
|
||||
'vendor.newVendor.enabled' => ['sometimes', 'boolean'],
|
||||
'vendor.newVendor.email' => ['sometimes', 'boolean'],
|
||||
'vendor.newVendor.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'vendor.creditRating' => ['sometimes', 'array'],
|
||||
'vendor.creditRating.enabled' => ['sometimes', 'boolean'],
|
||||
'vendor.creditRating.email' => ['sometimes', 'boolean'],
|
||||
'vendor.creditRating.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
|
||||
'attendance' => ['sometimes', 'array'],
|
||||
'attendance.enabled' => ['sometimes', 'boolean'],
|
||||
'attendance.annualLeave' => ['sometimes', 'array'],
|
||||
'attendance.annualLeave.enabled' => ['sometimes', 'boolean'],
|
||||
'attendance.annualLeave.email' => ['sometimes', 'boolean'],
|
||||
'attendance.annualLeave.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'attendance.clockIn' => ['sometimes', 'array'],
|
||||
'attendance.clockIn.enabled' => ['sometimes', 'boolean'],
|
||||
'attendance.clockIn.email' => ['sometimes', 'boolean'],
|
||||
'attendance.clockIn.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'attendance.late' => ['sometimes', 'array'],
|
||||
'attendance.late.enabled' => ['sometimes', 'boolean'],
|
||||
'attendance.late.email' => ['sometimes', 'boolean'],
|
||||
'attendance.late.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'attendance.absent' => ['sometimes', 'array'],
|
||||
'attendance.absent.enabled' => ['sometimes', 'boolean'],
|
||||
'attendance.absent.email' => ['sometimes', 'boolean'],
|
||||
'attendance.absent.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
|
||||
'order' => ['sometimes', 'array'],
|
||||
'order.enabled' => ['sometimes', 'boolean'],
|
||||
'order.salesOrder' => ['sometimes', 'array'],
|
||||
'order.salesOrder.enabled' => ['sometimes', 'boolean'],
|
||||
'order.salesOrder.email' => ['sometimes', 'boolean'],
|
||||
'order.salesOrder.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'order.purchaseOrder' => ['sometimes', 'array'],
|
||||
'order.purchaseOrder.enabled' => ['sometimes', 'boolean'],
|
||||
'order.purchaseOrder.email' => ['sometimes', 'boolean'],
|
||||
'order.purchaseOrder.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
|
||||
'approval' => ['sometimes', 'array'],
|
||||
'approval.enabled' => ['sometimes', 'boolean'],
|
||||
'approval.approvalRequest' => ['sometimes', 'array'],
|
||||
'approval.approvalRequest.enabled' => ['sometimes', 'boolean'],
|
||||
'approval.approvalRequest.email' => ['sometimes', 'boolean'],
|
||||
'approval.approvalRequest.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'approval.draftApproved' => ['sometimes', 'array'],
|
||||
'approval.draftApproved.enabled' => ['sometimes', 'boolean'],
|
||||
'approval.draftApproved.email' => ['sometimes', 'boolean'],
|
||||
'approval.draftApproved.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'approval.draftRejected' => ['sometimes', 'array'],
|
||||
'approval.draftRejected.enabled' => ['sometimes', 'boolean'],
|
||||
'approval.draftRejected.email' => ['sometimes', 'boolean'],
|
||||
'approval.draftRejected.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'approval.draftCompleted' => ['sometimes', 'array'],
|
||||
'approval.draftCompleted.enabled' => ['sometimes', 'boolean'],
|
||||
'approval.draftCompleted.email' => ['sometimes', 'boolean'],
|
||||
'approval.draftCompleted.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
|
||||
'production' => ['sometimes', 'array'],
|
||||
'production.enabled' => ['sometimes', 'boolean'],
|
||||
'production.safetyStock' => ['sometimes', 'array'],
|
||||
'production.safetyStock.enabled' => ['sometimes', 'boolean'],
|
||||
'production.safetyStock.email' => ['sometimes', 'boolean'],
|
||||
'production.safetyStock.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
'production.productionComplete' => ['sometimes', 'array'],
|
||||
'production.productionComplete.enabled' => ['sometimes', 'boolean'],
|
||||
'production.productionComplete.email' => ['sometimes', 'boolean'],
|
||||
'production.productionComplete.soundType' => ['sometimes', 'string', 'in:default,sam_voice,mute'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@ public function rules(): array
|
||||
return [
|
||||
'delivery_date' => 'nullable|date',
|
||||
'memo' => 'nullable|string',
|
||||
'delivery_method_code' => 'nullable|string',
|
||||
'options' => 'nullable|array',
|
||||
'options.receiver' => 'nullable|string',
|
||||
'options.receiver_contact' => 'nullable|string',
|
||||
'options.shipping_address' => 'nullable|string',
|
||||
'options.shipping_address_detail' => 'nullable|string',
|
||||
'options.shipping_cost_code' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public function rules(): array
|
||||
return [
|
||||
// 기본 정보
|
||||
'quote_id' => 'nullable|integer|exists:quotes,id',
|
||||
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])],
|
||||
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])],
|
||||
'status_code' => ['nullable', Rule::in([
|
||||
Order::STATUS_DRAFT,
|
||||
Order::STATUS_CONFIRMED,
|
||||
@@ -55,18 +55,6 @@ public function rules(): array
|
||||
'options.shipping_address' => 'nullable|string|max:500',
|
||||
'options.shipping_address_detail' => 'nullable|string|max:500',
|
||||
'options.manager_name' => 'nullable|string|max:100',
|
||||
'options.production_reason' => 'nullable|string|max:500',
|
||||
'options.target_stock_qty' => 'nullable|numeric|min:0',
|
||||
|
||||
// 절곡품 LOT 정보 (STOCK 전용)
|
||||
'options.bending_lot' => 'nullable|array',
|
||||
'options.bending_lot.lot_number' => 'nullable|string|max:30',
|
||||
'options.bending_lot.prod_code' => 'nullable|string|max:2',
|
||||
'options.bending_lot.spec_code' => 'nullable|string|max:2',
|
||||
'options.bending_lot.length_code' => 'nullable|string|max:2',
|
||||
'options.bending_lot.raw_lot_no' => 'nullable|string|max:50',
|
||||
'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50',
|
||||
'options.bending_lot.material' => 'nullable|string|max:50',
|
||||
|
||||
// 품목 배열
|
||||
'items' => 'nullable|array',
|
||||
|
||||
@@ -17,7 +17,7 @@ public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 기본 정보 (order_no는 수정 불가)
|
||||
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])],
|
||||
'order_type_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE])],
|
||||
'category_code' => 'nullable|string|max:50',
|
||||
|
||||
// 거래처 정보
|
||||
@@ -49,24 +49,12 @@ public function rules(): array
|
||||
'options.shipping_address' => 'nullable|string|max:500',
|
||||
'options.shipping_address_detail' => 'nullable|string|max:500',
|
||||
'options.manager_name' => 'nullable|string|max:100',
|
||||
'options.production_reason' => 'nullable|string|max:500',
|
||||
'options.target_stock_qty' => 'nullable|numeric|min:0',
|
||||
|
||||
// 절곡품 LOT 정보 (STOCK 전용)
|
||||
'options.bending_lot' => 'nullable|array',
|
||||
'options.bending_lot.lot_number' => 'nullable|string|max:30',
|
||||
'options.bending_lot.prod_code' => 'nullable|string|max:2',
|
||||
'options.bending_lot.spec_code' => 'nullable|string|max:2',
|
||||
'options.bending_lot.length_code' => 'nullable|string|max:2',
|
||||
'options.bending_lot.raw_lot_no' => 'nullable|string|max:50',
|
||||
'options.bending_lot.fabric_lot_no' => 'nullable|string|max:50',
|
||||
'options.bending_lot.material' => 'nullable|string|max:50',
|
||||
|
||||
// 품목 배열 (전체 교체)
|
||||
'items' => 'nullable|array',
|
||||
'items.*.item_id' => 'nullable|integer|exists:items,id',
|
||||
'items.*.item_code' => 'nullable|string|max:50',
|
||||
'items.*.item_name' => 'sometimes|required|string|max:200',
|
||||
'items.*.item_name' => 'required|string|max:200',
|
||||
'items.*.specification' => 'nullable|string|max:500',
|
||||
'items.*.quantity' => 'required|numeric|min:0',
|
||||
'items.*.unit' => 'nullable|string|max:20',
|
||||
|
||||
@@ -41,6 +41,17 @@ public function rules(): array
|
||||
'loading_manager' => 'nullable|string|max:50',
|
||||
'loading_time' => 'nullable|date',
|
||||
|
||||
// 물류/배차 정보
|
||||
'logistics_company' => 'nullable|string|max:50',
|
||||
'vehicle_tonnage' => 'nullable|string|max:20',
|
||||
'shipping_cost' => 'nullable|numeric|min:0',
|
||||
|
||||
// 차량/운전자 정보
|
||||
'vehicle_no' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'driver_contact' => 'nullable|string|max:50',
|
||||
'expected_arrival' => 'nullable|date',
|
||||
|
||||
// 기타
|
||||
'remarks' => 'nullable|string',
|
||||
|
||||
|
||||
@@ -39,6 +39,17 @@ public function rules(): array
|
||||
'loading_manager' => 'nullable|string|max:50',
|
||||
'loading_time' => 'nullable|date',
|
||||
|
||||
// 물류/배차 정보
|
||||
'logistics_company' => 'nullable|string|max:50',
|
||||
'vehicle_tonnage' => 'nullable|string|max:20',
|
||||
'shipping_cost' => 'nullable|numeric|min:0',
|
||||
|
||||
// 차량/운전자 정보
|
||||
'vehicle_no' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'driver_contact' => 'nullable|string|max:50',
|
||||
'expected_arrival' => 'nullable|date',
|
||||
|
||||
// 기타
|
||||
'remarks' => 'nullable|string',
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SwitchTenantRequest extends FormRequest
|
||||
{
|
||||
@@ -14,23 +13,8 @@ public function authorize(): bool
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$userId = app('api_user');
|
||||
|
||||
return [
|
||||
'tenant_id' => [
|
||||
'required',
|
||||
'integer',
|
||||
Rule::exists('user_tenants', 'tenant_id')
|
||||
->where('user_id', $userId)
|
||||
->where('is_active', 1),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id.exists' => __('error.tenant_access_denied'),
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AiTokenUsageListRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
'menu_name' => 'nullable|string|max:100',
|
||||
'per_page' => 'nullable|integer|min:10|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\CondolenceExpense;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreCondolenceExpenseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'event_date' => ['nullable', 'date'],
|
||||
'expense_date' => ['nullable', 'date'],
|
||||
'partner_name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string', 'max:200'],
|
||||
'category' => ['required', 'string', 'in:congratulation,condolence'],
|
||||
'has_cash' => ['nullable', 'boolean'],
|
||||
'cash_method' => ['required_if:has_cash,true', 'nullable', 'string', 'in:cash,transfer,card'],
|
||||
'cash_amount' => ['required_if:has_cash,true', 'nullable', 'integer', 'min:0'],
|
||||
'has_gift' => ['nullable', 'boolean'],
|
||||
'gift_type' => ['nullable', 'string', 'max:50'],
|
||||
'gift_amount' => ['required_if:has_gift,true', 'nullable', 'integer', 'min:0'],
|
||||
'memo' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'event_date' => '경조사일자',
|
||||
'expense_date' => '지출일자',
|
||||
'partner_name' => '거래처명',
|
||||
'description' => '내역',
|
||||
'category' => '구분',
|
||||
'has_cash' => '부조금 여부',
|
||||
'cash_method' => '지출방법',
|
||||
'cash_amount' => '부조금액',
|
||||
'has_gift' => '선물 여부',
|
||||
'gift_type' => '선물종류',
|
||||
'gift_amount' => '선물금액',
|
||||
'memo' => '비고',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\CondolenceExpense;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateCondolenceExpenseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'event_date' => ['nullable', 'date'],
|
||||
'expense_date' => ['nullable', 'date'],
|
||||
'partner_name' => ['sometimes', 'required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string', 'max:200'],
|
||||
'category' => ['sometimes', 'required', 'string', 'in:congratulation,condolence'],
|
||||
'has_cash' => ['nullable', 'boolean'],
|
||||
'cash_method' => ['required_if:has_cash,true', 'nullable', 'string', 'in:cash,transfer,card'],
|
||||
'cash_amount' => ['required_if:has_cash,true', 'nullable', 'integer', 'min:0'],
|
||||
'has_gift' => ['nullable', 'boolean'],
|
||||
'gift_type' => ['nullable', 'string', 'max:50'],
|
||||
'gift_amount' => ['required_if:has_gift,true', 'nullable', 'integer', 'min:0'],
|
||||
'memo' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'event_date' => '경조사일자',
|
||||
'expense_date' => '지출일자',
|
||||
'partner_name' => '거래처명',
|
||||
'description' => '내역',
|
||||
'category' => '구분',
|
||||
'has_cash' => '부조금 여부',
|
||||
'cash_method' => '지출방법',
|
||||
'cash_amount' => '부조금액',
|
||||
'has_gift' => '선물 여부',
|
||||
'gift_type' => '선물종류',
|
||||
'gift_amount' => '선물금액',
|
||||
'memo' => '비고',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ public function rules(): array
|
||||
return [
|
||||
'journal_date' => ['required', 'date'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'receipt_no' => ['nullable', 'string', 'max:100'],
|
||||
'rows' => ['required', 'array', 'min:2'],
|
||||
'rows.*.side' => ['required', 'in:debit,credit'],
|
||||
'rows.*.account_subject_id' => ['required', 'string', 'max:10'],
|
||||
|
||||
@@ -15,7 +15,6 @@ public function rules(): array
|
||||
{
|
||||
return [
|
||||
'journal_memo' => ['sometimes', 'nullable', 'string', 'max:1000'],
|
||||
'receipt_no' => ['nullable', 'string', 'max:100'],
|
||||
'rows' => ['sometimes', 'array', 'min:1'],
|
||||
'rows.*.side' => ['required_with:rows', 'in:debit,credit'],
|
||||
'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'],
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\V1\Stock;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreStockAdjustmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => ['required', 'numeric', 'not_in:0'],
|
||||
'remark' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'quantity.required' => __('error.stock.adjustment_qty_required'),
|
||||
'quantity.not_in' => __('error.stock.adjustment_qty_zero'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BendingItemResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'code' => $this->code,
|
||||
'legacy_code' => $this->legacy_code,
|
||||
// 정규 컬럼 직접 참조
|
||||
'item_name' => $this->item_name,
|
||||
'item_sep' => $this->item_sep,
|
||||
'item_bending' => $this->item_bending,
|
||||
'item_spec' => $this->item_spec,
|
||||
'material' => $this->material,
|
||||
'model_name' => $this->model_name,
|
||||
'model_UA' => $this->model_UA,
|
||||
'rail_width' => $this->rail_width ? (int) $this->rail_width : null,
|
||||
// 케이스 전용
|
||||
'exit_direction' => $this->exit_direction,
|
||||
'front_bottom' => $this->front_bottom ? (int) $this->front_bottom : null,
|
||||
'box_width' => $this->box_width ? (int) $this->box_width : null,
|
||||
'box_height' => $this->box_height ? (int) $this->box_height : null,
|
||||
'inspection_door' => $this->inspection_door,
|
||||
// 원자재 길이
|
||||
'length_code' => $this->length_code,
|
||||
'length_mm' => $this->length_mm,
|
||||
// 전개도 (JSON 컬럼)
|
||||
'bendingData' => $this->bending_data,
|
||||
// 비정형 속성 (options)
|
||||
'search_keyword' => $this->getOption('search_keyword'),
|
||||
'author' => $this->getOption('author'),
|
||||
'memo' => $this->getOption('memo'),
|
||||
'registration_date' => $this->getOption('registration_date'),
|
||||
// 이미지
|
||||
'image_file_id' => $this->getImageFileId(),
|
||||
'image_url' => $this->getImageUrl(),
|
||||
// 추적
|
||||
'legacy_bending_id' => $this->legacy_bending_id,
|
||||
'legacy_bending_num' => $this->legacy_bending_id, // MNG2 호환
|
||||
'modified_by' => $this->getOption('modified_by'),
|
||||
// MNG2 호환 (items 기반 필드명)
|
||||
'name' => $this->item_name,
|
||||
'front_bottom_width' => $this->front_bottom ? (int) $this->front_bottom : null,
|
||||
'item_type' => 'PT',
|
||||
'item_category' => 'BENDING',
|
||||
'unit' => 'EA',
|
||||
// 계산값
|
||||
'width_sum' => $this->width_sum,
|
||||
'bend_count' => $this->bend_count,
|
||||
// 메타
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getImageFile(): ?\App\Models\Commons\File
|
||||
{
|
||||
return $this->files()
|
||||
->where('field_key', 'bending_diagram')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function getImageFileId(): ?int
|
||||
{
|
||||
return $this->getImageFile()?->id;
|
||||
}
|
||||
|
||||
private function getImageUrl(): ?string
|
||||
{
|
||||
return $this->getImageFile()?->presignedUrl();
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class GuiderailModelResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$components = $this->components ?? [];
|
||||
$materialSummary = $this->material_summary;
|
||||
|
||||
if (empty($materialSummary) && ! empty($components)) {
|
||||
$materialSummary = $this->calcMaterialSummary($components);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'code' => $this->code,
|
||||
'name' => $this->name,
|
||||
'is_active' => $this->is_active,
|
||||
// MNG2 호환
|
||||
'item_type' => 'FG',
|
||||
'item_category' => $this->model_type,
|
||||
// 모델 속성 (정규 컬럼)
|
||||
'model_name' => $this->model_name,
|
||||
'check_type' => $this->check_type,
|
||||
'rail_width' => $this->rail_width ? (int) $this->rail_width : null,
|
||||
'rail_length' => $this->rail_length ? (int) $this->rail_length : null,
|
||||
'finishing_type' => $this->finishing_type,
|
||||
'item_sep' => $this->item_sep,
|
||||
'model_UA' => $this->model_UA,
|
||||
'search_keyword' => $this->search_keyword,
|
||||
'author' => $this->author,
|
||||
'memo' => $this->getOption('memo'),
|
||||
'registration_date' => $this->registration_date?->format('Y-m-d'),
|
||||
// 케이스 전용
|
||||
'exit_direction' => $this->exit_direction,
|
||||
'front_bottom_width' => $this->front_bottom_width ? (int) $this->front_bottom_width : null,
|
||||
'box_width' => $this->box_width ? (int) $this->box_width : null,
|
||||
'box_height' => $this->box_height ? (int) $this->box_height : null,
|
||||
// 하단마감재 전용
|
||||
'bar_width' => $this->bar_width ? (int) $this->bar_width : null,
|
||||
'bar_height' => $this->bar_height ? (int) $this->bar_height : null,
|
||||
// 수정자
|
||||
'modified_by' => $this->getOption('modified_by'),
|
||||
// 이미지
|
||||
'image_file_id' => $this->getImageFileId(),
|
||||
'image_url' => $this->getImageUrl(),
|
||||
// 부품 조합
|
||||
'components' => $this->enrichComponentsWithImageUrls($components),
|
||||
'material_summary' => $materialSummary,
|
||||
'component_count' => count($components),
|
||||
// 메타
|
||||
'created_at' => $this->created_at?->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
private function getImageFile(): ?\App\Models\Commons\File
|
||||
{
|
||||
$file = \App\Models\Commons\File::where('document_id', $this->id)
|
||||
->where('document_type', 'bending_model')
|
||||
->where('field_key', 'assembly_image')
|
||||
->whereNull('deleted_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
$file = $this->files()
|
||||
->where('field_key', 'bending_diagram')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
private function getImageFileId(): ?int
|
||||
{
|
||||
return $this->getImageFile()?->id;
|
||||
}
|
||||
|
||||
private function getImageUrl(): ?string
|
||||
{
|
||||
return $this->getImageFile()?->presignedUrl();
|
||||
}
|
||||
|
||||
private function enrichComponentsWithImageUrls(array $components): array
|
||||
{
|
||||
$fileIds = array_filter(array_column($components, 'image_file_id'));
|
||||
if (empty($fileIds)) {
|
||||
return $components;
|
||||
}
|
||||
|
||||
$files = \App\Models\Commons\File::whereIn('id', $fileIds)
|
||||
->whereNull('deleted_at')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($components as &$comp) {
|
||||
$fileId = $comp['image_file_id'] ?? null;
|
||||
$comp['image_url'] = $fileId && isset($files[$fileId])
|
||||
? $files[$fileId]->presignedUrl()
|
||||
: null;
|
||||
}
|
||||
unset($comp);
|
||||
|
||||
return $components;
|
||||
}
|
||||
|
||||
private function calcMaterialSummary(array $components): array
|
||||
{
|
||||
$summary = [];
|
||||
foreach ($components as $comp) {
|
||||
$material = $comp['material'] ?? null;
|
||||
$widthSum = $comp['widthsum'] ?? $comp['width_sum'] ?? 0;
|
||||
$qty = $comp['quantity'] ?? 1;
|
||||
if ($material && $widthSum) {
|
||||
$summary[$material] = ($summary[$material] ?? 0) + ($widthSum * $qty);
|
||||
}
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Barobill;
|
||||
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Services\Barobill\BarobillBankSyncService;
|
||||
use App\Services\Barobill\BarobillCardSyncService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 바로빌 데이터 자동 동기화 Job
|
||||
*
|
||||
* 스케줄러에서 매일 실행하여 활성 회원의 은행/카드 거래내역을 동기화한다.
|
||||
*/
|
||||
class SyncBarobillDataJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
public function __construct(
|
||||
private string $syncType = 'all',
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
BarobillBankSyncService $bankSyncService,
|
||||
BarobillCardSyncService $cardSyncService,
|
||||
): void {
|
||||
$members = BarobillMember::withoutGlobalScopes()
|
||||
->where('status', 'active')
|
||||
->where('server_mode', 'production')
|
||||
->get();
|
||||
|
||||
if ($members->isEmpty()) {
|
||||
Log::info('[SyncBarobill] 활성 회원 없음, 스킵');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$yesterday = Carbon::yesterday()->format('Ymd');
|
||||
$today = Carbon::today()->format('Ymd');
|
||||
|
||||
foreach ($members as $member) {
|
||||
try {
|
||||
if (in_array($this->syncType, ['all', 'bank'])) {
|
||||
$result = $bankSyncService->syncIfNeeded(
|
||||
$member->tenant_id,
|
||||
$yesterday,
|
||||
$today
|
||||
);
|
||||
Log::info('[SyncBarobill] 은행 동기화 완료', [
|
||||
'tenant_id' => $member->tenant_id,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($this->syncType, ['all', 'card'])) {
|
||||
$result = $cardSyncService->syncCardTransactions(
|
||||
$member->tenant_id,
|
||||
$yesterday,
|
||||
$today
|
||||
);
|
||||
Log::info('[SyncBarobill] 카드 동기화 완료', [
|
||||
'tenant_id' => $member->tenant_id,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('[SyncBarobill] 동기화 실패', [
|
||||
'tenant_id' => $member->tenant_id,
|
||||
'sync_type' => $this->syncType,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* 절곡 기초관리 마스터
|
||||
*
|
||||
* code: {제품Code}{종류Code}{YYMMDD} (예: CP260319 = 케이스 점검구)
|
||||
* bending_data: 전개도 JSON 배열 [{no, input, rate, sum, color, aAngle}]
|
||||
*/
|
||||
class BendingItem extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'bending_items';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'code',
|
||||
'legacy_code',
|
||||
'legacy_bending_id',
|
||||
'item_name',
|
||||
'item_sep',
|
||||
'item_bending',
|
||||
'material',
|
||||
'item_spec',
|
||||
'model_name',
|
||||
'model_UA',
|
||||
'rail_width',
|
||||
'exit_direction',
|
||||
'box_width',
|
||||
'box_height',
|
||||
'front_bottom',
|
||||
'inspection_door',
|
||||
'length_code',
|
||||
'length_mm',
|
||||
'bending_data',
|
||||
'options',
|
||||
'is_active',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'bending_data' => 'array',
|
||||
'options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'rail_width' => 'decimal:2',
|
||||
'box_width' => 'decimal:2',
|
||||
'box_height' => 'decimal:2',
|
||||
'front_bottom' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $hidden = ['deleted_at'];
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 관계
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(File::class, 'document_id')
|
||||
->where('document_type', 'bending_item');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// options 헬퍼
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): self
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 계산 accessor
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function getWidthSumAttribute(): ?float
|
||||
{
|
||||
$data = $this->bending_data ?? [];
|
||||
if (empty($data)) {
|
||||
return null;
|
||||
}
|
||||
$last = end($data);
|
||||
|
||||
return isset($last['sum']) ? (float) $last['sum'] : null;
|
||||
}
|
||||
|
||||
public function getBendCountAttribute(): int
|
||||
{
|
||||
$data = $this->bending_data ?? [];
|
||||
|
||||
return count(array_filter($data, fn ($d) => ($d['rate'] ?? '') !== ''));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// LOT 코드 테이블
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public const PROD_CODES = [
|
||||
'R' => '가이드레일(벽면형)',
|
||||
'S' => '가이드레일(측면형)',
|
||||
'C' => '케이스',
|
||||
'B' => '하단마감재(스크린)',
|
||||
'T' => '하단마감재(철재)',
|
||||
'L' => 'L-Bar',
|
||||
'G' => '연기차단재',
|
||||
];
|
||||
|
||||
public const SPEC_CODES = [
|
||||
'R' => ['M' => '본체', 'T' => '본체(철재)', 'C' => 'C형', 'D' => 'D형', 'S' => 'SUS 마감재'],
|
||||
'S' => ['M' => '본체디딤', 'T' => '본체(철재)', 'C' => 'C형', 'D' => 'D형', 'S' => 'SUS 마감재①', 'U' => 'SUS 마감재②'],
|
||||
'C' => ['F' => '전면부', 'P' => '점검구', 'L' => '린텔부', 'B' => '후면코너부'],
|
||||
'B' => ['S' => 'SUS', 'E' => 'EGI'],
|
||||
'T' => ['S' => 'SUS', 'E' => 'EGI'],
|
||||
'L' => ['A' => '스크린용'],
|
||||
'G' => ['I' => '화이바원단'],
|
||||
];
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class BendingModel extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'bending_models';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id', 'model_type', 'code', 'name', 'legacy_code', 'legacy_num',
|
||||
'model_name', 'model_UA', 'item_sep', 'finishing_type', 'author', 'remark',
|
||||
'check_type', 'rail_width', 'rail_length',
|
||||
'exit_direction', 'front_bottom_width', 'box_width', 'box_height',
|
||||
'bar_width', 'bar_height',
|
||||
'components', 'material_summary',
|
||||
'search_keyword', 'registration_date', 'options',
|
||||
'is_active', 'created_by', 'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'components' => 'array',
|
||||
'material_summary' => 'array',
|
||||
'options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'registration_date' => 'date',
|
||||
'rail_width' => 'decimal:2',
|
||||
'rail_length' => 'decimal:2',
|
||||
'front_bottom_width' => 'decimal:2',
|
||||
'box_width' => 'decimal:2',
|
||||
'box_height' => 'decimal:2',
|
||||
'bar_width' => 'decimal:2',
|
||||
'bar_height' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $hidden = ['deleted_at'];
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(File::class, 'document_id')
|
||||
->where('document_type', 'bending_model');
|
||||
}
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): self
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public const TYPE_GUIDERAIL = 'GUIDERAIL_MODEL';
|
||||
public const TYPE_SHUTTERBOX = 'SHUTTERBOX_MODEL';
|
||||
public const TYPE_BOTTOMBAR = 'BOTTOMBAR_MODEL';
|
||||
}
|
||||
@@ -98,21 +98,6 @@ public function fileable()
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* R2 Presigned URL 생성 (30분 유효)
|
||||
*/
|
||||
public function presignedUrl(int $minutes = 30): ?string
|
||||
{
|
||||
if (! $this->file_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk('r2')->temporaryUrl(
|
||||
$this->file_path,
|
||||
now()->addMinutes($minutes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full storage path
|
||||
*/
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
* @property int $id
|
||||
* @property int $template_id
|
||||
* @property string $title 섹션 제목
|
||||
* @property string|null $image_path 검사 기준 이미지 경로 (R2 key)
|
||||
* @property int|null $file_id 도해 이미지 파일 ID (files 테이블 참조)
|
||||
* @property string|null $image_path 검사 기준 이미지 경로
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateSection extends Model
|
||||
@@ -25,7 +24,6 @@ class DocumentTemplateSection extends Model
|
||||
'title',
|
||||
'description',
|
||||
'image_path',
|
||||
'file_id',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
|
||||
@@ -72,12 +72,12 @@ public function setOption(string $key, mixed $value): self
|
||||
|
||||
public function manager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'manager_id');
|
||||
return $this->belongsTo(\App\Models\User::class, 'manager_id');
|
||||
}
|
||||
|
||||
public function subManager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'sub_manager_id');
|
||||
return $this->belongsTo(\App\Models\User::class, 'sub_manager_id');
|
||||
}
|
||||
|
||||
public function canInspect(?int $userId = null): bool
|
||||
|
||||
@@ -33,7 +33,7 @@ public function equipment(): BelongsTo
|
||||
|
||||
public function inspector(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'inspector_id');
|
||||
return $this->belongsTo(\App\Models\User::class, 'inspector_id');
|
||||
}
|
||||
|
||||
public function details(): HasMany
|
||||
|
||||
@@ -57,6 +57,6 @@ public function equipment(): BelongsTo
|
||||
|
||||
public function repairer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'repaired_by');
|
||||
return $this->belongsTo(\App\Models\User::class, 'repaired_by');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Finance;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CorporateVehicle extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'corporate_vehicles';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'plate_number',
|
||||
'model',
|
||||
'vehicle_type',
|
||||
'ownership_type',
|
||||
'mileage',
|
||||
'options',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'mileage' => 'integer',
|
||||
'options' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function photos(): HasMany
|
||||
{
|
||||
return $this->hasMany(File::class, 'document_id')
|
||||
->where('document_type', 'corporate_vehicle')
|
||||
->orderBy('id');
|
||||
}
|
||||
}
|
||||
@@ -51,24 +51,6 @@ class Item extends Model
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'specification',
|
||||
];
|
||||
|
||||
/**
|
||||
* 규격 accessor — attributes JSON 내 spec/specification 값을 최상위 필드로 노출
|
||||
*/
|
||||
public function getSpecificationAttribute(): ?string
|
||||
{
|
||||
$attrs = $this->getAttributeValue('attributes');
|
||||
|
||||
if (is_array($attrs)) {
|
||||
return $attrs['spec'] ?? $attrs['specification'] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* item_type 상수
|
||||
*/
|
||||
@@ -200,24 +182,6 @@ public function scopeActive($query)
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// options 헬퍼
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function getOption(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, mixed $value): self
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 헬퍼 메서드
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
341
app/Models/Kyungdong/KdPriceTable.php
Normal file
341
app/Models/Kyungdong/KdPriceTable.php
Normal 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('가이드레일용 연기차단재');
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Materials;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Tenants\Department;
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Tenants\Approval;
|
||||
use App\Models\Members\User;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class NonconformingReport extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'nc_number',
|
||||
'status',
|
||||
'approval_id',
|
||||
'nc_type',
|
||||
'occurred_at',
|
||||
'confirmed_at',
|
||||
'site_name',
|
||||
'department_id',
|
||||
'order_id',
|
||||
'item_id',
|
||||
'defect_quantity',
|
||||
'unit',
|
||||
'defect_description',
|
||||
'cause_analysis',
|
||||
'corrective_action',
|
||||
'action_completed_at',
|
||||
'action_manager_id',
|
||||
'related_employee_id',
|
||||
'material_cost',
|
||||
'shipping_cost',
|
||||
'construction_cost',
|
||||
'other_cost',
|
||||
'total_cost',
|
||||
'remarks',
|
||||
'drawing_location',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'occurred_at' => 'date',
|
||||
'confirmed_at' => 'date',
|
||||
'action_completed_at' => 'date',
|
||||
'material_cost' => 'integer',
|
||||
'shipping_cost' => 'integer',
|
||||
'construction_cost' => 'integer',
|
||||
'other_cost' => 'integer',
|
||||
'total_cost' => 'integer',
|
||||
'defect_quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
// 상태 상수
|
||||
public const STATUS_RECEIVED = 'RECEIVED';
|
||||
|
||||
public const STATUS_ANALYZING = 'ANALYZING';
|
||||
|
||||
public const STATUS_RESOLVED = 'RESOLVED';
|
||||
|
||||
public const STATUS_CLOSED = 'CLOSED';
|
||||
|
||||
// 부적합 유형 상수
|
||||
public const TYPE_MATERIAL = 'material';
|
||||
|
||||
public const TYPE_PROCESS = 'process';
|
||||
|
||||
public const TYPE_CONSTRUCTION = 'construction';
|
||||
|
||||
public const TYPE_OTHER = 'other';
|
||||
|
||||
public const NC_TYPES = [
|
||||
self::TYPE_MATERIAL => '자재불량',
|
||||
self::TYPE_PROCESS => '공정불량',
|
||||
self::TYPE_CONSTRUCTION => '시공불량',
|
||||
self::TYPE_OTHER => '기타',
|
||||
];
|
||||
|
||||
public const STATUSES = [
|
||||
self::STATUS_RECEIVED => '접수',
|
||||
self::STATUS_ANALYZING => '분석중',
|
||||
self::STATUS_RESOLVED => '조치완료',
|
||||
self::STATUS_CLOSED => '종결',
|
||||
];
|
||||
|
||||
// ── 관계 ──
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(NonconformingReportItem::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function approval(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Approval::class);
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function actionManager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'action_manager_id');
|
||||
}
|
||||
|
||||
public function relatedEmployee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'related_employee_id');
|
||||
}
|
||||
|
||||
public function files(): MorphMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'fileable');
|
||||
}
|
||||
|
||||
// ── 스코프 ──
|
||||
|
||||
public function scopeStatus($query, string $status)
|
||||
{
|
||||
return $query->where('status', $status);
|
||||
}
|
||||
|
||||
public function scopeNcType($query, string $type)
|
||||
{
|
||||
return $query->where('nc_type', $type);
|
||||
}
|
||||
|
||||
// ── 헬퍼 ──
|
||||
|
||||
public function recalculateTotalCost(): void
|
||||
{
|
||||
$this->total_cost = $this->material_cost + $this->shipping_cost
|
||||
+ $this->construction_cost + $this->other_cost;
|
||||
}
|
||||
|
||||
public function recalculateMaterialCost(): void
|
||||
{
|
||||
$this->material_cost = (int) $this->items()->sum('amount');
|
||||
$this->recalculateTotalCost();
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Materials;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NonconformingReportItem extends Model
|
||||
{
|
||||
use BelongsToTenant, ModelTrait;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'nonconforming_report_id',
|
||||
'item_id',
|
||||
'item_name',
|
||||
'specification',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'amount',
|
||||
'sort_order',
|
||||
'remarks',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'options' => 'array',
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'integer',
|
||||
'amount' => 'integer',
|
||||
];
|
||||
|
||||
public function report(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(NonconformingReport::class, 'nonconforming_report_id');
|
||||
}
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
@@ -78,8 +78,6 @@ class Order extends Model
|
||||
|
||||
public const TYPE_PURCHASE = 'PURCHASE'; // 발주
|
||||
|
||||
public const TYPE_STOCK = 'STOCK'; // 재고생산
|
||||
|
||||
// 매출 인식 시점
|
||||
public const SALES_ON_ORDER_CONFIRM = 'on_order_confirm'; // 수주확정 시
|
||||
|
||||
@@ -340,46 +338,4 @@ public static function createFromQuote(Quote $quote, string $orderNo): self
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적의 개소(location) 단위로 수주 생성
|
||||
* 다중 개소 견적 → 개소별 독립 수주
|
||||
*/
|
||||
public static function createFromQuoteLocation(Quote $quote, string $orderNo, array $locItem, ?array $bomResult): self
|
||||
{
|
||||
$qty = (int) ($locItem['quantity'] ?? 1);
|
||||
$grandTotal = $bomResult['grand_total'] ?? 0;
|
||||
$supplyAmount = $grandTotal * $qty;
|
||||
$floor = $locItem['floor'] ?? '';
|
||||
$symbol = $locItem['code'] ?? '';
|
||||
$locLabel = trim("{$floor} {$symbol}") ?: '';
|
||||
$siteName = $quote->site_name;
|
||||
if ($locLabel) {
|
||||
$siteName = "{$siteName} [{$locLabel}]";
|
||||
}
|
||||
|
||||
return new self([
|
||||
'tenant_id' => $quote->tenant_id,
|
||||
'quote_id' => $quote->id,
|
||||
'order_no' => $orderNo,
|
||||
'order_type_code' => self::TYPE_ORDER,
|
||||
'status_code' => self::STATUS_DRAFT,
|
||||
'client_id' => $quote->client_id,
|
||||
'client_name' => $quote->client?->name,
|
||||
'client_contact' => $quote->contact,
|
||||
'site_name' => $siteName,
|
||||
'quantity' => $qty,
|
||||
'supply_amount' => $supplyAmount,
|
||||
'tax_amount' => round($supplyAmount * 0.1, 2),
|
||||
'total_amount' => round($supplyAmount * 1.1, 2),
|
||||
'delivery_date' => $quote->completion_date,
|
||||
'memo' => $quote->remarks,
|
||||
'options' => [
|
||||
'manager_name' => $quote->manager,
|
||||
'product_code' => $locItem['productCode'] ?? null,
|
||||
'location_floor' => $floor,
|
||||
'location_code' => $symbol,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +81,6 @@ class Price extends Model
|
||||
// Relations
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 품목 관계
|
||||
*/
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Items\Item::class, 'item_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 그룹 관계
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Traits\Auditable;
|
||||
@@ -73,12 +72,6 @@ public function performanceReport()
|
||||
return $this->hasOne(PerformanceReport::class);
|
||||
}
|
||||
|
||||
public function file()
|
||||
{
|
||||
return $this->hasOne(File::class, 'document_id')
|
||||
->where('document_type', static::class);
|
||||
}
|
||||
|
||||
// ===== 채번 =====
|
||||
|
||||
public static function generateDocNumber(int $tenantId): string
|
||||
|
||||
@@ -78,9 +78,9 @@ class Quote extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'registration_date' => 'date:Y-m-d',
|
||||
'receipt_date' => 'date:Y-m-d',
|
||||
'completion_date' => 'date:Y-m-d',
|
||||
'registration_date' => 'date',
|
||||
'receipt_date' => 'date',
|
||||
'completion_date' => 'date',
|
||||
'finalized_at' => 'datetime',
|
||||
'is_final' => 'boolean',
|
||||
'calculation_inputs' => 'array',
|
||||
@@ -331,21 +331,11 @@ public function scopeSearch($query, ?string $keyword)
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 확인
|
||||
* - 생산지시가 존재하는 수주에 연결된 견적은 수정 불가
|
||||
* - 그 외 모든 상태에서 수정 가능 (finalized, converted 포함)
|
||||
* - 모든 상태에서 수정 가능 (finalized, converted 포함)
|
||||
* - 수주 전환된 견적 수정 시 연결된 수주도 함께 동기화됨
|
||||
*/
|
||||
public function isEditable(): bool
|
||||
{
|
||||
if ($this->order_id) {
|
||||
$hasWorkOrders = Order::where('id', $this->order_id)
|
||||
->whereHas('workOrders')
|
||||
->exists();
|
||||
|
||||
if ($hasWorkOrders) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ public static function getExchangeRate(): float
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
$providers = ['gemini', 'claude', 'google-stt', 'google-gcs', 'cloudflare-r2'];
|
||||
$providers = ['gemini', 'claude', 'google-stt', 'google-gcs'];
|
||||
foreach ($providers as $provider) {
|
||||
Cache::forget("ai_pricing_{$provider}");
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use App\Traits\ModelTrait;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CondolenceExpense extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
|
||||
|
||||
protected $table = 'condolence_expenses';
|
||||
|
||||
// 카테고리 상수
|
||||
public const CATEGORY_CONGRATULATION = 'congratulation';
|
||||
|
||||
public const CATEGORY_CONDOLENCE = 'condolence';
|
||||
|
||||
public const CATEGORY_LABELS = [
|
||||
self::CATEGORY_CONGRATULATION => '축의',
|
||||
self::CATEGORY_CONDOLENCE => '부조',
|
||||
];
|
||||
|
||||
// 지출방법 상수
|
||||
public const CASH_METHOD_CASH = 'cash';
|
||||
|
||||
public const CASH_METHOD_TRANSFER = 'transfer';
|
||||
|
||||
public const CASH_METHOD_CARD = 'card';
|
||||
|
||||
public const CASH_METHOD_LABELS = [
|
||||
self::CASH_METHOD_CASH => '현금',
|
||||
self::CASH_METHOD_TRANSFER => '계좌이체',
|
||||
self::CASH_METHOD_CARD => '카드',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_date',
|
||||
'expense_date',
|
||||
'partner_name',
|
||||
'description',
|
||||
'category',
|
||||
'has_cash',
|
||||
'cash_method',
|
||||
'cash_amount',
|
||||
'has_gift',
|
||||
'gift_type',
|
||||
'gift_amount',
|
||||
'total_amount',
|
||||
'options',
|
||||
'memo',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'event_date' => 'date',
|
||||
'expense_date' => 'date',
|
||||
'has_cash' => 'boolean',
|
||||
'has_gift' => 'boolean',
|
||||
'cash_amount' => 'integer',
|
||||
'gift_amount' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'category_label',
|
||||
'cash_method_label',
|
||||
];
|
||||
|
||||
/**
|
||||
* 카테고리 라벨
|
||||
*/
|
||||
public function getCategoryLabelAttribute(): string
|
||||
{
|
||||
return self::CATEGORY_LABELS[$this->category] ?? $this->category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지출방법 라벨
|
||||
*/
|
||||
public function getCashMethodLabelAttribute(): ?string
|
||||
{
|
||||
if (! $this->cash_method) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::CASH_METHOD_LABELS[$this->cash_method] ?? $this->cash_method;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록자
|
||||
*/
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 필터 스코프
|
||||
*/
|
||||
public function scopeByCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도 필터 스코프
|
||||
*/
|
||||
public function scopeInYear($query, int $year)
|
||||
{
|
||||
return $query->whereYear('event_date', $year);
|
||||
}
|
||||
|
||||
/**
|
||||
* options 헬퍼
|
||||
*/
|
||||
public function getOption(string $key, $default = null)
|
||||
{
|
||||
return data_get($this->options, $key, $default);
|
||||
}
|
||||
|
||||
public function setOption(string $key, $value): self
|
||||
{
|
||||
$options = $this->options ?? [];
|
||||
data_set($options, $key, $value);
|
||||
$this->options = $options;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CorporateVehicle extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'plate_number',
|
||||
'model',
|
||||
'vehicle_type',
|
||||
'ownership_type',
|
||||
'year',
|
||||
'driver',
|
||||
'status',
|
||||
'mileage',
|
||||
'memo',
|
||||
'purchase_date',
|
||||
'purchase_price',
|
||||
'contract_date',
|
||||
'rent_company',
|
||||
'rent_company_tel',
|
||||
'rent_period',
|
||||
'agreed_mileage',
|
||||
'vehicle_price',
|
||||
'residual_value',
|
||||
'deposit',
|
||||
'monthly_rent',
|
||||
'monthly_rent_tax',
|
||||
'insurance_company',
|
||||
'insurance_company_tel',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'year' => 'integer',
|
||||
'mileage' => 'integer',
|
||||
'purchase_price' => 'integer',
|
||||
'vehicle_price' => 'integer',
|
||||
'residual_value' => 'integer',
|
||||
'deposit' => 'integer',
|
||||
'monthly_rent' => 'integer',
|
||||
'monthly_rent_tax' => 'integer',
|
||||
];
|
||||
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(VehicleLog::class, 'vehicle_id');
|
||||
}
|
||||
|
||||
public function maintenances(): HasMany
|
||||
{
|
||||
return $this->hasMany(VehicleMaintenance::class, 'vehicle_id');
|
||||
}
|
||||
}
|
||||
@@ -180,18 +180,16 @@ public static function createFromOrder(Order $order, string $saleNumber): self
|
||||
*/
|
||||
public static function createFromShipment(Shipment $shipment, string $saleNumber): self
|
||||
{
|
||||
$order = $shipment->order;
|
||||
|
||||
return new self([
|
||||
'tenant_id' => $shipment->tenant_id,
|
||||
'order_id' => $shipment->order_id,
|
||||
'shipment_id' => $shipment->id,
|
||||
'sale_number' => $saleNumber,
|
||||
'sale_date' => $shipment->shipped_date ?? now()->toDateString(),
|
||||
'client_id' => $order?->client_id,
|
||||
'supply_amount' => $order?->supply_amount ?? 0,
|
||||
'tax_amount' => $order?->tax_amount ?? 0,
|
||||
'total_amount' => $order?->total_amount ?? 0,
|
||||
'client_id' => $shipment->order?->client_id,
|
||||
'supply_amount' => $shipment->total_amount / 1.1, // 세전 역산
|
||||
'tax_amount' => $shipment->total_amount - ($shipment->total_amount / 1.1),
|
||||
'total_amount' => $shipment->total_amount,
|
||||
'description' => "출하 {$shipment->shipment_no} 매출",
|
||||
'status' => 'draft',
|
||||
'source_type' => self::SOURCE_SHIPMENT_COMPLETE,
|
||||
|
||||
@@ -42,6 +42,16 @@ class Shipment extends Model
|
||||
'loading_manager',
|
||||
'loading_completed_at',
|
||||
'loading_time',
|
||||
// 물류/배차 정보
|
||||
'logistics_company',
|
||||
'vehicle_tonnage',
|
||||
'shipping_cost',
|
||||
// 차량/운전자 정보
|
||||
'vehicle_no',
|
||||
'driver_name',
|
||||
'driver_contact',
|
||||
'expected_arrival',
|
||||
'confirmed_arrival',
|
||||
// 기타
|
||||
'remarks',
|
||||
'created_by',
|
||||
@@ -56,6 +66,9 @@ class Shipment extends Model
|
||||
'invoice_issued' => 'boolean',
|
||||
'loading_completed_at' => 'datetime',
|
||||
'loading_time' => 'datetime',
|
||||
'expected_arrival' => 'datetime',
|
||||
'confirmed_arrival' => 'datetime',
|
||||
'shipping_cost' => 'decimal:0',
|
||||
'order_id' => 'integer',
|
||||
'work_order_id' => 'integer',
|
||||
'client_id' => 'integer',
|
||||
@@ -75,7 +88,6 @@ class Shipment extends Model
|
||||
public const STATUSES = [
|
||||
'scheduled' => '출고예정',
|
||||
'ready' => '출하대기',
|
||||
'cancelled' => '취소',
|
||||
'shipping' => '배송중',
|
||||
'completed' => '배송완료',
|
||||
];
|
||||
@@ -135,7 +147,7 @@ public function vehicleDispatches(): HasMany
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Orders\Client::class);
|
||||
return $this->belongsTo(\App\Models\Clients\Client::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,8 +25,6 @@ class ShipmentItem extends Model
|
||||
'unit',
|
||||
'lot_no',
|
||||
'stock_lot_id',
|
||||
'order_item_id',
|
||||
'work_order_item_id',
|
||||
'remarks',
|
||||
];
|
||||
|
||||
@@ -36,8 +34,6 @@ class ShipmentItem extends Model
|
||||
'quantity' => 'decimal:2',
|
||||
'shipment_id' => 'integer',
|
||||
'stock_lot_id' => 'integer',
|
||||
'order_item_id' => 'integer',
|
||||
'work_order_item_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -56,22 +52,6 @@ public function stockLot(): BelongsTo
|
||||
return $this->belongsTo(StockLot::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 품목 관계
|
||||
*/
|
||||
public function orderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Orders\OrderItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 품목 관계
|
||||
*/
|
||||
public function workOrderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\WorkOrders\WorkOrderItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 순번 가져오기
|
||||
*/
|
||||
|
||||
@@ -50,8 +50,6 @@ class StockTransaction extends Model
|
||||
|
||||
public const REASON_PRODUCTION_OUTPUT = 'production_output';
|
||||
|
||||
public const REASON_ADJUSTMENT = 'adjustment';
|
||||
|
||||
public const REASONS = [
|
||||
self::REASON_RECEIVING => '입고',
|
||||
self::REASON_WORK_ORDER_INPUT => '생산투입',
|
||||
@@ -59,7 +57,6 @@ class StockTransaction extends Model
|
||||
self::REASON_ORDER_CONFIRM => '수주확정',
|
||||
self::REASON_ORDER_CANCEL => '수주취소',
|
||||
self::REASON_PRODUCTION_OUTPUT => '생산입고',
|
||||
self::REASON_ADJUSTMENT => '재고조정',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user