Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba8f6c0a0 | ||
|
|
ba8fc0834c | ||
|
|
ecfe389420 | ||
|
|
e83d0e90ff | ||
|
|
d1c65f5465 | ||
|
|
17a0d2f98d | ||
|
|
a96fd254e5 | ||
|
|
b10713344a | ||
|
|
b60e44ea3a | ||
| 9358c4112e | |||
| 0863afc8d0 | |||
| afc31be642 | |||
| e5da452fde | |||
| 5e65cbc93e | |||
| 8821509c99 | |||
|
|
7a9b800413 | ||
|
|
c11ac7867c | ||
|
|
269a17b49c | ||
| 7083057d59 | |||
| 13d91b7ab4 | |||
|
|
ead546e268 | ||
|
|
921f1ecba7 | ||
|
|
8404f29bca | ||
|
|
053323c144 | ||
|
|
750776d5c8 | ||
|
|
ae73275cf9 | ||
|
|
407afe38e4 | ||
| 57133541d0 | |||
|
|
a7f98ccdf5 | ||
|
|
9d2333bfb1 | ||
|
|
6e6843fd67 | ||
|
|
d8560d889c | ||
|
|
9d95b2c373 | ||
|
|
6b9673d21a | ||
|
|
926a7c7da6 | ||
|
|
877d15420a | ||
|
|
63b174811c | ||
|
|
95bae11042 | ||
|
|
adc07b7343 | ||
|
|
c942788119 | ||
|
|
2e284f6393 | ||
|
|
e12fc461a7 | ||
|
|
1eb8d2cb01 | ||
|
|
39844a3ba0 | ||
|
|
45c30aa2aa | ||
|
|
85d5b98966 | ||
| aeffd5be61 | |||
| d7c096b615 | |||
| 54686cfc8a | |||
|
|
a36b7a2514 | ||
| e241c6a681 | |||
| 73c8f78788 | |||
| 597aecb5e8 | |||
| ef591074c7 | |||
| 8b7d932f00 | |||
| 42d818596d | |||
|
|
0d9a840358 | ||
|
|
2c4f5ee91d | ||
|
|
08582261db | ||
| a50d69b243 | |||
| 37bb691838 |
@@ -1,6 +1,6 @@
|
||||
# 논리적 데이터베이스 관계 문서
|
||||
|
||||
> **자동 생성**: 2026-03-12 13:58:25
|
||||
> **자동 생성**: 2026-03-17 15:29:06
|
||||
> **소스**: Eloquent 모델 관계 분석
|
||||
|
||||
## 📊 모델별 관계 현황
|
||||
@@ -374,6 +374,8 @@ ### 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`
|
||||
@@ -384,6 +386,7 @@ ### equipment_inspections
|
||||
**모델**: `App\Models\Equipment\EquipmentInspection`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
- **inspector()**: belongsTo → `users`
|
||||
- **details()**: hasMany → `equipment_inspection_details`
|
||||
|
||||
### equipment_inspection_details
|
||||
@@ -407,6 +410,7 @@ ### equipment_repairs
|
||||
**모델**: `App\Models\Equipment\EquipmentRepair`
|
||||
|
||||
- **equipment()**: belongsTo → `equipments`
|
||||
- **repairer()**: belongsTo → `users`
|
||||
|
||||
### estimates
|
||||
**모델**: `App\Models\Estimate\Estimate`
|
||||
@@ -429,6 +433,12 @@ ### 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`
|
||||
|
||||
@@ -713,6 +723,11 @@ ### process_steps
|
||||
|
||||
- **process()**: belongsTo → `processes`
|
||||
|
||||
### bending_item_mappings
|
||||
**모델**: `App\Models\Production\BendingItemMapping`
|
||||
|
||||
- **item()**: belongsTo → `items`
|
||||
|
||||
### work_orders
|
||||
**모델**: `App\Models\Production\WorkOrder`
|
||||
|
||||
@@ -898,6 +913,7 @@ ### 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`
|
||||
@@ -1232,6 +1248,7 @@ ### shipments
|
||||
|
||||
- **order()**: belongsTo → `orders`
|
||||
- **workOrder()**: belongsTo → `work_orders`
|
||||
- **client()**: belongsTo → `clients`
|
||||
- **creator()**: belongsTo → `users`
|
||||
- **updater()**: belongsTo → `users`
|
||||
- **items()**: hasMany → `shipment_items`
|
||||
@@ -1242,6 +1259,7 @@ ### shipment_items
|
||||
|
||||
- **shipment()**: belongsTo → `shipments`
|
||||
- **stockLot()**: belongsTo → `stock_lots`
|
||||
- **orderItem()**: belongsTo → `order_items`
|
||||
|
||||
### shipment_vehicle_dispatchs
|
||||
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
|
||||
@@ -1353,6 +1371,16 @@ ### 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`
|
||||
|
||||
|
||||
353
app/Console/Commands/BendingFillOptions.php
Normal file
353
app/Console/Commands/BendingFillOptions.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 제거하면 실제 반영됩니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
169
app/Console/Commands/BendingImportImages.php
Normal file
169
app/Console/Commands/BendingImportImages.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* 레거시 5130 절곡 이미지 → SAM R2 + files 테이블 마이그레이션
|
||||
*
|
||||
* 소스: https://5130.codebridge-x.com/bending/img/{imgdata}
|
||||
* 대상: R2 저장 + files 테이블 + items.options 업데이트
|
||||
*/
|
||||
#[AsCommand(name: 'bending:import-images', description: '레거시 절곡 이미지 → R2 마이그레이션')]
|
||||
class BendingImportImages extends Command
|
||||
{
|
||||
protected $signature = 'bending: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('Source: '.$sourceBase);
|
||||
$this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE'));
|
||||
$this->newLine();
|
||||
|
||||
// 1. BENDING 아이템에서 legacy_bending_num이 있는 것 조회
|
||||
$items = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('item_category', 'BENDING')
|
||||
->whereNull('deleted_at')
|
||||
->get(['id', 'code', 'options']);
|
||||
|
||||
$this->info("BENDING 아이템: {$items->count()}건");
|
||||
|
||||
// legacy_bending_num → chandj imgdata 매핑
|
||||
$chandjImages = DB::connection('chandj')->table('bending')
|
||||
->whereNull('is_deleted')
|
||||
->whereNotNull('imgdata')
|
||||
->where('imgdata', '!=', '')
|
||||
->pluck('imgdata', 'num');
|
||||
|
||||
$this->info("chandj 이미지: {$chandjImages->count()}건");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$opts = json_decode($item->options ?? '{}', true) ?: [];
|
||||
$legacyNum = $opts['legacy_bending_num'] ?? null;
|
||||
|
||||
if (! $legacyNum || ! isset($chandjImages[$legacyNum])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 파일이 연결되어 있으면 스킵
|
||||
$existingFile = File::where('tenant_id', $tenantId)
|
||||
->where('document_type', '1')
|
||||
->where('document_id', $item->id)
|
||||
->where('field_key', 'bending_diagram')
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existingFile) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$imgFilename = $chandjImages[$legacyNum];
|
||||
$imageUrl = "{$sourceBase}/{$imgFilename}";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" ✅ {$item->code} ← {$imgFilename}");
|
||||
$this->uploaded++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미지 다운로드
|
||||
try {
|
||||
$response = Http::withoutVerifying()->timeout(15)->get($imageUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$this->warn(" ❌ {$item->code}: HTTP {$response->status()} ({$imageUrl})");
|
||||
$this->failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageContent = $response->body();
|
||||
$mimeType = $response->header('Content-Type', 'image/png');
|
||||
$extension = $this->getExtension($imgFilename, $mimeType);
|
||||
|
||||
// R2 저장
|
||||
$storedName = bin2hex(random_bytes(8)).'.'.$extension;
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$directory = sprintf('%d/items/%s/%s', $tenantId, $year, $month);
|
||||
$filePath = $directory.'/'.$storedName;
|
||||
|
||||
Storage::disk('r2')->put($filePath, $imageContent);
|
||||
|
||||
// files 테이블 저장
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $imgFilename,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($imageContent),
|
||||
'mime_type' => $mimeType,
|
||||
'file_type' => 'image',
|
||||
'field_key' => 'bending_diagram',
|
||||
'document_id' => $item->id,
|
||||
'document_type' => '1',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => 1,
|
||||
'created_by' => 1,
|
||||
]);
|
||||
|
||||
$this->line(" ✅ {$item->code} ← {$imgFilename} → file_id={$file->id}");
|
||||
$this->uploaded++;
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ {$item->code}: {$e->getMessage()}");
|
||||
$this->failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
$this->info("업로드: {$this->uploaded}건 | 스킵(이미 있음): {$this->skipped}건 | 실패: {$this->failed}건");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('🔍 DRY-RUN 완료.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function getExtension(string $filename, string $mimeType): string
|
||||
{
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
if ($ext) {
|
||||
return strtolower($ext);
|
||||
}
|
||||
|
||||
return match ($mimeType) {
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
default => 'png',
|
||||
};
|
||||
}
|
||||
}
|
||||
357
app/Console/Commands/BendingImportLegacy.php
Normal file
357
app/Console/Commands/BendingImportLegacy.php
Normal file
@@ -0,0 +1,357 @@
|
||||
<?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 제거하면 실제 반영됩니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
192
app/Console/Commands/BendingModelImportAssemblyImages.php
Normal file
192
app/Console/Commands/BendingModelImportAssemblyImages.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
160
app/Console/Commands/BendingModelImportImages.php
Normal file
160
app/Console/Commands/BendingModelImportImages.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
200
app/Console/Commands/BendingProductImportLegacy.php
Normal file
200
app/Console/Commands/BendingProductImportLegacy.php
Normal file
@@ -0,0 +1,200 @@
|
||||
<?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' => 'FG',
|
||||
'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));
|
||||
}
|
||||
}
|
||||
92
app/Console/Commands/CheckDemoExpiredCommand.php
Normal file
92
app/Console/Commands/CheckDemoExpiredCommand.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
103
app/Console/Commands/CheckDemoInactiveCommand.php
Normal file
103
app/Console/Commands/CheckDemoInactiveCommand.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
136
app/Console/Commands/GuiderailImportLegacy.php
Normal file
136
app/Console/Commands/GuiderailImportLegacy.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?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' => 'FG',
|
||||
'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,
|
||||
];
|
||||
}
|
||||
}
|
||||
174
app/Console/Commands/ResetDemoShowcaseCommand.php
Normal file
174
app/Console/Commands/ResetDemoShowcaseCommand.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ public function render($request, Throwable $exception)
|
||||
if ($exception instanceof BadRequestHttpException) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '잘못된 요청',
|
||||
'message' => $exception->getMessage() ?: '잘못된 요청',
|
||||
'data' => null,
|
||||
], 400);
|
||||
}
|
||||
|
||||
310
app/Helpers/SafeMathEvaluator.php
Normal file
310
app/Helpers/SafeMathEvaluator.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,18 @@
|
||||
|
||||
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 BarobillService $barobillService,
|
||||
private BarobillSoapService $soapService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -19,17 +24,43 @@ 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' => 0,
|
||||
'account_link_count' => 0,
|
||||
'member' => $setting ? [
|
||||
'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 ? [
|
||||
'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'));
|
||||
}
|
||||
|
||||
306
app/Http/Controllers/Api/V1/BarobillSyncController.php
Normal file
306
app/Http/Controllers/Api/V1/BarobillSyncController.php
Normal file
@@ -0,0 +1,306 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/Api/V1/BendingController.php
Normal file
119
app/Http/Controllers/Api/V1/BendingController.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?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 파라미터가 필요합니다.'];
|
||||
}
|
||||
|
||||
$item = $this->service->resolveItem($prodCode, $specCode, $lengthCode);
|
||||
|
||||
if (! $item) {
|
||||
return ['error' => 'NOT_MAPPED', 'code' => 404, 'message' => '해당 조합에 매핑된 품목이 없습니다.'];
|
||||
}
|
||||
|
||||
return $item;
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 원자재 LOT 목록 조회 (수입검사 완료 입고 기준)
|
||||
*
|
||||
* 재질(material)이 일치하는 입고 LOT 목록 반환
|
||||
*/
|
||||
public function materialLots(Request $request): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$material = $request->query('material');
|
||||
|
||||
$query = Receiving::where('status', 'completed')
|
||||
->whereNotNull('lot_no')
|
||||
->where('lot_no', '!=', '');
|
||||
|
||||
// 재질(item_name 또는 specification)으로 필터링
|
||||
if ($material) {
|
||||
$query->where(function ($q) use ($material) {
|
||||
$q->where('item_name', 'LIKE', "%{$material}%")
|
||||
->orWhere('specification', 'LIKE', "%{$material}%");
|
||||
});
|
||||
}
|
||||
|
||||
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 번호 생성 (프리뷰 + 일련번호 확정)
|
||||
*/
|
||||
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가 필요합니다.'];
|
||||
}
|
||||
|
||||
$dateCode = BendingCodeService::generateDateCode($regDate);
|
||||
$lotBase = "{$prodCode}{$specCode}{$dateCode}-{$lengthCode}";
|
||||
$lotNumber = $this->service->generateLotNumber($lotBase);
|
||||
$material = BendingCodeService::getMaterial($prodCode, $specCode);
|
||||
|
||||
return [
|
||||
'lot_base' => $lotBase,
|
||||
'lot_number' => $lotNumber,
|
||||
'date_code' => $dateCode,
|
||||
'material' => $material,
|
||||
];
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
}
|
||||
95
app/Http/Controllers/Api/V1/BendingItemController.php
Normal file
95
app/Http/Controllers/Api/V1/BendingItemController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?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,6 +20,16 @@ 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) {
|
||||
|
||||
62
app/Http/Controllers/Api/V1/DemoAnalyticsController.php
Normal file
62
app/Http/Controllers/Api/V1/DemoAnalyticsController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
95
app/Http/Controllers/Api/V1/DemoTenantController.php
Normal file
95
app/Http/Controllers/Api/V1/DemoTenantController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?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'));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,20 @@
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -85,8 +99,10 @@ public function trash()
|
||||
/**
|
||||
* Download file (attachment)
|
||||
*/
|
||||
public function download(int $id)
|
||||
public function download(int $id, Request $request)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
@@ -96,8 +112,10 @@ public function download(int $id)
|
||||
/**
|
||||
* View file inline (이미지/PDF 브라우저에서 바로 표시)
|
||||
*/
|
||||
public function view(int $id)
|
||||
public function view(int $id, Request $request)
|
||||
{
|
||||
$this->ensureContext($request);
|
||||
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
|
||||
89
app/Http/Controllers/Api/V1/GuiderailModelController.php
Normal file
89
app/Http/Controllers/Api/V1/GuiderailModelController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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', '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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public function index(Request $request)
|
||||
'category_id' => $request->input('category_id'),
|
||||
'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'),
|
||||
|
||||
@@ -26,6 +26,20 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 목록 조회
|
||||
*
|
||||
@@ -33,6 +47,7 @@ class ItemsFileController extends Controller
|
||||
*/
|
||||
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');
|
||||
@@ -69,6 +84,7 @@ 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');
|
||||
@@ -152,6 +168,7 @@ 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) {
|
||||
|
||||
@@ -30,10 +30,10 @@ public function index(Request $request)
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function stats()
|
||||
public function stats(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () {
|
||||
return $this->service->stats();
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->stats($request->input('order_type'));
|
||||
}, __('message.order.fetched'));
|
||||
}
|
||||
|
||||
|
||||
@@ -56,4 +56,12 @@ 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,4 +124,24 @@ 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,6 +4,7 @@
|
||||
|
||||
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;
|
||||
@@ -71,4 +72,32 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,30 @@
|
||||
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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -125,6 +141,48 @@ 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)
|
||||
// =========================================================================
|
||||
|
||||
38
app/Http/Controllers/Api/V1/VehiclePhotoController.php
Normal file
38
app/Http/Controllers/Api/V1/VehiclePhotoController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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(int $id): JsonResponse
|
||||
public function templates(Request $request, int $id): JsonResponse
|
||||
{
|
||||
return ApiResponse::handle(
|
||||
fn () => $this->service->getActiveCycles($id),
|
||||
fn () => $this->service->getTemplatesByEquipment($id, $request->input('cycle')),
|
||||
__('message.fetched')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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')
|
||||
);
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/V1/Vehicle/VehicleLogController.php
Normal file
66
app/Http/Controllers/V1/Vehicle/VehicleLogController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,13 @@ public function handle(Request $request, Closure $next)
|
||||
'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용)
|
||||
'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근)
|
||||
'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요)
|
||||
'api/v1/bending-items', // 절곡품 목록 (MNG에서 API Key만으로 접근)
|
||||
'api/v1/bending-items/*', // 절곡품 상세/필터
|
||||
'api/v1/guiderail-models', // 절곡품 모델 목록
|
||||
'api/v1/guiderail-models/*', // 절곡품 모델 상세/필터
|
||||
'api/v1/items/*/files', // 품목 파일 업로드/조회
|
||||
'api/v1/files/*/view', // 파일 인라인 보기 (MNG 이미지 표시)
|
||||
'api/v1/files/*/download', // 파일 다운로드
|
||||
];
|
||||
|
||||
// 현재 라우트 확인 (경로 또는 이름)
|
||||
|
||||
93
app/Http/Middleware/DemoLimitMiddleware.php
Normal file
93
app/Http/Middleware/DemoLimitMiddleware.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/Api/V1/BendingItemIndexRequest.php
Normal file
27
app/Http/Requests/Api/V1/BendingItemIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?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',
|
||||
'search' => 'nullable|string|max:100',
|
||||
'page' => 'nullable|integer|min:1',
|
||||
'size' => 'nullable|integer|min:1|max:200',
|
||||
];
|
||||
}
|
||||
}
|
||||
47
app/Http/Requests/Api/V1/BendingItemStoreRequest.php
Normal file
47
app/Http/Requests/Api/V1/BendingItemStoreRequest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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:100|unique:items,code',
|
||||
'name' => 'required|string|max:200',
|
||||
'unit' => 'nullable|string|max:20',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Api/V1/BendingItemUpdateRequest.php
Normal file
46
app/Http/Requests/Api/V1/BendingItemUpdateRequest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/Demo/DemoTenantStoreRequest.php
Normal file
35
app/Http/Requests/Demo/DemoTenantStoreRequest.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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' => '유효하지 않은 프리셋입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Requests/Finance/StoreVehiclePhotoRequest.php
Normal file
53
app/Http/Requests/Finance/StoreVehiclePhotoRequest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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,6 +16,13 @@ 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_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])],
|
||||
'status_code' => ['nullable', Rule::in([
|
||||
Order::STATUS_DRAFT,
|
||||
Order::STATUS_CONFIRMED,
|
||||
@@ -55,6 +55,18 @@ 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_code' => ['nullable', Rule::in([Order::TYPE_ORDER, Order::TYPE_PURCHASE, Order::TYPE_STOCK])],
|
||||
'category_code' => 'nullable|string|max:50',
|
||||
|
||||
// 거래처 정보
|
||||
@@ -49,6 +49,18 @@ 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',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SwitchTenantRequest extends FormRequest
|
||||
{
|
||||
@@ -13,8 +14,23 @@ public function authorize(): bool
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$userId = app('api_user');
|
||||
|
||||
return [
|
||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
29
app/Http/Requests/V1/Stock/StoreStockAdjustmentRequest.php
Normal file
29
app/Http/Requests/V1/Stock/StoreStockAdjustmentRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
72
app/Http/Resources/Api/V1/BendingItemResource.php
Normal file
72
app/Http/Resources/Api/V1/BendingItemResource.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?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,
|
||||
'name' => $this->name,
|
||||
'item_type' => $this->item_type,
|
||||
'item_category' => $this->item_category,
|
||||
'unit' => $this->unit,
|
||||
'is_active' => $this->is_active,
|
||||
// options → 최상위로 노출
|
||||
'item_name' => $this->getOption('item_name'),
|
||||
'item_sep' => $this->getOption('item_sep'),
|
||||
'item_bending' => $this->getOption('item_bending'),
|
||||
'item_spec' => $this->getOption('item_spec'),
|
||||
'material' => $this->getOption('material'),
|
||||
'model_name' => $this->getOption('model_name'),
|
||||
'model_UA' => $this->getOption('model_UA'),
|
||||
'search_keyword' => $this->getOption('search_keyword'),
|
||||
'rail_width' => $this->getOption('rail_width'),
|
||||
'registration_date' => $this->getOption('registration_date'),
|
||||
'author' => $this->getOption('author'),
|
||||
'memo' => $this->getOption('memo'),
|
||||
// 케이스 전용
|
||||
'exit_direction' => $this->getOption('exit_direction'),
|
||||
'front_bottom_width' => $this->getOption('front_bottom_width'),
|
||||
'box_width' => $this->getOption('box_width'),
|
||||
'box_height' => $this->getOption('box_height'),
|
||||
// 전개도
|
||||
'bendingData' => $this->getOption('bendingData'),
|
||||
// PREFIX 관련
|
||||
'prefix' => $this->getOption('prefix'),
|
||||
'length_code' => $this->getOption('length_code'),
|
||||
'length_mm' => $this->getOption('length_mm'),
|
||||
// 추적
|
||||
'legacy_bending_num' => $this->getOption('legacy_bending_num'),
|
||||
// 계산값
|
||||
'width_sum' => $this->getWidthSum(),
|
||||
'bend_count' => $this->getBendCount(),
|
||||
// 메타
|
||||
'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 getWidthSum(): ?int
|
||||
{
|
||||
$data = $this->getOption('bendingData', []);
|
||||
if (empty($data)) {
|
||||
return null;
|
||||
}
|
||||
$last = end($data);
|
||||
|
||||
return isset($last['sum']) ? (int) $last['sum'] : null;
|
||||
}
|
||||
|
||||
private function getBendCount(): int
|
||||
{
|
||||
$data = $this->getOption('bendingData', []);
|
||||
|
||||
return count(array_filter($data, fn ($d) => ($d['rate'] ?? '') !== ''));
|
||||
}
|
||||
}
|
||||
71
app/Http/Resources/Api/V1/GuiderailModelResource.php
Normal file
71
app/Http/Resources/Api/V1/GuiderailModelResource.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?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->getOption('components', []);
|
||||
$materialSummary = $this->getOption('material_summary');
|
||||
|
||||
// material_summary가 없으면 components에서 계산
|
||||
if (empty($materialSummary) && ! empty($components)) {
|
||||
$materialSummary = $this->calcMaterialSummary($components);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'code' => $this->code,
|
||||
'name' => $this->name,
|
||||
'item_type' => $this->item_type,
|
||||
'item_category' => $this->item_category,
|
||||
'is_active' => $this->is_active,
|
||||
// 모델 속성
|
||||
'model_name' => $this->getOption('model_name'),
|
||||
'check_type' => $this->getOption('check_type'),
|
||||
'rail_width' => $this->getOption('rail_width'),
|
||||
'rail_length' => $this->getOption('rail_length'),
|
||||
'finishing_type' => $this->getOption('finishing_type'),
|
||||
'item_sep' => $this->getOption('item_sep'),
|
||||
'model_UA' => $this->getOption('model_UA'),
|
||||
'search_keyword' => $this->getOption('search_keyword'),
|
||||
'author' => $this->getOption('author'),
|
||||
'memo' => $this->getOption('memo'),
|
||||
'registration_date' => $this->getOption('registration_date'),
|
||||
// 케이스(SHUTTERBOX_MODEL) 전용
|
||||
'exit_direction' => $this->getOption('exit_direction'),
|
||||
'front_bottom_width' => $this->getOption('front_bottom_width'),
|
||||
'box_width' => $this->getOption('box_width'),
|
||||
'box_height' => $this->getOption('box_height'),
|
||||
// 하단마감재(BOTTOMBAR_MODEL) 전용
|
||||
'bar_width' => $this->getOption('bar_width'),
|
||||
'bar_height' => $this->getOption('bar_height'),
|
||||
// 부품 조합
|
||||
'components' => $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 calcMaterialSummary(array $components): array
|
||||
{
|
||||
$summary = [];
|
||||
foreach ($components as $comp) {
|
||||
$material = $comp['material'] ?? null;
|
||||
$widthSum = $comp['width_sum'] ?? 0;
|
||||
$qty = $comp['quantity'] ?? 1;
|
||||
if ($material && $widthSum) {
|
||||
$summary[$material] = ($summary[$material] ?? 0) + ($widthSum * $qty);
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
85
app/Jobs/Barobill/SyncBarobillDataJob.php
Normal file
85
app/Jobs/Barobill/SyncBarobillDataJob.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
* @property int $id
|
||||
* @property int $template_id
|
||||
* @property string $title 섹션 제목
|
||||
* @property string|null $image_path 검사 기준 이미지 경로
|
||||
* @property string|null $image_path 검사 기준 이미지 경로 (R2 key)
|
||||
* @property int|null $file_id 도해 이미지 파일 ID (files 테이블 참조)
|
||||
* @property int $sort_order 정렬 순서
|
||||
*/
|
||||
class DocumentTemplateSection extends Model
|
||||
@@ -24,6 +25,7 @@ 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\User::class, 'manager_id');
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'manager_id');
|
||||
}
|
||||
|
||||
public function subManager(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'sub_manager_id');
|
||||
return $this->belongsTo(\App\Models\Members\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\User::class, 'inspector_id');
|
||||
return $this->belongsTo(\App\Models\Members\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\User::class, 'repaired_by');
|
||||
return $this->belongsTo(\App\Models\Members\User::class, 'repaired_by');
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Models/Finance/CorporateVehicle.php
Normal file
40
app/Models/Finance/CorporateVehicle.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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,6 +51,24 @@ 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 상수
|
||||
*/
|
||||
@@ -182,6 +200,24 @@ 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;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// 헬퍼 메서드
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -78,6 +78,8 @@ class Order extends Model
|
||||
|
||||
public const TYPE_PURCHASE = 'PURCHASE'; // 발주
|
||||
|
||||
public const TYPE_STOCK = 'STOCK'; // 재고생산
|
||||
|
||||
// 매출 인식 시점
|
||||
public const SALES_ON_ORDER_CONFIRM = 'on_order_confirm'; // 수주확정 시
|
||||
|
||||
@@ -338,4 +340,46 @@ 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
33
app/Models/Production/BendingItemMapping.php
Normal file
33
app/Models/Production/BendingItemMapping.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Production;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BendingItemMapping extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'bending_item_mappings';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'prod_code',
|
||||
'spec_code',
|
||||
'length_code',
|
||||
'item_id',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function item(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Members\User;
|
||||
use App\Models\Orders\Client;
|
||||
use App\Traits\Auditable;
|
||||
@@ -72,6 +73,12 @@ 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
|
||||
|
||||
@@ -331,11 +331,21 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
61
app/Models/Tenants/CorporateVehicle.php
Normal file
61
app/Models/Tenants/CorporateVehicle.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ public function vehicleDispatches(): HasMany
|
||||
*/
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Clients\Client::class);
|
||||
return $this->belongsTo(\App\Models\Orders\Client::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,8 @@ class ShipmentItem extends Model
|
||||
'unit',
|
||||
'lot_no',
|
||||
'stock_lot_id',
|
||||
'order_item_id',
|
||||
'work_order_item_id',
|
||||
'remarks',
|
||||
];
|
||||
|
||||
@@ -34,6 +36,8 @@ class ShipmentItem extends Model
|
||||
'quantity' => 'decimal:2',
|
||||
'shipment_id' => 'integer',
|
||||
'stock_lot_id' => 'integer',
|
||||
'order_item_id' => 'integer',
|
||||
'work_order_item_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -52,6 +56,22 @@ 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,6 +50,8 @@ 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 => '생산투입',
|
||||
@@ -57,6 +59,7 @@ class StockTransaction extends Model
|
||||
self::REASON_ORDER_CONFIRM => '수주확정',
|
||||
self::REASON_ORDER_CANCEL => '수주취소',
|
||||
self::REASON_PRODUCTION_OUTPUT => '생산입고',
|
||||
self::REASON_ADJUSTMENT => '재고조정',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -18,6 +18,32 @@ class Tenant extends Model
|
||||
{
|
||||
use ModelTrait, SoftDeletes;
|
||||
|
||||
// 데모 테넌트 유형 상수
|
||||
const TYPE_STD = 'STD';
|
||||
|
||||
const TYPE_TPL = 'TPL';
|
||||
|
||||
const TYPE_HQ = 'HQ';
|
||||
|
||||
const TYPE_DEMO_SHOWCASE = 'DEMO_SHOWCASE';
|
||||
|
||||
const TYPE_DEMO_PARTNER = 'DEMO_PARTNER';
|
||||
|
||||
const TYPE_DEMO_TRIAL = 'DEMO_TRIAL';
|
||||
|
||||
const DEMO_TYPES = [
|
||||
self::TYPE_DEMO_SHOWCASE,
|
||||
self::TYPE_DEMO_PARTNER,
|
||||
self::TYPE_DEMO_TRIAL,
|
||||
];
|
||||
|
||||
// 데모 options 키 상수
|
||||
const OPTION_DEMO_PRESET = 'demo_preset';
|
||||
|
||||
const OPTION_DEMO_LIMITS = 'demo_limits';
|
||||
|
||||
const OPTION_DEMO_READ_ONLY = 'demo_read_only';
|
||||
|
||||
protected $fillable = [
|
||||
'company_name',
|
||||
'code',
|
||||
@@ -47,6 +73,7 @@ class Tenant extends Model
|
||||
|
||||
protected $casts = [
|
||||
'trial_ends_at' => 'datetime',
|
||||
'demo_expires_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'last_paid_at' => 'datetime',
|
||||
'max_users' => 'integer',
|
||||
@@ -244,4 +271,133 @@ private function formatBytes(int $bytes): string
|
||||
|
||||
return round($bytes, 2).' '.$units[$pow];
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
// ─── 데모 테넌트 관련 메서드 ───
|
||||
|
||||
/**
|
||||
* 데모 테넌트인지 확인 (모든 데모 유형)
|
||||
*/
|
||||
public function isDemoTenant(): bool
|
||||
{
|
||||
return in_array($this->tenant_type, self::DEMO_TYPES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 쇼케이스(공용 읽기전용)인지 확인
|
||||
*/
|
||||
public function isDemoShowcase(): bool
|
||||
{
|
||||
return $this->tenant_type === self::TYPE_DEMO_SHOWCASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트너 데모인지 확인
|
||||
*/
|
||||
public function isDemoPartner(): bool
|
||||
{
|
||||
return $this->tenant_type === self::TYPE_DEMO_PARTNER;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 체험 데모인지 확인
|
||||
*/
|
||||
public function isDemoTrial(): bool
|
||||
{
|
||||
return $this->tenant_type === self::TYPE_DEMO_TRIAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 만료 여부 확인
|
||||
*/
|
||||
public function isDemoExpired(): bool
|
||||
{
|
||||
if (! $this->isDemoTenant()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 쇼케이스는 만료 없음
|
||||
if ($this->isDemoShowcase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->demo_expires_at && now()->gt($this->demo_expires_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 읽기전용인지 확인
|
||||
*/
|
||||
public function isDemoReadOnly(): bool
|
||||
{
|
||||
return $this->isDemoShowcase()
|
||||
|| $this->getOption(self::OPTION_DEMO_READ_ONLY, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트만 조회하는 스코프
|
||||
*/
|
||||
public function scopeDemo($query)
|
||||
{
|
||||
return $query->whereIn('tenant_type', self::DEMO_TYPES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 프리셋 조회
|
||||
*/
|
||||
public function getDemoPreset(): ?string
|
||||
{
|
||||
return $this->getOption(self::OPTION_DEMO_PRESET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 수량 제한 조회
|
||||
*/
|
||||
public function getDemoLimits(): array
|
||||
{
|
||||
return $this->getOption(self::OPTION_DEMO_LIMITS, [
|
||||
'max_items' => 100,
|
||||
'max_orders' => 50,
|
||||
'max_productions' => 30,
|
||||
'max_users' => 5,
|
||||
'max_storage_gb' => 1,
|
||||
'max_ai_tokens' => 100000,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 → 정식 테넌트로 전환
|
||||
* fillable 밖의 컬럼이므로 forceFill 사용
|
||||
*/
|
||||
public function convertToProduction(): void
|
||||
{
|
||||
$this->forceFill([
|
||||
'tenant_type' => self::TYPE_STD,
|
||||
'demo_expires_at' => null,
|
||||
'demo_source_partner_id' => null,
|
||||
])->save();
|
||||
|
||||
// options에서 데모 관련 키 제거
|
||||
$options = $this->options ?? [];
|
||||
unset(
|
||||
$options[self::OPTION_DEMO_PRESET],
|
||||
$options[self::OPTION_DEMO_LIMITS],
|
||||
$options[self::OPTION_DEMO_READ_ONLY]
|
||||
);
|
||||
$this->update(['options' => $options ?: null]);
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Models/Tenants/VehicleLog.php
Normal file
40
app/Models/Tenants/VehicleLog.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class VehicleLog extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'vehicle_id',
|
||||
'log_date',
|
||||
'department',
|
||||
'driver_name',
|
||||
'trip_type',
|
||||
'departure_type',
|
||||
'departure_name',
|
||||
'departure_address',
|
||||
'arrival_type',
|
||||
'arrival_name',
|
||||
'arrival_address',
|
||||
'distance_km',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'vehicle_id' => 'integer',
|
||||
'distance_km' => 'integer',
|
||||
];
|
||||
|
||||
public function vehicle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
|
||||
}
|
||||
}
|
||||
36
app/Models/Tenants/VehicleMaintenance.php
Normal file
36
app/Models/Tenants/VehicleMaintenance.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenants;
|
||||
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class VehicleMaintenance extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'vehicle_id',
|
||||
'date',
|
||||
'category',
|
||||
'description',
|
||||
'amount',
|
||||
'mileage',
|
||||
'vendor',
|
||||
'memo',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'vehicle_id' => 'integer',
|
||||
'amount' => 'integer',
|
||||
'mileage' => 'integer',
|
||||
];
|
||||
|
||||
public function vehicle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CorporateVehicle::class, 'vehicle_id');
|
||||
}
|
||||
}
|
||||
@@ -1536,7 +1536,7 @@ private function generateDocumentNumber(int $tenantId): string
|
||||
$prefix = 'AP';
|
||||
$date = now()->format('Ymd');
|
||||
|
||||
$lastNumber = Approval::query()
|
||||
$lastNumber = Approval::withTrashed()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('document_number', 'like', "{$prefix}-{$date}-%")
|
||||
->orderByDesc('document_number')
|
||||
|
||||
293
app/Services/Barobill/BarobillBankSyncService.php
Normal file
293
app/Services/Barobill/BarobillBankSyncService.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Barobill;
|
||||
|
||||
use App\Models\Barobill\BarobillBankSyncStatus;
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Services\Service;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 바로빌 은행 거래내역 동기화 서비스 (API 독립 구현)
|
||||
*
|
||||
* MNG의 BarobillBankSyncService 패턴을 참고하여 독립 작성.
|
||||
* SOAP API를 호출하여 은행 거래내역을 DB에 캐시/동기화한다.
|
||||
*/
|
||||
class BarobillBankSyncService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
protected BarobillSoapService $soapService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 지정 기간의 거래내역이 최신인지 확인하고, 필요 시 바로빌 API에서 동기화
|
||||
*/
|
||||
public function syncIfNeeded(int $tenantId, string $startDateYmd, string $endDateYmd): array
|
||||
{
|
||||
$member = BarobillMember::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $member || empty($member->barobill_id)) {
|
||||
return ['success' => false, 'error' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
|
||||
$accounts = $this->getRegisteredAccounts($member);
|
||||
if (empty($accounts)) {
|
||||
return ['success' => true, 'message' => '등록된 계좌 없음', 'synced' => 0];
|
||||
}
|
||||
|
||||
$currentYearMonth = Carbon::now()->format('Ym');
|
||||
$chunks = $this->splitDateRangeMonthly($startDateYmd, $endDateYmd);
|
||||
$totalSynced = 0;
|
||||
|
||||
foreach ($accounts as $acc) {
|
||||
$accNum = $acc['bankAccountNum'];
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$yearMonth = substr($chunk['start'], 0, 6);
|
||||
$isCurrentMonth = ($yearMonth === $currentYearMonth);
|
||||
|
||||
$syncStatus = BarobillBankSyncStatus::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('bank_account_num', $accNum)
|
||||
->where('synced_year_month', $yearMonth)
|
||||
->first();
|
||||
|
||||
if ($syncStatus) {
|
||||
if (! $isCurrentMonth) {
|
||||
continue;
|
||||
}
|
||||
if ($syncStatus->synced_at && $syncStatus->synced_at->diffInMinutes(now()) < 10) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$count = $this->fetchAndCache(
|
||||
$tenantId,
|
||||
$member->barobill_id,
|
||||
$accNum,
|
||||
$acc['bankName'],
|
||||
$acc['bankCode'],
|
||||
$chunk['start'],
|
||||
$chunk['end'],
|
||||
$yearMonth
|
||||
);
|
||||
$totalSynced += $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'synced' => $totalSynced,
|
||||
'accounts' => count($accounts),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 등록 계좌 목록 조회 (SOAP)
|
||||
*/
|
||||
public function getRegisteredAccounts(BarobillMember $member): array
|
||||
{
|
||||
$result = $this->soapService->getBankAccounts($member->biz_no, false);
|
||||
if (! $result['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$accountList = [];
|
||||
if (isset($data->BankAccount)) {
|
||||
$accountList = is_array($data->BankAccount) ? $data->BankAccount : [$data->BankAccount];
|
||||
} elseif (isset($data->BankAccountEx)) {
|
||||
$accountList = is_array($data->BankAccountEx) ? $data->BankAccountEx : [$data->BankAccountEx];
|
||||
}
|
||||
|
||||
$accounts = [];
|
||||
foreach ($accountList as $acc) {
|
||||
if (! is_object($acc)) {
|
||||
continue;
|
||||
}
|
||||
$bankAccountNum = $acc->BankAccountNum ?? '';
|
||||
if (empty($bankAccountNum) || (is_numeric($bankAccountNum) && $bankAccountNum < 0)) {
|
||||
continue;
|
||||
}
|
||||
$accounts[] = [
|
||||
'bankAccountNum' => $bankAccountNum,
|
||||
'bankCode' => $acc->BankCode ?? '',
|
||||
'bankName' => $acc->BankName ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 API에서 거래내역을 가져와 DB에 캐시
|
||||
*/
|
||||
protected function fetchAndCache(
|
||||
int $tenantId,
|
||||
string $userId,
|
||||
string $accNum,
|
||||
string $bankName,
|
||||
string $bankCode,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
string $yearMonth
|
||||
): int {
|
||||
$result = $this->soapService->call('bankaccount', 'GetPeriodBankAccountTransLog', [
|
||||
'ID' => $userId,
|
||||
'BankAccountNum' => $accNum,
|
||||
'StartDate' => $startDate,
|
||||
'EndDate' => $endDate,
|
||||
'TransDirection' => 1,
|
||||
'CountPerPage' => 1000,
|
||||
'CurrentPage' => 1,
|
||||
'OrderDirection' => 2,
|
||||
]);
|
||||
|
||||
if (! $result['success']) {
|
||||
$errorCode = $result['error_code'] ?? 0;
|
||||
if (in_array($errorCode, [-25005, -25001])) {
|
||||
$this->updateSyncStatus($tenantId, $accNum, $yearMonth);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$chunkData = $result['data'];
|
||||
|
||||
if (is_numeric($chunkData) && $chunkData < 0) {
|
||||
if (in_array((int) $chunkData, [-25005, -25001])) {
|
||||
$this->updateSyncStatus($tenantId, $accNum, $yearMonth);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rawLogs = [];
|
||||
if (isset($chunkData->BankAccountLogList) && isset($chunkData->BankAccountLogList->BankAccountTransLog)) {
|
||||
$logs = $chunkData->BankAccountLogList->BankAccountTransLog;
|
||||
$rawLogs = is_array($logs) ? $logs : [$logs];
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
if (! empty($rawLogs)) {
|
||||
$count = $this->cacheTransactions($tenantId, $accNum, $bankName, $bankCode, $rawLogs);
|
||||
Log::debug("[BankSync] 캐시 저장 ({$startDate}~{$endDate}): {$count}건");
|
||||
}
|
||||
|
||||
$this->updateSyncStatus($tenantId, $accNum, $yearMonth);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 DB에 배치 저장
|
||||
*/
|
||||
protected function cacheTransactions(int $tenantId, string $accNum, string $bankName, string $bankCode, array $rawLogs): int
|
||||
{
|
||||
$rows = [];
|
||||
$now = now();
|
||||
|
||||
foreach ($rawLogs as $log) {
|
||||
$transDT = $log->TransDT ?? '';
|
||||
$transDate = strlen($transDT) >= 8 ? substr($transDT, 0, 8) : '';
|
||||
$transTime = strlen($transDT) >= 14 ? substr($transDT, 8, 6) : '';
|
||||
$deposit = floatval($log->Deposit ?? 0);
|
||||
$withdraw = floatval($log->Withdraw ?? 0);
|
||||
$balance = floatval($log->Balance ?? 0);
|
||||
$summary = $log->TransRemark1 ?? $log->Summary ?? '';
|
||||
$remark2 = $log->TransRemark2 ?? '';
|
||||
|
||||
$cleanSummary = $this->cleanSummary($summary, $remark2);
|
||||
|
||||
$rows[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'bank_account_num' => $log->BankAccountNum ?? $accNum,
|
||||
'bank_code' => $log->BankCode ?? $bankCode,
|
||||
'bank_name' => $log->BankName ?? $bankName,
|
||||
'trans_date' => $transDate,
|
||||
'trans_time' => $transTime,
|
||||
'trans_dt' => $transDT,
|
||||
'deposit' => $deposit,
|
||||
'withdraw' => $withdraw,
|
||||
'balance' => $balance,
|
||||
'summary' => $cleanSummary,
|
||||
'cast' => $remark2,
|
||||
'memo' => $log->Memo ?? '',
|
||||
'trans_office' => $log->TransOffice ?? '',
|
||||
'is_manual' => false,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
foreach (array_chunk($rows, 100) as $batch) {
|
||||
$inserted += DB::table('barobill_bank_transactions')->insertOrIgnore($batch);
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
protected function updateSyncStatus(int $tenantId, string $accNum, string $yearMonth): void
|
||||
{
|
||||
BarobillBankSyncStatus::withoutGlobalScopes()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenantId,
|
||||
'bank_account_num' => $accNum,
|
||||
'synced_year_month' => $yearMonth,
|
||||
],
|
||||
['synced_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간을 월별 청크로 분할
|
||||
*/
|
||||
protected function splitDateRangeMonthly(string $startDate, string $endDate): array
|
||||
{
|
||||
$start = Carbon::createFromFormat('Ymd', $startDate)->startOfDay();
|
||||
$end = Carbon::createFromFormat('Ymd', $endDate)->endOfDay();
|
||||
|
||||
$chunks = [];
|
||||
$cursor = $start->copy();
|
||||
|
||||
while ($cursor->lte($end)) {
|
||||
$chunkStart = $cursor->copy();
|
||||
$chunkEnd = $cursor->copy()->endOfMonth()->startOfDay();
|
||||
|
||||
if ($chunkEnd->gt($end)) {
|
||||
$chunkEnd = $end->copy()->startOfDay();
|
||||
}
|
||||
|
||||
$chunks[] = [
|
||||
'start' => $chunkStart->format('Ymd'),
|
||||
'end' => $chunkEnd->format('Ymd'),
|
||||
];
|
||||
|
||||
$cursor = $chunkStart->copy()->addMonth()->startOfMonth();
|
||||
}
|
||||
|
||||
return $chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요약 정리 (중복 정보 제거)
|
||||
*/
|
||||
protected function cleanSummary(string $summary, string $remark): string
|
||||
{
|
||||
$summary = trim($summary);
|
||||
$remark = trim($remark);
|
||||
|
||||
if (! empty($remark) && str_contains($summary, $remark)) {
|
||||
$summary = trim(str_replace($remark, '', $summary));
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
186
app/Services/Barobill/BarobillCardSyncService.php
Normal file
186
app/Services/Barobill/BarobillCardSyncService.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Barobill;
|
||||
|
||||
use App\Models\Barobill\BarobillMember;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 바로빌 카드 거래내역 동기화 서비스 (API 독립 구현)
|
||||
*
|
||||
* MNG의 EcardController 카드 조회 패턴을 참고하여 독립 작성.
|
||||
* SOAP API를 호출하여 카드 거래내역을 DB에 캐시/동기화한다.
|
||||
*/
|
||||
class BarobillCardSyncService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
protected BarobillSoapService $soapService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 카드 거래내역 동기화
|
||||
*/
|
||||
public function syncCardTransactions(int $tenantId, string $startDateYmd, string $endDateYmd): array
|
||||
{
|
||||
$member = BarobillMember::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->first();
|
||||
|
||||
if (! $member || empty($member->barobill_id)) {
|
||||
return ['success' => false, 'error' => '바로빌 회원 정보 없음'];
|
||||
}
|
||||
|
||||
$this->soapService->initForMember($member);
|
||||
|
||||
$cards = $this->getRegisteredCards($member);
|
||||
if (empty($cards)) {
|
||||
return ['success' => true, 'message' => '등록된 카드 없음', 'synced' => 0];
|
||||
}
|
||||
|
||||
$totalSynced = 0;
|
||||
|
||||
foreach ($cards as $card) {
|
||||
$cardNum = $card['cardNum'];
|
||||
|
||||
$result = $this->soapService->getPeriodCardLog(
|
||||
$member->biz_no,
|
||||
$member->barobill_id,
|
||||
$cardNum,
|
||||
$startDateYmd,
|
||||
$endDateYmd
|
||||
);
|
||||
|
||||
if (! $result['success']) {
|
||||
Log::warning("[CardSync] 카드 조회 실패: {$cardNum}", [
|
||||
'error' => $result['error'] ?? '',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
|
||||
if (is_numeric($data) && $data < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawLogs = [];
|
||||
if (isset($data->CardLogList) && isset($data->CardLogList->CardLog)) {
|
||||
$logs = $data->CardLogList->CardLog;
|
||||
$rawLogs = is_array($logs) ? $logs : [$logs];
|
||||
}
|
||||
|
||||
if (! empty($rawLogs)) {
|
||||
$count = $this->cacheTransactions(
|
||||
$tenantId,
|
||||
$cardNum,
|
||||
$card['cardCompany'],
|
||||
$card['cardCompanyName'],
|
||||
$rawLogs
|
||||
);
|
||||
$totalSynced += $count;
|
||||
Log::debug("[CardSync] 카드 {$cardNum}: {$count}건 저장");
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'synced' => $totalSynced,
|
||||
'cards' => count($cards),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 바로빌 등록 카드 목록 조회 (SOAP)
|
||||
*/
|
||||
public function getRegisteredCards(BarobillMember $member): array
|
||||
{
|
||||
$result = $this->soapService->getCards($member->biz_no, false);
|
||||
if (! $result['success']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $result['data'];
|
||||
$cardList = [];
|
||||
if (isset($data->CardEx2)) {
|
||||
$cardList = is_array($data->CardEx2) ? $data->CardEx2 : [$data->CardEx2];
|
||||
} elseif (isset($data->Card)) {
|
||||
$cardList = is_array($data->Card) ? $data->Card : [$data->Card];
|
||||
}
|
||||
|
||||
$cards = [];
|
||||
foreach ($cardList as $card) {
|
||||
if (! is_object($card)) {
|
||||
continue;
|
||||
}
|
||||
$cardNum = $card->CardNum ?? '';
|
||||
if (empty($cardNum) || (is_numeric($cardNum) && $cardNum < 0)) {
|
||||
continue;
|
||||
}
|
||||
$cards[] = [
|
||||
'cardNum' => $cardNum,
|
||||
'cardCompany' => $card->CardCompany ?? '',
|
||||
'cardCompanyName' => BarobillSoapService::$cardCompanyCodes[$card->CardCompany ?? ''] ?? '',
|
||||
'alias' => $card->Alias ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return $cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 DB에 배치 저장
|
||||
*/
|
||||
protected function cacheTransactions(
|
||||
int $tenantId,
|
||||
string $cardNum,
|
||||
string $cardCompany,
|
||||
string $cardCompanyName,
|
||||
array $rawLogs
|
||||
): int {
|
||||
$rows = [];
|
||||
$now = now();
|
||||
|
||||
foreach ($rawLogs as $log) {
|
||||
$useDT = $log->UseDT ?? '';
|
||||
$useDate = strlen($useDT) >= 8 ? substr($useDT, 0, 8) : '';
|
||||
$useTime = strlen($useDT) >= 14 ? substr($useDT, 8, 6) : '';
|
||||
|
||||
$rows[] = [
|
||||
'tenant_id' => $tenantId,
|
||||
'card_num' => $log->CardNum ?? $cardNum,
|
||||
'card_company' => $log->CardCompany ?? $cardCompany,
|
||||
'card_company_name' => $cardCompanyName,
|
||||
'use_dt' => $useDT,
|
||||
'use_date' => $useDate,
|
||||
'use_time' => $useTime,
|
||||
'approval_num' => $log->ApprovalNum ?? '',
|
||||
'approval_type' => $log->ApprovalType ?? '',
|
||||
'approval_amount' => floatval($log->ApprovalAmount ?? 0),
|
||||
'tax' => floatval($log->Tax ?? 0),
|
||||
'service_charge' => floatval($log->ServiceCharge ?? 0),
|
||||
'payment_plan' => $log->PaymentPlan ?? '',
|
||||
'currency_code' => $log->CurrencyCode ?? '',
|
||||
'merchant_name' => $log->MerchantName ?? '',
|
||||
'merchant_biz_num' => $log->MerchantBizNum ?? '',
|
||||
'merchant_addr' => $log->MerchantAddr ?? '',
|
||||
'merchant_ceo' => '',
|
||||
'merchant_biz_type' => '',
|
||||
'merchant_tel' => '',
|
||||
'use_key' => $log->UseKey ?? '',
|
||||
'is_manual' => false,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
foreach (array_chunk($rows, 100) as $batch) {
|
||||
$inserted += DB::table('barobill_card_transactions')->insertOrIgnore($batch);
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
}
|
||||
1233
app/Services/Barobill/BarobillSoapService.php
Normal file
1233
app/Services/Barobill/BarobillSoapService.php
Normal file
File diff suppressed because it is too large
Load Diff
116
app/Services/Barobill/HometaxSyncService.php
Normal file
116
app/Services/Barobill/HometaxSyncService.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Barobill;
|
||||
|
||||
use App\Models\Barobill\HometaxInvoice;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 홈택스 세금계산서 동기화 서비스 (API 독립 구현)
|
||||
*
|
||||
* MNG의 HometaxSyncService 패턴을 참고하여 독립 작성.
|
||||
* 바로빌 API 응답 데이터를 로컬 DB에 upsert한다.
|
||||
*/
|
||||
class HometaxSyncService extends Service
|
||||
{
|
||||
/**
|
||||
* API 응답 데이터를 로컬 DB에 동기화
|
||||
*
|
||||
* @param array $invoices API에서 받은 세금계산서 목록
|
||||
* @param int $tenantId 테넌트 ID
|
||||
* @param string $invoiceType 'sales' 또는 'purchase'
|
||||
* @return array 동기화 결과
|
||||
*/
|
||||
public function syncInvoices(array $invoices, int $tenantId, string $invoiceType): array
|
||||
{
|
||||
$result = [
|
||||
'inserted' => 0,
|
||||
'updated' => 0,
|
||||
'failed' => 0,
|
||||
'total' => count($invoices),
|
||||
];
|
||||
|
||||
if (empty($invoices)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($invoices as $apiData) {
|
||||
if (empty($apiData['ntsConfirmNum'])) {
|
||||
$result['failed']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$modelData = HometaxInvoice::fromApiData($apiData, $tenantId, $invoiceType);
|
||||
|
||||
$existing = HometaxInvoice::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('nts_confirm_num', $modelData['nts_confirm_num'])
|
||||
->where('invoice_type', $invoiceType)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->update([
|
||||
'write_date' => $modelData['write_date'],
|
||||
'issue_date' => $modelData['issue_date'],
|
||||
'invoicer_corp_num' => $modelData['invoicer_corp_num'],
|
||||
'invoicer_corp_name' => $modelData['invoicer_corp_name'],
|
||||
'invoicer_ceo_name' => $modelData['invoicer_ceo_name'],
|
||||
'invoicee_corp_num' => $modelData['invoicee_corp_num'],
|
||||
'invoicee_corp_name' => $modelData['invoicee_corp_name'],
|
||||
'invoicee_ceo_name' => $modelData['invoicee_ceo_name'],
|
||||
'supply_amount' => $modelData['supply_amount'],
|
||||
'tax_amount' => $modelData['tax_amount'],
|
||||
'total_amount' => $modelData['total_amount'],
|
||||
'tax_type' => $modelData['tax_type'],
|
||||
'purpose_type' => $modelData['purpose_type'],
|
||||
'item_name' => $modelData['item_name'],
|
||||
'remark' => $modelData['remark'],
|
||||
'synced_at' => now(),
|
||||
]);
|
||||
$result['updated']++;
|
||||
} else {
|
||||
HometaxInvoice::create($modelData);
|
||||
$result['inserted']++;
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
Log::info('[HometaxSync] 동기화 완료', [
|
||||
'tenant_id' => $tenantId,
|
||||
'invoice_type' => $invoiceType,
|
||||
'result' => $result,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('[HometaxSync] 동기화 실패', [
|
||||
'tenant_id' => $tenantId,
|
||||
'invoice_type' => $invoiceType,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 동기화 시간 조회
|
||||
*/
|
||||
public function getLastSyncTime(int $tenantId, string $invoiceType): ?string
|
||||
{
|
||||
$lastSync = HometaxInvoice::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('invoice_type', $invoiceType)
|
||||
->orderByDesc('synced_at')
|
||||
->value('synced_at');
|
||||
|
||||
return $lastSync?->format('Y-m-d H:i:s');
|
||||
}
|
||||
}
|
||||
182
app/Services/BendingCodeService.php
Normal file
182
app/Services/BendingCodeService.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Production\BendingItemMapping;
|
||||
|
||||
class BendingCodeService extends Service
|
||||
{
|
||||
// =========================================================================
|
||||
// 제품 코드 (7종)
|
||||
// =========================================================================
|
||||
public const PRODUCTS = [
|
||||
['code' => 'R', 'name' => '가이드레일(벽면형)'],
|
||||
['code' => 'S', 'name' => '가이드레일(측면형)'],
|
||||
['code' => 'G', 'name' => '연기차단재'],
|
||||
['code' => 'B', 'name' => '하단마감재(스크린)'],
|
||||
['code' => 'T', 'name' => '하단마감재(철재)'],
|
||||
['code' => 'L', 'name' => 'L-Bar'],
|
||||
['code' => 'C', 'name' => '케이스'],
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 종류 코드 + 사용 가능 제품
|
||||
// =========================================================================
|
||||
public const SPECS = [
|
||||
['code' => 'M', 'name' => '본체', 'products' => ['R', 'S']],
|
||||
['code' => 'T', 'name' => '본체(철재)', 'products' => ['R', 'S']],
|
||||
['code' => 'C', 'name' => 'C형', 'products' => ['R', 'S']],
|
||||
['code' => 'D', 'name' => 'D형', 'products' => ['R', 'S']],
|
||||
['code' => 'S', 'name' => 'SUS(마감)', 'products' => ['R', 'S', 'B', 'T']],
|
||||
['code' => 'U', 'name' => 'SUS(마감)2', 'products' => ['S']],
|
||||
['code' => 'E', 'name' => 'EGI(마감)', 'products' => ['R', 'S', 'B', 'T']],
|
||||
['code' => 'I', 'name' => '화이바원단', 'products' => ['G']],
|
||||
['code' => 'A', 'name' => '스크린용', 'products' => ['L']],
|
||||
['code' => 'F', 'name' => '전면부', 'products' => ['C']],
|
||||
['code' => 'P', 'name' => '점검구', 'products' => ['C']],
|
||||
['code' => 'L', 'name' => '린텔부', 'products' => ['C']],
|
||||
['code' => 'B', 'name' => '후면코너부', 'products' => ['C']],
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 모양&길이 코드
|
||||
// =========================================================================
|
||||
public const LENGTHS_SMOKE_BARRIER = [
|
||||
['code' => '53', 'name' => 'W50 × 3000'],
|
||||
['code' => '54', 'name' => 'W50 × 4000'],
|
||||
['code' => '83', 'name' => 'W80 × 3000'],
|
||||
['code' => '84', 'name' => 'W80 × 4000'],
|
||||
];
|
||||
|
||||
public const LENGTHS_GENERAL = [
|
||||
['code' => '12', 'name' => '1219'],
|
||||
['code' => '24', 'name' => '2438'],
|
||||
['code' => '30', 'name' => '3000'],
|
||||
['code' => '35', 'name' => '3500'],
|
||||
['code' => '40', 'name' => '4000'],
|
||||
['code' => '41', 'name' => '4150'],
|
||||
['code' => '42', 'name' => '4200'],
|
||||
['code' => '43', 'name' => '4300'],
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// 제품+종류 → 원자재(재질) 매핑
|
||||
// =========================================================================
|
||||
public const MATERIAL_MAP = [
|
||||
'G:I' => '화이바원단',
|
||||
'B:S' => 'SUS 1.2T',
|
||||
'B:E' => 'EGI 1.55T',
|
||||
'T:S' => 'SUS 1.2T',
|
||||
'T:E' => 'EGI 1.55T',
|
||||
'L:A' => 'EGI 1.55T',
|
||||
'R:M' => 'EGI 1.55T',
|
||||
'R:T' => 'EGI 1.55T',
|
||||
'R:C' => 'EGI 1.55T',
|
||||
'R:D' => 'EGI 1.55T',
|
||||
'R:S' => 'SUS 1.2T',
|
||||
'R:E' => 'EGI 1.55T',
|
||||
'S:M' => 'EGI 1.55T',
|
||||
'S:T' => 'EGI 1.55T',
|
||||
'S:C' => 'EGI 1.55T',
|
||||
'S:D' => 'EGI 1.55T',
|
||||
'S:S' => 'SUS 1.2T',
|
||||
'S:U' => 'SUS 1.2T',
|
||||
'S:E' => 'EGI 1.55T',
|
||||
'C:F' => 'EGI 1.55T',
|
||||
'C:P' => 'EGI 1.55T',
|
||||
'C:L' => 'EGI 1.55T',
|
||||
'C:B' => 'EGI 1.55T',
|
||||
];
|
||||
|
||||
/**
|
||||
* 코드맵 전체 반환 (프론트엔드 드롭다운 구성용)
|
||||
*/
|
||||
public function getCodeMap(): array
|
||||
{
|
||||
return [
|
||||
'products' => self::PRODUCTS,
|
||||
'specs' => self::SPECS,
|
||||
'lengths' => [
|
||||
'smoke_barrier' => self::LENGTHS_SMOKE_BARRIER,
|
||||
'general' => self::LENGTHS_GENERAL,
|
||||
],
|
||||
'material_map' => self::MATERIAL_MAP,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운 선택 조합 → items 테이블 품목 매핑 조회
|
||||
*/
|
||||
public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array
|
||||
{
|
||||
$mapping = BendingItemMapping::where('tenant_id', $this->tenantId())
|
||||
->where('prod_code', $prodCode)
|
||||
->where('spec_code', $specCode)
|
||||
->where('length_code', $lengthCode)
|
||||
->where('is_active', true)
|
||||
->with('item:id,code,name,specification,unit')
|
||||
->first();
|
||||
|
||||
if (! $mapping || ! $mapping->item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'item_id' => $mapping->item->id,
|
||||
'item_code' => $mapping->item->code,
|
||||
'item_name' => $mapping->item->name,
|
||||
'specification' => $mapping->item->specification,
|
||||
'unit' => $mapping->item->unit ?? 'EA',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* LOT 번호 생성 (일련번호 suffix 포함)
|
||||
*
|
||||
* base: 'GI6317-53' → 결과: 'GI6317-53-001'
|
||||
*/
|
||||
public function generateLotNumber(string $lotBase): string
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 같은 base로 시작하는 기존 LOT 수 조회
|
||||
$count = Order::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('order_type_code', Order::TYPE_STOCK)
|
||||
->where('options->bending_lot->lot_number', 'LIKE', $lotBase.'-%')
|
||||
->count();
|
||||
|
||||
$seq = str_pad($count + 1, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
return "{$lotBase}-{$seq}";
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 → 4자리 날짜코드
|
||||
*
|
||||
* 2026-03-17 → '6317'
|
||||
* 2026-10-05 → '6A05'
|
||||
*/
|
||||
public static function generateDateCode(string $date): string
|
||||
{
|
||||
$dt = \Carbon\Carbon::parse($date);
|
||||
$year = $dt->year % 10;
|
||||
$month = $dt->month;
|
||||
$day = $dt->day;
|
||||
|
||||
$monthCode = $month >= 10
|
||||
? chr(55 + $month) // 10=A, 11=B, 12=C
|
||||
: (string) $month;
|
||||
|
||||
return $year.$monthCode.str_pad($day, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제품+종류 → 원자재(재질) 반환
|
||||
*/
|
||||
public static function getMaterial(string $prodCode, string $specCode): ?string
|
||||
{
|
||||
return self::MATERIAL_MAP["{$prodCode}:{$specCode}"] ?? null;
|
||||
}
|
||||
}
|
||||
119
app/Services/BendingItemService.php
Normal file
119
app/Services/BendingItemService.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class BendingItemService extends Service
|
||||
{
|
||||
public function list(array $params): LengthAwarePaginator
|
||||
{
|
||||
return Item::where('item_category', 'BENDING')
|
||||
->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('options->item_sep', $v))
|
||||
->when($params['item_bending'] ?? null, fn ($q, $v) => $q->where('options->item_bending', $v))
|
||||
->when($params['material'] ?? null, fn ($q, $v) => $q->where('options->material', 'like', "%{$v}%"))
|
||||
->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('options->model_UA', $v))
|
||||
->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('options->model_name', $v))
|
||||
->when($params['search'] ?? null, fn ($q, $v) => $q->where(
|
||||
fn ($q2) => $q2
|
||||
->where('name', 'like', "%{$v}%")
|
||||
->orWhere('code', 'like', "%{$v}%")
|
||||
->orWhere('options->search_keyword', 'like', "%{$v}%")
|
||||
->orWhere('options->item_spec', 'like', "%{$v}%")
|
||||
))
|
||||
->orderBy('code')
|
||||
->paginate($params['size'] ?? 50);
|
||||
}
|
||||
|
||||
public function filters(): array
|
||||
{
|
||||
$items = Item::where('item_category', 'BENDING')
|
||||
->select('options')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'item_sep' => $items->pluck('options.item_sep')->filter()->unique()->sort()->values(),
|
||||
'item_bending' => $items->pluck('options.item_bending')->filter()->unique()->sort()->values(),
|
||||
'material' => $items->pluck('options.material')->filter()->unique()->sort()->values(),
|
||||
'model_UA' => $items->pluck('options.model_UA')->filter()->unique()->sort()->values(),
|
||||
'model_name' => $items->pluck('options.model_name')->filter()->unique()->sort()->values(),
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): Item
|
||||
{
|
||||
return Item::where('item_category', 'BENDING')->findOrFail($id);
|
||||
}
|
||||
|
||||
public function create(array $data): Item
|
||||
{
|
||||
$options = $this->buildOptions($data);
|
||||
|
||||
return Item::create([
|
||||
'tenant_id' => $this->tenantId(),
|
||||
'item_type' => 'PT',
|
||||
'item_category' => 'BENDING',
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'unit' => $data['unit'] ?? 'EA',
|
||||
'options' => $options,
|
||||
'is_active' => true,
|
||||
'created_by' => $this->apiUserId(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): Item
|
||||
{
|
||||
$item = Item::where('item_category', 'BENDING')->findOrFail($id);
|
||||
|
||||
if (isset($data['code'])) {
|
||||
$item->code = $data['code'];
|
||||
}
|
||||
if (isset($data['name'])) {
|
||||
$item->name = $data['name'];
|
||||
}
|
||||
|
||||
$optionKeys = self::OPTION_KEYS;
|
||||
foreach ($optionKeys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$item->setOption($key, $data[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$item->updated_by = $this->apiUserId();
|
||||
$item->save();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$item = Item::where('item_category', 'BENDING')->findOrFail($id);
|
||||
$item->deleted_by = $this->apiUserId();
|
||||
$item->save();
|
||||
|
||||
return $item->delete();
|
||||
}
|
||||
|
||||
private function buildOptions(array $data): array
|
||||
{
|
||||
$options = [];
|
||||
foreach (self::OPTION_KEYS as $key) {
|
||||
if (isset($data[$key])) {
|
||||
$options[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private const OPTION_KEYS = [
|
||||
'item_name', 'item_sep', 'item_bending', 'item_spec',
|
||||
'material', 'model_name', 'model_UA', 'search_keyword',
|
||||
'rail_width', 'registration_date', 'author', 'memo',
|
||||
'parent_num', 'exit_direction', 'front_bottom_width',
|
||||
'box_width', 'box_height', 'bendingData',
|
||||
'prefix', 'length_code', 'length_mm',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Calculation;
|
||||
|
||||
use App\Helpers\SafeMathEvaluator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FormulaParser
|
||||
@@ -230,8 +231,8 @@ protected function executeSimpleMath(string $formula, array $variables): float
|
||||
throw new \InvalidArgumentException("안전하지 않은 수학 표현식: {$expression}");
|
||||
}
|
||||
|
||||
// 계산 실행
|
||||
return eval("return {$expression};");
|
||||
// 안전한 산술 파서로 계산 실행
|
||||
return SafeMathEvaluator::calculate($expression);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,7 +277,7 @@ protected function evaluateCondition(string $condition, array $variables): bool
|
||||
throw new \InvalidArgumentException("안전하지 않은 조건식: {$expression}");
|
||||
}
|
||||
|
||||
return eval("return {$expression};");
|
||||
return SafeMathEvaluator::compare($expression);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,7 +53,7 @@ public function index(array $params)
|
||||
$query->whereDate('created_at', '<=', $endDate);
|
||||
}
|
||||
|
||||
$query->orderBy('client_code')->orderBy('id');
|
||||
$query->orderBy('id', 'desc');
|
||||
|
||||
$paginator = $query->paginate($size, ['*'], 'page', $page);
|
||||
|
||||
@@ -113,6 +113,22 @@ public function index(array $params)
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처 간단 목록 (id, name만 반환) - vendors 엔드포인트용
|
||||
*/
|
||||
public function vendors(array $params): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$perPage = (int) ($params['per_page'] ?? 9999);
|
||||
|
||||
return Client::where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->orderBy('name')
|
||||
->limit($perPage)
|
||||
->get(['id', 'name'])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/** 단건 */
|
||||
public function show(int $id)
|
||||
{
|
||||
@@ -304,10 +320,14 @@ public function stats(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$total = Client::where('tenant_id', $tenantId)->count();
|
||||
$base = Client::where('tenant_id', $tenantId);
|
||||
|
||||
$total = (clone $base)->count();
|
||||
$active = (clone $base)->where('is_active', true)->count();
|
||||
$inactive = $total - $active;
|
||||
|
||||
// 거래처 유형별 통계
|
||||
$typeCounts = Client::where('tenant_id', $tenantId)
|
||||
$typeCounts = (clone $base)
|
||||
->selectRaw('client_type, COUNT(*) as count')
|
||||
->groupBy('client_type')
|
||||
->pluck('count', 'client_type')
|
||||
@@ -321,12 +341,14 @@ public function stats(): array
|
||||
->distinct('client_id')
|
||||
->pluck('client_id');
|
||||
|
||||
$badDebtCount = Client::where('tenant_id', $tenantId)
|
||||
$badDebtCount = (clone $base)
|
||||
->whereIn('id', $badDebtClientIds)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'active' => $active,
|
||||
'inactive' => $inactive,
|
||||
'sales' => $typeCounts['SALES'] ?? 0,
|
||||
'purchase' => $typeCounts['PURCHASE'] ?? 0,
|
||||
'both' => $typeCounts['BOTH'] ?? 0,
|
||||
|
||||
271
app/Services/Demo/DemoAnalyticsService.php
Normal file
271
app/Services/Demo/DemoAnalyticsService.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Demo;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 분석 서비스
|
||||
*
|
||||
* - 전환율 분석 (데모 → 정식)
|
||||
* - 활동 모니터링 (데모 테넌트별 사용량)
|
||||
* - 파트너별 영업 성과 분석
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 분석 로직만 포함
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoAnalyticsService extends Service
|
||||
{
|
||||
/**
|
||||
* 전환율 분석 (전체 또는 파트너별)
|
||||
*/
|
||||
public function conversionFunnel(array $params = []): array
|
||||
{
|
||||
$partnerId = $params['partner_id'] ?? null;
|
||||
$period = $params['period'] ?? 'all'; // all, monthly, quarterly
|
||||
|
||||
$baseQuery = Tenant::withoutGlobalScopes();
|
||||
|
||||
if ($partnerId) {
|
||||
$baseQuery->where('demo_source_partner_id', $partnerId);
|
||||
}
|
||||
|
||||
// Tier 3 체험 테넌트 전체 (현재 + 전환 완료)
|
||||
$totalTrials = (clone $baseQuery)
|
||||
->where(function ($q) {
|
||||
$q->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->orWhere(function ($q2) {
|
||||
// 정식 전환된 테넌트 (demo_source_partner_id가 있으면서 STD)
|
||||
$q2->where('tenant_type', Tenant::TYPE_STD)
|
||||
->whereNotNull('demo_source_partner_id');
|
||||
});
|
||||
})
|
||||
->count();
|
||||
|
||||
// 활성 체험 중
|
||||
$activeTrials = (clone $baseQuery)
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('demo_expires_at')
|
||||
->orWhere('demo_expires_at', '>', now());
|
||||
})
|
||||
->count();
|
||||
|
||||
// 만료된 체험
|
||||
$expiredTrials = (clone $baseQuery)
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '<', now())
|
||||
->count();
|
||||
|
||||
// 정식 전환 완료
|
||||
$converted = (clone $baseQuery)
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->whereNotNull('demo_source_partner_id')
|
||||
->count();
|
||||
|
||||
$conversionRate = $totalTrials > 0
|
||||
? round($converted / $totalTrials * 100, 1) : 0;
|
||||
|
||||
// 평균 전환 기간 (정식 전환된 건의 생성일 → 전환일 차이)
|
||||
$avgConversionDays = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->whereNotNull('demo_source_partner_id')
|
||||
->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId))
|
||||
->selectRaw('AVG(DATEDIFF(updated_at, created_at)) as avg_days')
|
||||
->value('avg_days');
|
||||
|
||||
return [
|
||||
'funnel' => [
|
||||
'total_trials' => $totalTrials,
|
||||
'active_trials' => $activeTrials,
|
||||
'expired_trials' => $expiredTrials,
|
||||
'converted' => $converted,
|
||||
],
|
||||
'conversion_rate' => $conversionRate,
|
||||
'avg_conversion_days' => $avgConversionDays ? (int) round($avgConversionDays) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트너별 성과 분석
|
||||
*/
|
||||
public function partnerPerformance(array $params = []): array
|
||||
{
|
||||
$partners = DB::table('sales_partners as sp')
|
||||
->leftJoin('users as u', 'sp.user_id', '=', 'u.id')
|
||||
->where('sp.status', 'active')
|
||||
->select('sp.id', 'sp.partner_code', 'u.name as partner_name')
|
||||
->get();
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($partners as $partner) {
|
||||
$demos = Tenant::withoutGlobalScopes()
|
||||
->where('demo_source_partner_id', $partner->id)
|
||||
->get();
|
||||
|
||||
$trials = $demos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL);
|
||||
$convertedCount = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->where('demo_source_partner_id', $partner->id)
|
||||
->count();
|
||||
|
||||
$totalTrials = $trials->count() + $convertedCount;
|
||||
$conversionRate = $totalTrials > 0
|
||||
? round($convertedCount / $totalTrials * 100, 1) : 0;
|
||||
|
||||
$results[] = [
|
||||
'partner_id' => $partner->id,
|
||||
'partner_code' => $partner->partner_code,
|
||||
'partner_name' => $partner->partner_name,
|
||||
'demo_count' => $demos->count(),
|
||||
'active_trials' => $trials->filter(fn ($t) => ! $t->isDemoExpired())->count(),
|
||||
'expired_trials' => $trials->filter(fn ($t) => $t->isDemoExpired())->count(),
|
||||
'converted' => $convertedCount,
|
||||
'conversion_rate' => $conversionRate,
|
||||
];
|
||||
}
|
||||
|
||||
// 전환율 내림차순 정렬
|
||||
usort($results, fn ($a, $b) => $b['conversion_rate'] <=> $a['conversion_rate']);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트 활동 현황
|
||||
*/
|
||||
public function activityReport(array $params = []): array
|
||||
{
|
||||
$demos = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES)
|
||||
->when(
|
||||
! empty($params['partner_id']),
|
||||
fn ($q) => $q->where('demo_source_partner_id', $params['partner_id'])
|
||||
)
|
||||
->get();
|
||||
|
||||
$report = [];
|
||||
|
||||
foreach ($demos as $tenant) {
|
||||
// 각 데모 테넌트의 데이터 입력량 조회
|
||||
$dataCounts = $this->getDataCounts($tenant->id);
|
||||
$totalRecords = array_sum($dataCounts);
|
||||
|
||||
// 최근 활동 시점 (가장 최근 레코드의 updated_at)
|
||||
$lastActivity = $this->getLastActivity($tenant->id);
|
||||
|
||||
$daysSinceActivity = $lastActivity
|
||||
? (int) now()->diffInDays($lastActivity) : null;
|
||||
|
||||
$report[] = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'tenant_type' => $tenant->tenant_type,
|
||||
'demo_expires_at' => $tenant->demo_expires_at?->toDateString(),
|
||||
'is_expired' => $tenant->isDemoExpired(),
|
||||
'data_counts' => $dataCounts,
|
||||
'total_records' => $totalRecords,
|
||||
'last_activity' => $lastActivity?->toDateString(),
|
||||
'days_since_activity' => $daysSinceActivity,
|
||||
'activity_status' => $this->classifyActivity($daysSinceActivity),
|
||||
];
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 요약 (대시보드용)
|
||||
*/
|
||||
public function summary(): array
|
||||
{
|
||||
$funnel = $this->conversionFunnel();
|
||||
|
||||
$allDemos = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES)
|
||||
->get();
|
||||
|
||||
$inactiveCount = 0;
|
||||
foreach ($allDemos as $tenant) {
|
||||
$lastActivity = $this->getLastActivity($tenant->id);
|
||||
if ($lastActivity && now()->diffInDays($lastActivity) >= 7) {
|
||||
$inactiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'funnel' => $funnel['funnel'],
|
||||
'conversion_rate' => $funnel['conversion_rate'],
|
||||
'avg_conversion_days' => $funnel['avg_conversion_days'],
|
||||
'total_demos' => $allDemos->count(),
|
||||
'inactive_count' => $inactiveCount,
|
||||
'by_type' => [
|
||||
'showcase' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(),
|
||||
'partner' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(),
|
||||
'trial' => $allDemos->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Private Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function getDataCounts(int $tenantId): array
|
||||
{
|
||||
$tables = ['departments', 'clients', 'items', 'quotes', 'orders'];
|
||||
$counts = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) {
|
||||
$counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count();
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private function classifyActivity(?int $daysSinceActivity): string
|
||||
{
|
||||
if ($daysSinceActivity === null) {
|
||||
return 'no_data';
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$daysSinceActivity <= 1 => 'active',
|
||||
$daysSinceActivity <= 3 => 'normal',
|
||||
$daysSinceActivity <= 7 => 'low',
|
||||
default => 'inactive',
|
||||
};
|
||||
}
|
||||
}
|
||||
469
app/Services/Demo/DemoTenantService.php
Normal file
469
app/Services/Demo/DemoTenantService.php
Normal file
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Demo;
|
||||
|
||||
use App\Models\Tenants\Tenant;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* 데모 테넌트 생성/관리 서비스
|
||||
*
|
||||
* 기존 코드 영향 없음: 데모 전용 로직만 포함
|
||||
*
|
||||
* @see docs/features/sales/demo-tenant-policy.md
|
||||
*/
|
||||
class DemoTenantService extends Service
|
||||
{
|
||||
/**
|
||||
* 기본 데모 수량 제한
|
||||
*/
|
||||
private const DEFAULT_DEMO_LIMITS = [
|
||||
'max_items' => 100,
|
||||
'max_orders' => 50,
|
||||
'max_productions' => 30,
|
||||
'max_users' => 5,
|
||||
'max_storage_gb' => 1,
|
||||
'max_ai_tokens' => 100000,
|
||||
];
|
||||
|
||||
/**
|
||||
* 사용 가능한 프리셋 목록
|
||||
*/
|
||||
private const AVAILABLE_PRESETS = [
|
||||
'manufacturing', // 제조업 기본
|
||||
// 향후 추가 예정:
|
||||
// 'blinds', // 블라인드/스크린
|
||||
// 'construction', // 시공/건설
|
||||
// 'distribution', // 유통/도소매
|
||||
];
|
||||
|
||||
/**
|
||||
* 파트너 데모 테넌트 생성 (Tier 2)
|
||||
* 파트너 승인 시 호출
|
||||
*/
|
||||
public function createPartnerDemo(int $partnerId, string $preset = 'manufacturing'): Tenant
|
||||
{
|
||||
$tenant = new Tenant;
|
||||
$tenant->forceFill([
|
||||
'company_name' => '파트너데모_'.$partnerId,
|
||||
'code' => 'DEMO_P_'.$partnerId,
|
||||
'email' => 'demo-partner-'.$partnerId.'@codebridge-x.com',
|
||||
'tenant_st_code' => 'active',
|
||||
'tenant_type' => Tenant::TYPE_DEMO_PARTNER,
|
||||
'demo_source_partner_id' => $partnerId,
|
||||
'options' => [
|
||||
Tenant::OPTION_DEMO_PRESET => $preset,
|
||||
Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS,
|
||||
],
|
||||
]);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('파트너 데모 테넌트 생성', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'partner_id' => $partnerId,
|
||||
'preset' => $preset,
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고객 체험 테넌트 생성 (Tier 3)
|
||||
* 파트너가 요청 → 본사 승인 후 호출
|
||||
*/
|
||||
public function createTrialDemo(
|
||||
int $partnerId,
|
||||
string $companyName,
|
||||
string $email,
|
||||
int $durationDays = 30,
|
||||
string $preset = 'manufacturing'
|
||||
): Tenant {
|
||||
$tenant = new Tenant;
|
||||
$tenant->forceFill([
|
||||
'company_name' => $companyName,
|
||||
'code' => 'DEMO_T_'.strtoupper(substr(md5(uniqid()), 0, 8)),
|
||||
'email' => $email,
|
||||
'tenant_st_code' => 'trial',
|
||||
'tenant_type' => Tenant::TYPE_DEMO_TRIAL,
|
||||
'demo_expires_at' => now()->addDays($durationDays),
|
||||
'demo_source_partner_id' => $partnerId,
|
||||
'options' => [
|
||||
Tenant::OPTION_DEMO_PRESET => $preset,
|
||||
Tenant::OPTION_DEMO_LIMITS => self::DEFAULT_DEMO_LIMITS,
|
||||
],
|
||||
]);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('고객 체험 테넌트 생성', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'partner_id' => $partnerId,
|
||||
'company_name' => $companyName,
|
||||
'expires_at' => $tenant->demo_expires_at->toDateString(),
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 체험 기간 연장 (최대 1회, 30일)
|
||||
*/
|
||||
public function extendTrial(Tenant $tenant, int $days = 30): bool
|
||||
{
|
||||
if (! $tenant->isDemoTrial()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 이미 연장한 이력이 있는지 체크 (options에 기록)
|
||||
if ($tenant->getOption('demo_extended', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newExpiry = ($tenant->demo_expires_at ?? now())->addDays($days);
|
||||
$tenant->forceFill(['demo_expires_at' => $newExpiry]);
|
||||
$tenant->setOption('demo_extended', true);
|
||||
$tenant->save();
|
||||
|
||||
Log::info('고객 체험 기간 연장', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'new_expires_at' => $newExpiry->toDateString(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 → 정식 전환
|
||||
*/
|
||||
public function convertToProduction(Tenant $tenant): void
|
||||
{
|
||||
$tenant->convertToProduction();
|
||||
|
||||
Log::info('데모 → 정식 테넌트 전환', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 프리셋 목록
|
||||
*/
|
||||
public function getAvailablePresets(): array
|
||||
{
|
||||
return self::AVAILABLE_PRESETS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 체험 테넌트 조회
|
||||
*/
|
||||
public function getExpiredTrials(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
|
||||
->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '<', now())
|
||||
->get();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Phase 3: API 엔드포인트용 메서드
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 내가 생성한 데모 테넌트 목록
|
||||
*/
|
||||
public function index(array $params): array
|
||||
{
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 현재 사용자의 파트너 ID 조회
|
||||
$partnerId = DB::table('sales_partners')
|
||||
->where('user_id', $userId)
|
||||
->value('id');
|
||||
|
||||
$query = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES);
|
||||
|
||||
// 파트너인 경우 자기가 생성한 데모만 조회
|
||||
if ($partnerId) {
|
||||
$query->where('demo_source_partner_id', $partnerId);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (! empty($params['status'])) {
|
||||
match ($params['status']) {
|
||||
'active' => $query->where(function ($q) {
|
||||
$q->whereNull('demo_expires_at')
|
||||
->orWhere('demo_expires_at', '>', now());
|
||||
}),
|
||||
'expired' => $query->whereNotNull('demo_expires_at')
|
||||
->where('demo_expires_at', '<', now()),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// 타입 필터
|
||||
if (! empty($params['type'])) {
|
||||
$query->where('tenant_type', $params['type']);
|
||||
}
|
||||
|
||||
$tenants = $query->orderByDesc('created_at')->get();
|
||||
|
||||
return $tenants->map(function (Tenant $t) {
|
||||
return $this->formatTenantResponse($t);
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 테넌트 상세 조회
|
||||
*/
|
||||
public function show(int $id): array
|
||||
{
|
||||
$tenant = $this->findDemoTenant($id);
|
||||
|
||||
$response = $this->formatTenantResponse($tenant);
|
||||
|
||||
// 상세 조회 시 데이터 현황도 포함
|
||||
$response['data_counts'] = $this->getDataCounts($tenant->id);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* API에서 고객 체험 테넌트 생성
|
||||
*/
|
||||
public function createTrialFromApi(array $data): array
|
||||
{
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$partnerId = DB::table('sales_partners')
|
||||
->where('user_id', $userId)
|
||||
->value('id');
|
||||
|
||||
// 파트너가 아닌 경우 관리자 권한으로 생성 (partnerId = 0)
|
||||
$partnerId = $partnerId ?? 0;
|
||||
|
||||
$preset = $data['preset'] ?? 'manufacturing';
|
||||
$durationDays = $data['duration_days'] ?? 30;
|
||||
|
||||
$tenant = $this->createTrialDemo(
|
||||
$partnerId,
|
||||
$data['company_name'],
|
||||
$data['email'],
|
||||
$durationDays,
|
||||
$preset
|
||||
);
|
||||
|
||||
// 샘플 데이터 시드
|
||||
try {
|
||||
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
|
||||
$seeder->run($tenant->id);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('데모 샘플 데이터 시드 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->formatTenantResponse($tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
* API에서 데모 데이터 리셋
|
||||
*/
|
||||
public function resetFromApi(int $id): array
|
||||
{
|
||||
$tenant = $this->findDemoTenant($id);
|
||||
$this->checkOwnership($tenant);
|
||||
|
||||
// 리셋 커맨드의 테이블 목록 사용
|
||||
$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',
|
||||
'departments', 'audit_logs',
|
||||
];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$totalDeleted = 0;
|
||||
foreach ($tables as $table) {
|
||||
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
|
||||
continue;
|
||||
}
|
||||
$totalDeleted += DB::table($table)->where('tenant_id', $tenant->id)->delete();
|
||||
}
|
||||
DB::commit();
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// 재시드
|
||||
$preset = $tenant->getDemoPreset() ?? 'manufacturing';
|
||||
try {
|
||||
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
|
||||
$seeder->run($tenant->id);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('리셋 후 재시드 실패', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('데모 데이터 API 리셋', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'deleted_count' => $totalDeleted,
|
||||
]);
|
||||
|
||||
return [
|
||||
'tenant_id' => $tenant->id,
|
||||
'deleted_count' => $totalDeleted,
|
||||
'data_counts' => $this->getDataCounts($tenant->id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* API에서 체험 기간 연장
|
||||
*/
|
||||
public function extendFromApi(int $id, int $days = 30): array
|
||||
{
|
||||
$tenant = $this->findDemoTenant($id);
|
||||
$this->checkOwnership($tenant);
|
||||
|
||||
if (! $tenant->isDemoTrial()) {
|
||||
return ['error' => __('error.demo_tenant.not_trial'), 'code' => 400];
|
||||
}
|
||||
|
||||
if (! $this->extendTrial($tenant, $days)) {
|
||||
return ['error' => __('error.demo_tenant.already_extended'), 'code' => 400];
|
||||
}
|
||||
|
||||
return $this->formatTenantResponse($tenant->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* API에서 데모 → 정식 전환
|
||||
*/
|
||||
public function convertFromApi(int $id): array
|
||||
{
|
||||
$tenant = $this->findDemoTenant($id);
|
||||
$this->checkOwnership($tenant);
|
||||
|
||||
$this->convertToProduction($tenant);
|
||||
|
||||
return $this->formatTenantResponse($tenant->fresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 현황 통계
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$partnerId = DB::table('sales_partners')
|
||||
->where('user_id', $userId)
|
||||
->value('id');
|
||||
|
||||
$baseQuery = Tenant::withoutGlobalScopes()
|
||||
->whereIn('tenant_type', Tenant::DEMO_TYPES);
|
||||
|
||||
if ($partnerId) {
|
||||
$baseQuery->where('demo_source_partner_id', $partnerId);
|
||||
}
|
||||
|
||||
$all = (clone $baseQuery)->get();
|
||||
|
||||
$active = $all->filter(fn (Tenant $t) => ! $t->isDemoExpired());
|
||||
$expired = $all->filter(fn (Tenant $t) => $t->isDemoExpired());
|
||||
$converted = Tenant::withoutGlobalScopes()
|
||||
->where('tenant_type', Tenant::TYPE_STD)
|
||||
->when($partnerId, fn ($q) => $q->where('demo_source_partner_id', $partnerId))
|
||||
->whereNotNull('demo_source_partner_id')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $all->count(),
|
||||
'active' => $active->count(),
|
||||
'expired' => $expired->count(),
|
||||
'converted' => $converted,
|
||||
'by_type' => [
|
||||
'showcase' => $all->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)->count(),
|
||||
'partner' => $all->where('tenant_type', Tenant::TYPE_DEMO_PARTNER)->count(),
|
||||
'trial' => $all->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)->count(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Private Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
private function findDemoTenant(int $id): Tenant
|
||||
{
|
||||
$tenant = Tenant::withoutGlobalScopes()->find($id);
|
||||
|
||||
if (! $tenant || ! $tenant->isDemoTenant()) {
|
||||
throw new NotFoundHttpException(__('error.demo_tenant.not_found'));
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
private function checkOwnership(Tenant $tenant): void
|
||||
{
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$partnerId = DB::table('sales_partners')
|
||||
->where('user_id', $userId)
|
||||
->value('id');
|
||||
|
||||
// 파트너가 아닌 경우 (관리자) → 통과
|
||||
if (! $partnerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 파트너인 경우 → 자기가 생성한 데모만 접근 가능
|
||||
if ($tenant->demo_source_partner_id !== $partnerId) {
|
||||
throw new NotFoundHttpException(__('error.demo_tenant.not_owned'));
|
||||
}
|
||||
}
|
||||
|
||||
private function formatTenantResponse(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'id' => $tenant->id,
|
||||
'company_name' => $tenant->company_name,
|
||||
'code' => $tenant->code,
|
||||
'email' => $tenant->email,
|
||||
'tenant_type' => $tenant->tenant_type,
|
||||
'tenant_st_code' => $tenant->tenant_st_code,
|
||||
'demo_preset' => $tenant->getDemoPreset(),
|
||||
'demo_limits' => $tenant->getDemoLimits(),
|
||||
'demo_expires_at' => $tenant->demo_expires_at?->toDateString(),
|
||||
'demo_source_partner_id' => $tenant->demo_source_partner_id,
|
||||
'is_expired' => $tenant->isDemoExpired(),
|
||||
'is_extended' => (bool) $tenant->getOption('demo_extended', false),
|
||||
'created_at' => $tenant->created_at?->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDataCounts(int $tenantId): array
|
||||
{
|
||||
$tables = ['departments', 'clients', 'items', 'quotes', 'orders'];
|
||||
$counts = [];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (\Schema::hasTable($table) && \Schema::hasColumn($table, 'tenant_id')) {
|
||||
$counts[$table] = DB::table($table)->where('tenant_id', $tenantId)->count();
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@@ -982,6 +982,7 @@ public function formatTemplateForReact(DocumentTemplate $template): array
|
||||
'name' => $section->title,
|
||||
'title' => $section->title,
|
||||
'image_path' => $section->image_path,
|
||||
'file_id' => $section->file_id,
|
||||
'sort_order' => $section->sort_order,
|
||||
'items' => $section->items->map(function ($item) use ($methodCodes) {
|
||||
// method 코드를 한글 이름으로 변환
|
||||
@@ -1034,7 +1035,7 @@ private function formatDocumentForReact(Document $document): array
|
||||
'submitted_at' => $document->submitted_at?->toIso8601String(),
|
||||
'completed_at' => $document->completed_at?->toIso8601String(),
|
||||
'created_at' => $document->created_at?->toIso8601String(),
|
||||
'data' => $document->data->map(fn ($d) => [
|
||||
'data' => ($document->getRelation('data') ?? collect())->map(fn ($d) => [
|
||||
'section_id' => $d->section_id,
|
||||
'column_id' => $d->column_id,
|
||||
'row_index' => $d->row_index,
|
||||
|
||||
@@ -224,6 +224,15 @@ public function update(int $id, array $data): TenantUserProfile
|
||||
|
||||
if (! empty($profileUpdates)) {
|
||||
$profile->update($profileUpdates);
|
||||
|
||||
// 퇴직/복직 시 user_tenants.is_active 동기화
|
||||
if (isset($profileUpdates['employee_status'])) {
|
||||
$isActive = $profileUpdates['employee_status'] !== 'resigned';
|
||||
DB::table('user_tenants')
|
||||
->where('user_id', $profile->user_id)
|
||||
->where('tenant_id', $profile->tenant_id)
|
||||
->update(['is_active' => $isActive]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. json_extra 사원 정보 업데이트
|
||||
@@ -275,6 +284,12 @@ public function destroy(int $id): array
|
||||
// 또는 employee_status를 resigned로 변경
|
||||
$profile->update(['employee_status' => 'resigned']);
|
||||
|
||||
// 해당 테넌트 접근 차단 (다른 테넌트는 영향 없음)
|
||||
DB::table('user_tenants')
|
||||
->where('user_id', $profile->user_id)
|
||||
->where('tenant_id', $tenantId)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'deleted_at' => now()->toDateTimeString(),
|
||||
@@ -288,11 +303,25 @@ public function bulkDelete(array $ids): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
// 퇴직 처리 대상의 user_id 추출
|
||||
$userIds = TenantUserProfile::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->pluck('user_id');
|
||||
|
||||
$updated = TenantUserProfile::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('id', $ids)
|
||||
->update(['employee_status' => 'resigned']);
|
||||
|
||||
// 해당 테넌트 접근 일괄 차단
|
||||
if ($userIds->isNotEmpty()) {
|
||||
DB::table('user_tenants')
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('tenant_id', $tenantId)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
return [
|
||||
'processed' => count($ids),
|
||||
'updated' => $updated,
|
||||
|
||||
@@ -34,6 +34,9 @@ public function getInspections(string $cycle, string $period, ?string $productio
|
||||
$labels = InspectionCycle::columnLabels($cycle, $period);
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$tenantId = $this->tenantId();
|
||||
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($equipments as $equipment) {
|
||||
@@ -68,6 +71,7 @@ public function getInspections(string $cycle, string $period, ?string $productio
|
||||
'details' => $details,
|
||||
'labels' => $labels,
|
||||
'can_inspect' => $equipment->canInspect($userId),
|
||||
'non_working_days' => array_keys($holidayDates),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -365,6 +369,15 @@ public function copyTemplates(int $equipmentId, string $sourceCycle, array $targ
|
||||
});
|
||||
}
|
||||
|
||||
public function getTemplatesByEquipment(int $equipmentId, ?string $cycle = null): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
|
||||
->when($cycle, fn ($q) => $q->byCycle($cycle))
|
||||
->active()
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getActiveCycles(int $equipmentId): array
|
||||
{
|
||||
return EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Equipment;
|
||||
|
||||
use App\Models\Equipment\Equipment;
|
||||
use App\Models\Equipment\EquipmentInspection;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -126,7 +127,37 @@ public function stats(): array
|
||||
$idle = Equipment::where('status', 'idle')->count();
|
||||
$disposed = Equipment::where('status', 'disposed')->count();
|
||||
|
||||
return compact('total', 'active', 'idle', 'disposed');
|
||||
// 설비 유형별 현황
|
||||
$typeDistribution = Equipment::where('status', '!=', 'disposed')
|
||||
->whereNotNull('equipment_type')
|
||||
->selectRaw('equipment_type, count(*) as count')
|
||||
->groupBy('equipment_type')
|
||||
->orderByDesc('count')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
// 이번달 점검 현황
|
||||
$yearMonth = now()->format('Y-m');
|
||||
$targetCount = Equipment::where('status', 'active')->count();
|
||||
$completedCount = EquipmentInspection::where('year_month', $yearMonth)->distinct('equipment_id')->count('equipment_id');
|
||||
$issueCount = EquipmentInspection::where('year_month', $yearMonth)
|
||||
->where(function ($q) {
|
||||
$q->whereNotNull('issue_note')->where('issue_note', '!=', '');
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'active' => $active,
|
||||
'idle' => $idle,
|
||||
'disposed' => $disposed,
|
||||
'type_distribution' => $typeDistribution,
|
||||
'inspection_stats' => [
|
||||
'target_count' => $targetCount,
|
||||
'completed_count' => $completedCount,
|
||||
'issue_count' => $issueCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
|
||||
124
app/Services/Finance/VehiclePhotoService.php
Normal file
124
app/Services/Finance/VehiclePhotoService.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Finance\CorporateVehicle;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class VehiclePhotoService extends Service
|
||||
{
|
||||
private const MAX_PHOTOS = 10;
|
||||
|
||||
public function index(int $vehicleId): array
|
||||
{
|
||||
$vehicle = $this->getVehicle($vehicleId);
|
||||
|
||||
return $vehicle->photos->map(fn ($file) => $this->formatFileResponse($file))->values()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param UploadedFile[] $files
|
||||
*/
|
||||
public function store(int $vehicleId, array $files): array
|
||||
{
|
||||
$vehicle = $this->getVehicle($vehicleId);
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$currentCount = File::where('document_id', $vehicleId)
|
||||
->where('document_type', 'corporate_vehicle')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
if ($currentCount + count($files) > self::MAX_PHOTOS) {
|
||||
throw new \Exception(__('error.vehicle.photo_limit_exceeded'));
|
||||
}
|
||||
|
||||
$uploaded = [];
|
||||
|
||||
foreach ($files as $uploadedFile) {
|
||||
$extension = $uploadedFile->getClientOriginalExtension();
|
||||
$storedName = bin2hex(random_bytes(8)).'.'.$extension;
|
||||
$displayName = $uploadedFile->getClientOriginalName();
|
||||
|
||||
$year = date('Y');
|
||||
$month = date('m');
|
||||
$directory = sprintf('%d/corporate-vehicles/%s/%s', $tenantId, $year, $month);
|
||||
$filePath = $directory.'/'.$storedName;
|
||||
|
||||
Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
|
||||
|
||||
$mimeType = $uploadedFile->getMimeType();
|
||||
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $displayName,
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $uploadedFile->getSize(),
|
||||
'mime_type' => $mimeType,
|
||||
'file_type' => 'image',
|
||||
'document_id' => $vehicleId,
|
||||
'document_type' => 'corporate_vehicle',
|
||||
'is_temp' => false,
|
||||
'uploaded_by' => $userId,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
$uploaded[] = $this->formatFileResponse($file);
|
||||
}
|
||||
|
||||
return $uploaded;
|
||||
}
|
||||
|
||||
public function destroy(int $vehicleId, int $fileId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$file = File::where('tenant_id', $tenantId)
|
||||
->where('document_id', $vehicleId)
|
||||
->where('document_type', 'corporate_vehicle')
|
||||
->where('id', $fileId)
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
throw new NotFoundHttpException(__('error.file.not_found'));
|
||||
}
|
||||
|
||||
$file->softDeleteFile($userId);
|
||||
|
||||
return [
|
||||
'file_id' => $fileId,
|
||||
'deleted' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function getVehicle(int $vehicleId): CorporateVehicle
|
||||
{
|
||||
$vehicle = CorporateVehicle::find($vehicleId);
|
||||
|
||||
if (! $vehicle) {
|
||||
throw new NotFoundHttpException(__('error.vehicle.not_found'));
|
||||
}
|
||||
|
||||
return $vehicle;
|
||||
}
|
||||
|
||||
private function formatFileResponse(File $file): array
|
||||
{
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'file_name' => $file->display_name,
|
||||
'file_path' => $file->file_path,
|
||||
'file_url' => url("/api/v1/files/{$file->id}/download"),
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'created_at' => $file->created_at?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
120
app/Services/GuiderailModelService.php
Normal file
120
app/Services/GuiderailModelService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class GuiderailModelService extends Service
|
||||
{
|
||||
private const CATEGORIES = ['GUIDERAIL_MODEL', 'SHUTTERBOX_MODEL', 'BOTTOMBAR_MODEL'];
|
||||
|
||||
public function list(array $params): LengthAwarePaginator
|
||||
{
|
||||
return Item::whereIn('item_category', self::CATEGORIES)
|
||||
->when($params['item_category'] ?? null, fn ($q, $v) => $q->where('item_category', $v))
|
||||
->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('options->item_sep', $v))
|
||||
->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('options->model_UA', $v))
|
||||
->when($params['check_type'] ?? null, fn ($q, $v) => $q->where('options->check_type', $v))
|
||||
->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('options->model_name', $v))
|
||||
->when($params['search'] ?? null, fn ($q, $v) => $q->where(
|
||||
fn ($q2) => $q2
|
||||
->where('name', 'like', "%{$v}%")
|
||||
->orWhere('code', 'like', "%{$v}%")
|
||||
->orWhere('options->model_name', 'like', "%{$v}%")
|
||||
->orWhere('options->search_keyword', 'like', "%{$v}%")
|
||||
))
|
||||
->orderBy('code')
|
||||
->paginate($params['size'] ?? 50);
|
||||
}
|
||||
|
||||
public function filters(): array
|
||||
{
|
||||
$items = Item::whereIn('item_category', self::CATEGORIES)->select('options')->get();
|
||||
|
||||
return [
|
||||
'item_sep' => $items->pluck('options.item_sep')->filter()->unique()->sort()->values(),
|
||||
'model_UA' => $items->pluck('options.model_UA')->filter()->unique()->sort()->values(),
|
||||
'check_type' => $items->pluck('options.check_type')->filter()->unique()->sort()->values(),
|
||||
'model_name' => $items->pluck('options.model_name')->filter()->unique()->sort()->values(),
|
||||
'finishing_type' => $items->pluck('options.finishing_type')->filter()->unique()->sort()->values(),
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): Item
|
||||
{
|
||||
return Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id);
|
||||
}
|
||||
|
||||
public function create(array $data): Item
|
||||
{
|
||||
$options = $this->buildOptions($data);
|
||||
|
||||
return Item::create([
|
||||
'tenant_id' => $this->tenantId(),
|
||||
'item_type' => 'FG',
|
||||
'item_category' => $data['item_category'] ?? 'GUIDERAIL_MODEL',
|
||||
'code' => $data['code'],
|
||||
'name' => $data['name'],
|
||||
'unit' => 'SET',
|
||||
'options' => $options,
|
||||
'is_active' => true,
|
||||
'created_by' => $this->apiUserId(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): Item
|
||||
{
|
||||
$item = Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id);
|
||||
|
||||
if (isset($data['code'])) {
|
||||
$item->code = $data['code'];
|
||||
}
|
||||
if (isset($data['name'])) {
|
||||
$item->name = $data['name'];
|
||||
}
|
||||
|
||||
foreach (self::OPTION_KEYS as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$item->setOption($key, $data[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$item->updated_by = $this->apiUserId();
|
||||
$item->save();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$item = Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id);
|
||||
$item->deleted_by = $this->apiUserId();
|
||||
$item->save();
|
||||
|
||||
return $item->delete();
|
||||
}
|
||||
|
||||
private function buildOptions(array $data): array
|
||||
{
|
||||
$options = [];
|
||||
foreach (self::OPTION_KEYS as $key) {
|
||||
if (isset($data[$key])) {
|
||||
$options[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private const OPTION_KEYS = [
|
||||
'model_name', 'check_type', 'rail_width', 'rail_length',
|
||||
'finishing_type', 'item_sep', 'model_UA', 'search_keyword',
|
||||
'author', 'memo', 'registration_date',
|
||||
'components', 'material_summary',
|
||||
// 케이스(SHUTTERBOX_MODEL) 전용
|
||||
'exit_direction', 'front_bottom_width', 'box_width', 'box_height',
|
||||
// 하단마감재(BOTTOMBAR_MODEL) 전용
|
||||
'bar_width', 'bar_height',
|
||||
];
|
||||
}
|
||||
@@ -357,6 +357,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
$categoryId = $params['category_id'] ?? null;
|
||||
$itemType = $params['item_type'] ?? null;
|
||||
$itemCategory = $params['item_category'] ?? null;
|
||||
$bomCategory = $params['bom_category'] ?? null;
|
||||
$groupId = $params['group_id'] ?? null;
|
||||
$active = $params['active'] ?? null;
|
||||
$hasBom = $params['has_bom'] ?? null;
|
||||
@@ -410,6 +411,11 @@ public function index(array $params): LengthAwarePaginator
|
||||
$query->where('item_category', $itemCategory);
|
||||
}
|
||||
|
||||
// BOM 카테고리 (options->bom_category)
|
||||
if ($bomCategory) {
|
||||
$query->where('options->bom_category', $bomCategory);
|
||||
}
|
||||
|
||||
// 활성 상태
|
||||
if ($active !== null && $active !== '') {
|
||||
$query->where('is_active', (bool) $active);
|
||||
@@ -743,6 +749,9 @@ public function update(int $id, array $data): Model
|
||||
$data['attributes'] = array_merge($existingAttributes, $data['attributes']);
|
||||
}
|
||||
|
||||
// 변경 전 스냅샷 (감사 로그용)
|
||||
$before = $item->toArray();
|
||||
|
||||
// 테이블 업데이트
|
||||
$itemData = array_intersect_key($data, array_flip([
|
||||
'item_type', 'code', 'name', 'unit', 'category_id',
|
||||
@@ -768,7 +777,19 @@ public function update(int $id, array $data): Model
|
||||
$item->load('details');
|
||||
}
|
||||
|
||||
return $item->refresh();
|
||||
$item->refresh();
|
||||
|
||||
// 감사 로그
|
||||
app(\App\Services\Audit\AuditLogger::class)->log(
|
||||
tenantId: $tenantId,
|
||||
targetType: 'item',
|
||||
targetId: $item->id,
|
||||
action: 'updated',
|
||||
before: $before,
|
||||
after: $item->toArray()
|
||||
);
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,17 +109,22 @@ public function index(array $params)
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
public function stats(): array
|
||||
public function stats(?string $orderType = null): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$counts = Order::where('tenant_id', $tenantId)
|
||||
$baseQuery = Order::where('tenant_id', $tenantId);
|
||||
if ($orderType !== null) {
|
||||
$baseQuery->where('order_type_code', $orderType);
|
||||
}
|
||||
|
||||
$counts = (clone $baseQuery)
|
||||
->select('status_code', DB::raw('count(*) as count'))
|
||||
->groupBy('status_code')
|
||||
->pluck('count', 'status_code')
|
||||
->toArray();
|
||||
|
||||
$amounts = Order::where('tenant_id', $tenantId)
|
||||
$amounts = (clone $baseQuery)
|
||||
->select('status_code', DB::raw('sum(total_amount) as total'))
|
||||
->groupBy('status_code')
|
||||
->pluck('total', 'status_code')
|
||||
@@ -162,10 +167,13 @@ public function store(array $data)
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($data, $tenantId, $userId) {
|
||||
// 수주번호 자동 생성
|
||||
// 수주번호 자동 생성 (재고생산은 STK 접두사)
|
||||
$pairCode = $data['pair_code'] ?? null;
|
||||
unset($data['pair_code']);
|
||||
$data['order_no'] = $this->generateOrderNo($tenantId, $pairCode);
|
||||
$isStock = ($data['order_type_code'] ?? null) === Order::TYPE_STOCK;
|
||||
$data['order_no'] = $isStock
|
||||
? $this->generateStockOrderNo($tenantId)
|
||||
: $this->generateOrderNo($tenantId, $pairCode);
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['created_by'] = $userId;
|
||||
$data['updated_by'] = $userId;
|
||||
@@ -174,6 +182,21 @@ public function store(array $data)
|
||||
$data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT;
|
||||
$data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER;
|
||||
|
||||
// 재고생산: 현장명 + 담당자 자동 설정
|
||||
if ($isStock) {
|
||||
$data['site_name'] = '재고생산';
|
||||
|
||||
// 담당자(manager_name)가 비어 있으면 로그인 사용자 이름으로 설정
|
||||
$options = $data['options'] ?? [];
|
||||
if (empty($options['manager_name'])) {
|
||||
$user = \App\Models\Members\User::find($userId);
|
||||
if ($user) {
|
||||
$options['manager_name'] = $user->name;
|
||||
$data['options'] = $options;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$items = $data['items'] ?? [];
|
||||
unset($data['items']);
|
||||
|
||||
@@ -293,6 +316,14 @@ public function store(array $data)
|
||||
$order->refresh();
|
||||
$order->recalculateTotals()->save();
|
||||
|
||||
// 견적 연결: Quote.order_id 동기화
|
||||
if ($order->quote_id) {
|
||||
Quote::withoutGlobalScopes()
|
||||
->where('id', $order->quote_id)
|
||||
->whereNull('order_id')
|
||||
->update(['order_id' => $order->id]);
|
||||
}
|
||||
|
||||
return $this->loadDetailRelations($order);
|
||||
});
|
||||
}
|
||||
@@ -629,8 +660,8 @@ public function updateStatus(int $id, string $status)
|
||||
$createdSale = null;
|
||||
$previousStatus = $order->status_code;
|
||||
|
||||
// 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우)
|
||||
if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) {
|
||||
// 수주확정 시 매출 자동 생성 (재고생산은 매출 생성 불필요)
|
||||
if ($status === Order::STATUS_CONFIRMED && $order->order_type_code !== Order::TYPE_STOCK && $order->shouldCreateSaleOnConfirm()) {
|
||||
$createdSale = $this->createSaleFromOrder($order, $userId);
|
||||
$order->sale_id = $createdSale->id;
|
||||
}
|
||||
@@ -776,6 +807,29 @@ private function generateOrderNoLegacy(int $tenantId): string
|
||||
return sprintf('%s%s%04d', $prefix, $date, $seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고생산 번호 생성 (STK{YYYYMMDD}{NNNN})
|
||||
*/
|
||||
private function generateStockOrderNo(int $tenantId): string
|
||||
{
|
||||
$prefix = 'STK';
|
||||
$date = now()->format('Ymd');
|
||||
|
||||
$lastNo = Order::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('order_no', 'like', "{$prefix}{$date}%")
|
||||
->orderByDesc('order_no')
|
||||
->value('order_no');
|
||||
|
||||
if ($lastNo) {
|
||||
$seq = (int) substr($lastNo, -4) + 1;
|
||||
} else {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return sprintf('%s%s%04d', $prefix, $date, $seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적에서 수주 생성
|
||||
*/
|
||||
@@ -803,54 +857,99 @@ public function createFromQuote(int $quoteId, array $data = [])
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($quote, $data, $tenantId, $userId) {
|
||||
// 수주번호 생성
|
||||
$pairCode = $data['pair_code'] ?? null;
|
||||
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
|
||||
|
||||
// Order 모델의 createFromQuote 사용
|
||||
// calculation_inputs에서 제품 정보 추출
|
||||
$calculationInputs = $quote->calculation_inputs ?? [];
|
||||
$productItems = $calculationInputs['items'] ?? [];
|
||||
$bomResults = $calculationInputs['bomResults'] ?? [];
|
||||
$locationCount = count($productItems);
|
||||
|
||||
// 품목→개소 매핑 사전 계산
|
||||
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
|
||||
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
|
||||
? intdiv($quote->items->count(), $locationCount)
|
||||
: 0;
|
||||
|
||||
// 견적 품목을 개소별로 그룹핑
|
||||
$itemsByLocation = [];
|
||||
foreach ($quote->items as $index => $quoteItem) {
|
||||
$locIdx = $this->resolveQuoteItemLocationIndex($quoteItem, $productItems, $itemsPerLocation, $index, $locationCount);
|
||||
$itemsByLocation[$locIdx][] = $quoteItem;
|
||||
}
|
||||
|
||||
// 개소 × 수량 → 노드 목록 확장 (qty=10 → 노드 10개, 각 qty=1)
|
||||
$expandedNodes = [];
|
||||
foreach ($productItems as $idx => $locItem) {
|
||||
$qty = (int) ($locItem['quantity'] ?? 1);
|
||||
for ($q = 0; $q < $qty; $q++) {
|
||||
$expandedNodes[] = [
|
||||
'locItem' => $locItem,
|
||||
'bomResult' => $bomResults[$idx] ?? null,
|
||||
'origIdx' => $idx,
|
||||
'seqNo' => $q + 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 수주 1건 생성
|
||||
$orderNo = $this->generateOrderNo($tenantId, $pairCode);
|
||||
$order = Order::createFromQuote($quote, $orderNo);
|
||||
$order->created_by = $userId;
|
||||
$order->updated_by = $userId;
|
||||
|
||||
// 추가 데이터 병합 (납품일, 메모 등)
|
||||
if (! empty($data['delivery_date'])) {
|
||||
$order->delivery_date = $data['delivery_date'];
|
||||
}
|
||||
if (! empty($data['memo'])) {
|
||||
$order->memo = $data['memo'];
|
||||
}
|
||||
if (! empty($data['delivery_method_code'])) {
|
||||
$order->delivery_method_code = $data['delivery_method_code'];
|
||||
}
|
||||
|
||||
// options 병합 (수신자, 수신처, 운임 등)
|
||||
if (! empty($data['options'])) {
|
||||
$order->options = array_merge($order->options ?? [], $data['options']);
|
||||
}
|
||||
|
||||
$order->save();
|
||||
|
||||
// calculation_inputs에서 제품 정보 추출
|
||||
$calculationInputs = $quote->calculation_inputs ?? [];
|
||||
$productItems = $calculationInputs['items'] ?? [];
|
||||
$bomResults = $calculationInputs['bomResults'] ?? [];
|
||||
|
||||
// OrderNode 생성 (개소별)
|
||||
$nodeMap = [];
|
||||
foreach ($productItems as $idx => $locItem) {
|
||||
$bomResult = $bomResults[$idx] ?? null;
|
||||
// 확장된 노드별로 OrderNode + OrderItem 생성
|
||||
foreach ($expandedNodes as $nodeIdx => $expanded) {
|
||||
$locItem = $expanded['locItem'];
|
||||
$bomResult = $expanded['bomResult'];
|
||||
$origIdx = $expanded['origIdx'];
|
||||
$bomVars = $bomResult['variables'] ?? [];
|
||||
$grandTotal = $bomResult['grand_total'] ?? 0;
|
||||
$qty = (int) ($locItem['quantity'] ?? 1);
|
||||
$floor = $locItem['floor'] ?? '';
|
||||
$symbol = $locItem['code'] ?? '';
|
||||
|
||||
$nodeMap[$idx] = OrderNode::create([
|
||||
// 노드명 = 제품명, 코드/부호에 수량 번호 부여
|
||||
$productName = $locItem['productName'] ?? '';
|
||||
$nodeCode = trim("{$floor}-{$symbol}", '-') ?: "LOC-{$nodeIdx}";
|
||||
$nodeSymbol = $symbol;
|
||||
$totalQty = (int) ($locItem['quantity'] ?? 1);
|
||||
if ($totalQty > 1) {
|
||||
$nodeCode .= '-'.$expanded['seqNo'];
|
||||
$nodeSymbol .= ' #'.$expanded['seqNo'];
|
||||
}
|
||||
$nodeName = $productName ?: trim("{$floor} {$nodeSymbol}") ?: '개소 '.($nodeIdx + 1);
|
||||
|
||||
$node = OrderNode::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'order_id' => $order->id,
|
||||
'parent_id' => null,
|
||||
'node_type' => 'location',
|
||||
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
|
||||
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1),
|
||||
'code' => $nodeCode,
|
||||
'name' => $nodeName,
|
||||
'status_code' => OrderNode::STATUS_PENDING,
|
||||
'quantity' => $qty,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $grandTotal,
|
||||
'total_price' => $grandTotal * $qty,
|
||||
'total_price' => $grandTotal,
|
||||
'options' => [
|
||||
'floor' => $floor,
|
||||
'symbol' => $symbol,
|
||||
'symbol' => $nodeSymbol,
|
||||
'product_code' => $locItem['productCode'] ?? null,
|
||||
'product_name' => $locItem['productName'] ?? null,
|
||||
'open_width' => $locItem['openWidth'] ?? null,
|
||||
@@ -866,68 +965,19 @@ public function createFromQuote(int $quoteId, array $data = [])
|
||||
'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem),
|
||||
],
|
||||
'depth' => 0,
|
||||
'sort_order' => $idx,
|
||||
'sort_order' => $nodeIdx,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
// 견적 품목을 수주 품목으로 변환 (노드 연결 포함)
|
||||
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
|
||||
$locationCount = count($productItems);
|
||||
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
|
||||
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
|
||||
? intdiv($quote->items->count(), $locationCount)
|
||||
: 0;
|
||||
|
||||
foreach ($quote->items as $index => $quoteItem) {
|
||||
$floorCode = null;
|
||||
$symbolCode = null;
|
||||
$locIdx = 0;
|
||||
|
||||
// 1순위: formula_source에서 인덱스 추출
|
||||
$formulaSource = $quoteItem->formula_source ?? '';
|
||||
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
|
||||
$locIdx = (int) $matches[1];
|
||||
}
|
||||
|
||||
// 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터)
|
||||
if ($locIdx === 0 && $itemsPerLocation > 0) {
|
||||
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
|
||||
}
|
||||
|
||||
// calculation_inputs에서 floor/code 가져오기
|
||||
if (isset($productItems[$locIdx])) {
|
||||
$floorCode = $productItems[$locIdx]['floor'] ?? null;
|
||||
$symbolCode = $productItems[$locIdx]['code'] ?? null;
|
||||
} elseif (count($productItems) === 1) {
|
||||
$floorCode = $productItems[0]['floor'] ?? null;
|
||||
$symbolCode = $productItems[0]['code'] ?? null;
|
||||
}
|
||||
|
||||
// calculation_inputs에서 못 찾은 경우 note에서 파싱 시도
|
||||
if (empty($floorCode) && empty($symbolCode)) {
|
||||
$note = trim($quoteItem->note ?? '');
|
||||
if ($note !== '' && $note !== '-' && $note !== '- -') {
|
||||
$parts = preg_split('/\s+/', $note, 2);
|
||||
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
|
||||
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
|
||||
}
|
||||
}
|
||||
|
||||
// note 파싱으로 locIdx 결정 (formula_source 없는 경우)
|
||||
if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) {
|
||||
foreach ($productItems as $pidx => $pItem) {
|
||||
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
|
||||
$locIdx = $pidx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 해당 개소 소속 품목 → OrderItem 복제
|
||||
foreach ($itemsByLocation[$origIdx] ?? [] as $serialIdx => $quoteItem) {
|
||||
$floorCode = $locItem['floor'] ?? null;
|
||||
$symbolCode = $locItem['code'] ?? null;
|
||||
|
||||
$order->items()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'order_node_id' => $nodeMap[$locIdx]->id ?? null,
|
||||
'serial_no' => $index + 1,
|
||||
'order_node_id' => $node->id,
|
||||
'serial_no' => $serialIdx + 1,
|
||||
'item_id' => $quoteItem->item_id,
|
||||
'item_code' => $quoteItem->item_code,
|
||||
'item_name' => $quoteItem->item_name,
|
||||
@@ -941,9 +991,10 @@ public function createFromQuote(int $quoteId, array $data = [])
|
||||
'tax_amount' => round($quoteItem->total_price * 0.1, 2),
|
||||
'total_amount' => round($quoteItem->total_price * 1.1, 2),
|
||||
'note' => $quoteItem->formula_category,
|
||||
'sort_order' => $index,
|
||||
'sort_order' => $serialIdx,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 합계 재계산
|
||||
$order->refresh();
|
||||
@@ -959,6 +1010,48 @@ public function createFromQuote(int $quoteId, array $data = [])
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 품목이 속하는 개소 인덱스 결정
|
||||
*/
|
||||
private function resolveQuoteItemLocationIndex(
|
||||
$quoteItem,
|
||||
array $productItems,
|
||||
int $itemsPerLocation,
|
||||
int $itemIndex,
|
||||
int $locationCount
|
||||
): int {
|
||||
$locIdx = 0;
|
||||
|
||||
// 1순위: formula_source에서 인덱스 추출
|
||||
$formulaSource = $quoteItem->formula_source ?? '';
|
||||
if (preg_match('/product_(\d+)/', $formulaSource, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
// 2순위: sort_order 기반 분배
|
||||
if ($itemsPerLocation > 0) {
|
||||
return min(intdiv($itemIndex, $itemsPerLocation), $locationCount - 1);
|
||||
}
|
||||
|
||||
// 3순위: note에서 floor/code 매칭
|
||||
$note = trim($quoteItem->note ?? '');
|
||||
if ($note !== '' && $note !== '-' && $note !== '- -') {
|
||||
$parts = preg_split('/\s+/', $note, 2);
|
||||
$floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null;
|
||||
$symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null;
|
||||
|
||||
if (! empty($floorCode) && ! empty($symbolCode)) {
|
||||
foreach ($productItems as $pidx => $pItem) {
|
||||
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
|
||||
return $pidx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $locIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 변경사항을 수주에 동기화
|
||||
*
|
||||
@@ -1202,9 +1295,29 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
throw new BadRequestHttpException(__('error.order.production_order_already_exists'));
|
||||
}
|
||||
|
||||
// order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
|
||||
// 재고생산(STOCK): 절곡 공정에 모든 품목 직접 배정 (BOM 매칭 스킵)
|
||||
$isStock = $order->order_type_code === Order::TYPE_STOCK;
|
||||
$nodesBomMap = [];
|
||||
|
||||
if ($isStock) {
|
||||
$bendingProcess = \App\Models\Process::where('tenant_id', $tenantId)
|
||||
->where('process_name', '절곡')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $bendingProcess) {
|
||||
throw new BadRequestHttpException(__('error.order.bending_process_not_found'));
|
||||
}
|
||||
|
||||
$itemsByProcess = [
|
||||
$bendingProcess->id => [
|
||||
'process_id' => $bendingProcess->id,
|
||||
'items' => $order->items->all(),
|
||||
],
|
||||
];
|
||||
} else {
|
||||
// 기존 로직: order_nodes의 BOM 결과를 기반으로 공정별 자동 분류
|
||||
$bomItemIds = [];
|
||||
$nodesBomMap = []; // node_id => [item_name => bom_item]
|
||||
|
||||
foreach ($order->rootNodes as $node) {
|
||||
$bomResult = $node->options['bom_result'] ?? [];
|
||||
@@ -1236,7 +1349,7 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
}
|
||||
}
|
||||
|
||||
// item_code → item_id 매핑 구축 (fallback용)
|
||||
// item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회)
|
||||
$codeToIdMap = [];
|
||||
if (! empty($bomItemIds)) {
|
||||
$codeToIdRows = DB::table('items')
|
||||
@@ -1250,6 +1363,38 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
}
|
||||
}
|
||||
|
||||
// order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지)
|
||||
$orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all();
|
||||
$unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap));
|
||||
if (! empty($unmappedCodes)) {
|
||||
$extraRows = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('code', $unmappedCodes)
|
||||
->whereNull('deleted_at')
|
||||
->select('id', 'code')
|
||||
->get();
|
||||
foreach ($extraRows as $row) {
|
||||
$codeToIdMap[$row->code] = $row->id;
|
||||
}
|
||||
}
|
||||
|
||||
// 사전 매핑된 item_id에 대한 process_items도 일괄 조회
|
||||
$allResolvedIds = array_values(array_unique(array_merge(
|
||||
array_keys($itemProcessMap),
|
||||
array_values($codeToIdMap)
|
||||
)));
|
||||
$unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap));
|
||||
if (! empty($unmappedProcessIds)) {
|
||||
$extraProcessItems = DB::table('process_items')
|
||||
->whereIn('item_id', $unmappedProcessIds)
|
||||
->where('is_active', true)
|
||||
->select('item_id', 'process_id')
|
||||
->get();
|
||||
foreach ($extraProcessItems as $pi) {
|
||||
$itemProcessMap[$pi->item_id] = $pi->process_id;
|
||||
}
|
||||
}
|
||||
|
||||
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
|
||||
$itemsByProcess = [];
|
||||
foreach ($order->items as $orderItem) {
|
||||
@@ -1268,31 +1413,11 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. fallback: item_code로 items 마스터 조회 → process_items 매핑
|
||||
// 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거)
|
||||
if ($processId === null && $orderItem->item_code) {
|
||||
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
|
||||
if (! $resolvedId) {
|
||||
$resolvedId = DB::table('items')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('code', $orderItem->item_code)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
if ($resolvedId) {
|
||||
$codeToIdMap[$orderItem->item_code] = $resolvedId;
|
||||
}
|
||||
}
|
||||
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
|
||||
$processId = $itemProcessMap[$resolvedId];
|
||||
} elseif ($resolvedId) {
|
||||
// process_items에서도 조회
|
||||
$pi = DB::table('process_items')
|
||||
->where('item_id', $resolvedId)
|
||||
->where('is_active', true)
|
||||
->value('process_id');
|
||||
if ($pi) {
|
||||
$processId = $pi;
|
||||
$itemProcessMap[$resolvedId] = $pi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,8 +1431,9 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
}
|
||||
$itemsByProcess[$key]['items'][] = $orderItem;
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) {
|
||||
return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap, $isStock) {
|
||||
$workOrders = [];
|
||||
|
||||
// 담당자 ID 배열 처리 (assignee_ids 우선, fallback으로 assignee_id)
|
||||
@@ -1327,6 +1453,7 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
|
||||
// 공정 옵션 초기화 (보조 공정 플래그 포함)
|
||||
$workOrderOptions = null;
|
||||
$process = null;
|
||||
if ($processId) {
|
||||
$process = \App\Models\Process::find($processId);
|
||||
if ($process && ! empty($process->options['is_auxiliary'])) {
|
||||
@@ -1346,17 +1473,33 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
}
|
||||
}
|
||||
|
||||
// team_id 결정: 명시적 전달값 > 공정 담당부서 자동 매핑
|
||||
$teamId = $data['team_id'] ?? null;
|
||||
if (! $teamId && $process && $process->department) {
|
||||
$teamId = DB::table('departments')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('name', $process->department)
|
||||
->value('id');
|
||||
}
|
||||
|
||||
// priority 결정: 문자열 → 숫자 변환 (urgent=1, high=4, normal=7)
|
||||
$priorityMap = ['urgent' => 1, 'high' => 4, 'normal' => 7];
|
||||
$priority = is_numeric($data['priority'] ?? null)
|
||||
? (int) $data['priority']
|
||||
: ($priorityMap[$data['priority'] ?? 'normal'] ?? 7);
|
||||
|
||||
// 작업지시 생성
|
||||
$workOrder = WorkOrder::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'work_order_no' => $workOrderNo,
|
||||
'sales_order_id' => $order->id,
|
||||
'project_name' => $order->site_name ?? $order->client_name,
|
||||
'project_name' => $isStock ? '재고생산' : ($order->site_name ?? $order->client_name),
|
||||
'process_id' => $processId,
|
||||
'status' => ! empty($assigneeIds) ? WorkOrder::STATUS_PENDING : WorkOrder::STATUS_UNASSIGNED,
|
||||
'status' => (! empty($assigneeIds) || $teamId) ? WorkOrder::STATUS_WAITING : WorkOrder::STATUS_UNASSIGNED,
|
||||
'priority' => $priority,
|
||||
'assignee_id' => $primaryAssigneeId,
|
||||
'team_id' => $data['team_id'] ?? null,
|
||||
'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date,
|
||||
'team_id' => $teamId,
|
||||
'scheduled_date' => $data['scheduled_date'] ?? ($isStock ? now()->toDateString() : $order->delivery_date),
|
||||
'memo' => $data['memo'] ?? null,
|
||||
'options' => $workOrderOptions,
|
||||
'is_active' => true,
|
||||
@@ -1445,7 +1588,7 @@ public function createProductionOrder(int $orderId, array $data)
|
||||
'item_id' => $itemId,
|
||||
'item_name' => $orderItem->item_name,
|
||||
'specification' => $orderItem->specification,
|
||||
'quantity' => $orderItem->quantity,
|
||||
'quantity' => (int) $orderItem->quantity,
|
||||
'unit' => $orderItem->unit,
|
||||
'sort_order' => $sortOrder++,
|
||||
'status' => 'pending',
|
||||
@@ -1877,16 +2020,22 @@ public function checkBendingStockForOrder(int $orderId): array
|
||||
return [];
|
||||
}
|
||||
|
||||
$stockService = app(StockService::class);
|
||||
// 배치 조회로 N+1 방지 (루프 내 개별 Stock 조회 제거)
|
||||
$bendingItemIds = $bendingItems->pluck('id')->all();
|
||||
$stocksMap = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
||||
->whereIn('item_id', $bendingItemIds)
|
||||
->get()
|
||||
->keyBy('item_id');
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($bendingItems as $item) {
|
||||
$neededQty = $itemQtyMap[$item->id];
|
||||
$stockInfo = $stockService->getAvailableStock($item->id);
|
||||
$stock = $stocksMap->get($item->id);
|
||||
|
||||
$availableQty = $stockInfo ? (float) $stockInfo['available_qty'] : 0;
|
||||
$reservedQty = $stockInfo ? (float) $stockInfo['reserved_qty'] : 0;
|
||||
$stockQty = $stockInfo ? (float) $stockInfo['stock_qty'] : 0;
|
||||
$availableQty = $stock ? (float) $stock->available_qty : 0;
|
||||
$reservedQty = $stock ? (float) $stock->reserved_qty : 0;
|
||||
$stockQty = $stock ? (float) $stock->stock_qty : 0;
|
||||
$shortfallQty = max(0, $neededQty - $availableQty);
|
||||
|
||||
$result[] = [
|
||||
|
||||
462
app/Services/PerformanceReportExcelService.php
Normal file
462
app/Services/PerformanceReportExcelService.php
Normal file
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Qualitys\PerformanceReport;
|
||||
use App\Models\Qualitys\QualityDocumentLocation;
|
||||
use App\Models\Tenants\Tenant;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PerformanceReportExcelService extends Service
|
||||
{
|
||||
// 카테고리별 배경색
|
||||
private const COLOR_MATERIAL = 'DAEEF3';
|
||||
|
||||
private const COLOR_SITE = 'E2EFDA';
|
||||
|
||||
private const COLOR_SUPERVISOR = 'FCE4D6';
|
||||
|
||||
private const COLOR_CONTRACTOR = 'EDEDED';
|
||||
|
||||
private const COLOR_DISTRIBUTOR = 'FFF2CC';
|
||||
|
||||
// 병합 대상 컬럼 (같은 품질관리서 내 개소가 여러개일 때)
|
||||
private const MERGE_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'AA'];
|
||||
|
||||
// 비병합 컬럼 (개소별 데이터)
|
||||
private const NO_MERGE_COLS = ['J', 'K', 'L'];
|
||||
|
||||
/**
|
||||
* 확정건 엑셀 생성 및 스트림 응답
|
||||
*/
|
||||
public function generate(int $year, int $quarter): StreamedResponse
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$tenant = Tenant::find($tenantId);
|
||||
|
||||
$reports = $this->getConfirmedReports($tenantId, $year, $quarter);
|
||||
$spreadsheet = new Spreadsheet;
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('판매실적대장');
|
||||
|
||||
// 시트 구성
|
||||
$this->setColumnWidths($sheet);
|
||||
$this->writeTitle($sheet, $year, $quarter);
|
||||
$this->writeCompanyInfo($sheet, $tenant);
|
||||
$this->writeCategoryHeaders($sheet);
|
||||
$this->writeColumnHeaders($sheet);
|
||||
$dataStartRow = 13;
|
||||
$lastDataRow = $this->writeDataRows($sheet, $reports, $dataStartRow);
|
||||
|
||||
// 데이터 영역 테두리
|
||||
if ($lastDataRow >= $dataStartRow) {
|
||||
$this->applyDataBorders($sheet, $dataStartRow, $lastDataRow);
|
||||
}
|
||||
|
||||
// 파일명
|
||||
$companyName = $tenant?->company_name ?? 'SAM';
|
||||
$filename = "{$companyName}_품질인정자재등의_판매실적_대장_{$year}년_{$quarter}분기.xlsx";
|
||||
|
||||
return $this->createStreamedResponse($spreadsheet, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정건 데이터 조회
|
||||
*/
|
||||
private function getConfirmedReports(int $tenantId, int $year, int $quarter)
|
||||
{
|
||||
return PerformanceReport::where('tenant_id', $tenantId)
|
||||
->where('year', $year)
|
||||
->where('quarter', $quarter)
|
||||
->where('confirmation_status', PerformanceReport::STATUS_CONFIRMED)
|
||||
->with([
|
||||
'qualityDocument.locations.orderItem',
|
||||
'qualityDocument.locations.qualityDocumentOrder.order',
|
||||
'qualityDocument.client',
|
||||
])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 너비 설정
|
||||
*/
|
||||
private function setColumnWidths($sheet): void
|
||||
{
|
||||
$widths = [
|
||||
'A' => 6, 'B' => 16, 'C' => 12,
|
||||
'D' => 10, 'E' => 14, 'F' => 10, 'G' => 12, 'H' => 10, 'I' => 10,
|
||||
'J' => 10, 'K' => 14, 'L' => 8,
|
||||
'M' => 20, 'N' => 18, 'O' => 10,
|
||||
'P' => 14, 'Q' => 18, 'R' => 10, 'S' => 14,
|
||||
'T' => 14, 'U' => 18, 'V' => 10, 'W' => 14,
|
||||
'X' => 14, 'Y' => 18, 'Z' => 10, 'AA' => 14,
|
||||
];
|
||||
|
||||
foreach ($widths as $col => $width) {
|
||||
$sheet->getColumnDimension($col)->setWidth($width);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row 1, 3: 제목/부제목
|
||||
*/
|
||||
private function writeTitle($sheet, int $year, int $quarter): void
|
||||
{
|
||||
// Row 1: 제목
|
||||
$sheet->mergeCells('A1:AA1');
|
||||
$sheet->setCellValue('A1', '품질인정자재등의 판매실적 제출서식');
|
||||
$sheet->getStyle('A1')->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 24, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(40);
|
||||
|
||||
// Row 3: 부제목
|
||||
$sheet->mergeCells('A3:AA3');
|
||||
$sheet->setCellValue('A3', "품질인정자재등의 판매실적 대장({$year}년 {$quarter}분기)");
|
||||
$sheet->getStyle('A3')->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 18, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(3)->setRowHeight(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row 5~9: 회사 정보
|
||||
*/
|
||||
private function writeCompanyInfo($sheet, ?Tenant $tenant): void
|
||||
{
|
||||
$infoRows = [
|
||||
5 => ['label' => '회사명', 'value' => $tenant?->company_name ?? ''],
|
||||
6 => ['label' => '대표자', 'value' => $tenant?->ceo_name ?? ''],
|
||||
7 => ['label' => '사업자등록번호', 'value' => $tenant?->business_num ?? ''],
|
||||
8 => ['label' => '주소', 'value' => $tenant?->address ?? ''],
|
||||
9 => ['label' => '연락처', 'value' => $tenant?->phone ?? ''],
|
||||
];
|
||||
|
||||
foreach ($infoRows as $row => $info) {
|
||||
$sheet->mergeCells("A{$row}:C{$row}");
|
||||
$sheet->mergeCells("D{$row}:AA{$row}");
|
||||
$sheet->setCellValue("A{$row}", $info['label']);
|
||||
$sheet->setCellValue("D{$row}", $info['value']);
|
||||
|
||||
$sheet->getStyle("A{$row}:C{$row}")->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 11, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F2F2F2']],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
$sheet->getStyle("D{$row}:AA{$row}")->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 11],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row 11: 카테고리 헤더 (건축자재내역, 건축공사장, 공사감리자, 공사시공자, 자재유통업자)
|
||||
*/
|
||||
private function writeCategoryHeaders($sheet): void
|
||||
{
|
||||
$categories = [
|
||||
['range' => 'A11:L11', 'label' => '건축자재내역', 'color' => self::COLOR_MATERIAL],
|
||||
['range' => 'M11:O11', 'label' => '건축공사장', 'color' => self::COLOR_SITE],
|
||||
['range' => 'P11:S11', 'label' => '공사감리자', 'color' => self::COLOR_SUPERVISOR],
|
||||
['range' => 'T11:W11', 'label' => '공사시공자', 'color' => self::COLOR_CONTRACTOR],
|
||||
['range' => 'X11:AA11', 'label' => '자재유통업자', 'color' => self::COLOR_DISTRIBUTOR],
|
||||
];
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$sheet->mergeCells($cat['range']);
|
||||
$startCell = explode(':', $cat['range'])[0];
|
||||
$sheet->setCellValue($startCell, $cat['label']);
|
||||
$sheet->getStyle($cat['range'])->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 11, 'bold' => true],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => $cat['color']]],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
}
|
||||
|
||||
$sheet->getRowDimension(11)->setRowHeight(28);
|
||||
}
|
||||
|
||||
/**
|
||||
* Row 12: 컬럼 헤더
|
||||
*/
|
||||
private function writeColumnHeaders($sheet): void
|
||||
{
|
||||
$headers = [
|
||||
'A' => '일련번호', 'B' => '품질관리서번호', 'C' => '작성일',
|
||||
'D' => '인정품목', 'E' => '규격(품명)', 'F' => '규격(종류)',
|
||||
'G' => '제품검사일', 'H' => '내화성능시간', 'I' => '사용부위',
|
||||
'J' => '로트번호', 'K' => '규격(치수)', 'L' => '수량',
|
||||
'M' => '공사명칭', 'N' => '소재지', 'O' => '번지',
|
||||
'P' => '사무소명', 'Q' => '사무소주소', 'R' => '성명', 'S' => '연락처',
|
||||
'T' => '업체명', 'U' => '업체주소', 'V' => '성명', 'W' => '연락처',
|
||||
'X' => '업체명', 'Y' => '업체주소', 'Z' => '대표자명', 'AA' => '연락처',
|
||||
];
|
||||
|
||||
// 카테고리별 컬럼 색상 매핑
|
||||
$colColors = [];
|
||||
foreach (['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L'] as $c) {
|
||||
$colColors[$c] = self::COLOR_MATERIAL;
|
||||
}
|
||||
foreach (['M', 'N', 'O'] as $c) {
|
||||
$colColors[$c] = self::COLOR_SITE;
|
||||
}
|
||||
foreach (['P', 'Q', 'R', 'S'] as $c) {
|
||||
$colColors[$c] = self::COLOR_SUPERVISOR;
|
||||
}
|
||||
foreach (['T', 'U', 'V', 'W'] as $c) {
|
||||
$colColors[$c] = self::COLOR_CONTRACTOR;
|
||||
}
|
||||
foreach (['X', 'Y', 'Z', 'AA'] as $c) {
|
||||
$colColors[$c] = self::COLOR_DISTRIBUTOR;
|
||||
}
|
||||
|
||||
foreach ($headers as $col => $label) {
|
||||
$cell = "{$col}12";
|
||||
$sheet->setCellValue($cell, $label);
|
||||
$sheet->getStyle($cell)->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 10, 'bold' => true],
|
||||
'alignment' => [
|
||||
'horizontal' => Alignment::HORIZONTAL_CENTER,
|
||||
'vertical' => Alignment::VERTICAL_CENTER,
|
||||
'wrapText' => true,
|
||||
],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => $colColors[$col]]],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
}
|
||||
|
||||
$sheet->getRowDimension(12)->setRowHeight(32);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 행 쓰기 (Row 13+)
|
||||
*
|
||||
* @return int 마지막 데이터 행 번호
|
||||
*/
|
||||
private function writeDataRows($sheet, $reports, int $startRow): int
|
||||
{
|
||||
$currentRow = $startRow;
|
||||
$serialNo = 1;
|
||||
|
||||
foreach ($reports as $report) {
|
||||
$doc = $report->qualityDocument;
|
||||
if (! $doc) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$locations = $doc->locations ?? collect();
|
||||
$locationCount = $locations->count();
|
||||
|
||||
if ($locationCount === 0) {
|
||||
// 개소 없는 경우에도 1행 출력
|
||||
$this->writeDocumentRow($sheet, $currentRow, $serialNo, $doc, null);
|
||||
$currentRow++;
|
||||
} else {
|
||||
$firstRow = $currentRow;
|
||||
|
||||
foreach ($locations as $idx => $location) {
|
||||
$this->writeDocumentRow(
|
||||
$sheet,
|
||||
$currentRow,
|
||||
$idx === 0 ? $serialNo : null,
|
||||
$idx === 0 ? $doc : null,
|
||||
$location
|
||||
);
|
||||
$currentRow++;
|
||||
}
|
||||
|
||||
// 같은 품질관리서의 여러 개소 → 병합
|
||||
if ($locationCount > 1) {
|
||||
$lastRow = $currentRow - 1;
|
||||
foreach (self::MERGE_COLS as $col) {
|
||||
$sheet->mergeCells("{$col}{$firstRow}:{$col}{$lastRow}");
|
||||
$sheet->getStyle("{$col}{$firstRow}")->getAlignment()
|
||||
->setVertical(Alignment::VERTICAL_CENTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$serialNo++;
|
||||
}
|
||||
|
||||
return $currentRow - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 행 쓰기
|
||||
*/
|
||||
private function writeDocumentRow($sheet, int $row, ?int $serialNo, ?object $doc, ?QualityDocumentLocation $location): void
|
||||
{
|
||||
$options = $doc?->options ?? [];
|
||||
$orderItem = $location?->orderItem;
|
||||
|
||||
// === 병합 컬럼 (문서 수준 - 첫 행에만 기록) ===
|
||||
if ($doc !== null) {
|
||||
$sheet->setCellValue("A{$row}", $serialNo);
|
||||
$sheet->setCellValue("B{$row}", $doc->quality_doc_number ?? '');
|
||||
$sheet->setCellValue("C{$row}", $doc->received_date?->format('Y-m-d') ?? '');
|
||||
|
||||
// 자재 정보 (D~I)
|
||||
$sheet->setCellValue("D{$row}", $this->getProductCategory($location));
|
||||
$sheet->setCellValue("E{$row}", $orderItem?->item_name ?? '');
|
||||
$sheet->setCellValue("F{$row}", $orderItem?->specification ?? '');
|
||||
$sheet->setCellValue("G{$row}", $this->getInspectionDate($doc));
|
||||
$sheet->setCellValue("H{$row}", $this->getFireResistanceTime($location));
|
||||
$sheet->setCellValue("I{$row}", $this->getUsagePart($location));
|
||||
|
||||
// 건축공사장 (M~O)
|
||||
$site = $options['construction_site'] ?? [];
|
||||
$sheet->setCellValue("M{$row}", $site['name'] ?? '');
|
||||
$sheet->setCellValue("N{$row}", $site['land_location'] ?? '');
|
||||
$sheet->setCellValue("O{$row}", $site['lot_number'] ?? '');
|
||||
|
||||
// 공사감리자 (P~S)
|
||||
$supervisor = $options['supervisor'] ?? [];
|
||||
$sheet->setCellValue("P{$row}", $supervisor['office'] ?? '');
|
||||
$sheet->setCellValue("Q{$row}", $supervisor['address'] ?? '');
|
||||
$sheet->setCellValue("R{$row}", $supervisor['name'] ?? '');
|
||||
$sheet->setCellValue("S{$row}", $supervisor['phone'] ?? '');
|
||||
|
||||
// 공사시공자 (T~W)
|
||||
$contractor = $options['contractor'] ?? [];
|
||||
$sheet->setCellValue("T{$row}", $contractor['company'] ?? '');
|
||||
$sheet->setCellValue("U{$row}", $contractor['address'] ?? '');
|
||||
$sheet->setCellValue("V{$row}", $contractor['name'] ?? '');
|
||||
$sheet->setCellValue("W{$row}", $contractor['phone'] ?? '');
|
||||
|
||||
// 자재유통업자 (X~AA)
|
||||
$distributor = $options['material_distributor'] ?? [];
|
||||
$sheet->setCellValue("X{$row}", $distributor['company'] ?? '');
|
||||
$sheet->setCellValue("Y{$row}", $distributor['address'] ?? '');
|
||||
$sheet->setCellValue("Z{$row}", $distributor['ceo'] ?? '');
|
||||
$sheet->setCellValue("AA{$row}", $distributor['phone'] ?? '');
|
||||
}
|
||||
|
||||
// === 비병합 컬럼 (개소별 - 매 행 기록) ===
|
||||
if ($location !== null) {
|
||||
$sheet->setCellValue("J{$row}", $this->getLotNumber($location));
|
||||
$sheet->setCellValue("K{$row}", $this->formatDimension($location));
|
||||
$sheet->setCellValue("L{$row}", $this->getQuantity($location));
|
||||
}
|
||||
|
||||
// 행 스타일
|
||||
$sheet->getStyle("A{$row}:AA{$row}")->applyFromArray([
|
||||
'font' => ['name' => '돋움', 'size' => 10],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
|
||||
]);
|
||||
$sheet->getStyle("A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
$sheet->getStyle("L{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 영역 테두리 적용
|
||||
*/
|
||||
private function applyDataBorders($sheet, int $startRow, int $endRow): void
|
||||
{
|
||||
$sheet->getStyle("A{$startRow}:AA{$endRow}")->applyFromArray([
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스트림 응답 생성
|
||||
*/
|
||||
private function createStreamedResponse(Spreadsheet $spreadsheet, string $filename): StreamedResponse
|
||||
{
|
||||
$encodedFilename = rawurlencode($filename);
|
||||
|
||||
return new StreamedResponse(function () use ($spreadsheet) {
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
$writer->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, 200, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition' => "attachment; filename*=UTF-8''{$encodedFilename}",
|
||||
'Cache-Control' => 'max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 미확정 필드 (추후 데이터 매핑)
|
||||
// ========================================
|
||||
|
||||
/** 인정품목 (추후 구현) */
|
||||
private function getProductCategory(?QualityDocumentLocation $location): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 내화성능시간 (추후 구현) */
|
||||
private function getFireResistanceTime(?QualityDocumentLocation $location): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 사용부위 (추후 구현) */
|
||||
private function getUsagePart(?QualityDocumentLocation $location): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 로트번호 (추후 구현) */
|
||||
private function getLotNumber(?QualityDocumentLocation $location): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 헬퍼 메서드
|
||||
// ========================================
|
||||
|
||||
/** 제품검사일 (품질관리서의 검사 완료일) */
|
||||
private function getInspectionDate(?object $doc): string
|
||||
{
|
||||
if (! $doc) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$options = $doc->options ?? [];
|
||||
$endDate = $options['inspection']['end_date'] ?? '';
|
||||
|
||||
return $endDate ?: '';
|
||||
}
|
||||
|
||||
/** 규격(치수): 너비 × 높이 */
|
||||
private function formatDimension(?QualityDocumentLocation $location): string
|
||||
{
|
||||
if (! $location) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$w = $location->post_width;
|
||||
$h = $location->post_height;
|
||||
|
||||
if (! $w && ! $h) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return "{$w} × {$h}";
|
||||
}
|
||||
|
||||
/** 수량 (개소당 1 또는 orderItem 수량) */
|
||||
private function getQuantity(?QualityDocumentLocation $location): int
|
||||
{
|
||||
if (! $location) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($location->orderItem?->quantity ?? 1);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,18 @@ class PerformanceReportService extends Service
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly QualityDocumentService $qualityDocumentService
|
||||
private readonly QualityDocumentService $qualityDocumentService,
|
||||
private readonly PerformanceReportExcelService $excelService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 확정건 엑셀 다운로드
|
||||
*/
|
||||
public function exportConfirmed(int $year, int $quarter)
|
||||
{
|
||||
return $this->excelService->generate($year, $quarter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 조회
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ public function index(array $params): array
|
||||
{
|
||||
$query = QualityDocument::with([
|
||||
'documentOrders.order.item',
|
||||
'locations',
|
||||
'documentOrders.locations',
|
||||
'performanceReport',
|
||||
])
|
||||
->where('status', QualityDocument::STATUS_COMPLETED);
|
||||
@@ -142,8 +142,18 @@ public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
);
|
||||
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection);
|
||||
|
||||
// 8. 품질관리서
|
||||
$documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
|
||||
// 8. 품질관리서 (파일 정보 포함)
|
||||
$qualityDoc->loadMissing('file');
|
||||
$qualityDocFormatted = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
|
||||
|
||||
// 파일 정보 추가
|
||||
if ($qualityDoc->file) {
|
||||
$qualityDocFormatted['file_id'] = $qualityDoc->file->id;
|
||||
$qualityDocFormatted['file_name'] = $qualityDoc->file->display_name ?? $qualityDoc->file->original_name;
|
||||
$qualityDocFormatted['file_size'] = $qualityDoc->file->file_size;
|
||||
}
|
||||
|
||||
$documents[] = $qualityDocFormatted;
|
||||
|
||||
return $documents;
|
||||
}
|
||||
@@ -200,8 +210,18 @@ public function confirm(int $locationId, array $data): array
|
||||
private function transformReportToFrontend(QualityDocument $doc): array
|
||||
{
|
||||
$performanceReport = $doc->performanceReport;
|
||||
$confirmedCount = $doc->locations->filter(function ($loc) {
|
||||
return data_get($loc->options, 'lot_audit_confirmed', false);
|
||||
|
||||
// 수주로트 건수 = documentOrders 수
|
||||
$totalRoutes = $doc->documentOrders->count();
|
||||
|
||||
// 확인 완료 수주로트 = 해당 주문의 모든 개소가 확인된 건수
|
||||
$confirmedRoutes = $doc->documentOrders->filter(function ($docOrder) {
|
||||
$locations = $docOrder->locations;
|
||||
if ($locations->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $locations->every(fn ($loc) => data_get($loc->options, 'lot_audit_confirmed', false));
|
||||
})->count();
|
||||
|
||||
return [
|
||||
@@ -209,8 +229,8 @@ private function transformReportToFrontend(QualityDocument $doc): array
|
||||
'code' => $doc->quality_doc_number,
|
||||
'site_name' => $doc->site_name,
|
||||
'item' => $this->getFgProductName($doc),
|
||||
'route_count' => $confirmedCount,
|
||||
'total_routes' => $doc->locations->count(),
|
||||
'route_count' => $confirmedRoutes,
|
||||
'total_routes' => $totalRoutes,
|
||||
'quarter' => $performanceReport
|
||||
? $performanceReport->year.'년 '.$performanceReport->quarter.'분기'
|
||||
: '',
|
||||
@@ -415,21 +435,175 @@ private function getInspectionDetail(int $id, string $type): array
|
||||
|
||||
private function getOrderDetail(int $id): array
|
||||
{
|
||||
$order = Order::with(['nodes' => fn ($q) => $q->whereNull('parent_id')])->findOrFail($id);
|
||||
$order = Order::with([
|
||||
'client',
|
||||
'nodes' => fn ($q) => $q->whereNull('parent_id')->orderBy('id'),
|
||||
])->findOrFail($id);
|
||||
|
||||
$rootNodes = $order->nodes;
|
||||
$options = $order->options ?? [];
|
||||
|
||||
// 개소별 제품 정보
|
||||
$products = $rootNodes->map(function ($node, $index) {
|
||||
$opts = $node->options ?? [];
|
||||
$vars = data_get($opts, 'bom_result.variables', []);
|
||||
|
||||
return [
|
||||
'no' => $index + 1,
|
||||
'floor' => $opts['floor'] ?? '-',
|
||||
'symbol' => $opts['symbol'] ?? '-',
|
||||
'product_name' => $opts['product_name'] ?? '-',
|
||||
'product_code' => $opts['product_code'] ?? null,
|
||||
'open_width' => $opts['open_width'] ?? null,
|
||||
'open_height' => $opts['open_height'] ?? null,
|
||||
'made_width' => $opts['width'] ?? null,
|
||||
'made_height' => $opts['height'] ?? null,
|
||||
'guide_rail' => $vars['installation_type'] ?? '-',
|
||||
'shaft' => $vars['bracket_inch'] ?? '-',
|
||||
'case_inch' => $vars['bracket_inch'] ?? '-',
|
||||
'bracket' => $vars['BRACKET_SIZE'] ?? '-',
|
||||
'capacity' => $vars['MOTOR_CAPACITY'] ?? '-',
|
||||
'finish' => $vars['finishing_type'] ?? '-',
|
||||
'product_type' => $vars['product_type'] ?? null,
|
||||
'joint_bar' => null, // 철재 전용 — 아래에서 bom_items에서 보강
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
// BOM items 집계 (모든 노드에서)
|
||||
$allBomItems = $rootNodes->flatMap(function ($node) {
|
||||
return collect(data_get($node->options, 'bom_result.items', []));
|
||||
});
|
||||
|
||||
// 철재 제품의 조인트바 수량 보강
|
||||
foreach ($rootNodes->values() as $index => $node) {
|
||||
$bomItems = collect(data_get($node->options, 'bom_result.items', []));
|
||||
$jointBar = $bomItems->first(fn ($i) => str_contains($i['item_name'] ?? '', '조인트바'));
|
||||
if ($jointBar) {
|
||||
$products[$index]['joint_bar'] = $jointBar['quantity'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// 모터 정보 (category: motor, controller)
|
||||
$motorItems = $allBomItems->filter(fn ($i) => in_array($i['item_category'] ?? '', ['motor', 'controller']));
|
||||
$motorLeft = [];
|
||||
$motorRight = [];
|
||||
foreach ($motorItems->groupBy('item_name') as $name => $group) {
|
||||
$item = $group->first();
|
||||
$totalQty = $group->sum('quantity');
|
||||
$row = [
|
||||
'item' => $item['item_name'],
|
||||
'type' => $item['specification'] ?? '-',
|
||||
'spec' => $item['item_code'] ?? '-',
|
||||
'qty' => $totalQty,
|
||||
];
|
||||
// 모터/브라켓 → 좌, 제어기/전동개폐기 → 우
|
||||
if (in_array($item['item_category'], ['controller'])) {
|
||||
$motorRight[] = $row;
|
||||
} else {
|
||||
$motorLeft[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// 절곡물 (category: steel)
|
||||
$steelItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'steel');
|
||||
$bendingParts = $this->groupBendingParts($steelItems);
|
||||
|
||||
// 부자재 (category: parts)
|
||||
$partItems = $allBomItems->filter(fn ($i) => ($i['item_category'] ?? '') === 'parts');
|
||||
$subsidiaryParts = [];
|
||||
foreach ($partItems->groupBy('item_name') as $name => $group) {
|
||||
$item = $group->first();
|
||||
$subsidiaryParts[] = [
|
||||
'name' => $item['item_name'],
|
||||
'spec' => $item['specification'] ?? '-',
|
||||
'qty' => $group->sum('quantity'),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'order',
|
||||
'data' => [
|
||||
'id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'status' => $order->status,
|
||||
'status_code' => $order->status_code,
|
||||
'category_code' => $order->category_code,
|
||||
'received_at' => $order->received_at?->toDateString(),
|
||||
'delivery_date' => $order->delivery_date?->toDateString(),
|
||||
'delivery_method_code' => $order->delivery_method_code,
|
||||
'site_name' => $order->site_name,
|
||||
'nodes_count' => $order->nodes->count(),
|
||||
'client_name' => $order->client_name ?? $order->client?->name,
|
||||
'client_contact' => $order->client_contact,
|
||||
'manager_name' => $options['manager_name'] ?? null,
|
||||
'receiver' => $options['receiver'] ?? null,
|
||||
'receiver_contact' => $options['receiver_contact'] ?? null,
|
||||
'shipping_address' => $options['shipping_address'] ?? null,
|
||||
'shipping_address_detail' => $options['shipping_address_detail'] ?? null,
|
||||
'shipping_cost_code' => $options['shipping_cost_code'] ?? null,
|
||||
'quantity' => $order->quantity,
|
||||
'supply_amount' => $order->supply_amount,
|
||||
'tax_amount' => $order->tax_amount,
|
||||
'total_amount' => $order->total_amount,
|
||||
'remarks' => $order->remarks,
|
||||
'nodes_count' => $rootNodes->count(),
|
||||
'products' => $products,
|
||||
'motors' => [
|
||||
'left' => $motorLeft,
|
||||
'right' => $motorRight,
|
||||
],
|
||||
'bending_parts' => $bendingParts,
|
||||
'subsidiary_parts' => $subsidiaryParts,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 절곡물 BOM items를 그룹별로 분류
|
||||
*/
|
||||
private function groupBendingParts($steelItems): array
|
||||
{
|
||||
$groups = [
|
||||
'가이드레일' => [],
|
||||
'케이스' => [],
|
||||
'하단마감' => [],
|
||||
'연기차단재' => [],
|
||||
'기타' => [],
|
||||
];
|
||||
|
||||
foreach ($steelItems->groupBy('item_name') as $name => $group) {
|
||||
$item = $group->first();
|
||||
$totalQty = $group->sum('quantity');
|
||||
$row = [
|
||||
'name' => $item['item_name'],
|
||||
'spec' => $item['specification'] ?? '-',
|
||||
'qty' => $totalQty,
|
||||
];
|
||||
|
||||
if (str_contains($name, '연기차단재')) {
|
||||
$groups['연기차단재'][] = $row;
|
||||
} elseif (str_contains($name, '가이드레일')) {
|
||||
$groups['가이드레일'][] = $row;
|
||||
} elseif (str_contains($name, '케이스') || str_contains($name, '마구리')) {
|
||||
$groups['케이스'][] = $row;
|
||||
} elseif (str_contains($name, '하장바') || str_contains($name, 'L-BAR') || str_contains($name, '보강평철')) {
|
||||
$groups['하단마감'][] = $row;
|
||||
} else {
|
||||
$groups['기타'][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($groups as $groupName => $items) {
|
||||
if (! empty($items)) {
|
||||
$result[] = [
|
||||
'group' => $groupName,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getWorkOrderLogDetail(int $id): array
|
||||
{
|
||||
$workOrder = WorkOrder::with('process')->findOrFail($id);
|
||||
@@ -449,22 +623,65 @@ private function getWorkOrderLogDetail(int $id): array
|
||||
|
||||
private function getShipmentDetail(int $id): array
|
||||
{
|
||||
$shipment = Shipment::findOrFail($id);
|
||||
$shipment = Shipment::with([
|
||||
'vehicleDispatches',
|
||||
'items',
|
||||
'order.nodes' => fn ($q) => $q->whereNull('parent_id'),
|
||||
])->findOrFail($id);
|
||||
|
||||
// 배차정보
|
||||
$vehicleDispatches = $shipment->vehicleDispatches->map(fn ($d) => [
|
||||
'logistics_company' => $d->logistics_company,
|
||||
'arrival_datetime' => $d->arrival_datetime,
|
||||
'tonnage' => $d->tonnage,
|
||||
'vehicle_no' => $d->vehicle_no,
|
||||
'driver_contact' => $d->driver_contact,
|
||||
'remarks' => $d->remarks,
|
||||
])->values()->toArray();
|
||||
|
||||
// 출하 품목 → 제품 그룹별 분류
|
||||
$productGroups = [];
|
||||
$otherParts = [];
|
||||
foreach ($shipment->items as $item) {
|
||||
$row = [
|
||||
'item_name' => $item->item_name,
|
||||
'specification' => $item->specification,
|
||||
'quantity' => $item->quantity,
|
||||
'unit' => $item->unit,
|
||||
'lot_no' => $item->lot_no,
|
||||
'floor_unit' => $item->floor_unit,
|
||||
];
|
||||
// floor_unit가 있으면 해당 제품 그룹에, 없으면 기타 부품
|
||||
if ($item->floor_unit) {
|
||||
$productGroups[$item->floor_unit][] = $row;
|
||||
} else {
|
||||
$otherParts[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'shipping',
|
||||
'data' => [
|
||||
'id' => $shipment->id,
|
||||
'shipment_no' => $shipment->shipment_no,
|
||||
'lot_no' => $shipment->lot_no,
|
||||
'status' => $shipment->status,
|
||||
'scheduled_date' => $shipment->scheduled_date?->toDateString(),
|
||||
'customer_name' => $shipment->customer_name,
|
||||
'customer_grade' => $shipment->customer_grade,
|
||||
'site_name' => $shipment->site_name,
|
||||
'delivery_address' => $shipment->delivery_address,
|
||||
'delivery_method' => $shipment->delivery_method,
|
||||
'shipping_cost' => $shipment->shipping_cost,
|
||||
'receiver' => $shipment->receiver,
|
||||
'receiver_contact' => $shipment->receiver_contact,
|
||||
'vehicle_no' => $shipment->vehicle_no,
|
||||
'driver_name' => $shipment->driver_name,
|
||||
'driver_contact' => $shipment->driver_contact,
|
||||
'remarks' => $shipment->remarks,
|
||||
'vehicle_dispatches' => $vehicleDispatches,
|
||||
'product_groups' => $productGroups,
|
||||
'other_parts' => $otherParts,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Documents\Document;
|
||||
use App\Models\Documents\DocumentData;
|
||||
use App\Models\Documents\DocumentTemplate;
|
||||
@@ -13,7 +14,9 @@
|
||||
use App\Models\Qualitys\QualityDocumentLocation;
|
||||
use App\Models\Qualitys\QualityDocumentOrder;
|
||||
use App\Services\Audit\AuditLogger;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
@@ -1248,4 +1251,78 @@ private function formatInspectionPeriod(array $options): string
|
||||
|
||||
return $start ?: $end ?: '';
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 파일 업로드/삭제
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 품질관리서 파일 업로드 (1건당 1파일, 기존 파일 있으면 교체)
|
||||
*/
|
||||
public function uploadFile(int $id, UploadedFile $uploadedFile): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$doc = QualityDocument::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// 기존 파일이 있으면 물리 삭제 (교체)
|
||||
$existingFile = $doc->file;
|
||||
if ($existingFile) {
|
||||
$existingFile->permanentDelete();
|
||||
}
|
||||
|
||||
// 저장 경로: {tenant_id}/quality-documents/{year}/{month}/{stored_name}
|
||||
$date = now();
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension();
|
||||
$filePath = sprintf(
|
||||
'%d/quality-documents/%s/%s/%s',
|
||||
$tenantId,
|
||||
$date->format('Y'),
|
||||
$date->format('m'),
|
||||
$storedName
|
||||
);
|
||||
|
||||
// R2에 파일 저장
|
||||
Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname()));
|
||||
|
||||
// DB 레코드 생성
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => QualityDocument::class,
|
||||
'document_id' => $doc->id,
|
||||
'display_name' => $uploadedFile->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $uploadedFile->getSize(),
|
||||
'mime_type' => $uploadedFile->getClientMimeType(),
|
||||
'uploaded_by' => $userId,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'display_name' => $file->display_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 품질관리서 파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $id): void
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$doc = QualityDocument::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
$file = $doc->file;
|
||||
if (! $file) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,8 +312,7 @@ private function calculateExpression(string $expression): float
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: 프로덕션에서는 symfony/expression-language 등 안전한 라이브러리 사용 권장
|
||||
return (float) eval("return {$expression};");
|
||||
return \App\Helpers\SafeMathEvaluator::calculate($expression);
|
||||
} catch (\Throwable $e) {
|
||||
$this->errors[] = __('error.formula_calculation_error', ['expression' => $expression]);
|
||||
|
||||
@@ -1849,7 +1848,8 @@ private function calculateTenantBom(
|
||||
'formulas' => $itemFormulas,
|
||||
]);
|
||||
|
||||
// Step 8: 카테고리별 그룹화
|
||||
// Step 8: 카테고리별 그룹화 (고정 순서: 주자재→모터→제어기→절곡품→부자재→검사비→기타)
|
||||
$categoryOrder = ['material', 'motor', 'controller', 'steel', 'parts', 'inspection'];
|
||||
$groupedItems = [];
|
||||
foreach ($calculatedItems as $item) {
|
||||
$category = $item['category_group'];
|
||||
@@ -1863,6 +1863,19 @@ private function calculateTenantBom(
|
||||
$groupedItems[$category]['items'][] = $item;
|
||||
$groupedItems[$category]['subtotal'] += $item['total_price'];
|
||||
}
|
||||
// 고정 순서로 정렬 (미정의 카테고리는 뒤에 배치)
|
||||
$sorted = [];
|
||||
foreach ($categoryOrder as $cat) {
|
||||
if (isset($groupedItems[$cat])) {
|
||||
$sorted[$cat] = $groupedItems[$cat];
|
||||
}
|
||||
}
|
||||
foreach ($groupedItems as $cat => $group) {
|
||||
if (! isset($sorted[$cat])) {
|
||||
$sorted[$cat] = $group;
|
||||
}
|
||||
}
|
||||
$groupedItems = $sorted;
|
||||
|
||||
$this->addDebugStep(8, '카테고리그룹화', [
|
||||
'groups' => array_map(fn ($g) => [
|
||||
@@ -1928,6 +1941,7 @@ private function getTenantCategoryName(string $category): string
|
||||
'controller' => '제어기',
|
||||
'steel' => '절곡품',
|
||||
'parts' => '부자재',
|
||||
'inspection' => '검사비',
|
||||
default => $category,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1090,11 +1090,11 @@ public function calculateDynamicItems(array $inputs): array
|
||||
'total_price' => $motorPrice * $quantity,
|
||||
], $motorCode);
|
||||
|
||||
// 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17)
|
||||
// 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17
|
||||
// col15/col16/col17은 고정 수량 (QTY와 무관, $su를 곱하지 않음)
|
||||
// 3. 제어기 — 셔터 수량(QTY)만큼 필요
|
||||
// 5130 원본은 col15/col16/col17을 QTY와 무관하게 처리했으나,
|
||||
// SAM에서는 개소별 수량(QTY)에 비례하여 계산
|
||||
$controllerType = $inputs['controller_type'] ?? '매립형';
|
||||
$controllerQty = (int) ($inputs['controller_qty'] ?? 1);
|
||||
$controllerQty = (int) ($inputs['controller_qty'] ?? 1) * $quantity;
|
||||
$controllerPrice = $this->getControllerPrice($controllerType);
|
||||
if ($controllerPrice > 0 && $controllerQty > 0) {
|
||||
$ctrlCode = "EST-CTRL-{$controllerType}";
|
||||
@@ -1109,8 +1109,8 @@ public function calculateDynamicItems(array $inputs): array
|
||||
], $ctrlCode);
|
||||
}
|
||||
|
||||
// 뒷박스 (5130: col17 수량, QTY와 무관)
|
||||
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1);
|
||||
// 뒷박스 — 제어기와 동일하게 수량(QTY) 반영
|
||||
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1) * $quantity;
|
||||
if ($backboxQty > 0) {
|
||||
$backboxPrice = $this->getControllerPrice('뒷박스');
|
||||
if ($backboxPrice > 0) {
|
||||
|
||||
@@ -52,10 +52,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
|
||||
// 수주 전환용 조회: 아직 수주가 생성되지 않은 견적만
|
||||
if ($forOrder) {
|
||||
// 1. Quote.order_id가 null인 것 (빠른 체크)
|
||||
$query->whereNull('order_id');
|
||||
// 2. Orders 테이블에 해당 quote_id가 없는 것 (이중 체크, 인덱스 있음)
|
||||
$query->whereDoesntHave('orders');
|
||||
}
|
||||
|
||||
// items 포함 (수주 전환용)
|
||||
@@ -77,7 +74,10 @@ public function index(array $params): LengthAwarePaginator
|
||||
if ($status === Quote::STATUS_CONVERTED) {
|
||||
$query->whereNotNull('order_id');
|
||||
} elseif ($status) {
|
||||
$query->where('status', $status)->whereNull('order_id');
|
||||
$query->where('status', $status);
|
||||
if (! $forOrder) {
|
||||
$query->whereNull('order_id');
|
||||
}
|
||||
}
|
||||
|
||||
// 제품 카테고리 필터
|
||||
@@ -196,6 +196,13 @@ public function show(int $id): Quote
|
||||
$quote->setAttribute('bom_materials', $bomMaterials);
|
||||
}
|
||||
|
||||
// 프론트 제어용 플래그
|
||||
$quote->setAttribute('is_editable', $quote->isEditable());
|
||||
$quote->setAttribute('has_work_orders', $quote->order_id
|
||||
? Order::where('id', $quote->order_id)->whereHas('workOrders')->exists()
|
||||
: false
|
||||
);
|
||||
|
||||
return $quote;
|
||||
}
|
||||
|
||||
@@ -634,39 +641,86 @@ public function convertToOrder(int $id): Quote
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($quote, $userId, $tenantId) {
|
||||
// 수주번호 생성
|
||||
$orderNo = $this->generateOrderNumber($tenantId);
|
||||
|
||||
// 수주 마스터 생성
|
||||
$order = Order::createFromQuote($quote, $orderNo);
|
||||
$order->created_by = $userId;
|
||||
$order->save();
|
||||
|
||||
// calculation_inputs에서 개소(제품) 정보 추출
|
||||
$calculationInputs = $quote->calculation_inputs ?? [];
|
||||
$productItems = $calculationInputs['items'] ?? [];
|
||||
$bomResults = $calculationInputs['bomResults'] ?? [];
|
||||
$locationCount = count($productItems);
|
||||
|
||||
// OrderNode 생성 (개소별)
|
||||
$nodeMap = []; // productIndex → OrderNode
|
||||
// 품목→개소 매핑 사전 계산
|
||||
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
|
||||
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
|
||||
? intdiv($quote->items->count(), $locationCount)
|
||||
: 0;
|
||||
|
||||
// 견적 품목을 개소별로 그룹핑
|
||||
$itemsByLocation = [];
|
||||
foreach ($quote->items as $index => $quoteItem) {
|
||||
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
|
||||
|
||||
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
|
||||
if ($locIdx === 0 && $itemsPerLocation > 0) {
|
||||
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
|
||||
}
|
||||
|
||||
$itemsByLocation[$locIdx][] = [
|
||||
'quoteItem' => $quoteItem,
|
||||
'mapping' => $this->resolveLocationMapping($quoteItem, $productItems),
|
||||
];
|
||||
}
|
||||
|
||||
// 개소(items) × 수량(quantity) = 총 수주 건수 계산
|
||||
// 예: items 1건(qty=10) → 10건, items 3건(각 qty=1) → 3건
|
||||
$expandedLocations = [];
|
||||
foreach ($productItems as $idx => $locItem) {
|
||||
$bomResult = $bomResults[$idx] ?? null;
|
||||
$grandTotal = $bomResult['grand_total'] ?? 0;
|
||||
$qty = (int) ($locItem['quantity'] ?? 1);
|
||||
for ($q = 0; $q < $qty; $q++) {
|
||||
$expandedLocations[] = [
|
||||
'locItem' => $locItem,
|
||||
'bomResult' => $bomResult,
|
||||
'origIdx' => $idx,
|
||||
'unitIndex' => $q,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$totalOrders = count($expandedLocations);
|
||||
$orderNumbers = $this->generateOrderNumbers($tenantId, max($totalOrders, 1));
|
||||
|
||||
// 개소×수량별로 독립 수주 생성
|
||||
$firstOrderId = null;
|
||||
|
||||
foreach ($expandedLocations as $orderIdx => $expanded) {
|
||||
$locItem = $expanded['locItem'];
|
||||
$bomResult = $expanded['bomResult'];
|
||||
$origIdx = $expanded['origIdx'];
|
||||
$grandTotal = $bomResult['grand_total'] ?? 0;
|
||||
$floor = $locItem['floor'] ?? '';
|
||||
$symbol = $locItem['code'] ?? '';
|
||||
|
||||
// 수주 마스터 생성 (qty=1 단위)
|
||||
$unitLocItem = array_merge($locItem, ['quantity' => 1]);
|
||||
$order = Order::createFromQuoteLocation($quote, $orderNumbers[$orderIdx], $unitLocItem, $bomResult);
|
||||
$order->created_by = $userId;
|
||||
$order->save();
|
||||
|
||||
if ($firstOrderId === null) {
|
||||
$firstOrderId = $order->id;
|
||||
}
|
||||
|
||||
// OrderNode 생성 (1수주 = 1노드)
|
||||
$node = OrderNode::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'order_id' => $order->id,
|
||||
'parent_id' => null,
|
||||
'node_type' => 'location',
|
||||
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}",
|
||||
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1),
|
||||
'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$orderIdx}",
|
||||
'name' => trim("{$floor} {$symbol}") ?: '개소 '.($orderIdx + 1),
|
||||
'status_code' => OrderNode::STATUS_PENDING,
|
||||
'quantity' => $qty,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $grandTotal,
|
||||
'total_price' => $grandTotal * $qty,
|
||||
'total_price' => $grandTotal,
|
||||
'options' => [
|
||||
'floor' => $floor,
|
||||
'symbol' => $symbol,
|
||||
@@ -682,33 +736,17 @@ public function convertToOrder(int $id): Quote
|
||||
'bom_result' => $bomResult,
|
||||
],
|
||||
'depth' => 0,
|
||||
'sort_order' => $idx,
|
||||
'sort_order' => 0,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
$nodeMap[$idx] = $node;
|
||||
}
|
||||
|
||||
// 수주 상세 품목 생성 (노드 연결 포함)
|
||||
// formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비
|
||||
$locationCount = count($productItems);
|
||||
$hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source));
|
||||
$itemsPerLocation = (! $hasFormulaSource && $locationCount > 1)
|
||||
? intdiv($quote->items->count(), $locationCount)
|
||||
: 0;
|
||||
|
||||
// 해당 개소 소속 품목 → OrderItem 복제 (모든 수량 분할 건에 동일 품목)
|
||||
$serialIndex = 1;
|
||||
foreach ($quote->items as $index => $quoteItem) {
|
||||
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
|
||||
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
|
||||
foreach ($itemsByLocation[$origIdx] ?? [] as $entry) {
|
||||
$mapping = $entry['mapping'];
|
||||
$mapping['order_node_id'] = $node->id;
|
||||
|
||||
// sort_order 기반 분배 (formula_source/note 매칭 모두 실패 시)
|
||||
if ($locIdx === 0 && $itemsPerLocation > 0) {
|
||||
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
|
||||
}
|
||||
|
||||
$productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null;
|
||||
|
||||
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
|
||||
$orderItem = OrderItem::createFromQuoteItem($entry['quoteItem'], $order->id, $serialIndex, $mapping);
|
||||
$orderItem->created_by = $userId;
|
||||
$orderItem->save();
|
||||
$serialIndex++;
|
||||
@@ -718,14 +756,15 @@ public function convertToOrder(int $id): Quote
|
||||
$order->load('items');
|
||||
$order->recalculateTotals();
|
||||
$order->save();
|
||||
}
|
||||
|
||||
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
|
||||
$quote->update([
|
||||
'order_id' => $order->id,
|
||||
'order_id' => $firstOrderId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
return $quote->refresh()->load(['items', 'client', 'order']);
|
||||
return $quote->refresh()->load(['items', 'client', 'orders']);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -816,10 +855,10 @@ private function extractProductCodeFromInputs(array $data): ?string
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주번호 생성
|
||||
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001)
|
||||
* 수주번호 N개 연속 생성
|
||||
* 형식: ORD-YYMMDD-NNN (예: ORD-260105-001, -002, -003)
|
||||
*/
|
||||
private function generateOrderNumber(int $tenantId): string
|
||||
private function generateOrderNumbers(int $tenantId, int $count = 1): array
|
||||
{
|
||||
$dateStr = now()->format('ymd');
|
||||
$prefix = "ORD-{$dateStr}-";
|
||||
@@ -839,9 +878,13 @@ private function generateOrderNumber(int $tenantId): string
|
||||
}
|
||||
}
|
||||
|
||||
$seqStr = str_pad((string) $sequence, 3, '0', STR_PAD_LEFT);
|
||||
$numbers = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$seqStr = str_pad((string) ($sequence + $i), 3, '0', STR_PAD_LEFT);
|
||||
$numbers[] = "{$prefix}{$seqStr}";
|
||||
}
|
||||
|
||||
return "{$prefix}{$seqStr}";
|
||||
return $numbers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -427,16 +427,47 @@ private function generateReceivingNumber(int $tenantId): string
|
||||
|
||||
/**
|
||||
* LOT번호 자동 생성
|
||||
*
|
||||
* 채번규칙이 있으면 NumberingService 사용, 없으면 레거시 로직 (YYMMDD-NN)
|
||||
*/
|
||||
private function generateLotNo(): string
|
||||
{
|
||||
$now = now();
|
||||
$year = $now->format('y');
|
||||
$month = $now->format('m');
|
||||
$day = $now->format('d');
|
||||
$seq = str_pad(rand(1, 99), 2, '0', STR_PAD_LEFT);
|
||||
$numberingService = app(NumberingService::class);
|
||||
$numberingService->setContext($this->tenantId(), $this->apiUserId());
|
||||
|
||||
return "{$year}{$month}{$day}-{$seq}";
|
||||
$number = $numberingService->generate('material_receipt');
|
||||
|
||||
if ($number !== null) {
|
||||
return $number;
|
||||
}
|
||||
|
||||
return $this->generateLotNoLegacy();
|
||||
}
|
||||
|
||||
/**
|
||||
* LOT번호 레거시 생성
|
||||
*
|
||||
* 5130 레거시 차용: YYMMDD-NN (일별 시퀀스, 01부터 시작)
|
||||
*/
|
||||
private function generateLotNoLegacy(): string
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$prefix = now()->format('ymd');
|
||||
|
||||
$lastReceiving = Receiving::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('lot_no', 'like', $prefix.'-%')
|
||||
->orderBy('lot_no', 'desc')
|
||||
->first(['lot_no']);
|
||||
|
||||
if ($lastReceiving) {
|
||||
$lastSeq = (int) substr($lastReceiving->lot_no, -2);
|
||||
$newSeq = $lastSeq + 1;
|
||||
} else {
|
||||
$newSeq = 1;
|
||||
}
|
||||
|
||||
return $prefix.'-'.str_pad($newSeq, 2, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -309,6 +309,13 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
|
||||
|
||||
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// 출하 가능 여부 검증 (scheduled → ready 이상 전환 시)
|
||||
if (in_array($status, ['ready', 'shipping', 'completed']) && ! $shipment->can_ship) {
|
||||
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException(
|
||||
__('error.shipment.cannot_ship')
|
||||
);
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'status' => $status,
|
||||
'updated_by' => $userId,
|
||||
@@ -344,10 +351,8 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
|
||||
$previousStatus = $shipment->status;
|
||||
$shipment->update($updateData);
|
||||
|
||||
// 🆕 출하완료 시 재고 차감 (FIFO)
|
||||
if ($status === 'completed' && $previousStatus !== 'completed') {
|
||||
$this->decreaseStockForShipment($shipment);
|
||||
}
|
||||
// 재고 차감 비활성화: 수주생산은 재고 미경유, 선생산 완성품은 자재 투입 시 차감됨
|
||||
// TODO: 선생산 로직 검증 후 재검토 (decreaseStockForShipment)
|
||||
|
||||
// 연결된 수주(Order) 상태 동기화
|
||||
$this->syncOrderStatus($shipment, $tenantId);
|
||||
@@ -357,10 +362,21 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
|
||||
|
||||
/**
|
||||
* 출하 완료 시 재고 차감
|
||||
*
|
||||
* 수주 연결 출하(order_id 있음)는 재고를 거치지 않으므로 차감 skip.
|
||||
* 재고 출고(order_id 없음)만 재고 차감 수행.
|
||||
*
|
||||
* @return array 실패 내역 (빈 배열이면 전체 성공)
|
||||
*/
|
||||
private function decreaseStockForShipment(Shipment $shipment): void
|
||||
private function decreaseStockForShipment(Shipment $shipment): array
|
||||
{
|
||||
// 수주 연결 출하는 재고 입고 없이 바로 출하하므로 차감하지 않음
|
||||
if ($shipment->order_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stockService = app(StockService::class);
|
||||
$failures = [];
|
||||
|
||||
// 출하 품목 조회
|
||||
$items = $shipment->items;
|
||||
@@ -389,15 +405,23 @@ private function decreaseStockForShipment(Shipment $shipment): void
|
||||
stockLotId: $item->stock_lot_id
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 재고 부족 등의 에러는 로그만 기록하고 계속 진행
|
||||
\Illuminate\Support\Facades\Log::warning('Failed to decrease stock for shipment item', [
|
||||
'shipment_id' => $shipment->id,
|
||||
'item_code' => $item->item_code,
|
||||
'quantity' => $item->quantity,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$failures[] = [
|
||||
'item_code' => $item->item_code,
|
||||
'item_name' => $item->item_name,
|
||||
'quantity' => $item->quantity,
|
||||
'reason' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $failures;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -191,6 +191,77 @@ public function show(int $id): Item
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 조정 이력 조회
|
||||
*/
|
||||
public function adjustments(int $stockId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$stock = Stock::where('tenant_id', $tenantId)->findOrFail($stockId);
|
||||
|
||||
$transactions = StockTransaction::where('tenant_id', $tenantId)
|
||||
->where('stock_id', $stock->id)
|
||||
->where('reason', StockTransaction::REASON_ADJUSTMENT)
|
||||
->with('creator:id,name')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
return $transactions->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'adjusted_at' => $t->created_at->format('Y-m-d H:i'),
|
||||
'quantity' => (float) $t->qty,
|
||||
'balance_qty' => (float) $t->balance_qty,
|
||||
'remark' => $t->remark,
|
||||
'inspector' => $t->creator?->name ?? '-',
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 조정 등록
|
||||
*/
|
||||
public function createAdjustment(int $stockId, array $data): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
return DB::transaction(function () use ($stockId, $data, $tenantId, $userId) {
|
||||
$stock = Stock::where('tenant_id', $tenantId)->findOrFail($stockId);
|
||||
|
||||
$qty = (float) $data['quantity'];
|
||||
|
||||
// 재고량 직접 조정
|
||||
$stock->stock_qty += $qty;
|
||||
$stock->available_qty += $qty;
|
||||
$stock->status = $stock->calculateStatus();
|
||||
$stock->updated_by = $userId;
|
||||
$stock->save();
|
||||
|
||||
// 거래 유형: 양수 → IN, 음수 → OUT
|
||||
$type = $qty >= 0 ? StockTransaction::TYPE_IN : StockTransaction::TYPE_OUT;
|
||||
|
||||
// 거래 이력 기록
|
||||
$this->recordTransaction(
|
||||
stock: $stock,
|
||||
type: $type,
|
||||
qty: $qty,
|
||||
reason: StockTransaction::REASON_ADJUSTMENT,
|
||||
referenceType: 'stock',
|
||||
referenceId: $stock->id,
|
||||
remark: $data['remark'] ?? null
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $stock->id,
|
||||
'adjusted_at' => now()->format('Y-m-d H:i'),
|
||||
'quantity' => $qty,
|
||||
'balance_qty' => (float) $stock->stock_qty,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'inspector' => \App\Models\Members\User::find($userId)?->name ?? '-',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목코드로 재고 조회 (Item 기준)
|
||||
*/
|
||||
|
||||
@@ -66,7 +66,7 @@ public function run(int $tenantId): void
|
||||
'template' => json_encode([
|
||||
'fields' => [
|
||||
['name' => 'user_name', 'type' => 'text', 'label' => '신청자', 'required' => true],
|
||||
['name' => 'request_type', 'type' => 'select', 'label' => '신청유형', 'required' => true],
|
||||
['name' => 'request_type', 'type' => 'select', 'label' => '신청유형', 'required' => true, 'options' => ['휴가', '출장', '재택근무', '외근']],
|
||||
['name' => 'period', 'type' => 'daterange', 'label' => '기간', 'required' => true],
|
||||
['name' => 'days', 'type' => 'number', 'label' => '일수', 'required' => true],
|
||||
['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true],
|
||||
@@ -80,7 +80,7 @@ public function run(int $tenantId): void
|
||||
'template' => json_encode([
|
||||
'fields' => [
|
||||
['name' => 'user_name', 'type' => 'text', 'label' => '작성자', 'required' => true],
|
||||
['name' => 'report_type', 'type' => 'select', 'label' => '사유유형', 'required' => true],
|
||||
['name' => 'report_type', 'type' => 'select', 'label' => '사유유형', 'required' => true, 'options' => ['지각', '조퇴', '결근', '외출', '기타']],
|
||||
['name' => 'target_date', 'type' => 'date', 'label' => '대상일', 'required' => true],
|
||||
['name' => 'reason', 'type' => 'textarea', 'label' => '사유', 'required' => true],
|
||||
],
|
||||
|
||||
97
app/Services/Vehicle/CorporateVehicleService.php
Normal file
97
app/Services/Vehicle/CorporateVehicleService.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Vehicle;
|
||||
|
||||
use App\Models\Tenants\CorporateVehicle;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class CorporateVehicleService extends Service
|
||||
{
|
||||
public function index(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = CorporateVehicle::query();
|
||||
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('plate_number', 'like', "%{$search}%")
|
||||
->orWhere('model', 'like', "%{$search}%")
|
||||
->orWhere('driver', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['ownership_type'])) {
|
||||
$query->where('ownership_type', $filters['ownership_type']);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('id');
|
||||
|
||||
return $query->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function show(int $id): CorporateVehicle
|
||||
{
|
||||
$vehicle = CorporateVehicle::find($id);
|
||||
|
||||
if (! $vehicle) {
|
||||
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $vehicle;
|
||||
}
|
||||
|
||||
public function store(array $data): CorporateVehicle
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$data['tenant_id'] = $this->tenantId();
|
||||
|
||||
return CorporateVehicle::create($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): CorporateVehicle
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$vehicle = CorporateVehicle::find($id);
|
||||
|
||||
if (! $vehicle) {
|
||||
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$vehicle->update($data);
|
||||
|
||||
return $vehicle->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$vehicle = CorporateVehicle::find($id);
|
||||
|
||||
if (! $vehicle) {
|
||||
throw new NotFoundHttpException('차량을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $vehicle->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭다운 목록 (차량일지, 정비이력에서 사용)
|
||||
*/
|
||||
public function dropdown(): array
|
||||
{
|
||||
return CorporateVehicle::where('status', '!=', 'disposed')
|
||||
->orderBy('plate_number')
|
||||
->get(['id', 'plate_number', 'model'])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
129
app/Services/Vehicle/VehicleLogService.php
Normal file
129
app/Services/Vehicle/VehicleLogService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Vehicle;
|
||||
|
||||
use App\Models\Tenants\VehicleLog;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class VehicleLogService extends Service
|
||||
{
|
||||
public function index(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = VehicleLog::query()->with(['vehicle:id,plate_number,model']);
|
||||
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('driver_name', 'like', "%{$search}%")
|
||||
->orWhere('departure_name', 'like', "%{$search}%")
|
||||
->orWhere('arrival_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['vehicle_id'])) {
|
||||
$query->where('vehicle_id', $filters['vehicle_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['year']) && ! empty($filters['month'])) {
|
||||
$query->whereYear('log_date', $filters['year'])
|
||||
->whereMonth('log_date', $filters['month']);
|
||||
}
|
||||
|
||||
if (! empty($filters['trip_type'])) {
|
||||
$query->where('trip_type', $filters['trip_type']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('log_date')->orderByDesc('id');
|
||||
|
||||
return $query->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function show(int $id): VehicleLog
|
||||
{
|
||||
$log = VehicleLog::with('vehicle:id,plate_number,model')->find($id);
|
||||
|
||||
if (! $log) {
|
||||
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
public function store(array $data): VehicleLog
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$data['tenant_id'] = $this->tenantId();
|
||||
|
||||
return VehicleLog::create($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): VehicleLog
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$log = VehicleLog::find($id);
|
||||
|
||||
if (! $log) {
|
||||
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$log->update($data);
|
||||
|
||||
return $log->fresh(['vehicle:id,plate_number,model']);
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$log = VehicleLog::find($id);
|
||||
|
||||
if (! $log) {
|
||||
throw new NotFoundHttpException('운행기록을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $log->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 월별 통계
|
||||
*/
|
||||
public function summary(array $filters = []): array
|
||||
{
|
||||
$query = VehicleLog::query();
|
||||
|
||||
if (! empty($filters['vehicle_id'])) {
|
||||
$query->where('vehicle_id', $filters['vehicle_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['year']) && ! empty($filters['month'])) {
|
||||
$query->whereYear('log_date', $filters['year'])
|
||||
->whereMonth('log_date', $filters['month']);
|
||||
}
|
||||
|
||||
$totalDistance = (clone $query)->sum('distance_km');
|
||||
$totalCount = (clone $query)->count();
|
||||
|
||||
$commuteToQuery = (clone $query)->whereIn('trip_type', ['commute_to', 'commute_round']);
|
||||
$commuteFromQuery = (clone $query)->whereIn('trip_type', ['commute_from', 'commute_round']);
|
||||
$businessQuery = (clone $query)->whereIn('trip_type', ['business', 'business_round']);
|
||||
$personalQuery = (clone $query)->whereIn('trip_type', ['personal', 'personal_round']);
|
||||
|
||||
return [
|
||||
'total_distance' => (int) $totalDistance,
|
||||
'total_count' => $totalCount,
|
||||
'commute_to_distance' => (int) $commuteToQuery->sum('distance_km'),
|
||||
'commute_to_count' => $commuteToQuery->count(),
|
||||
'commute_from_distance' => (int) $commuteFromQuery->sum('distance_km'),
|
||||
'commute_from_count' => $commuteFromQuery->count(),
|
||||
'business_distance' => (int) $businessQuery->sum('distance_km'),
|
||||
'business_count' => $businessQuery->count(),
|
||||
'personal_distance' => (int) $personalQuery->sum('distance_km'),
|
||||
'personal_count' => $personalQuery->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
93
app/Services/Vehicle/VehicleMaintenanceService.php
Normal file
93
app/Services/Vehicle/VehicleMaintenanceService.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Vehicle;
|
||||
|
||||
use App\Models\Tenants\VehicleMaintenance;
|
||||
use App\Services\Service;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class VehicleMaintenanceService extends Service
|
||||
{
|
||||
public function index(array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$query = VehicleMaintenance::query()->with(['vehicle:id,plate_number,model']);
|
||||
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('description', 'like', "%{$search}%")
|
||||
->orWhere('vendor', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($filters['vehicle_id'])) {
|
||||
$query->where('vehicle_id', $filters['vehicle_id']);
|
||||
}
|
||||
|
||||
if (! empty($filters['category'])) {
|
||||
$query->where('category', $filters['category']);
|
||||
}
|
||||
|
||||
if (! empty($filters['start_date'])) {
|
||||
$query->where('date', '>=', $filters['start_date']);
|
||||
}
|
||||
|
||||
if (! empty($filters['end_date'])) {
|
||||
$query->where('date', '<=', $filters['end_date']);
|
||||
}
|
||||
|
||||
$query->orderByDesc('date')->orderByDesc('id');
|
||||
|
||||
return $query->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function show(int $id): VehicleMaintenance
|
||||
{
|
||||
$item = VehicleMaintenance::with('vehicle:id,plate_number,model')->find($id);
|
||||
|
||||
if (! $item) {
|
||||
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function store(array $data): VehicleMaintenance
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$data['tenant_id'] = $this->tenantId();
|
||||
|
||||
return VehicleMaintenance::create($data);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): VehicleMaintenance
|
||||
{
|
||||
return DB::transaction(function () use ($id, $data) {
|
||||
$item = VehicleMaintenance::find($id);
|
||||
|
||||
if (! $item) {
|
||||
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$item->update($data);
|
||||
|
||||
return $item->fresh(['vehicle:id,plate_number,model']);
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(int $id): bool
|
||||
{
|
||||
return DB::transaction(function () use ($id) {
|
||||
$item = VehicleMaintenance::find($id);
|
||||
|
||||
if (! $item) {
|
||||
throw new NotFoundHttpException('정비이력을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
return $item->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,8 @@ public function index(array $params)
|
||||
|
||||
$query = WorkOrder::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereNotNull('process_id')
|
||||
->where(fn ($q) => $q->whereNull('options->is_auxiliary')->orWhere('options->is_auxiliary', false))
|
||||
->with([
|
||||
'assignee:id,name',
|
||||
'assignees.user:id,name',
|
||||
@@ -80,15 +82,10 @@ public function index(array $params)
|
||||
}
|
||||
|
||||
// 공정 필터 (process_id)
|
||||
// - 'none' 또는 '0': 공정 미지정 (process_id IS NULL)
|
||||
// - 숫자: 해당 공정 ID로 필터
|
||||
if ($processId !== null) {
|
||||
if ($processId === 'none' || $processId === '0' || $processId === 0) {
|
||||
$query->whereNull('process_id');
|
||||
} else {
|
||||
// 기본 조건으로 process_id IS NOT NULL이므로 'none'은 무의미
|
||||
if ($processId !== null && $processId !== 'none' && $processId !== '0' && $processId !== 0) {
|
||||
$query->where('process_id', $processId);
|
||||
}
|
||||
}
|
||||
|
||||
// 공정 코드 필터 (process_code) - 대시보드용
|
||||
if ($processCode !== null) {
|
||||
@@ -163,30 +160,30 @@ public function stats(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$counts = WorkOrder::where('tenant_id', $tenantId)
|
||||
// 실제 작업건만 카운트 (공정 배정 + 보조공정 제외)
|
||||
$baseQuery = WorkOrder::where('tenant_id', $tenantId)
|
||||
->whereNotNull('process_id')
|
||||
->where(fn ($q) => $q->whereNull('options->is_auxiliary')->orWhere('options->is_auxiliary', false));
|
||||
|
||||
$counts = (clone $baseQuery)
|
||||
->select('status', DB::raw('count(*) as count'))
|
||||
->groupBy('status')
|
||||
->pluck('count', 'status')
|
||||
->toArray();
|
||||
|
||||
// 공정별 카운트 (탭 숫자 표시용)
|
||||
$byProcess = WorkOrder::where('tenant_id', $tenantId)
|
||||
$byProcess = (clone $baseQuery)
|
||||
->select('process_id', DB::raw('count(*) as count'))
|
||||
->groupBy('process_id')
|
||||
->pluck('count', 'process_id')
|
||||
->toArray();
|
||||
|
||||
$total = array_sum($counts);
|
||||
$noneCount = $byProcess[''] ?? $byProcess[0] ?? 0;
|
||||
// null 키는 빈 문자열로 변환되므로 별도 처리
|
||||
// process_id IS NOT NULL 기본 필터 적용으로 null 키 처리 불필요
|
||||
$processedByProcess = [];
|
||||
foreach ($byProcess as $key => $count) {
|
||||
if ($key === '' || $key === 0 || $key === null) {
|
||||
$processedByProcess['none'] = $count;
|
||||
} else {
|
||||
$processedByProcess[(string) $key] = $count;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
@@ -579,8 +576,8 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
|
||||
// Fast-track 완료의 경우 started_at도 설정 (중간 상태 생략)
|
||||
$workOrder->started_at = $workOrder->started_at ?? now();
|
||||
$workOrder->completed_at = now();
|
||||
// 모든 품목에 결과 데이터 저장
|
||||
$this->saveItemResults($workOrder, $resultData, $userId);
|
||||
// 모든 품목에 결과 데이터 저장 (LOT 번호 반환)
|
||||
$lotNo = $this->saveItemResults($workOrder, $resultData, $userId);
|
||||
break;
|
||||
case WorkOrder::STATUS_SHIPPED:
|
||||
$workOrder->shipped_at = now();
|
||||
@@ -607,7 +604,14 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
|
||||
$this->stockInFromProduction($workOrder);
|
||||
}
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
||||
$result = $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
||||
|
||||
// 완료 시 LOT 번호를 응답에 포함
|
||||
if (isset($lotNo)) {
|
||||
$result->setAttribute('lot_no', $lotNo);
|
||||
}
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -767,6 +771,8 @@ private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $ten
|
||||
'quantity' => $result['good_qty'] ?? $woItem->quantity,
|
||||
'unit' => $woItem->unit,
|
||||
'lot_no' => $lotNo,
|
||||
'order_item_id' => $woItem->source_order_item_id,
|
||||
'work_order_item_id' => $woItem->id,
|
||||
'remarks' => null,
|
||||
]);
|
||||
}
|
||||
@@ -787,6 +793,8 @@ private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $ten
|
||||
'quantity' => $orderItem->quantity,
|
||||
'unit' => $orderItem->unit,
|
||||
'lot_no' => null,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'work_order_item_id' => null,
|
||||
'remarks' => null,
|
||||
]);
|
||||
}
|
||||
@@ -1105,7 +1113,7 @@ private function autoStartWorkOrderOnMaterialInput(WorkOrder $workOrder, int $te
|
||||
/**
|
||||
* 작업지시 품목에 결과 데이터 저장
|
||||
*/
|
||||
private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): void
|
||||
private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $userId): string
|
||||
{
|
||||
$items = $workOrder->items;
|
||||
$lotNo = $this->generateLotNo($workOrder);
|
||||
@@ -1140,6 +1148,8 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
|
||||
$item->options = $options;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
return $lotNo;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1476,6 +1486,35 @@ public function getMaterials(int $workOrderId): array
|
||||
->keyBy('id');
|
||||
}
|
||||
|
||||
// ── Step 1.5: 기존 BOM용 item_id 일괄 사전 로드 (N+1 방지) ──
|
||||
$bomParentItemIds = $workOrder->items->pluck('item_id')->filter()->unique()->values()->all();
|
||||
$bomItemsMap = collect();
|
||||
$bomChildItemsMap = collect();
|
||||
if (! empty($bomParentItemIds)) {
|
||||
$bomItemsMap = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', $bomParentItemIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// BOM 자식 item_id 수집 및 배치 조회
|
||||
$allChildIds = [];
|
||||
foreach ($bomItemsMap as $parentItem) {
|
||||
if (! empty($parentItem->bom)) {
|
||||
foreach ($parentItem->bom as $bomEntry) {
|
||||
if (! empty($bomEntry['child_item_id'])) {
|
||||
$allChildIds[] = $bomEntry['child_item_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! empty($allChildIds)) {
|
||||
$bomChildItemsMap = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->whereIn('id', array_unique($allChildIds))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: 유니크 자재 목록 수집 ──
|
||||
// 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}"
|
||||
$uniqueMaterials = [];
|
||||
@@ -1517,12 +1556,11 @@ public function getMaterials(int $workOrderId): array
|
||||
continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀
|
||||
}
|
||||
|
||||
// 기존 BOM 로직 (하위 호환)
|
||||
// 기존 BOM 로직 (하위 호환) — 사전 로드된 맵 사용
|
||||
$materialItems = [];
|
||||
|
||||
if ($woItem->item_id) {
|
||||
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->find($woItem->item_id);
|
||||
$item = $bomItemsMap[$woItem->item_id] ?? null;
|
||||
|
||||
if ($item && ! empty($item->bom)) {
|
||||
foreach ($item->bom as $bomItem) {
|
||||
@@ -1533,8 +1571,7 @@ public function getMaterials(int $workOrderId): array
|
||||
continue;
|
||||
}
|
||||
|
||||
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->find($childItemId);
|
||||
$childItem = $bomChildItemsMap[$childItemId] ?? null;
|
||||
|
||||
if (! $childItem) {
|
||||
continue;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user