Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop
This commit is contained in:
@@ -103,3 +103,7 @@ default_modes:
|
|||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
fixed_tools: []
|
fixed_tools: []
|
||||||
|
|
||||||
|
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||||
|
# If null or missing, the value from the global config is used.
|
||||||
|
symbol_info_budget:
|
||||||
|
|||||||
2692
CURRENT_WORKS.md
2692
CURRENT_WORKS.md
File diff suppressed because it is too large
Load Diff
137
Jenkinsfile
vendored
Normal file
137
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
DEPLOY_USER = 'hskwon'
|
||||||
|
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
script {
|
||||||
|
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
|
||||||
|
}
|
||||||
|
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||||
|
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main → 운영서버 Stage 배포 ──
|
||||||
|
stage('Deploy Stage') {
|
||||||
|
when { branch 'main' }
|
||||||
|
steps {
|
||||||
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
|
sh """
|
||||||
|
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='storage/app' \
|
||||||
|
--exclude='storage/logs' \
|
||||||
|
--exclude='storage/framework/sessions' \
|
||||||
|
--exclude='storage/framework/cache' \
|
||||||
|
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
|
||||||
|
|
||||||
|
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||||
|
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
|
||||||
|
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||||
|
ln -sfn /home/webservice/api-stage/shared/.env .env &&
|
||||||
|
ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||||
|
php artisan config:cache &&
|
||||||
|
php artisan route:cache &&
|
||||||
|
php artisan view:cache &&
|
||||||
|
php artisan migrate --force &&
|
||||||
|
ln -sfn /home/webservice/api-stage/releases/${RELEASE_ID} /home/webservice/api-stage/current &&
|
||||||
|
sudo systemctl reload php8.4-fpm &&
|
||||||
|
cd /home/webservice/api-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 운영 배포 승인 ──
|
||||||
|
stage('Production Approval') {
|
||||||
|
when { branch 'main' }
|
||||||
|
steps {
|
||||||
|
slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
|
||||||
|
message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
|
||||||
|
timeout(time: 24, unit: 'HOURS') {
|
||||||
|
input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
|
||||||
|
ok: '운영 배포 진행'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main → 운영서버 Production 배포 ──
|
||||||
|
stage('Deploy Production') {
|
||||||
|
when { branch 'main' }
|
||||||
|
steps {
|
||||||
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
|
sh """
|
||||||
|
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='storage/app' \
|
||||||
|
--exclude='storage/logs' \
|
||||||
|
--exclude='storage/framework/sessions' \
|
||||||
|
--exclude='storage/framework/cache' \
|
||||||
|
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
|
||||||
|
|
||||||
|
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||||
|
cd /home/webservice/api/releases/${RELEASE_ID} &&
|
||||||
|
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||||
|
ln -sfn /home/webservice/api/shared/.env .env &&
|
||||||
|
ln -sfn /home/webservice/api/shared/storage/app storage/app &&
|
||||||
|
composer install --no-dev --optimize-autoloader --no-interaction &&
|
||||||
|
php artisan config:cache &&
|
||||||
|
php artisan route:cache &&
|
||||||
|
php artisan view:cache &&
|
||||||
|
php artisan migrate --force &&
|
||||||
|
ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
|
||||||
|
sudo systemctl reload php8.4-fpm &&
|
||||||
|
sudo supervisorctl restart sam-queue-worker:* &&
|
||||||
|
cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// develop → Jenkins 관여 안함 (기존 post-update hook 유지)
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
|
||||||
|
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
|
||||||
|
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
script {
|
||||||
|
if (env.BRANCH_NAME == 'main') {
|
||||||
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
|
sh """
|
||||||
|
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||||
|
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
|
||||||
|
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
|
||||||
|
sudo systemctl reload php8.4-fpm
|
||||||
|
' || true
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 논리적 데이터베이스 관계 문서
|
# 논리적 데이터베이스 관계 문서
|
||||||
|
|
||||||
> **자동 생성**: 2026-02-19 16:26:58
|
> **자동 생성**: 2026-02-21 16:28:35
|
||||||
> **소스**: Eloquent 모델 관계 분석
|
> **소스**: Eloquent 모델 관계 분석
|
||||||
|
|
||||||
## 📊 모델별 관계 현황
|
## 📊 모델별 관계 현황
|
||||||
@@ -1079,6 +1079,7 @@ ### stock_lots
|
|||||||
|
|
||||||
- **stock()**: belongsTo → `stocks`
|
- **stock()**: belongsTo → `stocks`
|
||||||
- **receiving()**: belongsTo → `receivings`
|
- **receiving()**: belongsTo → `receivings`
|
||||||
|
- **workOrder()**: belongsTo → `work_orders`
|
||||||
- **creator()**: belongsTo → `users`
|
- **creator()**: belongsTo → `users`
|
||||||
|
|
||||||
### stock_transactions
|
### stock_transactions
|
||||||
|
|||||||
631
app/Console/Commands/Migrate5130BendingStock.php
Normal file
631
app/Console/Commands/Migrate5130BendingStock.php
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Attributes\AsCommand;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 코드 생성 + BD-* 전체 품목 초기 재고 셋팅')]
|
||||||
|
class Migrate5130BendingStock extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'migrate:5130-bending-stock
|
||||||
|
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
|
||||||
|
{--dry-run : 실제 저장 없이 시뮬레이션만 수행}
|
||||||
|
{--min-stock=100 : 품목별 초기 재고 수량 (기본: 100)}
|
||||||
|
{--rollback : 초기 재고 셋팅 롤백 (init_stock 소스 데이터 삭제)}';
|
||||||
|
|
||||||
|
private string $sourceDb = 'chandj';
|
||||||
|
|
||||||
|
private string $targetDb = 'mysql';
|
||||||
|
|
||||||
|
// 5130 prod 코드 → 한글명
|
||||||
|
private array $prodNames = [
|
||||||
|
'R' => '가이드레일(벽면)', 'S' => '가이드레일(측면)',
|
||||||
|
'G' => '연기차단재', 'B' => '하단마감재(스크린)',
|
||||||
|
'T' => '하단마감재(철재)', 'L' => 'L-Bar', 'C' => '케이스',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 5130 spec 코드 → 한글명
|
||||||
|
private array $specNames = [
|
||||||
|
'I' => '화이바원단', 'S' => 'SUS', 'U' => 'SUS2', 'E' => 'EGI',
|
||||||
|
'A' => '스크린용', 'D' => 'D형', 'C' => 'C형', 'M' => '본체',
|
||||||
|
'T' => '본체(철재)', 'B' => '후면코너부', 'L' => '린텔부',
|
||||||
|
'P' => '점검구', 'F' => '전면부',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 5130 slength 코드 → 한글명
|
||||||
|
private array $slengthNames = [
|
||||||
|
'53' => 'W50×3000', '54' => 'W50×4000', '83' => 'W80×3000',
|
||||||
|
'84' => 'W80×4000', '12' => '1219mm', '24' => '2438mm',
|
||||||
|
'30' => '3000mm', '35' => '3500mm', '40' => '4000mm',
|
||||||
|
'41' => '4150mm', '42' => '4200mm', '43' => '4300mm',
|
||||||
|
];
|
||||||
|
|
||||||
|
private array $stats = [
|
||||||
|
'items_found' => 0,
|
||||||
|
'items_created_5130' => 0,
|
||||||
|
'items_category_updated' => 0,
|
||||||
|
'stocks_created' => 0,
|
||||||
|
'stocks_skipped' => 0,
|
||||||
|
'lots_created' => 0,
|
||||||
|
'transactions_created' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenantId = (int) $this->option('tenant_id');
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$rollback = $this->option('rollback');
|
||||||
|
$minStock = (int) $this->option('min-stock');
|
||||||
|
|
||||||
|
$this->info('=== BD-* 절곡품 초기 재고 셋팅 ===');
|
||||||
|
$this->info("Tenant ID: {$tenantId}");
|
||||||
|
$this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
|
||||||
|
$this->info("초기 재고: {$minStock}개/품목");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
if ($rollback) {
|
||||||
|
return $this->rollbackInitStock($tenantId, $dryRun);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
|
||||||
|
$this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...');
|
||||||
|
$this->createLegacyItems($tenantId, $dryRun);
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// 1. 전체 BD-* 아이템 조회 (기존 58개 + 5130 생성분)
|
||||||
|
$this->info('📥 Step 1: BD-* 절곡품 품목 조회...');
|
||||||
|
$items = DB::connection($this->targetDb)
|
||||||
|
->table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('code', 'like', 'BD-%')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->select('id', 'code', 'name', 'item_type', 'item_category', 'unit', 'options')
|
||||||
|
->orderBy('code')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->stats['items_found'] = $items->count();
|
||||||
|
$this->info(" - BD-* 품목: {$items->count()}건");
|
||||||
|
|
||||||
|
if ($items->isEmpty()) {
|
||||||
|
$this->warn('BD-* 품목이 없습니다. 종료합니다.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. item_category 미설정 품목 업데이트
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('🏷️ Step 2: item_category 업데이트...');
|
||||||
|
$needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING');
|
||||||
|
|
||||||
|
if ($needsCategoryUpdate->isNotEmpty()) {
|
||||||
|
$this->info(" - item_category 미설정/불일치: {$needsCategoryUpdate->count()}건");
|
||||||
|
if (! $dryRun) {
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('code', 'like', 'BD-%')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('item_category')
|
||||||
|
->orWhere('item_category', '!=', 'BENDING');
|
||||||
|
})
|
||||||
|
->update(['item_category' => 'BENDING', 'updated_at' => now()]);
|
||||||
|
}
|
||||||
|
$this->stats['items_category_updated'] = $needsCategoryUpdate->count();
|
||||||
|
} else {
|
||||||
|
$this->info(' - 모든 품목 BENDING 카테고리 설정 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 현재 재고 현황 표시
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('📊 Step 3: 현재 재고 현황...');
|
||||||
|
$this->showCurrentStockStatus($tenantId, $items);
|
||||||
|
|
||||||
|
// 4. 재고 셋팅 대상 확인
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('📦 Step 4: 재고 셋팅 대상 확인...');
|
||||||
|
$itemsNeedingStock = $this->getItemsNeedingStock($tenantId, $items, $minStock);
|
||||||
|
|
||||||
|
if ($itemsNeedingStock->isEmpty()) {
|
||||||
|
$this->info(" - 모든 품목이 이미 {$minStock}개 이상 재고 보유. 추가 작업 불필요.");
|
||||||
|
$this->showStats();
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(" - 재고 셋팅 필요: {$itemsNeedingStock->count()}건");
|
||||||
|
$this->table(
|
||||||
|
['코드', '품목명', '현재고', '목표', '추가수량'],
|
||||||
|
$itemsNeedingStock->map(fn ($item) => [
|
||||||
|
$item->code,
|
||||||
|
mb_strlen($item->name) > 30 ? mb_substr($item->name, 0, 30).'...' : $item->name,
|
||||||
|
number_format($item->current_qty),
|
||||||
|
number_format($minStock),
|
||||||
|
number_format($item->supplement_qty),
|
||||||
|
])->toArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->stats['stocks_created'] = $itemsNeedingStock->filter(fn ($i) => ! $i->has_stock)->count();
|
||||||
|
$this->stats['lots_created'] = $itemsNeedingStock->count();
|
||||||
|
$this->stats['transactions_created'] = $itemsNeedingStock->count();
|
||||||
|
$this->showStats();
|
||||||
|
$this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->confirm('초기 재고를 셋팅하시겠습니까?')) {
|
||||||
|
$this->info('취소되었습니다.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 실행
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('🚀 Step 5: 초기 재고 셋팅 실행...');
|
||||||
|
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $itemsNeedingStock, $minStock) {
|
||||||
|
$this->executeStockSetup($tenantId, $itemsNeedingStock, $minStock);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->showStats();
|
||||||
|
$this->info('✅ 초기 재고 셋팅 완료!');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 재고 현황 표시
|
||||||
|
*/
|
||||||
|
private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void
|
||||||
|
{
|
||||||
|
$itemIds = $items->pluck('id');
|
||||||
|
|
||||||
|
$stocks = DB::connection($this->targetDb)
|
||||||
|
->table('stocks')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('item_id', $itemIds)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->get()
|
||||||
|
->keyBy('item_id');
|
||||||
|
|
||||||
|
$hasStock = 0;
|
||||||
|
$noStock = 0;
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$stock = $stocks->get($item->id);
|
||||||
|
if ($stock && (float) $stock->stock_qty > 0) {
|
||||||
|
$hasStock++;
|
||||||
|
} else {
|
||||||
|
$noStock++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(" - 재고 있음: {$hasStock}건");
|
||||||
|
$this->info(" - 재고 없음: {$noStock}건");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 재고 셋팅이 필요한 품목 목록 조회
|
||||||
|
*/
|
||||||
|
private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$itemIds = $items->pluck('id');
|
||||||
|
|
||||||
|
$stocks = DB::connection($this->targetDb)
|
||||||
|
->table('stocks')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('item_id', $itemIds)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->get()
|
||||||
|
->keyBy('item_id');
|
||||||
|
|
||||||
|
$result = collect();
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$stock = $stocks->get($item->id);
|
||||||
|
$currentQty = $stock ? (float) $stock->stock_qty : 0;
|
||||||
|
|
||||||
|
if ($currentQty >= $minStock) {
|
||||||
|
$this->stats['stocks_skipped']++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$supplementQty = $minStock - $currentQty;
|
||||||
|
|
||||||
|
$item->has_stock = (bool) $stock;
|
||||||
|
$item->stock_id = $stock?->id;
|
||||||
|
$item->current_qty = $currentQty;
|
||||||
|
$item->supplement_qty = $supplementQty;
|
||||||
|
|
||||||
|
$result->push($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기 재고 셋팅 실행
|
||||||
|
*/
|
||||||
|
private function executeStockSetup(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): void
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$stockId = $item->stock_id;
|
||||||
|
|
||||||
|
// Stock 레코드가 없으면 생성
|
||||||
|
if (! $item->has_stock) {
|
||||||
|
$stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'item_id' => $item->id,
|
||||||
|
'item_code' => $item->code,
|
||||||
|
'item_name' => $item->name,
|
||||||
|
'item_type' => 'bent_part',
|
||||||
|
'unit' => $item->unit ?? 'EA',
|
||||||
|
'stock_qty' => 0,
|
||||||
|
'safety_stock' => 0,
|
||||||
|
'reserved_qty' => 0,
|
||||||
|
'available_qty' => 0,
|
||||||
|
'lot_count' => 0,
|
||||||
|
'status' => 'out',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->stats['stocks_created']++;
|
||||||
|
$this->line(" + Stock 생성: {$item->code}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIFO 순서 계산
|
||||||
|
$maxFifo = DB::connection($this->targetDb)
|
||||||
|
->table('stock_lots')
|
||||||
|
->where('stock_id', $stockId)
|
||||||
|
->max('fifo_order');
|
||||||
|
$nextFifo = ($maxFifo ?? 0) + 1;
|
||||||
|
|
||||||
|
// LOT 번호 생성
|
||||||
|
$lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code);
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
$existingLot = DB::connection($this->targetDb)
|
||||||
|
->table('stock_lots')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('stock_id', $stockId)
|
||||||
|
->where('lot_no', $lotNo)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingLot) {
|
||||||
|
$this->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$supplementQty = $item->supplement_qty;
|
||||||
|
|
||||||
|
// StockLot 생성
|
||||||
|
$stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'stock_id' => $stockId,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'fifo_order' => $nextFifo,
|
||||||
|
'receipt_date' => now()->toDateString(),
|
||||||
|
'qty' => $supplementQty,
|
||||||
|
'reserved_qty' => 0,
|
||||||
|
'available_qty' => $supplementQty,
|
||||||
|
'unit' => $item->unit ?? 'EA',
|
||||||
|
'status' => 'available',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->stats['lots_created']++;
|
||||||
|
|
||||||
|
// StockTransaction 생성
|
||||||
|
DB::connection($this->targetDb)->table('stock_transactions')->insert([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'stock_id' => $stockId,
|
||||||
|
'stock_lot_id' => $stockLotId,
|
||||||
|
'type' => 'IN',
|
||||||
|
'qty' => $supplementQty,
|
||||||
|
'balance_qty' => 0,
|
||||||
|
'reference_type' => 'init_stock',
|
||||||
|
'reference_id' => 0,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
'reason' => 'receiving',
|
||||||
|
'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})",
|
||||||
|
'item_code' => $item->code,
|
||||||
|
'item_name' => $item->name,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->stats['transactions_created']++;
|
||||||
|
|
||||||
|
// Stock 집계 갱신
|
||||||
|
$this->refreshStockFromLots($stockId, $tenantId);
|
||||||
|
|
||||||
|
$this->line(" ✅ {$item->code}: 0 → {$supplementQty} (+{$supplementQty})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock 집계 갱신 (LOT 기반)
|
||||||
|
*/
|
||||||
|
private function refreshStockFromLots(int $stockId, int $tenantId): void
|
||||||
|
{
|
||||||
|
$lotStats = DB::connection($this->targetDb)
|
||||||
|
->table('stock_lots')
|
||||||
|
->where('stock_id', $stockId)
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->selectRaw('
|
||||||
|
COALESCE(SUM(qty), 0) as total_qty,
|
||||||
|
COALESCE(SUM(reserved_qty), 0) as total_reserved,
|
||||||
|
COALESCE(SUM(available_qty), 0) as total_available,
|
||||||
|
COUNT(*) as lot_count,
|
||||||
|
MIN(receipt_date) as oldest_lot_date,
|
||||||
|
MAX(receipt_date) as latest_receipt_date
|
||||||
|
')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$stockQty = (float) $lotStats->total_qty;
|
||||||
|
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('stocks')
|
||||||
|
->where('id', $stockId)
|
||||||
|
->update([
|
||||||
|
'stock_qty' => $stockQty,
|
||||||
|
'reserved_qty' => (float) $lotStats->total_reserved,
|
||||||
|
'available_qty' => (float) $lotStats->total_available,
|
||||||
|
'lot_count' => (int) $lotStats->lot_count,
|
||||||
|
'oldest_lot_date' => $lotStats->oldest_lot_date,
|
||||||
|
'last_receipt_date' => $lotStats->latest_receipt_date,
|
||||||
|
'status' => $stockQty > 0 ? 'normal' : 'out',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 롤백: init_stock 참조 데이터 삭제
|
||||||
|
*/
|
||||||
|
private function rollbackInitStock(int $tenantId, bool $dryRun): int
|
||||||
|
{
|
||||||
|
$this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.');
|
||||||
|
|
||||||
|
// init_stock으로 생성된 트랜잭션
|
||||||
|
$txCount = DB::connection($this->targetDb)
|
||||||
|
->table('stock_transactions')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('reference_type', 'init_stock')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// init_stock 트랜잭션에 연결된 LOT
|
||||||
|
$lotIds = DB::connection($this->targetDb)
|
||||||
|
->table('stock_transactions')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('reference_type', 'init_stock')
|
||||||
|
->whereNotNull('stock_lot_id')
|
||||||
|
->pluck('stock_lot_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
// 5130으로 생성된 아이템
|
||||||
|
$legacyItemCount = DB::connection($this->targetDb)
|
||||||
|
->table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('options->source', '5130_migration')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->info(' 삭제 대상:');
|
||||||
|
$this->info(" - stock_transactions (reference_type=init_stock): {$txCount}건");
|
||||||
|
$this->info(" - stock_lots (연결 LOT): {$lotIds->count()}건");
|
||||||
|
$this->info(" - items (source=5130_migration): {$legacyItemCount}건");
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->info('DRY RUN - 실제 삭제 없음');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) {
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) {
|
||||||
|
// 1. 트랜잭션 삭제
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('stock_transactions')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('reference_type', 'init_stock')
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
// 2. LOT에서 stock_id 목록 수집 (집계 갱신용)
|
||||||
|
$affectedStockIds = collect();
|
||||||
|
if ($lotIds->isNotEmpty()) {
|
||||||
|
$affectedStockIds = DB::connection($this->targetDb)
|
||||||
|
->table('stock_lots')
|
||||||
|
->whereIn('id', $lotIds)
|
||||||
|
->pluck('stock_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
// LOT 삭제
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('stock_lots')
|
||||||
|
->whereIn('id', $lotIds)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 영향받은 Stock 집계 갱신
|
||||||
|
foreach ($affectedStockIds as $stockId) {
|
||||||
|
$this->refreshStockFromLots($stockId, $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 5130 migration으로 생성된 아이템 + 연결 stocks 삭제
|
||||||
|
$migrationItemIds = DB::connection($this->targetDb)
|
||||||
|
->table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('options->source', '5130_migration')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($migrationItemIds->isNotEmpty()) {
|
||||||
|
$migrationStockIds = DB::connection($this->targetDb)
|
||||||
|
->table('stocks')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('item_id', $migrationItemIds)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($migrationStockIds->isNotEmpty()) {
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('stock_lots')
|
||||||
|
->whereIn('stock_id', $migrationStockIds)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('stocks')
|
||||||
|
->whereIn('id', $migrationStockIds)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::connection($this->targetDb)
|
||||||
|
->table('items')
|
||||||
|
->whereIn('id', $migrationItemIds)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('✅ 롤백 완료');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
|
||||||
|
*/
|
||||||
|
private function createLegacyItems(int $tenantId, bool $dryRun): void
|
||||||
|
{
|
||||||
|
// 5130 lot 테이블에서 고유 prod+spec+slength 조합 추출
|
||||||
|
$lots = DB::connection($this->sourceDb)
|
||||||
|
->table('lot')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('is_deleted')
|
||||||
|
->orWhere('is_deleted', 0);
|
||||||
|
})
|
||||||
|
->whereNotNull('prod')
|
||||||
|
->where('prod', '!=', '')
|
||||||
|
->whereNotNull('surang')
|
||||||
|
->where('surang', '>', 0)
|
||||||
|
->select('prod', 'spec', 'slength')
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// bending_work_log 테이블에서도 추출 (lot에 없는 조합 포함)
|
||||||
|
$workLogs = DB::connection($this->sourceDb)
|
||||||
|
->table('bending_work_log')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('is_deleted')
|
||||||
|
->orWhere('is_deleted', 0);
|
||||||
|
})
|
||||||
|
->whereNotNull('prod_code')
|
||||||
|
->where('prod_code', '!=', '')
|
||||||
|
->select('prod_code as prod', 'spec_code as spec', 'slength_code as slength')
|
||||||
|
->distinct()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$allRecords = $lots->merge($workLogs);
|
||||||
|
|
||||||
|
if ($allRecords->isEmpty()) {
|
||||||
|
$this->info(' - 5130 데이터 없음');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 고유 제품 조합 추출
|
||||||
|
$uniqueProducts = [];
|
||||||
|
foreach ($allRecords as $row) {
|
||||||
|
$key = trim($row->prod).'-'.trim($row->spec ?? '').'-'.trim($row->slength ?? '');
|
||||||
|
if (! isset($uniqueProducts[$key])) {
|
||||||
|
$uniqueProducts[$key] = [
|
||||||
|
'prod' => trim($row->prod),
|
||||||
|
'spec' => trim($row->spec ?? ''),
|
||||||
|
'slength' => trim($row->slength ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(" - 5130 고유 제품 조합: ".count($uniqueProducts).'개');
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($uniqueProducts as $data) {
|
||||||
|
$itemCode = "BD-{$data['prod']}{$data['spec']}-{$data['slength']}";
|
||||||
|
$prodName = $this->prodNames[$data['prod']] ?? $data['prod'];
|
||||||
|
$specName = $this->specNames[$data['spec']] ?? $data['spec'];
|
||||||
|
$slengthName = $this->slengthNames[$data['slength']] ?? $data['slength'];
|
||||||
|
$itemName = implode(' ', array_filter([$prodName, $specName, $slengthName]));
|
||||||
|
|
||||||
|
// 이미 존재하는지 확인
|
||||||
|
$existing = DB::connection($this->targetDb)
|
||||||
|
->table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('code', $itemCode)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
DB::connection($this->targetDb)->table('items')->insert([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'code' => $itemCode,
|
||||||
|
'name' => $itemName,
|
||||||
|
'item_type' => 'PT',
|
||||||
|
'item_category' => 'BENDING',
|
||||||
|
'unit' => 'EA',
|
||||||
|
'options' => json_encode([
|
||||||
|
'source' => '5130_migration',
|
||||||
|
'lot_managed' => true,
|
||||||
|
'consumption_method' => 'auto',
|
||||||
|
'production_source' => 'self_produced',
|
||||||
|
'input_tracking' => true,
|
||||||
|
'legacy_prod' => $data['prod'],
|
||||||
|
'legacy_spec' => $data['spec'],
|
||||||
|
'legacy_slength' => $data['slength'],
|
||||||
|
]),
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->stats['items_created_5130'] = $created;
|
||||||
|
$this->info(" - 신규 생성: {$created}건, 기존 존재 (skip): {$skipped}건");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 출력
|
||||||
|
*/
|
||||||
|
private function showStats(): void
|
||||||
|
{
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
$this->info('📊 실행 통계');
|
||||||
|
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
$this->info(" 5130 아이템 생성: {$this->stats['items_created_5130']}건");
|
||||||
|
$this->info(" BD-* 품목 수 (전체): {$this->stats['items_found']}건");
|
||||||
|
$this->info(" 카테고리 업데이트: {$this->stats['items_category_updated']}건");
|
||||||
|
$this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건");
|
||||||
|
$this->info(" 기존 재고 충분 (skip): {$this->stats['stocks_skipped']}건");
|
||||||
|
$this->info(" StockLot 생성: {$this->stats['lots_created']}건");
|
||||||
|
$this->info(" 입고 트랜잭션: {$this->stats['transactions_created']}건");
|
||||||
|
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/Console/Commands/ValidateBendingItems.php
Normal file
137
app/Console/Commands/ValidateBendingItems.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Attributes\AsCommand;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'bending:validate-items', description: 'BD-* 절곡 세부품목 마스터 데이터 검증 (prefix × lengthCode 전 조합)')]
|
||||||
|
class ValidateBendingItems extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bending:validate-items
|
||||||
|
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prefix별 유효 길이코드 정의
|
||||||
|
*
|
||||||
|
* 가이드레일: 30, 35, 40, 43 (벽면/측면 공통)
|
||||||
|
* 하단마감재: 30, 40
|
||||||
|
* 셔터박스: 12, 24, 30, 35, 40, 41
|
||||||
|
* 연기차단재: 53, 54, 83, 84 (W50/W80 전용 코드)
|
||||||
|
* XX: 12, 24, 30, 35, 40, 41, 43 (하부BASE + 셔터 상부/마구리)
|
||||||
|
* YY: 30, 35, 40, 43 (별도 SUS 마감)
|
||||||
|
* HH: 30, 40 (보강평철)
|
||||||
|
*/
|
||||||
|
private function getPrefixLengthCodes(): array
|
||||||
|
{
|
||||||
|
$guideRailCodes = ['30', '35', '40', '43'];
|
||||||
|
$guideRailCodesWithExtra = ['24', '30', '35', '40', '43']; // RT/ST는 적은 종류
|
||||||
|
$bottomBarCodes = ['30', '40'];
|
||||||
|
$shutterBoxCodes = ['12', '24', '30', '35', '40', '41'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
// 가이드레일 벽면형
|
||||||
|
'RS' => $guideRailCodes, // 벽면 SUS 마감재
|
||||||
|
'RM' => ['24', '30', '35', '40', '42', '43'], // 벽면 본체 (EGI)
|
||||||
|
'RC' => ['24', '30', '35', '40', '42', '43'], // 벽면 C형
|
||||||
|
'RD' => ['24', '30', '35', '40', '42', '43'], // 벽면 D형
|
||||||
|
'RT' => ['30', '43'], // 벽면 본체 (철재)
|
||||||
|
|
||||||
|
// 가이드레일 측면형
|
||||||
|
'SS' => ['30', '35', '40'], // 측면 SUS 마감재
|
||||||
|
'SM' => ['24', '30', '35', '40', '43'], // 측면 본체 (EGI)
|
||||||
|
'SC' => ['24', '30', '35', '40', '43'], // 측면 C형
|
||||||
|
'SD' => ['24', '30', '35', '40', '43'], // 측면 D형
|
||||||
|
'ST' => ['43'], // 측면 본체 (철재)
|
||||||
|
'SU' => ['30', '35', '40', '43'], // 측면 SUS (SUS2)
|
||||||
|
|
||||||
|
// 하단마감재
|
||||||
|
'BE' => $bottomBarCodes, // EGI 마감
|
||||||
|
'BS' => ['24', '30', '35', '40', '43'], // SUS 마감
|
||||||
|
'TS' => ['43'], // 철재 SUS
|
||||||
|
'LA' => $bottomBarCodes, // L-Bar
|
||||||
|
|
||||||
|
// 셔터박스
|
||||||
|
'CF' => $shutterBoxCodes, // 전면부
|
||||||
|
'CL' => $shutterBoxCodes, // 린텔부
|
||||||
|
'CP' => $shutterBoxCodes, // 점검구
|
||||||
|
'CB' => $shutterBoxCodes, // 후면코너부
|
||||||
|
|
||||||
|
// 연기차단재
|
||||||
|
'GI' => ['53', '54', '83', '84', '30', '35', '40'], // W50/W80 + 일반
|
||||||
|
|
||||||
|
// 공용/기타
|
||||||
|
'XX' => ['12', '24', '30', '35', '40', '41', '43'], // 하부BASE/셔터 상부/마구리
|
||||||
|
'YY' => ['30', '35', '40', '43'], // 별도 SUS 마감
|
||||||
|
'HH' => ['30', '40'], // 보강평철
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenantId = (int) $this->option('tenant_id');
|
||||||
|
|
||||||
|
$this->info("=== BD-* 절곡 세부품목 마스터 검증 (tenant: {$tenantId}) ===");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// DB에서 전체 BD-* 품목 조회
|
||||||
|
$existingItems = DB::table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('code', 'like', 'BD-%')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('code')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$existingSet = array_flip($existingItems);
|
||||||
|
|
||||||
|
$this->info('현재 등록된 BD-* 품목: '.count($existingItems).'개');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$prefixMap = $this->getPrefixLengthCodes();
|
||||||
|
$totalExpected = 0;
|
||||||
|
$missing = [];
|
||||||
|
$found = 0;
|
||||||
|
|
||||||
|
foreach ($prefixMap as $prefix => $codes) {
|
||||||
|
$prefixMissing = [];
|
||||||
|
foreach ($codes as $code) {
|
||||||
|
$itemCode = "BD-{$prefix}-{$code}";
|
||||||
|
$totalExpected++;
|
||||||
|
|
||||||
|
if (isset($existingSet[$itemCode])) {
|
||||||
|
$found++;
|
||||||
|
} else {
|
||||||
|
$prefixMissing[] = $itemCode;
|
||||||
|
$missing[] = $itemCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = empty($prefixMissing) ? '✅' : '❌';
|
||||||
|
$countStr = count($codes) - count($prefixMissing).'/'.count($codes);
|
||||||
|
$this->line(" {$status} BD-{$prefix}: {$countStr}");
|
||||||
|
|
||||||
|
if (! empty($prefixMissing)) {
|
||||||
|
foreach ($prefixMissing as $m) {
|
||||||
|
$this->line(" ⚠️ 누락: {$m}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
$this->info("검증 결과: {$found}/{$totalExpected} 등록 완료");
|
||||||
|
|
||||||
|
if (empty($missing)) {
|
||||||
|
$this->info('✅ All items registered — 누락 0건');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->warn('❌ 누락 항목: '.count($missing).'건');
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(['누락 품목코드'], array_map(fn ($m) => [$m], $missing));
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/DTOs/Production/DynamicBomEntry.php
Normal file
101
app/DTOs/Production/DynamicBomEntry.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTOs\Production;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dynamic_bom JSON 항목 DTO
|
||||||
|
*
|
||||||
|
* work_order_items.options.dynamic_bom 배열의 각 엔트리를 표현
|
||||||
|
*/
|
||||||
|
class DynamicBomEntry
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $child_item_id,
|
||||||
|
public readonly string $child_item_code,
|
||||||
|
public readonly string $lot_prefix,
|
||||||
|
public readonly string $part_type,
|
||||||
|
public readonly string $category,
|
||||||
|
public readonly string $material_type,
|
||||||
|
public readonly int $length_mm,
|
||||||
|
public readonly int|float $qty,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열에서 DTO 생성
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
self::validate($data);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
child_item_id: (int) $data['child_item_id'],
|
||||||
|
child_item_code: (string) $data['child_item_code'],
|
||||||
|
lot_prefix: (string) $data['lot_prefix'],
|
||||||
|
part_type: (string) $data['part_type'],
|
||||||
|
category: (string) $data['category'],
|
||||||
|
material_type: (string) $data['material_type'],
|
||||||
|
length_mm: (int) $data['length_mm'],
|
||||||
|
qty: $data['qty'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO → 배열 변환 (JSON 저장용)
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'child_item_id' => $this->child_item_id,
|
||||||
|
'child_item_code' => $this->child_item_code,
|
||||||
|
'lot_prefix' => $this->lot_prefix,
|
||||||
|
'part_type' => $this->part_type,
|
||||||
|
'category' => $this->category,
|
||||||
|
'material_type' => $this->material_type,
|
||||||
|
'length_mm' => $this->length_mm,
|
||||||
|
'qty' => $this->qty,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 필드 검증
|
||||||
|
*
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public static function validate(array $data): bool
|
||||||
|
{
|
||||||
|
$required = ['child_item_id', 'child_item_code', 'lot_prefix', 'part_type', 'category', 'material_type', 'length_mm', 'qty'];
|
||||||
|
|
||||||
|
foreach ($required as $field) {
|
||||||
|
if (! array_key_exists($field, $data) || $data[$field] === null) {
|
||||||
|
throw new InvalidArgumentException("DynamicBomEntry: '{$field}' is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $data['child_item_id'] <= 0) {
|
||||||
|
throw new InvalidArgumentException('DynamicBomEntry: child_item_id must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validCategories = ['guideRail', 'bottomBar', 'shutterBox', 'smokeBarrier'];
|
||||||
|
if (! in_array($data['category'], $validCategories, true)) {
|
||||||
|
throw new InvalidArgumentException('DynamicBomEntry: category must be one of: '.implode(', ', $validCategories));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['qty'] <= 0) {
|
||||||
|
throw new InvalidArgumentException('DynamicBomEntry: qty must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicBomEntry 배열 → JSON 저장용 배열 변환
|
||||||
|
*
|
||||||
|
* @param DynamicBomEntry[] $entries
|
||||||
|
*/
|
||||||
|
public static function toArrayList(array $entries): array
|
||||||
|
{
|
||||||
|
return array_map(fn (self $e) => $e->toArray(), $entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Order\CreateFromQuoteRequest;
|
use App\Http\Requests\Order\CreateFromQuoteRequest;
|
||||||
use App\Http\Requests\Order\CreateProductionOrderRequest;
|
use App\Http\Requests\Order\CreateProductionOrderRequest;
|
||||||
|
use App\Http\Requests\Order\OrderBulkDeleteRequest;
|
||||||
use App\Http\Requests\Order\StoreOrderRequest;
|
use App\Http\Requests\Order\StoreOrderRequest;
|
||||||
use App\Http\Requests\Order\UpdateOrderRequest;
|
use App\Http\Requests\Order\UpdateOrderRequest;
|
||||||
use App\Http\Requests\Order\UpdateOrderStatusRequest;
|
use App\Http\Requests\Order\UpdateOrderStatusRequest;
|
||||||
@@ -66,6 +67,21 @@ public function update(UpdateOrderRequest $request, int $id)
|
|||||||
}, __('message.order.updated'));
|
}, __('message.order.updated'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄 삭제
|
||||||
|
*/
|
||||||
|
public function bulkDestroy(OrderBulkDeleteRequest $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
return $this->service->bulkDestroy(
|
||||||
|
$validated['ids'],
|
||||||
|
$validated['force'] ?? false
|
||||||
|
);
|
||||||
|
}, __('message.order.bulk_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 삭제
|
* 삭제
|
||||||
*/
|
*/
|
||||||
@@ -119,12 +135,25 @@ public function revertOrderConfirmation(int $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
* 절곡 BOM 품목 재고 확인
|
||||||
*/
|
*/
|
||||||
public function revertProductionOrder(int $id)
|
public function checkBendingStock(int $id)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($id) {
|
return ApiResponse::handle(function () use ($id) {
|
||||||
return $this->service->revertProductionOrder($id);
|
return $this->service->checkBendingStockForOrder($id);
|
||||||
|
}, __('message.fetched'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
||||||
|
*/
|
||||||
|
public function revertProductionOrder(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$force = $request->boolean('force', false);
|
||||||
|
$reason = $request->input('reason');
|
||||||
|
|
||||||
|
return ApiResponse::handle(function () use ($id, $force, $reason) {
|
||||||
|
return $this->service->revertProductionOrder($id, $force, $reason);
|
||||||
}, __('message.order.production_order_reverted'));
|
}, __('message.order.production_order_reverted'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public function index(Request $request): JsonResponse
|
|||||||
$params = $request->only([
|
$params = $request->only([
|
||||||
'search',
|
'search',
|
||||||
'item_type',
|
'item_type',
|
||||||
|
'item_category',
|
||||||
'status',
|
'status',
|
||||||
'location',
|
'location',
|
||||||
'sort_by',
|
'sort_by',
|
||||||
|
|||||||
22
app/Http/Requests/Order/OrderBulkDeleteRequest.php
Normal file
22
app/Http/Requests/Order/OrderBulkDeleteRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Order;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class OrderBulkDeleteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ids' => 'required|array|min:1',
|
||||||
|
'ids.*' => 'required|integer',
|
||||||
|
'force' => 'sometimes|boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,13 @@ public function rules(): array
|
|||||||
'order_qty' => ['nullable', 'numeric', 'min:0'],
|
'order_qty' => ['nullable', 'numeric', 'min:0'],
|
||||||
'order_unit' => ['nullable', 'string', 'max:20'],
|
'order_unit' => ['nullable', 'string', 'max:20'],
|
||||||
'due_date' => ['nullable', 'date'],
|
'due_date' => ['nullable', 'date'],
|
||||||
|
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'receiving_date' => ['nullable', 'date'],
|
||||||
|
'lot_no' => ['nullable', 'string', 'max:50'],
|
||||||
'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],
|
'status' => ['nullable', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending'],
|
||||||
'remark' => ['nullable', 'string', 'max:1000'],
|
'remark' => ['nullable', 'string', 'max:1000'],
|
||||||
|
'manufacturer' => ['nullable', 'string', 'max:100'],
|
||||||
|
'material_no' => ['nullable', 'string', 'max:50'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public function rules(): array
|
|||||||
'order_qty' => ['sometimes', 'numeric', 'min:0'],
|
'order_qty' => ['sometimes', 'numeric', 'min:0'],
|
||||||
'order_unit' => ['nullable', 'string', 'max:20'],
|
'order_unit' => ['nullable', 'string', 'max:20'],
|
||||||
'due_date' => ['nullable', 'date'],
|
'due_date' => ['nullable', 'date'],
|
||||||
'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,completed'],
|
'status' => ['sometimes', 'string', 'in:order_completed,shipping,inspection_pending,receiving_pending,completed,inspection_completed'],
|
||||||
'remark' => ['nullable', 'string', 'max:1000'],
|
'remark' => ['nullable', 'string', 'max:1000'],
|
||||||
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
|
'receiving_qty' => ['nullable', 'numeric', 'min:0'],
|
||||||
'receiving_date' => ['nullable', 'date'],
|
'receiving_date' => ['nullable', 'date'],
|
||||||
@@ -31,6 +31,8 @@ public function rules(): array
|
|||||||
'inspection_status' => ['nullable', 'string', 'max:10'],
|
'inspection_status' => ['nullable', 'string', 'max:10'],
|
||||||
'inspection_date' => ['nullable', 'date'],
|
'inspection_date' => ['nullable', 'date'],
|
||||||
'inspection_result' => ['nullable', 'string', 'max:20'],
|
'inspection_result' => ['nullable', 'string', 'max:20'],
|
||||||
|
'manufacturer' => ['nullable', 'string', 'max:100'],
|
||||||
|
'material_no' => ['nullable', 'string', 'max:50'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class WorkOrder extends Model
|
|||||||
|
|
||||||
public const STATUS_SHIPPED = 'shipped'; // 출하
|
public const STATUS_SHIPPED = 'shipped'; // 출하
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled'; // 취소
|
||||||
|
|
||||||
public const STATUSES = [
|
public const STATUSES = [
|
||||||
self::STATUS_UNASSIGNED,
|
self::STATUS_UNASSIGNED,
|
||||||
self::STATUS_PENDING,
|
self::STATUS_PENDING,
|
||||||
@@ -108,6 +110,7 @@ class WorkOrder extends Model
|
|||||||
self::STATUS_IN_PROGRESS,
|
self::STATUS_IN_PROGRESS,
|
||||||
self::STATUS_COMPLETED,
|
self::STATUS_COMPLETED,
|
||||||
self::STATUS_SHIPPED,
|
self::STATUS_SHIPPED,
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ public function scopeFinalized($query)
|
|||||||
|
|
||||||
public function scopeConverted($query)
|
public function scopeConverted($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', self::STATUS_CONVERTED);
|
return $query->whereNotNull('order_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,12 +339,29 @@ public function isEditable(): bool
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 접근자: order_id가 존재하면 자동으로 'converted' 반환
|
||||||
|
* DB에 status='converted'를 저장하지 않고, 수주 존재 여부로 판별
|
||||||
|
*/
|
||||||
|
public function getStatusAttribute($value): string
|
||||||
|
{
|
||||||
|
if ($this->order_id) {
|
||||||
|
return self::STATUS_CONVERTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 삭제 가능 여부 확인
|
* 삭제 가능 여부 확인
|
||||||
*/
|
*/
|
||||||
public function isDeletable(): bool
|
public function isDeletable(): bool
|
||||||
{
|
{
|
||||||
return ! in_array($this->status, [self::STATUS_FINALIZED, self::STATUS_CONVERTED]);
|
if ($this->order_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getRawOriginal('status') !== self::STATUS_FINALIZED;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class StockLot extends Model
|
|||||||
'location',
|
'location',
|
||||||
'status',
|
'status',
|
||||||
'receiving_id',
|
'receiving_id',
|
||||||
|
'work_order_id',
|
||||||
'created_by',
|
'created_by',
|
||||||
'updated_by',
|
'updated_by',
|
||||||
'deleted_by',
|
'deleted_by',
|
||||||
@@ -41,6 +42,7 @@ class StockLot extends Model
|
|||||||
'available_qty' => 'decimal:3',
|
'available_qty' => 'decimal:3',
|
||||||
'stock_id' => 'integer',
|
'stock_id' => 'integer',
|
||||||
'receiving_id' => 'integer',
|
'receiving_id' => 'integer',
|
||||||
|
'work_order_id' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +70,14 @@ public function receiving(): BelongsTo
|
|||||||
return $this->belongsTo(Receiving::class);
|
return $this->belongsTo(Receiving::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업지시 관계 (생산입고)
|
||||||
|
*/
|
||||||
|
public function workOrder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Production\WorkOrder::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생성자 관계
|
* 생성자 관계
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -48,12 +48,15 @@ class StockTransaction extends Model
|
|||||||
|
|
||||||
public const REASON_ORDER_CANCEL = 'order_cancel';
|
public const REASON_ORDER_CANCEL = 'order_cancel';
|
||||||
|
|
||||||
|
public const REASON_PRODUCTION_OUTPUT = 'production_output';
|
||||||
|
|
||||||
public const REASONS = [
|
public const REASONS = [
|
||||||
self::REASON_RECEIVING => '입고',
|
self::REASON_RECEIVING => '입고',
|
||||||
self::REASON_WORK_ORDER_INPUT => '생산투입',
|
self::REASON_WORK_ORDER_INPUT => '생산투입',
|
||||||
self::REASON_SHIPMENT => '출하',
|
self::REASON_SHIPMENT => '출하',
|
||||||
self::REASON_ORDER_CONFIRM => '수주확정',
|
self::REASON_ORDER_CONFIRM => '수주확정',
|
||||||
self::REASON_ORDER_CANCEL => '수주취소',
|
self::REASON_ORDER_CANCEL => '수주취소',
|
||||||
|
self::REASON_PRODUCTION_OUTPUT => '생산입고',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
@@ -111,4 +114,4 @@ public function getReasonLabelAttribute(): string
|
|||||||
{
|
{
|
||||||
return self::REASONS[$this->reason] ?? ($this->reason ?? '-');
|
return self::REASONS[$this->reason] ?? ($this->reason ?? '-');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
// 개발환경 + API 라우트에서만 쿼리 로그 수집
|
// 개발환경 + API 라우트에서만 쿼리 로그 수집
|
||||||
if (app()->environment('local')) {
|
if (app()->environment('local') && !app()->runningInConsole()) {
|
||||||
// 콘솔/큐 등 non-HTTP 컨텍스트 보호
|
if (request()->is('api/*')) {
|
||||||
if (function_exists('request') && request() && request()->is('api/*')) {
|
|
||||||
DB::enableQueryLog();
|
DB::enableQueryLog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ public function __construct(
|
|||||||
private NumberingService $numberingService
|
private NumberingService $numberingService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상세 조회용 공통 relations (show와 동일한 구조 보장)
|
||||||
|
*/
|
||||||
|
private function loadDetailRelations(Order $order): Order
|
||||||
|
{
|
||||||
|
return $order->load([
|
||||||
|
'client:id,name,contact_person,phone,email,manager_name',
|
||||||
|
'items' => fn ($q) => $q->orderBy('sort_order'),
|
||||||
|
'rootNodes' => fn ($q) => $q->withRecursiveChildren(),
|
||||||
|
'quote:id,quote_number,site_name,calculation_inputs',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 목록 조회 (검색/필터링/페이징)
|
* 목록 조회 (검색/필터링/페이징)
|
||||||
*/
|
*/
|
||||||
@@ -131,20 +144,13 @@ public function show(int $id)
|
|||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
$order = Order::where('tenant_id', $tenantId)
|
$order = Order::where('tenant_id', $tenantId)->find($id);
|
||||||
->with([
|
|
||||||
'client:id,name,contact_person,phone,email,manager_name',
|
|
||||||
'items' => fn ($q) => $q->orderBy('sort_order'),
|
|
||||||
'rootNodes' => fn ($q) => $q->withRecursiveChildren(),
|
|
||||||
'quote:id,quote_number,site_name,calculation_inputs',
|
|
||||||
])
|
|
||||||
->find($id);
|
|
||||||
|
|
||||||
if (! $order) {
|
if (! $order) {
|
||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $order;
|
return $this->loadDetailRelations($order);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,6 +228,19 @@ public function store(array $data)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 품목 저장
|
// 품목 저장
|
||||||
|
// sort_order 기반 분배 준비
|
||||||
|
$locationCount = count($productItems);
|
||||||
|
$itemsPerLocation = ($locationCount > 1)
|
||||||
|
? intdiv(count($items), $locationCount)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// floor/code 조합이 개소별로 고유한지 확인 (모두 동일하면 매칭 무의미)
|
||||||
|
$uniqueLocations = collect($productItems)
|
||||||
|
->map(fn ($p) => ($p['floor'] ?? '').'-'.($p['code'] ?? ''))
|
||||||
|
->unique()
|
||||||
|
->count();
|
||||||
|
$canMatchByFloorCode = $uniqueLocations > 1;
|
||||||
|
|
||||||
foreach ($items as $index => $item) {
|
foreach ($items as $index => $item) {
|
||||||
$item['tenant_id'] = $tenantId;
|
$item['tenant_id'] = $tenantId;
|
||||||
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
|
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
|
||||||
@@ -239,18 +258,32 @@ public function store(array $data)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// floor_code/symbol_code로 노드 매칭
|
// 노드 매칭 (개소 분배)
|
||||||
if (! empty($nodeMap) && ! empty($productItems)) {
|
if (! empty($nodeMap) && ! empty($productItems)) {
|
||||||
$floorCode = $item['floor_code'] ?? null;
|
$locIdx = 0;
|
||||||
$symbolCode = $item['symbol_code'] ?? null;
|
$matched = false;
|
||||||
if ($floorCode && $symbolCode) {
|
|
||||||
foreach ($productItems as $pidx => $pItem) {
|
// 1순위: floor_code/symbol_code로 매칭 (개소별 고유값이 있는 경우만)
|
||||||
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
|
if ($canMatchByFloorCode) {
|
||||||
$item['order_node_id'] = $nodeMap[$pidx]->id ?? null;
|
$floorCode = $item['floor_code'] ?? null;
|
||||||
break;
|
$symbolCode = $item['symbol_code'] ?? null;
|
||||||
|
if ($floorCode && $symbolCode) {
|
||||||
|
foreach ($productItems as $pidx => $pItem) {
|
||||||
|
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) {
|
||||||
|
$locIdx = $pidx;
|
||||||
|
$matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2순위: sort_order 기반 균등 분배
|
||||||
|
if (! $matched && $itemsPerLocation > 0) {
|
||||||
|
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item['order_node_id'] = $nodeMap[$locIdx]->id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$order->items()->create($item);
|
$order->items()->create($item);
|
||||||
@@ -260,7 +293,7 @@ public function store(array $data)
|
|||||||
$order->refresh();
|
$order->refresh();
|
||||||
$order->recalculateTotals()->save();
|
$order->recalculateTotals()->save();
|
||||||
|
|
||||||
return $order->load(['client:id,name', 'items']);
|
return $this->loadDetailRelations($order);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +359,7 @@ public function update(int $id, array $data)
|
|||||||
$order->recalculateTotals()->save();
|
$order->recalculateTotals()->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return $order->load(['client:id,name', 'items']);
|
return $this->loadDetailRelations($order);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +435,167 @@ public function destroy(int $id)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄 삭제
|
||||||
|
*/
|
||||||
|
public function bulkDestroy(array $ids, bool $force = false): array
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
// force=true는 운영 환경에서 차단
|
||||||
|
if ($force && app()->environment('production')) {
|
||||||
|
throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$orders = Order::where('tenant_id', $tenantId)
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$deletedCount = 0;
|
||||||
|
$skippedIds = [];
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($orders, $force, $userId, $tenantId, &$deletedCount, &$skippedIds) {
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
if ($force) {
|
||||||
|
// force=true (개발환경 완전삭제): 모든 상태 허용, 연관 데이터 모두 삭제
|
||||||
|
$this->forceDeleteWorkOrders($order, $tenantId);
|
||||||
|
} else {
|
||||||
|
// 일반 삭제: 상태/작업지시/출하 검증
|
||||||
|
if (! in_array($order->status_code, [
|
||||||
|
Order::STATUS_DRAFT,
|
||||||
|
Order::STATUS_CONFIRMED,
|
||||||
|
Order::STATUS_CANCELLED,
|
||||||
|
])) {
|
||||||
|
$skippedIds[] = $order->id;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($order->workOrders()->exists()) {
|
||||||
|
$skippedIds[] = $order->id;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($order->shipments()->exists()) {
|
||||||
|
$skippedIds[] = $order->id;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 견적 연결 해제
|
||||||
|
if ($order->quote_id) {
|
||||||
|
Quote::withoutGlobalScopes()
|
||||||
|
->where('id', $order->quote_id)
|
||||||
|
->where('order_id', $order->id)
|
||||||
|
->update([
|
||||||
|
'order_id' => null,
|
||||||
|
'status' => Quote::STATUS_FINALIZED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($force) {
|
||||||
|
// hard delete: 컴포넌트 → 품목 → 노드 → 마스터
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$item->components()->forceDelete();
|
||||||
|
}
|
||||||
|
$order->items()->forceDelete();
|
||||||
|
$order->nodes()->forceDelete();
|
||||||
|
$order->forceDelete();
|
||||||
|
} else {
|
||||||
|
// soft delete: 기존 destroy() 로직과 동일
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$item->components()->update(['deleted_by' => $userId]);
|
||||||
|
$item->components()->delete();
|
||||||
|
}
|
||||||
|
$order->items()->update(['deleted_by' => $userId]);
|
||||||
|
$order->items()->delete();
|
||||||
|
$order->nodes()->update(['deleted_by' => $userId]);
|
||||||
|
$order->nodes()->delete();
|
||||||
|
$order->deleted_by = $userId;
|
||||||
|
$order->save();
|
||||||
|
$order->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'deleted_count' => $deletedCount,
|
||||||
|
'skipped_count' => count($skippedIds),
|
||||||
|
'skipped_ids' => $skippedIds,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업지시 및 연관 데이터 강제 삭제 (개발환경 완전삭제용)
|
||||||
|
*/
|
||||||
|
private function forceDeleteWorkOrders(Order $order, int $tenantId): void
|
||||||
|
{
|
||||||
|
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
|
||||||
|
->where('sales_order_id', $order->id)
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
if (empty($workOrderIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 자재 투입 재고 복구 + 삭제
|
||||||
|
$materialInputs = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->get();
|
||||||
|
if ($materialInputs->isNotEmpty()) {
|
||||||
|
$stockService = app(StockService::class);
|
||||||
|
foreach ($materialInputs as $input) {
|
||||||
|
try {
|
||||||
|
$stockService->increaseToLot(
|
||||||
|
stockLotId: $input->stock_lot_id,
|
||||||
|
qty: (float) $input->qty,
|
||||||
|
reason: 'work_order_input_cancel',
|
||||||
|
referenceId: $input->work_order_id
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('완전삭제: 재고 복원 실패', [
|
||||||
|
'input_id' => $input->id,
|
||||||
|
'stock_lot_id' => $input->stock_lot_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 문서 삭제
|
||||||
|
$documentIds = Document::where('linkable_type', 'work_order')
|
||||||
|
->whereIn('linkable_id', $workOrderIds)
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
if (! empty($documentIds)) {
|
||||||
|
DocumentData::whereIn('document_id', $documentIds)->delete();
|
||||||
|
DocumentApproval::whereIn('document_id', $documentIds)->delete();
|
||||||
|
Document::whereIn('id', $documentIds)->forceDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 출하 참조 해제
|
||||||
|
DB::table('shipments')
|
||||||
|
->whereIn('work_order_id', $workOrderIds)
|
||||||
|
->update(['work_order_id' => null]);
|
||||||
|
|
||||||
|
// 4. 부속 데이터 삭제
|
||||||
|
DB::table('work_order_step_progress')->whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
DB::table('work_order_assignees')->whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
DB::table('work_order_bending_details')->whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
DB::table('work_order_issues')->whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
DB::table('work_results')->whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
|
||||||
|
// 5. 작업지시 품목 → 작업지시 삭제
|
||||||
|
DB::table('work_order_items')->whereIn('work_order_id', $workOrderIds)->delete();
|
||||||
|
WorkOrder::whereIn('id', $workOrderIds)->forceDelete();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 상태 변경
|
* 상태 변경
|
||||||
*/
|
*/
|
||||||
@@ -457,7 +651,7 @@ public function updateStatus(int $id, string $status)
|
|||||||
$order->updated_by = $userId;
|
$order->updated_by = $userId;
|
||||||
$order->save();
|
$order->save();
|
||||||
|
|
||||||
$result = $order->load(['client:id,name', 'items']);
|
$result = $this->loadDetailRelations($order);
|
||||||
|
|
||||||
// 매출이 생성된 경우 응답에 포함
|
// 매출이 생성된 경우 응답에 포함
|
||||||
if ($createdSale) {
|
if ($createdSale) {
|
||||||
@@ -755,14 +949,13 @@ public function createFromQuote(int $quoteId, array $data = [])
|
|||||||
$order->refresh();
|
$order->refresh();
|
||||||
$order->recalculateTotals()->save();
|
$order->recalculateTotals()->save();
|
||||||
|
|
||||||
// 견적 상태를 '수주전환완료'로 변경
|
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
|
||||||
$quote->update([
|
$quote->update([
|
||||||
'status' => Quote::STATUS_CONVERTED,
|
|
||||||
'order_id' => $order->id,
|
'order_id' => $order->id,
|
||||||
'updated_by' => $userId,
|
'updated_by' => $userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $order->load(['client:id,name', 'items', 'quote:id,quote_number']);
|
return $this->loadDetailRelations($order);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +1167,7 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order
|
|||||||
'created_by' => $userId,
|
'created_by' => $userId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $order->load(['client:id,name', 'items', 'quote:id,quote_number']);
|
return $this->loadDetailRelations($order);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,9 +1336,9 @@ public function createProductionOrder(int $orderId, array $data)
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
|
$buildResult = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null);
|
||||||
if ($bendingInfo) {
|
if ($buildResult) {
|
||||||
$workOrderOptions = ['bending_info' => $bendingInfo];
|
$workOrderOptions = ['bending_info' => $buildResult['bending_info']];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,17 +1405,33 @@ public function createProductionOrder(int $orderId, array $data)
|
|||||||
$slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty;
|
$slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$woHeight = $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null;
|
||||||
|
|
||||||
$woItemOptions = array_filter([
|
$woItemOptions = array_filter([
|
||||||
'floor' => $orderItem->floor_code,
|
'floor' => $orderItem->floor_code,
|
||||||
'code' => $orderItem->symbol_code,
|
'code' => $orderItem->symbol_code,
|
||||||
'width' => $woWidth,
|
'width' => $woWidth,
|
||||||
'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null,
|
'height' => $woHeight,
|
||||||
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
|
'cutting_info' => $nodeOptions['cutting_info'] ?? null,
|
||||||
'slat_info' => $slatInfo,
|
'slat_info' => $slatInfo,
|
||||||
'bending_info' => $nodeOptions['bending_info'] ?? null,
|
'bending_info' => $nodeOptions['bending_info'] ?? null,
|
||||||
'wip_info' => $nodeOptions['wip_info'] ?? null,
|
'wip_info' => $nodeOptions['wip_info'] ?? null,
|
||||||
], fn ($v) => $v !== null);
|
], fn ($v) => $v !== null);
|
||||||
|
|
||||||
|
// 절곡 공정: 개소별 dynamic_bom 생성
|
||||||
|
if (! empty($buildResult['context']) && $woWidth && $woHeight) {
|
||||||
|
$dynamicBom = app(BendingInfoBuilder::class)->buildDynamicBomForItem(
|
||||||
|
$buildResult['context'],
|
||||||
|
(int) $woWidth,
|
||||||
|
(int) $woHeight,
|
||||||
|
(int) ($orderItem->quantity ?? 1),
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
if (! empty($dynamicBom)) {
|
||||||
|
$woItemOptions['dynamic_bom'] = $dynamicBom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DB::table('work_order_items')->insert([
|
DB::table('work_order_items')->insert([
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'work_order_id' => $workOrder->id,
|
'work_order_id' => $workOrder->id,
|
||||||
@@ -1251,7 +1460,7 @@ public function createProductionOrder(int $orderId, array $data)
|
|||||||
return [
|
return [
|
||||||
'work_orders' => $workOrders,
|
'work_orders' => $workOrders,
|
||||||
'work_order' => $workOrders[0] ?? null, // 하위 호환성
|
'work_order' => $workOrders[0] ?? null, // 하위 호환성
|
||||||
'order' => $order->load(['client:id,name', 'items']),
|
'order' => $this->loadDetailRelations($order),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1383,7 +1592,7 @@ public function revertOrderConfirmation(int $orderId): array
|
|||||||
$order->save();
|
$order->save();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'order' => $order->load(['client:id,name', 'items']),
|
'order' => $this->loadDetailRelations($order),
|
||||||
'previous_status' => $previousStatus,
|
'previous_status' => $previousStatus,
|
||||||
'deleted_sale_id' => $deletedSaleId,
|
'deleted_sale_id' => $deletedSaleId,
|
||||||
];
|
];
|
||||||
@@ -1391,13 +1600,26 @@ public function revertOrderConfirmation(int $orderId): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
* 생산지시 되돌리기
|
||||||
|
*
|
||||||
|
* force=true: 개발 모드 - 모든 데이터 hard delete + 재고 복구 (운영환경 차단)
|
||||||
|
* force=false: 운영 모드 - 작업지시 취소 상태 변경 + 재고 역분개 (데이터 보존)
|
||||||
*/
|
*/
|
||||||
public function revertProductionOrder(int $orderId): array
|
public function revertProductionOrder(int $orderId, bool $force = false, ?string $reason = null): array
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
// force=true는 운영 환경에서 차단
|
||||||
|
if ($force && app()->environment('production')) {
|
||||||
|
throw new \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException(__('error.forbidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 운영 모드에서는 reason 필수
|
||||||
|
if (! $force && empty($reason)) {
|
||||||
|
throw new BadRequestHttpException(__('error.order.cancel_reason_required'));
|
||||||
|
}
|
||||||
|
|
||||||
// 수주 조회
|
// 수주 조회
|
||||||
$order = Order::where('tenant_id', $tenantId)
|
$order = Order::where('tenant_id', $tenantId)
|
||||||
->find($orderId);
|
->find($orderId);
|
||||||
@@ -1411,8 +1633,19 @@ public function revertProductionOrder(int $orderId): array
|
|||||||
throw new BadRequestHttpException(__('error.order.cannot_revert_completed'));
|
throw new BadRequestHttpException(__('error.order.cannot_revert_completed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($force) {
|
||||||
|
return $this->revertProductionOrderForce($order, $tenantId, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->revertProductionOrderCancel($order, $tenantId, $userId, $reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생산지시 되돌리기 - 개발 모드 (hard delete)
|
||||||
|
*/
|
||||||
|
private function revertProductionOrderForce(Order $order, int $tenantId, int $userId): array
|
||||||
|
{
|
||||||
return DB::transaction(function () use ($order, $tenantId, $userId) {
|
return DB::transaction(function () use ($order, $tenantId, $userId) {
|
||||||
// 관련 작업지시 ID 조회
|
|
||||||
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
|
$workOrderIds = WorkOrder::where('tenant_id', $tenantId)
|
||||||
->where('sales_order_id', $order->id)
|
->where('sales_order_id', $order->id)
|
||||||
->pluck('id')
|
->pluck('id')
|
||||||
@@ -1509,10 +1742,161 @@ public function revertProductionOrder(int $orderId): array
|
|||||||
$order->save();
|
$order->save();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'order' => $order->load(['client:id,name', 'items']),
|
'order' => $this->loadDetailRelations($order),
|
||||||
'deleted_counts' => $deletedCounts,
|
'deleted_counts' => $deletedCounts,
|
||||||
'previous_status' => $previousStatus,
|
'previous_status' => $previousStatus,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생산지시 되돌리기 - 운영 모드 (취소 상태 변경, 데이터 보존)
|
||||||
|
*/
|
||||||
|
private function revertProductionOrderCancel(Order $order, int $tenantId, int $userId, string $reason): array
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($order, $tenantId, $userId, $reason) {
|
||||||
|
$workOrders = WorkOrder::where('tenant_id', $tenantId)
|
||||||
|
->where('sales_order_id', $order->id)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$cancelledCount = 0;
|
||||||
|
$skippedIds = [];
|
||||||
|
|
||||||
|
$stockService = app(StockService::class);
|
||||||
|
|
||||||
|
foreach ($workOrders as $workOrder) {
|
||||||
|
// completed/shipped 상태는 취소 거부
|
||||||
|
if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) {
|
||||||
|
$skippedIds[] = $workOrder->id;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작업지시 상태를 cancelled로 변경
|
||||||
|
$workOrder->status = WorkOrder::STATUS_CANCELLED;
|
||||||
|
$workOrder->updated_by = $userId;
|
||||||
|
|
||||||
|
// options에 취소 정보 기록
|
||||||
|
$options = $workOrder->options ?? [];
|
||||||
|
$options['cancelled_at'] = now()->toIso8601String();
|
||||||
|
$options['cancelled_by'] = $userId;
|
||||||
|
$options['cancel_reason'] = $reason;
|
||||||
|
$workOrder->options = $options;
|
||||||
|
$workOrder->save();
|
||||||
|
|
||||||
|
// 자재 투입분 재고 역분개
|
||||||
|
$materialInputs = WorkOrderMaterialInput::where('work_order_id', $workOrder->id)->get();
|
||||||
|
foreach ($materialInputs as $input) {
|
||||||
|
try {
|
||||||
|
$stockService->increaseToLot(
|
||||||
|
stockLotId: $input->stock_lot_id,
|
||||||
|
qty: (float) $input->qty,
|
||||||
|
reason: 'work_order_cancel',
|
||||||
|
referenceId: $workOrder->id
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::warning('생산지시 취소: 재고 복원 실패', [
|
||||||
|
'input_id' => $input->id,
|
||||||
|
'stock_lot_id' => $input->stock_lot_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cancelledCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수주 상태를 CONFIRMED로 복원
|
||||||
|
$previousStatus = $order->status_code;
|
||||||
|
$order->status_code = Order::STATUS_CONFIRMED;
|
||||||
|
$order->updated_by = $userId;
|
||||||
|
$order->save();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order' => $this->loadDetailRelations($order),
|
||||||
|
'cancelled_count' => $cancelledCount,
|
||||||
|
'skipped_count' => count($skippedIds),
|
||||||
|
'skipped_ids' => $skippedIds,
|
||||||
|
'previous_status' => $previousStatus,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주의 절곡 BOM 품목별 재고 현황 조회
|
||||||
|
*
|
||||||
|
* order_items에서 item_category='BENDING'인 품목을 추출하고
|
||||||
|
* 각 품목의 재고 가용량/부족량을 반환합니다.
|
||||||
|
*/
|
||||||
|
public function checkBendingStockForOrder(int $orderId): array
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
|
$order = Order::where('tenant_id', $tenantId)
|
||||||
|
->with(['items'])
|
||||||
|
->find($orderId);
|
||||||
|
|
||||||
|
if (! $order) {
|
||||||
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// order_items에서 item_id가 있는 품목의 ID 수집 + 수량 합산
|
||||||
|
$itemQtyMap = []; // item_id => total_qty
|
||||||
|
foreach ($order->items as $orderItem) {
|
||||||
|
$itemId = $orderItem->item_id;
|
||||||
|
if (! $itemId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$qty = (float) ($orderItem->quantity ?? 0);
|
||||||
|
if ($qty <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$itemQtyMap[$itemId] = ($itemQtyMap[$itemId] ?? 0) + $qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($itemQtyMap)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// items 테이블에서 item_category = 'BENDING'인 것만 필터
|
||||||
|
$bendingItems = DB::table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereIn('id', array_keys($itemQtyMap))
|
||||||
|
->where('item_category', 'BENDING')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->select('id', 'code', 'name', 'unit')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($bendingItems->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stockService = app(StockService::class);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($bendingItems as $item) {
|
||||||
|
$neededQty = $itemQtyMap[$item->id];
|
||||||
|
$stockInfo = $stockService->getAvailableStock($item->id);
|
||||||
|
|
||||||
|
$availableQty = $stockInfo ? (float) $stockInfo['available_qty'] : 0;
|
||||||
|
$reservedQty = $stockInfo ? (float) $stockInfo['reserved_qty'] : 0;
|
||||||
|
$stockQty = $stockInfo ? (float) $stockInfo['stock_qty'] : 0;
|
||||||
|
$shortfallQty = max(0, $neededQty - $availableQty);
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'item_id' => $item->id,
|
||||||
|
'item_code' => $item->code,
|
||||||
|
'item_name' => $item->name,
|
||||||
|
'unit' => $item->unit,
|
||||||
|
'needed_qty' => $neededQty,
|
||||||
|
'stock_qty' => $stockQty,
|
||||||
|
'reserved_qty' => $reservedQty,
|
||||||
|
'available_qty' => $availableQty,
|
||||||
|
'shortfall_qty' => $shortfallQty,
|
||||||
|
'status' => $shortfallQty > 0 ? 'insufficient' : 'sufficient',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
namespace App\Services\Production;
|
namespace App\Services\Production;
|
||||||
|
|
||||||
|
use App\DTOs\Production\DynamicBomEntry;
|
||||||
use App\Models\Orders\Order;
|
use App\Models\Orders\Order;
|
||||||
use App\Models\Process;
|
use App\Models\Process;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수주 → 생산지시 시 절곡 공정용 bending_info JSON 자동 생성
|
* 수주 → 생산지시 시 절곡 공정용 bending_info JSON 자동 생성
|
||||||
*
|
*
|
||||||
* 입력: Order (rootNodes eager loaded) + processId
|
* 입력: Order (rootNodes eager loaded) + processId
|
||||||
* 출력: BendingInfoExtended 구조의 array (work_orders.options.bending_info에 저장)
|
* 출력: ['bending_info' => array, 'context' => array] — bending_info + dynamic_bom 생성 컨텍스트
|
||||||
*
|
*
|
||||||
* @see react/src/components/production/WorkOrders/documents/bending/types.ts
|
* @see react/src/components/production/WorkOrders/documents/bending/types.ts
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +20,7 @@ class BendingInfoBuilder
|
|||||||
{
|
{
|
||||||
// 표준 원자재 길이 버킷 (5130 레거시 write_form.php 기준)
|
// 표준 원자재 길이 버킷 (5130 레거시 write_form.php 기준)
|
||||||
private const GUIDE_RAIL_LENGTHS = [2438, 3000, 3500, 4000, 4300];
|
private const GUIDE_RAIL_LENGTHS = [2438, 3000, 3500, 4000, 4300];
|
||||||
|
|
||||||
private const SHUTTER_BOX_LENGTHS = [1219, 2438, 3000, 3500, 4000, 4150];
|
private const SHUTTER_BOX_LENGTHS = [1219, 2438, 3000, 3500, 4000, 4150];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +68,307 @@ public function build(Order $order, int $processId, ?array $nodeIds = null): ?ar
|
|||||||
$aggregated = $this->aggregateNodes($nodes);
|
$aggregated = $this->aggregateNodes($nodes);
|
||||||
|
|
||||||
// 6. bending_info 조립
|
// 6. bending_info 조립
|
||||||
return $this->assembleBendingInfo($productInfo, $materials, $aggregated);
|
$bendingInfo = $this->assembleBendingInfo($productInfo, $materials, $aggregated);
|
||||||
|
|
||||||
|
// 7. 셔터박스 크기 추출 (dynamic_bom 컨텍스트용)
|
||||||
|
$caseBom = $aggregated['bomCategories']['shutterBox_case'] ?? null;
|
||||||
|
$motorBom = $aggregated['bomCategories']['motor'] ?? null;
|
||||||
|
$boxSize = null;
|
||||||
|
if ($caseBom) {
|
||||||
|
$motorCapacity = $this->extractMotorCapacity($motorBom);
|
||||||
|
$boxSize = $motorCapacity ? $this->getShutterBoxSize($motorCapacity) : null;
|
||||||
|
if (! $boxSize) {
|
||||||
|
$boxSize = str_replace('BD-케이스-', '', $caseBom['item_code'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'bending_info' => $bendingInfo,
|
||||||
|
'context' => [
|
||||||
|
'productCode' => $productInfo['productCode'],
|
||||||
|
'guideType' => $productInfo['guideType'],
|
||||||
|
'finishMaterial' => $productInfo['finishMaterial'],
|
||||||
|
'materials' => $materials,
|
||||||
|
'boxSize' => $boxSize,
|
||||||
|
'hasSmokeRail' => isset($aggregated['bomCategories']['smokeBarrier_rail']),
|
||||||
|
'hasSmokeCase' => isset($aggregated['bomCategories']['smokeBarrier_case']),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개소(work_order_item) 단위 dynamic_bom 생성
|
||||||
|
*
|
||||||
|
* @param array $context build() 반환값의 'context'
|
||||||
|
* @param int $width 개소의 오픈폭 (mm)
|
||||||
|
* @param int $height 개소의 오픈높이 (mm)
|
||||||
|
* @param int $qty 개소 수량
|
||||||
|
* @param int $tenantId 테넌트 ID (item 조회용)
|
||||||
|
* @return array DynamicBomEntry::toArray() 배열
|
||||||
|
*/
|
||||||
|
public function buildDynamicBomForItem(array $context, int $width, int $height, int $qty, int $tenantId = 287): array
|
||||||
|
{
|
||||||
|
$resolver = new PrefixResolver;
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
$productCode = $context['productCode'];
|
||||||
|
$guideType = $context['guideType'];
|
||||||
|
$finishMaterial = $context['finishMaterial'];
|
||||||
|
$materials = $context['materials'];
|
||||||
|
$boxSize = $context['boxSize'];
|
||||||
|
$hasExtraFinish = ! empty($materials['guideRailExtraFinish']);
|
||||||
|
|
||||||
|
// ─── 1. 가이드레일 세부품목 ───
|
||||||
|
$dimGroups = [['height' => $height, 'width' => $width, 'qty' => $qty]];
|
||||||
|
$heightData = $this->heightLengthData($dimGroups);
|
||||||
|
|
||||||
|
// 가이드레일은 개구부 양쪽 2개이므로 수량 ×2
|
||||||
|
foreach ($heightData as &$entry) {
|
||||||
|
$entry['quantity'] *= 2;
|
||||||
|
}
|
||||||
|
unset($entry);
|
||||||
|
|
||||||
|
$guideTypes = match ($guideType) {
|
||||||
|
'혼합형' => ['wall', 'side'],
|
||||||
|
'측면형' => ['side'],
|
||||||
|
default => ['wall'],
|
||||||
|
};
|
||||||
|
|
||||||
|
$guidePartTypes = ['finish', 'body', 'c_type', 'd_type'];
|
||||||
|
if ($hasExtraFinish) {
|
||||||
|
$guidePartTypes[] = 'extra_finish';
|
||||||
|
}
|
||||||
|
$guidePartTypes[] = 'base';
|
||||||
|
|
||||||
|
foreach ($guideTypes as $gType) {
|
||||||
|
foreach ($heightData as $ld) {
|
||||||
|
foreach ($guidePartTypes as $partType) {
|
||||||
|
$prefix = $resolver->resolveGuideRailPrefix($partType, $gType, $productCode);
|
||||||
|
if (empty($prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemCode = $resolver->buildItemCode($prefix, $ld['length']);
|
||||||
|
if (! $itemCode) {
|
||||||
|
Log::warning('BendingInfoBuilder: lengthCode 변환 실패', ['prefix' => $prefix, 'length' => $ld['length']]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
|
||||||
|
if (! $itemId) {
|
||||||
|
Log::warning('BendingInfoBuilder: 미등록 품목', ['code' => $itemCode]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $itemId,
|
||||||
|
child_item_code: $itemCode,
|
||||||
|
lot_prefix: $prefix,
|
||||||
|
part_type: PrefixResolver::partTypeName($partType),
|
||||||
|
category: 'guideRail',
|
||||||
|
material_type: $this->resolvePartMaterial($partType, $gType, $materials),
|
||||||
|
length_mm: $ld['length'],
|
||||||
|
qty: $ld['quantity'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. 하단마감재 세부품목 ───
|
||||||
|
[$qty3000, $qty4000] = $this->bottomBarDistribution($width);
|
||||||
|
$bottomLengths = array_filter([3000 => $qty3000 * $qty, 4000 => $qty4000 * $qty]);
|
||||||
|
|
||||||
|
$bottomPartTypes = ['main', 'lbar', 'reinforce'];
|
||||||
|
$hasBottomExtra = ! empty($materials['bottomBarExtraFinish']) && $materials['bottomBarExtraFinish'] !== '없음';
|
||||||
|
if ($hasBottomExtra) {
|
||||||
|
$bottomPartTypes[] = 'extra';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($bottomLengths as $length => $lengthQty) {
|
||||||
|
if ($lengthQty <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($bottomPartTypes as $partType) {
|
||||||
|
$prefix = $resolver->resolveBottomBarPrefix($partType, $productCode, $finishMaterial);
|
||||||
|
$itemCode = $resolver->buildItemCode($prefix, $length);
|
||||||
|
if (! $itemCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
|
||||||
|
if (! $itemId) {
|
||||||
|
Log::warning('BendingInfoBuilder: 미등록 하단마감재', ['code' => $itemCode]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $itemId,
|
||||||
|
child_item_code: $itemCode,
|
||||||
|
lot_prefix: $prefix,
|
||||||
|
part_type: PrefixResolver::partTypeName($partType),
|
||||||
|
category: 'bottomBar',
|
||||||
|
material_type: $materials['bottomBarFinish'],
|
||||||
|
length_mm: $length,
|
||||||
|
qty: $lengthQty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. 셔터박스 세부품목 ───
|
||||||
|
if ($boxSize) {
|
||||||
|
$isStandard = $boxSize === '500*380';
|
||||||
|
$dist = $this->shutterBoxDistribution($width);
|
||||||
|
$shutterPartTypes = ['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'];
|
||||||
|
|
||||||
|
foreach ($dist as $length => $count) {
|
||||||
|
$totalCount = $count * $qty;
|
||||||
|
if ($totalCount <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($shutterPartTypes as $partType) {
|
||||||
|
$prefix = $resolver->resolveShutterBoxPrefix($partType, $isStandard);
|
||||||
|
$itemCode = $resolver->buildItemCode($prefix, $length);
|
||||||
|
if (! $itemCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$itemId = $resolver->resolveItemId($itemCode, $tenantId);
|
||||||
|
if (! $itemId) {
|
||||||
|
Log::warning('BendingInfoBuilder: 미등록 셔터박스', ['code' => $itemCode]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $itemId,
|
||||||
|
child_item_code: $itemCode,
|
||||||
|
lot_prefix: $prefix,
|
||||||
|
part_type: PrefixResolver::partTypeName($partType),
|
||||||
|
category: 'shutterBox',
|
||||||
|
material_type: 'EGI',
|
||||||
|
length_mm: $length,
|
||||||
|
qty: $totalCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상부덮개 수량: ceil(width / 1219) × qty (1219mm 단위)
|
||||||
|
$coverQty = (int) ceil($width / 1219) * $qty;
|
||||||
|
if ($coverQty > 0) {
|
||||||
|
$coverPrefix = $resolver->resolveShutterBoxPrefix('top_cover', $isStandard);
|
||||||
|
$coverCode = $resolver->buildItemCode($coverPrefix, 1219);
|
||||||
|
if ($coverCode) {
|
||||||
|
$coverId = $resolver->resolveItemId($coverCode, $tenantId);
|
||||||
|
if ($coverId) {
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $coverId,
|
||||||
|
child_item_code: $coverCode,
|
||||||
|
lot_prefix: $coverPrefix,
|
||||||
|
part_type: PrefixResolver::partTypeName('top_cover'),
|
||||||
|
category: 'shutterBox',
|
||||||
|
material_type: 'EGI',
|
||||||
|
length_mm: 1219,
|
||||||
|
qty: $coverQty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마구리 수량: qty × 2
|
||||||
|
$finQty = $qty * 2;
|
||||||
|
if ($finQty > 0) {
|
||||||
|
$finPrefix = $resolver->resolveShutterBoxPrefix('fin_cover', $isStandard);
|
||||||
|
// 마구리는 박스 높이 기준이므로 셔터박스 최소 단위 사용
|
||||||
|
$finCode = $resolver->buildItemCode($finPrefix, 1219);
|
||||||
|
if ($finCode) {
|
||||||
|
$finId = $resolver->resolveItemId($finCode, $tenantId);
|
||||||
|
if ($finId) {
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $finId,
|
||||||
|
child_item_code: $finCode,
|
||||||
|
lot_prefix: $finPrefix,
|
||||||
|
part_type: PrefixResolver::partTypeName('fin_cover'),
|
||||||
|
category: 'shutterBox',
|
||||||
|
material_type: 'EGI',
|
||||||
|
length_mm: 1219,
|
||||||
|
qty: $finQty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. 연기차단재 세부품목 ───
|
||||||
|
if ($context['hasSmokeRail'] || $context['hasSmokeCase']) {
|
||||||
|
$smokePrefix = $resolver->resolveSmokeBarrierPrefix();
|
||||||
|
|
||||||
|
// W50 (레일용): open_height + 250 → 표준 길이
|
||||||
|
if ($context['hasSmokeRail']) {
|
||||||
|
$col24 = $height + 250;
|
||||||
|
$w50Length = $this->bucketToStandardLength($col24, [2438, 3000, 3500, 4000, 4300]);
|
||||||
|
if ($w50Length && $col24 <= 4300) {
|
||||||
|
$w50Code = $resolver->buildItemCode($smokePrefix, $w50Length, 'w50');
|
||||||
|
if ($w50Code) {
|
||||||
|
$w50Id = $resolver->resolveItemId($w50Code, $tenantId);
|
||||||
|
if ($w50Id) {
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $w50Id,
|
||||||
|
child_item_code: $w50Code,
|
||||||
|
lot_prefix: $smokePrefix,
|
||||||
|
part_type: '연기차단재(W50)',
|
||||||
|
category: 'smokeBarrier',
|
||||||
|
material_type: 'GI',
|
||||||
|
length_mm: $w50Length,
|
||||||
|
qty: 2 * $qty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// W80 (케이스용): floor((width+240)*2/3000 + 1) × qty
|
||||||
|
if ($context['hasSmokeCase']) {
|
||||||
|
$col38 = $width + 240;
|
||||||
|
$w80PerNode = (int) floor(($col38 * 2 / 3000) + 1);
|
||||||
|
$w80Qty = $w80PerNode * $qty;
|
||||||
|
if ($w80Qty > 0) {
|
||||||
|
// W80은 3000mm 기본 (레거시 동일)
|
||||||
|
$w80Code = $resolver->buildItemCode($smokePrefix, 3000, 'w80');
|
||||||
|
if ($w80Code) {
|
||||||
|
$w80Id = $resolver->resolveItemId($w80Code, $tenantId);
|
||||||
|
if ($w80Id) {
|
||||||
|
$entries[] = new DynamicBomEntry(
|
||||||
|
child_item_id: $w80Id,
|
||||||
|
child_item_code: $w80Code,
|
||||||
|
lot_prefix: $smokePrefix,
|
||||||
|
part_type: '연기차단재(W80)',
|
||||||
|
category: 'smokeBarrier',
|
||||||
|
material_type: 'GI',
|
||||||
|
length_mm: 3000,
|
||||||
|
qty: $w80Qty,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DynamicBomEntry::toArrayList($entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파트타입 + 가이드타입 → 실제 재질 결정
|
||||||
|
*/
|
||||||
|
private function resolvePartMaterial(string $partType, string $guideType, array $materials): string
|
||||||
|
{
|
||||||
|
return match ($partType) {
|
||||||
|
'finish' => $materials['guideRailFinish'],
|
||||||
|
'extra_finish' => $materials['guideRailExtraFinish'],
|
||||||
|
'body', 'c_type', 'd_type' => $materials['bodyMaterial'],
|
||||||
|
'base' => 'EGI',
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
307
app/Services/Production/PrefixResolver.php
Normal file
307
app/Services/Production/PrefixResolver.php
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Production;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 절곡 세부품목 LOT Prefix 결정 및 BD-XX-NN 코드 생성
|
||||||
|
*
|
||||||
|
* 제품코드 + 마감재질 + 가이드타입 → LOT prefix → BD-XX-NN 코드 → items.id
|
||||||
|
*/
|
||||||
|
class PrefixResolver
|
||||||
|
{
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 가이드레일 Prefix 맵
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 벽면형(Wall) prefix: partType → prefix (finish는 productCode별 분기) */
|
||||||
|
private const WALL_PREFIXES = [
|
||||||
|
'finish' => ['KSS' => 'RS', 'KQTS' => 'RS', 'KSE' => 'RE', 'KWE' => 'RE', 'KTE' => 'RS'],
|
||||||
|
'body' => 'RM',
|
||||||
|
'c_type' => 'RC',
|
||||||
|
'd_type' => 'RD',
|
||||||
|
'extra_finish' => 'YY',
|
||||||
|
'base' => 'XX',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 측면형(Side) prefix */
|
||||||
|
private const SIDE_PREFIXES = [
|
||||||
|
'finish' => ['KSS' => 'SS', 'KQTS' => 'SS', 'KSE' => 'SE', 'KWE' => 'SE', 'KTE' => 'SS'],
|
||||||
|
'body' => 'SM',
|
||||||
|
'c_type' => 'SC',
|
||||||
|
'd_type' => 'SD',
|
||||||
|
'extra_finish' => 'YY',
|
||||||
|
'base' => 'XX',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 철재(KTE01) body 오버라이드 */
|
||||||
|
private const STEEL_BODY_OVERRIDES = [
|
||||||
|
'wall' => 'RT',
|
||||||
|
'side' => 'ST',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 하단마감재 Prefix 맵
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 하단마감재 main prefix: finishMaterial 기반 */
|
||||||
|
private const BOTTOM_BAR_MAIN = [
|
||||||
|
'EGI' => 'BE',
|
||||||
|
'SUS' => 'BS',
|
||||||
|
'STEEL' => 'TS',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 셔터박스 Prefix 맵
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 표준 사이즈(500*380) 셔터박스 prefix */
|
||||||
|
private const SHUTTER_STANDARD = [
|
||||||
|
'front' => 'CF',
|
||||||
|
'lintel' => 'CL',
|
||||||
|
'inspection' => 'CP',
|
||||||
|
'rear_corner' => 'CB',
|
||||||
|
'top_cover' => 'XX',
|
||||||
|
'fin_cover' => 'XX',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 길이코드 매핑
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const LENGTH_TO_CODE = [
|
||||||
|
1219 => '12',
|
||||||
|
2438 => '24',
|
||||||
|
3000 => '30',
|
||||||
|
3500 => '35',
|
||||||
|
4000 => '40',
|
||||||
|
4150 => '41',
|
||||||
|
4200 => '42',
|
||||||
|
4300 => '43',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 연기차단재 전용 길이코드 */
|
||||||
|
private const SMOKE_LENGTH_TO_CODE = [
|
||||||
|
'w50' => [3000 => '53', 4000 => '54'],
|
||||||
|
'w80' => [3000 => '83', 4000 => '84'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 파트타입 한글명 */
|
||||||
|
private const PART_TYPE_NAMES = [
|
||||||
|
'finish' => '마감재',
|
||||||
|
'body' => '본체',
|
||||||
|
'c_type' => 'C형',
|
||||||
|
'd_type' => 'D형',
|
||||||
|
'extra_finish' => '별도마감',
|
||||||
|
'base' => '하부BASE',
|
||||||
|
'main' => '메인',
|
||||||
|
'lbar' => 'L-Bar',
|
||||||
|
'reinforce' => '보강평철',
|
||||||
|
'extra' => '별도마감',
|
||||||
|
'front' => '전면부',
|
||||||
|
'lintel' => '린텔부',
|
||||||
|
'inspection' => '점검구',
|
||||||
|
'rear_corner' => '후면코너부',
|
||||||
|
'top_cover' => '상부덮개',
|
||||||
|
'fin_cover' => '마구리',
|
||||||
|
'smoke' => '연기차단재',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** items.id 캐시: code → id */
|
||||||
|
private array $itemIdCache = [];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 가이드레일
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가이드레일 세부품목의 prefix 결정
|
||||||
|
*
|
||||||
|
* @param string $partType 'finish', 'body', 'c_type', 'd_type', 'extra_finish', 'base'
|
||||||
|
* @param string $guideType 'wall', 'side'
|
||||||
|
* @param string $productCode 'KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'
|
||||||
|
* @return string prefix (빈 문자열이면 해당 파트 없음)
|
||||||
|
*/
|
||||||
|
public function resolveGuideRailPrefix(string $partType, string $guideType, string $productCode): string
|
||||||
|
{
|
||||||
|
$prefixMap = $guideType === 'wall' ? self::WALL_PREFIXES : self::SIDE_PREFIXES;
|
||||||
|
$codePrefix = $this->extractCodePrefix($productCode);
|
||||||
|
$isSteel = $codePrefix === 'KTE';
|
||||||
|
|
||||||
|
// body: 철재 오버라이드
|
||||||
|
if ($partType === 'body' && $isSteel) {
|
||||||
|
return self::STEEL_BODY_OVERRIDES[$guideType] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// finish: productCode별 분기
|
||||||
|
if ($partType === 'finish') {
|
||||||
|
$finishMap = $prefixMap['finish'] ?? [];
|
||||||
|
|
||||||
|
return $finishMap[$codePrefix] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// extra_finish, base, c_type, d_type, body: 고정 prefix
|
||||||
|
return $prefixMap[$partType] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 하단마감재
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하단마감재 세부품목의 prefix 결정
|
||||||
|
*
|
||||||
|
* @param string $partType 'main', 'lbar', 'reinforce', 'extra'
|
||||||
|
* @param string $productCode 'KSS01', 'KSE01', etc.
|
||||||
|
* @param string $finishMaterial 'EGI마감', 'SUS마감'
|
||||||
|
* @return string prefix
|
||||||
|
*/
|
||||||
|
public function resolveBottomBarPrefix(string $partType, string $productCode, string $finishMaterial): string
|
||||||
|
{
|
||||||
|
if ($partType === 'lbar') {
|
||||||
|
return 'LA';
|
||||||
|
}
|
||||||
|
if ($partType === 'reinforce') {
|
||||||
|
return 'HH';
|
||||||
|
}
|
||||||
|
if ($partType === 'extra') {
|
||||||
|
return 'YY';
|
||||||
|
}
|
||||||
|
|
||||||
|
// main: 재질 기반
|
||||||
|
$codePrefix = $this->extractCodePrefix($productCode);
|
||||||
|
$isSteel = $codePrefix === 'KTE';
|
||||||
|
|
||||||
|
if ($isSteel) {
|
||||||
|
return 'TS';
|
||||||
|
}
|
||||||
|
|
||||||
|
$isSUS = in_array($codePrefix, ['KSS', 'KQTS']);
|
||||||
|
|
||||||
|
return $isSUS ? 'BS' : 'BE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 셔터박스
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 셔터박스 세부품목의 prefix 결정
|
||||||
|
*
|
||||||
|
* @param string $partType 'front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'
|
||||||
|
* @param bool $isStandardSize 500*380인지
|
||||||
|
* @return string prefix
|
||||||
|
*/
|
||||||
|
public function resolveShutterBoxPrefix(string $partType, bool $isStandardSize): string
|
||||||
|
{
|
||||||
|
if (! $isStandardSize) {
|
||||||
|
return 'XX';
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SHUTTER_STANDARD[$partType] ?? 'XX';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 연기차단재
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연기차단재 세부품목의 prefix 결정 (항상 GI)
|
||||||
|
*/
|
||||||
|
public function resolveSmokeBarrierPrefix(): string
|
||||||
|
{
|
||||||
|
return 'GI';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 코드 생성 및 조회
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* prefix + 길이(mm) → BD-XX-NN 코드 생성
|
||||||
|
*
|
||||||
|
* @param string $prefix LOT prefix (RS, RM, etc.)
|
||||||
|
* @param int $lengthMm 길이 (mm)
|
||||||
|
* @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80')
|
||||||
|
* @return string|null BD 코드 (길이코드 변환 실패 시 null)
|
||||||
|
*/
|
||||||
|
public function buildItemCode(string $prefix, int $lengthMm, ?string $smokeCategory = null): ?string
|
||||||
|
{
|
||||||
|
$lengthCode = self::lengthToCode($lengthMm, $smokeCategory);
|
||||||
|
if ($lengthCode === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "BD-{$prefix}-{$lengthCode}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BD-XX-NN 코드 → items.id 조회 (캐시)
|
||||||
|
*
|
||||||
|
* @return int|null items.id (미등록 시 null)
|
||||||
|
*/
|
||||||
|
public function resolveItemId(string $itemCode, int $tenantId = 287): ?int
|
||||||
|
{
|
||||||
|
$cacheKey = "{$tenantId}:{$itemCode}";
|
||||||
|
|
||||||
|
if (isset($this->itemIdCache[$cacheKey])) {
|
||||||
|
return $this->itemIdCache[$cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = DB::table('items')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('code', $itemCode)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
$this->itemIdCache[$cacheKey] = $id;
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 길이(mm) → 길이코드 변환
|
||||||
|
*
|
||||||
|
* @param int $lengthMm 길이 (mm)
|
||||||
|
* @param string|null $smokeCategory 연기차단재 카테고리 ('w50', 'w80')
|
||||||
|
* @return string|null 길이코드 (변환 불가 시 null)
|
||||||
|
*/
|
||||||
|
public static function lengthToCode(int $lengthMm, ?string $smokeCategory = null): ?string
|
||||||
|
{
|
||||||
|
// 연기차단재 전용 코드
|
||||||
|
if ($smokeCategory && isset(self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm])) {
|
||||||
|
return self::SMOKE_LENGTH_TO_CODE[$smokeCategory][$lengthMm];
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::LENGTH_TO_CODE[$lengthMm] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파트타입 한글명 반환
|
||||||
|
*/
|
||||||
|
public static function partTypeName(string $partType): string
|
||||||
|
{
|
||||||
|
return self::PART_TYPE_NAMES[$partType] ?? $partType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 초기화 (테스트 용)
|
||||||
|
*/
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
$this->itemIdCache = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// private
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 'KSS01' → 'KSS', 'KQTS01' → 'KQTS' 등 제품코드 prefix 추출
|
||||||
|
*/
|
||||||
|
private function extractCodePrefix(string $productCode): string
|
||||||
|
{
|
||||||
|
return preg_replace('/\d+$/', '', $productCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1662,11 +1662,11 @@ private function calculateTenantBom(
|
|||||||
$weightFormula = 'AREA × 25';
|
$weightFormula = 'AREA × 25';
|
||||||
$weightCalc = "{$area} × 25";
|
$weightCalc = "{$area} × 25";
|
||||||
} elseif ($productType === 'steel') {
|
} elseif ($productType === 'steel') {
|
||||||
// 철재: W1 × (H1 + 550) / 1M, 중량 = 면적 × 25
|
// 철재: W1 × H1 / 1M, 중량 = 면적 × 25 (레거시 Slat_updateCol12and13 동일)
|
||||||
$area = ($W1 * ($H1 + 550)) / 1000000;
|
$area = ($W1 * $H1) / 1000000;
|
||||||
$weight = $area * 25;
|
$weight = $area * 25;
|
||||||
$areaFormula = '(W1 × (H1 + 550)) / 1,000,000';
|
$areaFormula = '(W1 × H1) / 1,000,000';
|
||||||
$areaCalc = "({$W1} × ({$H1} + 550)) / 1,000,000";
|
$areaCalc = "({$W1} × {$H1}) / 1,000,000";
|
||||||
$weightFormula = 'AREA × 25';
|
$weightFormula = 'AREA × 25';
|
||||||
$weightCalc = "{$area} × 25";
|
$weightCalc = "{$area} × 25";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -73,9 +73,11 @@ public function index(array $params): LengthAwarePaginator
|
|||||||
$query->where('quote_type', $quoteType);
|
$query->where('quote_type', $quoteType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 필터
|
// 상태 필터 (converted는 order_id 기반으로 판별)
|
||||||
if ($status) {
|
if ($status === Quote::STATUS_CONVERTED) {
|
||||||
$query->where('status', $status);
|
$query->whereNotNull('order_id');
|
||||||
|
} elseif ($status) {
|
||||||
|
$query->where('status', $status)->whereNull('order_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 제품 카테고리 필터
|
// 제품 카테고리 필터
|
||||||
@@ -592,12 +594,12 @@ public function cancelFinalize(int $id): Quote
|
|||||||
throw new NotFoundHttpException(__('error.quote_not_found'));
|
throw new NotFoundHttpException(__('error.quote_not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($quote->status !== Quote::STATUS_FINALIZED) {
|
if ($quote->order_id) {
|
||||||
throw new BadRequestHttpException(__('error.quote_not_finalized'));
|
throw new BadRequestHttpException(__('error.quote_already_converted'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($quote->status === Quote::STATUS_CONVERTED) {
|
if ($quote->getRawOriginal('status') !== Quote::STATUS_FINALIZED) {
|
||||||
throw new BadRequestHttpException(__('error.quote_already_converted'));
|
throw new BadRequestHttpException(__('error.quote_not_finalized'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$quote->update([
|
$quote->update([
|
||||||
@@ -687,11 +689,23 @@ public function convertToOrder(int $id): Quote
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 수주 상세 품목 생성 (노드 연결 포함)
|
// 수주 상세 품목 생성 (노드 연결 포함)
|
||||||
|
// 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;
|
||||||
|
|
||||||
$serialIndex = 1;
|
$serialIndex = 1;
|
||||||
foreach ($quote->items as $quoteItem) {
|
foreach ($quote->items as $index => $quoteItem) {
|
||||||
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
|
$productMapping = $this->resolveLocationMapping($quoteItem, $productItems);
|
||||||
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
|
$locIdx = $this->resolveLocationIndex($quoteItem, $productItems);
|
||||||
|
|
||||||
|
// 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;
|
$productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null;
|
||||||
|
|
||||||
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
|
$orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping);
|
||||||
@@ -705,9 +719,8 @@ public function convertToOrder(int $id): Quote
|
|||||||
$order->recalculateTotals();
|
$order->recalculateTotals();
|
||||||
$order->save();
|
$order->save();
|
||||||
|
|
||||||
// 견적 상태 변경
|
// 견적에 수주 연결 (status는 accessor가 자동으로 'converted' 반환)
|
||||||
$quote->update([
|
$quote->update([
|
||||||
'status' => Quote::STATUS_CONVERTED,
|
|
||||||
'order_id' => $order->id,
|
'order_id' => $order->id,
|
||||||
'updated_by' => $userId,
|
'updated_by' => $userId,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -194,10 +194,13 @@ public function store(array $data): Receiving
|
|||||||
$receiving->item_name = $data['item_name'];
|
$receiving->item_name = $data['item_name'];
|
||||||
$receiving->specification = $data['specification'] ?? null;
|
$receiving->specification = $data['specification'] ?? null;
|
||||||
$receiving->supplier = $data['supplier'];
|
$receiving->supplier = $data['supplier'];
|
||||||
$receiving->order_qty = $data['order_qty'];
|
$receiving->order_qty = $data['order_qty'] ?? null;
|
||||||
$receiving->order_unit = $data['order_unit'] ?? 'EA';
|
$receiving->order_unit = $data['order_unit'] ?? 'EA';
|
||||||
$receiving->due_date = $data['due_date'] ?? null;
|
$receiving->due_date = $data['due_date'] ?? null;
|
||||||
$receiving->status = $data['status'] ?? 'order_completed';
|
$receiving->receiving_qty = $data['receiving_qty'] ?? null;
|
||||||
|
$receiving->receiving_date = $data['receiving_date'] ?? null;
|
||||||
|
$receiving->lot_no = $data['lot_no'] ?? $this->generateLotNo();
|
||||||
|
$receiving->status = $data['status'] ?? 'receiving_pending';
|
||||||
$receiving->remark = $data['remark'] ?? null;
|
$receiving->remark = $data['remark'] ?? null;
|
||||||
|
|
||||||
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
|
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public function aggregateDaily(int $tenantId, Carbon $date): int
|
|||||||
COALESCE(SUM(total_amount), 0) as total_amount,
|
COALESCE(SUM(total_amount), 0) as total_amount,
|
||||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||||
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
|
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
|
||||||
SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as conversion_count
|
SUM(CASE WHEN order_id IS NOT NULL THEN 1 ELSE 0 END) as conversion_count
|
||||||
")
|
")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Items\Item;
|
use App\Models\Items\Item;
|
||||||
|
use App\Models\Production\WorkOrder;
|
||||||
|
use App\Models\Production\WorkOrderItem;
|
||||||
use App\Models\Tenants\Receiving;
|
use App\Models\Tenants\Receiving;
|
||||||
use App\Models\Tenants\Stock;
|
use App\Models\Tenants\Stock;
|
||||||
use App\Models\Tenants\StockLot;
|
use App\Models\Tenants\StockLot;
|
||||||
@@ -67,6 +69,11 @@ public function index(array $params): LengthAwarePaginator
|
|||||||
$query->where('items.item_type', strtoupper($params['item_type']));
|
$query->where('items.item_type', strtoupper($params['item_type']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 품목 카테고리 필터 (Item.item_category: BENDING, SCREEN, STEEL 등)
|
||||||
|
if (! empty($params['item_category'])) {
|
||||||
|
$query->where('items.item_category', strtoupper($params['item_category']));
|
||||||
|
}
|
||||||
|
|
||||||
// 재고 상태 필터 (Stock.status)
|
// 재고 상태 필터 (Stock.status)
|
||||||
if (! empty($params['status'])) {
|
if (! empty($params['status'])) {
|
||||||
$query->whereHas('stock', function ($q) use ($params) {
|
$query->whereHas('stock', function ($q) use ($params) {
|
||||||
@@ -313,6 +320,99 @@ public function increaseFromReceiving(Receiving $receiving): StockLot
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 생산 완료 시 완성품 재고 입고
|
||||||
|
*
|
||||||
|
* increaseFromReceiving()을 기반으로 구현.
|
||||||
|
* 선생산(수주 없는 작업지시) 완료 시 양품을 재고로 적재.
|
||||||
|
*
|
||||||
|
* @param WorkOrder $workOrder 선생산 작업지시
|
||||||
|
* @param WorkOrderItem $woItem 작업지시 품목
|
||||||
|
* @param float $goodQty 양품 수량
|
||||||
|
* @param string $lotNo LOT 번호
|
||||||
|
* @return StockLot 생성된 StockLot
|
||||||
|
*/
|
||||||
|
public function increaseFromProduction(
|
||||||
|
WorkOrder $workOrder,
|
||||||
|
WorkOrderItem $woItem,
|
||||||
|
float $goodQty,
|
||||||
|
string $lotNo
|
||||||
|
): StockLot {
|
||||||
|
if (! $woItem->item_id) {
|
||||||
|
throw new \Exception(__('error.stock.item_id_required'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($workOrder, $woItem, $goodQty, $lotNo, $tenantId, $userId) {
|
||||||
|
// 1. Stock 조회 또는 생성
|
||||||
|
$stock = $this->getOrCreateStock($woItem->item_id);
|
||||||
|
|
||||||
|
// 2. FIFO 순서 계산
|
||||||
|
$fifoOrder = $this->getNextFifoOrder($stock->id);
|
||||||
|
|
||||||
|
// 3. StockLot 생성
|
||||||
|
$stockLot = new StockLot;
|
||||||
|
$stockLot->tenant_id = $tenantId;
|
||||||
|
$stockLot->stock_id = $stock->id;
|
||||||
|
$stockLot->lot_no = $lotNo;
|
||||||
|
$stockLot->fifo_order = $fifoOrder;
|
||||||
|
$stockLot->receipt_date = now()->toDateString();
|
||||||
|
$stockLot->qty = $goodQty;
|
||||||
|
$stockLot->reserved_qty = 0;
|
||||||
|
$stockLot->available_qty = $goodQty;
|
||||||
|
$stockLot->unit = $woItem->unit ?? 'EA';
|
||||||
|
$stockLot->supplier = null;
|
||||||
|
$stockLot->supplier_lot = null;
|
||||||
|
$stockLot->po_number = null;
|
||||||
|
$stockLot->location = null;
|
||||||
|
$stockLot->status = 'available';
|
||||||
|
$stockLot->receiving_id = null;
|
||||||
|
$stockLot->work_order_id = $workOrder->id;
|
||||||
|
$stockLot->created_by = $userId;
|
||||||
|
$stockLot->updated_by = $userId;
|
||||||
|
$stockLot->save();
|
||||||
|
|
||||||
|
// 4. Stock 합계 갱신
|
||||||
|
$stock->refreshFromLots();
|
||||||
|
|
||||||
|
// 5. 거래 이력 기록
|
||||||
|
$this->recordTransaction(
|
||||||
|
stock: $stock,
|
||||||
|
type: StockTransaction::TYPE_IN,
|
||||||
|
qty: $goodQty,
|
||||||
|
reason: StockTransaction::REASON_PRODUCTION_OUTPUT,
|
||||||
|
referenceType: 'work_order',
|
||||||
|
referenceId: $workOrder->id,
|
||||||
|
lotNo: $lotNo,
|
||||||
|
stockLotId: $stockLot->id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 감사 로그 기록
|
||||||
|
$this->logStockChange(
|
||||||
|
stock: $stock,
|
||||||
|
action: 'production_in',
|
||||||
|
reason: 'production_output',
|
||||||
|
referenceType: 'work_order',
|
||||||
|
referenceId: $workOrder->id,
|
||||||
|
qtyChange: $goodQty,
|
||||||
|
lotNo: $lotNo
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info('Stock increased from production', [
|
||||||
|
'work_order_id' => $workOrder->id,
|
||||||
|
'item_id' => $woItem->item_id,
|
||||||
|
'stock_id' => $stock->id,
|
||||||
|
'stock_lot_id' => $stockLot->id,
|
||||||
|
'qty' => $goodQty,
|
||||||
|
'lot_no' => $lotNo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stockLot;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 입고 수정 시 재고 조정 (차이만큼 증감)
|
* 입고 수정 시 재고 조정 (차이만큼 증감)
|
||||||
*
|
*
|
||||||
@@ -425,18 +525,41 @@ public function getOrCreateStock(int $itemId, ?Receiving $receiving = null): Sto
|
|||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
$stock = Stock::where('tenant_id', $tenantId)
|
$item = Item::where('tenant_id', $tenantId)
|
||||||
|
->findOrFail($itemId);
|
||||||
|
|
||||||
|
// 1차: item_id로 조회 (SoftDeletes 포함)
|
||||||
|
$stock = Stock::withTrashed()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
->where('item_id', $itemId)
|
->where('item_id', $itemId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
// 2차: item_code로 조회 (unique key 기준, item_id가 다를 수 있음)
|
||||||
|
if (! $stock) {
|
||||||
|
$stock = Stock::withTrashed()
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('item_code', $item->code)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// item_id가 변경된 경우 업데이트
|
||||||
|
if ($stock && $stock->item_id !== $itemId) {
|
||||||
|
$stock->item_id = $itemId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($stock) {
|
if ($stock) {
|
||||||
|
if ($stock->trashed()) {
|
||||||
|
$stock->restore();
|
||||||
|
$stock->status = 'out';
|
||||||
|
}
|
||||||
|
$stock->item_name = $item->name;
|
||||||
|
$stock->updated_by = $userId;
|
||||||
|
$stock->save();
|
||||||
|
|
||||||
return $stock;
|
return $stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stock이 없으면 새로 생성
|
// Stock이 없으면 새로 생성
|
||||||
$item = Item::where('tenant_id', $tenantId)
|
|
||||||
->findOrFail($itemId);
|
|
||||||
|
|
||||||
$stock = new Stock;
|
$stock = new Stock;
|
||||||
$stock->tenant_id = $tenantId;
|
$stock->tenant_id = $tenantId;
|
||||||
$stock->item_id = $itemId;
|
$stock->item_id = $itemId;
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class WorkOrderService extends Service
|
|||||||
private const AUDIT_TARGET = 'work_order';
|
private const AUDIT_TARGET = 'work_order';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AuditLogger $auditLogger
|
private readonly AuditLogger $auditLogger,
|
||||||
|
private readonly StockService $stockService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -587,15 +588,62 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
|
|||||||
// 연결된 수주(Order) 상태 동기화
|
// 연결된 수주(Order) 상태 동기화
|
||||||
$this->syncOrderStatus($workOrder, $tenantId);
|
$this->syncOrderStatus($workOrder, $tenantId);
|
||||||
|
|
||||||
// 작업완료 시 자동 출하 생성
|
// 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고
|
||||||
if ($status === WorkOrder::STATUS_COMPLETED) {
|
if ($status === WorkOrder::STATUS_COMPLETED) {
|
||||||
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
if ($workOrder->sales_order_id) {
|
||||||
|
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
||||||
|
} else {
|
||||||
|
$this->stockInFromProduction($workOrder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선생산 작업지시 완료 시 완성품을 재고로 입고
|
||||||
|
*
|
||||||
|
* 수주 없는 작업지시(sales_order_id = null)가 완료되면
|
||||||
|
* 각 품목의 양품 수량을 재고 시스템에 입고 처리합니다.
|
||||||
|
*/
|
||||||
|
private function stockInFromProduction(WorkOrder $workOrder): void
|
||||||
|
{
|
||||||
|
$workOrder->loadMissing('items.item');
|
||||||
|
|
||||||
|
foreach ($workOrder->items as $woItem) {
|
||||||
|
if ($this->shouldStockIn($woItem)) {
|
||||||
|
$resultData = $woItem->options['result'] ?? [];
|
||||||
|
$goodQty = (float) ($resultData['good_qty'] ?? $woItem->quantity);
|
||||||
|
$lotNo = $resultData['lot_no'] ?? '';
|
||||||
|
|
||||||
|
if ($goodQty > 0 && $lotNo) {
|
||||||
|
$this->stockService->increaseFromProduction(
|
||||||
|
$workOrder, $woItem, $goodQty, $lotNo
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목이 생산입고 대상인지 판단
|
||||||
|
*
|
||||||
|
* items.options의 production_source와 lot_managed 속성으로 판단.
|
||||||
|
*/
|
||||||
|
private function shouldStockIn(WorkOrderItem $woItem): bool
|
||||||
|
{
|
||||||
|
$item = $woItem->item;
|
||||||
|
if (! $item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = $item->options ?? [];
|
||||||
|
|
||||||
|
return ($options['production_source'] ?? null) === 'self_produced'
|
||||||
|
&& ($options['lot_managed'] ?? false) === true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 작업지시 완료 시 자동 출하 생성
|
* 작업지시 완료 시 자동 출하 생성
|
||||||
*
|
*
|
||||||
@@ -1144,13 +1192,69 @@ public function getMaterials(int $workOrderId): array
|
|||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: 작업지시 품목들에서 유니크 자재 목록 수집 (item_id 기준 합산)
|
// ── Step 1: dynamic_bom 대상 item_id 일괄 수집 (N+1 방지) ──
|
||||||
|
$allDynamicItemIds = [];
|
||||||
|
foreach ($workOrder->items as $woItem) {
|
||||||
|
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
|
||||||
|
$dynamicBom = $options['dynamic_bom'] ?? null;
|
||||||
|
if ($dynamicBom && is_array($dynamicBom)) {
|
||||||
|
$allDynamicItemIds = array_merge($allDynamicItemIds, array_column($dynamicBom, 'child_item_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배치 조회 (dynamic_bom 품목)
|
||||||
|
$dynamicItems = [];
|
||||||
|
if (! empty($allDynamicItemIds)) {
|
||||||
|
$dynamicItems = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||||
|
->whereIn('id', array_unique($allDynamicItemIds))
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: 유니크 자재 목록 수집 ──
|
||||||
|
// 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}"
|
||||||
$uniqueMaterials = [];
|
$uniqueMaterials = [];
|
||||||
|
|
||||||
foreach ($workOrder->items as $woItem) {
|
foreach ($workOrder->items as $woItem) {
|
||||||
|
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
|
||||||
|
$dynamicBom = $options['dynamic_bom'] ?? null;
|
||||||
|
|
||||||
|
// dynamic_bom 우선 — 있으면 BOM 무시
|
||||||
|
if ($dynamicBom && is_array($dynamicBom)) {
|
||||||
|
foreach ($dynamicBom as $bomEntry) {
|
||||||
|
$childItemId = $bomEntry['child_item_id'] ?? null;
|
||||||
|
if (! $childItemId || ! isset($dynamicItems[$childItemId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 합산 키: (item_id, work_order_item_id) 쌍
|
||||||
|
$key = $childItemId.'_'.$woItem->id;
|
||||||
|
// dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출
|
||||||
|
$bomQty = (float) ($bomEntry['qty'] ?? 1);
|
||||||
|
$woItemQty = max(1, (float) ($woItem->quantity ?? 1));
|
||||||
|
$perNodeQty = $bomQty / $woItemQty;
|
||||||
|
|
||||||
|
if (isset($uniqueMaterials[$key])) {
|
||||||
|
$uniqueMaterials[$key]['required_qty'] += $perNodeQty;
|
||||||
|
} else {
|
||||||
|
$uniqueMaterials[$key] = [
|
||||||
|
'item' => $dynamicItems[$childItemId],
|
||||||
|
'bom_qty' => $perNodeQty,
|
||||||
|
'required_qty' => $perNodeQty,
|
||||||
|
'work_order_item_id' => $woItem->id,
|
||||||
|
'lot_prefix' => $bomEntry['lot_prefix'] ?? null,
|
||||||
|
'part_type' => $bomEntry['part_type'] ?? null,
|
||||||
|
'category' => $bomEntry['category'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 BOM 로직 (하위 호환)
|
||||||
$materialItems = [];
|
$materialItems = [];
|
||||||
|
|
||||||
// BOM이 있으면 자식 품목들을 자재로 사용
|
|
||||||
if ($woItem->item_id) {
|
if ($woItem->item_id) {
|
||||||
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||||
->find($woItem->item_id);
|
->find($woItem->item_id);
|
||||||
@@ -1189,7 +1293,7 @@ public function getMaterials(int $workOrderId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유니크 자재 수집 (같은 item_id면 required_qty 합산)
|
// 기존 방식: item_id 기준 합산
|
||||||
foreach ($materialItems as $matInfo) {
|
foreach ($materialItems as $matInfo) {
|
||||||
$itemId = $matInfo['item']->id;
|
$itemId = $matInfo['item']->id;
|
||||||
if (isset($uniqueMaterials[$itemId])) {
|
if (isset($uniqueMaterials[$itemId])) {
|
||||||
@@ -1200,30 +1304,67 @@ public function getMaterials(int $workOrderId): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: 유니크 자재별로 StockLot 조회
|
// ── Step 3: 유니크 자재별로 StockLot 조회 ──
|
||||||
|
// 배치 조회를 위해 전체 item_id 수집
|
||||||
|
$allItemIds = [];
|
||||||
|
foreach ($uniqueMaterials as $matInfo) {
|
||||||
|
$allItemIds[] = $matInfo['item']->id;
|
||||||
|
}
|
||||||
|
$allItemIds = array_unique($allItemIds);
|
||||||
|
|
||||||
|
// Stock 배치 조회 (N+1 방지)
|
||||||
|
$stockMap = [];
|
||||||
|
if (! empty($allItemIds)) {
|
||||||
|
$stocks = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
||||||
|
->whereIn('item_id', $allItemIds)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($stocks as $stock) {
|
||||||
|
$stockMap[$stock->item_id] = $stock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StockLot 배치 조회 (N+1 방지)
|
||||||
|
$lotsByStockId = [];
|
||||||
|
$stockIds = array_map(fn ($s) => $s->id, $stockMap);
|
||||||
|
if (! empty($stockIds)) {
|
||||||
|
$allLots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
|
||||||
|
->whereIn('stock_id', $stockIds)
|
||||||
|
->where('status', 'available')
|
||||||
|
->where('available_qty', '>', 0)
|
||||||
|
->orderBy('fifo_order', 'asc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($allLots as $lot) {
|
||||||
|
$lotsByStockId[$lot->stock_id][] = $lot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$materials = [];
|
$materials = [];
|
||||||
$rank = 1;
|
$rank = 1;
|
||||||
|
|
||||||
foreach ($uniqueMaterials as $matInfo) {
|
foreach ($uniqueMaterials as $matInfo) {
|
||||||
$materialItem = $matInfo['item'];
|
$materialItem = $matInfo['item'];
|
||||||
|
$stock = $stockMap[$materialItem->id] ?? null;
|
||||||
$stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
|
|
||||||
->where('item_id', $materialItem->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$lotsFound = false;
|
$lotsFound = false;
|
||||||
|
|
||||||
|
// 공통 필드 (dynamic_bom 추가 필드 포함)
|
||||||
|
$extraFields = [];
|
||||||
|
if (isset($matInfo['work_order_item_id'])) {
|
||||||
|
$extraFields = [
|
||||||
|
'work_order_item_id' => $matInfo['work_order_item_id'],
|
||||||
|
'lot_prefix' => $matInfo['lot_prefix'],
|
||||||
|
'part_type' => $matInfo['part_type'],
|
||||||
|
'category' => $matInfo['category'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
if ($stock) {
|
if ($stock) {
|
||||||
$lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId)
|
$lots = $lotsByStockId[$stock->id] ?? [];
|
||||||
->where('stock_id', $stock->id)
|
|
||||||
->where('status', 'available')
|
|
||||||
->where('available_qty', '>', 0)
|
|
||||||
->orderBy('fifo_order', 'asc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($lots as $lot) {
|
foreach ($lots as $lot) {
|
||||||
$lotsFound = true;
|
$lotsFound = true;
|
||||||
$materials[] = [
|
$materials[] = array_merge([
|
||||||
'stock_lot_id' => $lot->id,
|
'stock_lot_id' => $lot->id,
|
||||||
'item_id' => $materialItem->id,
|
'item_id' => $materialItem->id,
|
||||||
'lot_no' => $lot->lot_no,
|
'lot_no' => $lot->lot_no,
|
||||||
@@ -1239,13 +1380,13 @@ public function getMaterials(int $workOrderId): array
|
|||||||
'receipt_date' => $lot->receipt_date,
|
'receipt_date' => $lot->receipt_date,
|
||||||
'supplier' => $lot->supplier,
|
'supplier' => $lot->supplier,
|
||||||
'fifo_rank' => $rank++,
|
'fifo_rank' => $rank++,
|
||||||
];
|
], $extraFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
|
// 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시)
|
||||||
if (! $lotsFound) {
|
if (! $lotsFound) {
|
||||||
$materials[] = [
|
$materials[] = array_merge([
|
||||||
'stock_lot_id' => null,
|
'stock_lot_id' => null,
|
||||||
'item_id' => $materialItem->id,
|
'item_id' => $materialItem->id,
|
||||||
'lot_no' => null,
|
'lot_no' => null,
|
||||||
@@ -1261,7 +1402,7 @@ public function getMaterials(int $workOrderId): array
|
|||||||
'receipt_date' => null,
|
'receipt_date' => null,
|
||||||
'supplier' => null,
|
'supplier' => null,
|
||||||
'fifo_rank' => $rank++,
|
'fifo_rank' => $rank++,
|
||||||
];
|
], $extraFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1289,11 +1430,50 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
|
|||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) {
|
// work_order_item_id가 있는 항목은 registerMaterialInputForItem()으로 위임
|
||||||
|
$groupedByItem = [];
|
||||||
|
$noItemInputs = [];
|
||||||
|
|
||||||
|
foreach ($inputs as $input) {
|
||||||
|
$woItemId = $input['work_order_item_id'] ?? null;
|
||||||
|
if ($woItemId) {
|
||||||
|
$groupedByItem[$woItemId][] = $input;
|
||||||
|
} else {
|
||||||
|
$noItemInputs[] = $input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// work_order_item_id가 있는 항목 → 개소별 투입으로 위임
|
||||||
|
$delegatedResults = [];
|
||||||
|
foreach ($groupedByItem as $woItemId => $itemInputs) {
|
||||||
|
$delegatedResults[] = $this->registerMaterialInputForItem($workOrderId, $woItemId, $itemInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// work_order_item_id가 없는 항목 → 기존 방식 + WorkOrderMaterialInput 레코드 생성
|
||||||
|
if (empty($noItemInputs)) {
|
||||||
|
// 전부 위임된 경우
|
||||||
|
$totalCount = array_sum(array_column($delegatedResults, 'material_count'));
|
||||||
|
$allResults = array_merge(...array_map(fn ($r) => $r['input_results'], $delegatedResults));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'work_order_id' => $workOrderId,
|
||||||
|
'material_count' => $totalCount,
|
||||||
|
'input_results' => $allResults,
|
||||||
|
'input_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: 첫 번째 work_order_item_id로 매핑
|
||||||
|
$fallbackWoItemId = WorkOrderItem::where('tenant_id', $tenantId)
|
||||||
|
->where('work_order_id', $workOrderId)
|
||||||
|
->orderBy('id')
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($noItemInputs, $tenantId, $userId, $workOrderId, $fallbackWoItemId, $delegatedResults) {
|
||||||
$stockService = app(StockService::class);
|
$stockService = app(StockService::class);
|
||||||
$inputResults = [];
|
$inputResults = [];
|
||||||
|
|
||||||
foreach ($inputs as $input) {
|
foreach ($noItemInputs as $input) {
|
||||||
$stockLotId = $input['stock_lot_id'] ?? null;
|
$stockLotId = $input['stock_lot_id'] ?? null;
|
||||||
$qty = (float) ($input['qty'] ?? 0);
|
$qty = (float) ($input['qty'] ?? 0);
|
||||||
|
|
||||||
@@ -1309,6 +1489,21 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
|
|||||||
referenceId: $workOrderId
|
referenceId: $workOrderId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// WorkOrderMaterialInput 레코드 생성 (이력 통일)
|
||||||
|
$lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId);
|
||||||
|
$lotItemId = $lot?->stock?->item_id;
|
||||||
|
|
||||||
|
WorkOrderMaterialInput::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'work_order_id' => $workOrderId,
|
||||||
|
'work_order_item_id' => $fallbackWoItemId,
|
||||||
|
'stock_lot_id' => $stockLotId,
|
||||||
|
'item_id' => $lotItemId ?? 0,
|
||||||
|
'qty' => $qty,
|
||||||
|
'input_by' => $userId,
|
||||||
|
'input_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
$inputResults[] = [
|
$inputResults[] = [
|
||||||
'stock_lot_id' => $stockLotId,
|
'stock_lot_id' => $stockLotId,
|
||||||
'qty' => $qty,
|
'qty' => $qty,
|
||||||
@@ -1325,17 +1520,23 @@ public function registerMaterialInput(int $workOrderId, array $inputs): array
|
|||||||
'material_input',
|
'material_input',
|
||||||
null,
|
null,
|
||||||
[
|
[
|
||||||
'inputs' => $inputs,
|
'inputs' => $noItemInputs,
|
||||||
'input_results' => $inputResults,
|
'input_results' => $inputResults,
|
||||||
'input_by' => $userId,
|
'input_by' => $userId,
|
||||||
'input_at' => now()->toDateTimeString(),
|
'input_at' => now()->toDateTimeString(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 위임된 결과와 합산
|
||||||
|
$allResults = $inputResults;
|
||||||
|
foreach ($delegatedResults as $dr) {
|
||||||
|
$allResults = array_merge($allResults, $dr['input_results']);
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'work_order_id' => $workOrderId,
|
'work_order_id' => $workOrderId,
|
||||||
'material_count' => count($inputResults),
|
'material_count' => count($allResults),
|
||||||
'input_results' => $inputResults,
|
'input_results' => $allResults,
|
||||||
'input_at' => now()->toDateTimeString(),
|
'input_at' => now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -2193,8 +2394,8 @@ public function getInspectionReport(int $workOrderId): array
|
|||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
|
|
||||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
||||||
->with(['order', 'items' => function ($q) {
|
->with(['salesOrder', 'items' => function ($q) {
|
||||||
$q->ordered();
|
$q->ordered()->with('sourceOrderItem');
|
||||||
}])
|
}])
|
||||||
->find($workOrderId);
|
->find($workOrderId);
|
||||||
|
|
||||||
@@ -2202,18 +2403,61 @@ public function getInspectionReport(int $workOrderId): array
|
|||||||
throw new NotFoundHttpException(__('error.not_found'));
|
throw new NotFoundHttpException(__('error.not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$items = $workOrder->items->map(function ($item) {
|
// 개소(order_node_id)별 그룹핑 — WorkerScreen과 동일한 구조
|
||||||
return [
|
$grouped = $workOrder->items->groupBy(
|
||||||
'id' => $item->id,
|
fn ($item) => $item->sourceOrderItem?->order_node_id ?? 'unassigned'
|
||||||
'item_name' => $item->item_name,
|
);
|
||||||
'specification' => $item->specification,
|
|
||||||
'quantity' => $item->quantity,
|
$nodeIds = $grouped->keys()->filter(fn ($k) => $k !== 'unassigned')->values()->all();
|
||||||
'sort_order' => $item->sort_order,
|
$nodes = ! empty($nodeIds)
|
||||||
'status' => $item->status,
|
? \App\Models\Orders\OrderNode::whereIn('id', $nodeIds)->get()->keyBy('id')
|
||||||
'options' => $item->options,
|
: collect();
|
||||||
'inspection_data' => $item->getInspectionData(),
|
|
||||||
|
$nodeGroups = [];
|
||||||
|
foreach ($grouped as $nodeId => $groupItems) {
|
||||||
|
$node = $nodeId !== 'unassigned' ? $nodes->get($nodeId) : null;
|
||||||
|
$nodeOpts = $node?->options ?? [];
|
||||||
|
|
||||||
|
$firstItem = $groupItems->first();
|
||||||
|
$soi = $firstItem->sourceOrderItem;
|
||||||
|
$floorCode = $soi?->floor_code ?? '-';
|
||||||
|
$symbolCode = $soi?->symbol_code ?? '-';
|
||||||
|
$floorLabel = collect([$floorCode, $symbolCode])
|
||||||
|
->filter(fn ($v) => $v && $v !== '-')->join('/');
|
||||||
|
|
||||||
|
$nodeGroups[] = [
|
||||||
|
'node_id' => $nodeId !== 'unassigned' ? (int) $nodeId : null,
|
||||||
|
'node_name' => $floorLabel ?: ($node?->name ?? '미지정'),
|
||||||
|
'floor' => $nodeOpts['floor'] ?? $floorCode,
|
||||||
|
'code' => $nodeOpts['symbol'] ?? $symbolCode,
|
||||||
|
'width' => $nodeOpts['width'] ?? 0,
|
||||||
|
'height' => $nodeOpts['height'] ?? 0,
|
||||||
|
'total_quantity' => $groupItems->sum('quantity'),
|
||||||
|
'options' => $nodeOpts,
|
||||||
|
'items' => $groupItems->map(fn ($item) => [
|
||||||
|
'id' => $item->id,
|
||||||
|
'item_name' => $item->item_name,
|
||||||
|
'specification' => $item->specification,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'sort_order' => $item->sort_order,
|
||||||
|
'status' => $item->status,
|
||||||
|
'options' => $item->options,
|
||||||
|
'inspection_data' => $item->getInspectionData(),
|
||||||
|
])->values()->all(),
|
||||||
];
|
];
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 플랫 아이템 목록 (summary 계산용)
|
||||||
|
$items = $workOrder->items->map(fn ($item) => [
|
||||||
|
'id' => $item->id,
|
||||||
|
'item_name' => $item->item_name,
|
||||||
|
'specification' => $item->specification,
|
||||||
|
'quantity' => $item->quantity,
|
||||||
|
'sort_order' => $item->sort_order,
|
||||||
|
'status' => $item->status,
|
||||||
|
'options' => $item->options,
|
||||||
|
'inspection_data' => $item->getInspectionData(),
|
||||||
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'work_order' => [
|
'work_order' => [
|
||||||
@@ -2223,13 +2467,14 @@ public function getInspectionReport(int $workOrderId): array
|
|||||||
'planned_date' => $workOrder->planned_date,
|
'planned_date' => $workOrder->planned_date,
|
||||||
'due_date' => $workOrder->due_date,
|
'due_date' => $workOrder->due_date,
|
||||||
],
|
],
|
||||||
'order' => $workOrder->order ? [
|
'order' => $workOrder->salesOrder ? [
|
||||||
'id' => $workOrder->order->id,
|
'id' => $workOrder->salesOrder->id,
|
||||||
'order_no' => $workOrder->order->order_no,
|
'order_no' => $workOrder->salesOrder->order_no,
|
||||||
'client_name' => $workOrder->order->client_name ?? null,
|
'client_name' => $workOrder->salesOrder->client_name ?? null,
|
||||||
'site_name' => $workOrder->order->site_name ?? null,
|
'site_name' => $workOrder->salesOrder->site_name ?? null,
|
||||||
'order_date' => $workOrder->order->order_date ?? null,
|
'order_date' => $workOrder->salesOrder->order_date ?? null,
|
||||||
] : null,
|
] : null,
|
||||||
|
'node_groups' => $nodeGroups,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'total_items' => $items->count(),
|
'total_items' => $items->count(),
|
||||||
@@ -2604,7 +2849,45 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
|
|||||||
// 해당 개소의 BOM 기반 자재 추출
|
// 해당 개소의 BOM 기반 자재 추출
|
||||||
$materialItems = [];
|
$materialItems = [];
|
||||||
|
|
||||||
if ($woItem->item_id) {
|
// ① dynamic_bom 우선 체크 (절곡 등 동적 BOM 사용 공정)
|
||||||
|
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
|
||||||
|
$dynamicBom = $options['dynamic_bom'] ?? null;
|
||||||
|
|
||||||
|
if ($dynamicBom && is_array($dynamicBom)) {
|
||||||
|
// dynamic_bom child_item_id 배치 조회 (N+1 방지)
|
||||||
|
$childItemIds = array_filter(array_column($dynamicBom, 'child_item_id'));
|
||||||
|
$childItems = [];
|
||||||
|
if (! empty($childItemIds)) {
|
||||||
|
$childItems = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||||
|
->whereIn('id', array_unique($childItemIds))
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($dynamicBom as $bomEntry) {
|
||||||
|
$childItemId = $bomEntry['child_item_id'] ?? null;
|
||||||
|
if (! $childItemId || ! isset($childItems[$childItemId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamic_bom.qty는 아이템 수량이 곱해져 있으므로 나눠서 개소당 수량 산출
|
||||||
|
// (작업일지 bendingInfo와 동일한 수량)
|
||||||
|
$bomQty = (float) ($bomEntry['qty'] ?? 1);
|
||||||
|
$woItemQty = max(1, (float) ($woItem->quantity ?? 1));
|
||||||
|
$perNodeQty = $bomQty / $woItemQty;
|
||||||
|
$materialItems[] = [
|
||||||
|
'item' => $childItems[$childItemId],
|
||||||
|
'bom_qty' => $perNodeQty,
|
||||||
|
'required_qty' => $perNodeQty,
|
||||||
|
'lot_prefix' => $bomEntry['lot_prefix'] ?? null,
|
||||||
|
'part_type' => $bomEntry['part_type'] ?? null,
|
||||||
|
'category' => $bomEntry['category'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② dynamic_bom이 없으면 정적 BOM fallback
|
||||||
|
if (empty($materialItems) && $woItem->item_id) {
|
||||||
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||||
->find($woItem->item_id);
|
->find($woItem->item_id);
|
||||||
|
|
||||||
@@ -2693,6 +2976,9 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
|
|||||||
'receipt_date' => $lot->receipt_date,
|
'receipt_date' => $lot->receipt_date,
|
||||||
'supplier' => $lot->supplier,
|
'supplier' => $lot->supplier,
|
||||||
'fifo_rank' => $rank++,
|
'fifo_rank' => $rank++,
|
||||||
|
'lot_prefix' => $matInfo['lot_prefix'] ?? null,
|
||||||
|
'part_type' => $matInfo['part_type'] ?? null,
|
||||||
|
'category' => $matInfo['category'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2716,6 +3002,9 @@ public function getMaterialsForItem(int $workOrderId, int $itemId): array
|
|||||||
'receipt_date' => null,
|
'receipt_date' => null,
|
||||||
'supplier' => null,
|
'supplier' => null,
|
||||||
'fifo_rank' => $rank++,
|
'fifo_rank' => $rank++,
|
||||||
|
'lot_prefix' => $matInfo['lot_prefix'] ?? null,
|
||||||
|
'part_type' => $matInfo['part_type'] ?? null,
|
||||||
|
'category' => $matInfo['category'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2764,9 +3053,9 @@ public function registerMaterialInputForItem(int $workOrderId, int $itemId, arra
|
|||||||
referenceId: $workOrderId
|
referenceId: $workOrderId
|
||||||
);
|
);
|
||||||
|
|
||||||
// 로트의 품목 ID 조회
|
// 로트의 품목 ID 조회 (Eager Loading으로 N+1 방지)
|
||||||
$lot = \App\Models\Tenants\StockLot::find($stockLotId);
|
$lot = \App\Models\Tenants\StockLot::with('stock')->find($stockLotId);
|
||||||
$lotItemId = $lot ? ($lot->stock->item_id ?? null) : null;
|
$lotItemId = $lot?->stock?->item_id;
|
||||||
|
|
||||||
// 개소별 매핑 레코드 생성
|
// 개소별 매핑 레코드 생성
|
||||||
WorkOrderMaterialInput::create([
|
WorkOrderMaterialInput::create([
|
||||||
|
|||||||
@@ -153,6 +153,23 @@
|
|||||||
*
|
*
|
||||||
* @OA\Property(property="status", type="string", enum={"DRAFT", "CONFIRMED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}, example="CONFIRMED")
|
* @OA\Property(property="status", type="string", enum={"DRAFT", "CONFIRMED", "IN_PROGRESS", "COMPLETED", "CANCELLED"}, example="CONFIRMED")
|
||||||
* )
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="OrderBulkDeleteRequest",
|
||||||
|
* type="object",
|
||||||
|
* required={"ids"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="ids", type="array", @OA\Items(type="integer"), example={1, 2, 3}),
|
||||||
|
* @OA\Property(property="force", type="boolean", description="강제 삭제 여부 (진행중 수주 포함)", example=false)
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="OrderRevertProductionRequest",
|
||||||
|
* type="object",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="force", type="boolean", description="강제 되돌리기 (물리 삭제, 기본값 false)", example=false),
|
||||||
|
* @OA\Property(property="reason", type="string", description="되돌리기 사유 (운영 모드 시 필수)", example="고객 요청에 의한 생산지시 취소")
|
||||||
|
* )
|
||||||
*/
|
*/
|
||||||
class OrderApi
|
class OrderApi
|
||||||
{
|
{
|
||||||
@@ -364,4 +381,86 @@ public function destroy() {}
|
|||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
public function updateStatus() {}
|
public function updateStatus() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/v1/orders/bulk",
|
||||||
|
* tags={"Order"},
|
||||||
|
* summary="수주 일괄 삭제",
|
||||||
|
* description="여러 수주를 일괄 삭제합니다 (Soft Delete). 진행중/완료 수주는 건너뜁니다.",
|
||||||
|
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/OrderBulkDeleteRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string"),
|
||||||
|
* @OA\Property(property="data", type="object",
|
||||||
|
* @OA\Property(property="deleted_count", type="integer", example=3),
|
||||||
|
* @OA\Property(property="skipped_count", type="integer", example=1),
|
||||||
|
* @OA\Property(property="skipped_ids", type="array", @OA\Items(type="integer"), example={5})
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=422, description="유효성 검증 실패")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function bulkDestroy() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/orders/{id}/revert-production",
|
||||||
|
* tags={"Order"},
|
||||||
|
* summary="생산지시 되돌리기",
|
||||||
|
* description="생산지시를 되돌립니다. 기본 모드(force=false)에서는 작업지시를 취소 처리하며, 강제 모드(force=true)에서는 물리 삭제합니다.",
|
||||||
|
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=false,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/OrderRevertProductionRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="success", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="message", type="string"),
|
||||||
|
* @OA\Property(property="data", type="object",
|
||||||
|
* @OA\Property(property="order", ref="#/components/schemas/Order"),
|
||||||
|
* @OA\Property(property="deleted_counts", type="object",
|
||||||
|
* @OA\Property(property="work_results", type="integer", example=0),
|
||||||
|
* @OA\Property(property="work_order_items", type="integer", example=5),
|
||||||
|
* @OA\Property(property="work_orders", type="integer", example=2)
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="cancelled_counts", type="object", nullable=true,
|
||||||
|
* @OA\Property(property="work_orders", type="integer", example=2),
|
||||||
|
* @OA\Property(property="work_order_items", type="integer", example=5)
|
||||||
|
* ),
|
||||||
|
* @OA\Property(property="previous_status", type="string", example="IN_PROGRESS")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="되돌리기 불가 상태 (수주확정/수주등록 상태)"),
|
||||||
|
* @OA\Response(response=404, description="수주를 찾을 수 없음"),
|
||||||
|
* @OA\Response(response=422, description="운영 모드에서 사유 미입력")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function revertProductionOrder() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 선생산 완료 시 재고 입고를 위해 stock_lots에 work_order_id FK 추가
|
||||||
|
* - 구매입고: receiving_id 참조
|
||||||
|
* - 생산입고: work_order_id 참조
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('stock_lots', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('work_order_id')
|
||||||
|
->nullable()
|
||||||
|
->after('receiving_id')
|
||||||
|
->comment('생산입고 시 작업지시 참조');
|
||||||
|
|
||||||
|
$table->foreign('work_order_id')
|
||||||
|
->references('id')
|
||||||
|
->on('work_orders')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('stock_lots', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['work_order_id']);
|
||||||
|
$table->dropColumn('work_order_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
151
database/seeders/Kyungdong/BendingItemSeeder.php
Normal file
151
database/seeders/Kyungdong/BendingItemSeeder.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders\Kyungdong;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BD-* 미등록 절곡 세부품목 일괄 등록
|
||||||
|
*
|
||||||
|
* Phase 0.1: BD-XX (7개), BD-YY (4개), BD-HH (2개)
|
||||||
|
* 추가 누락: BD-RM/RC/RD-42, BD-SM-24, BD-BS-35/43, BD-TS-43, BD-GI-54/84
|
||||||
|
*
|
||||||
|
* 실행: php artisan db:seed --class="Database\Seeders\Kyungdong\BendingItemSeeder"
|
||||||
|
*/
|
||||||
|
class BendingItemSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private int $tenantId = 287; // 경동기업
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 길이코드 → mm 매핑
|
||||||
|
*/
|
||||||
|
private const LENGTH_MAP = [
|
||||||
|
'12' => 1219,
|
||||||
|
'24' => 2438,
|
||||||
|
'30' => 3000,
|
||||||
|
'35' => 3500,
|
||||||
|
'40' => 4000,
|
||||||
|
'41' => 4150,
|
||||||
|
'42' => 4200,
|
||||||
|
'43' => 4300,
|
||||||
|
'53' => 3000, // 연기차단재50 전용
|
||||||
|
'54' => 4000, // 연기차단재50 전용
|
||||||
|
'83' => 3000, // 연기차단재80 전용
|
||||||
|
'84' => 4000, // 연기차단재80 전용
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록 대상 정의
|
||||||
|
*/
|
||||||
|
private function getItemDefinitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Phase 0.1 대상
|
||||||
|
'XX' => [
|
||||||
|
'name' => '하부BASE/셔터 상부/마구리',
|
||||||
|
'lengthCodes' => ['12', '24', '30', '35', '40', '41', '43'],
|
||||||
|
],
|
||||||
|
'YY' => [
|
||||||
|
'name' => '별도SUS마감',
|
||||||
|
'lengthCodes' => ['30', '35', '40', '43'],
|
||||||
|
],
|
||||||
|
'HH' => [
|
||||||
|
'name' => '보강평철',
|
||||||
|
'lengthCodes' => ['30', '40'],
|
||||||
|
],
|
||||||
|
// 추가 누락분
|
||||||
|
'RM' => [
|
||||||
|
'name' => '가이드레일(벽면) 본체',
|
||||||
|
'lengthCodes' => ['42'],
|
||||||
|
],
|
||||||
|
'RC' => [
|
||||||
|
'name' => '가이드레일(벽면) C형',
|
||||||
|
'lengthCodes' => ['42'],
|
||||||
|
],
|
||||||
|
'RD' => [
|
||||||
|
'name' => '가이드레일(벽면) D형',
|
||||||
|
'lengthCodes' => ['42'],
|
||||||
|
],
|
||||||
|
'SM' => [
|
||||||
|
'name' => '가이드레일(측면) 본체',
|
||||||
|
'lengthCodes' => ['24'],
|
||||||
|
],
|
||||||
|
'BS' => [
|
||||||
|
'name' => '하단마감재 SUS',
|
||||||
|
'lengthCodes' => ['35', '43'],
|
||||||
|
],
|
||||||
|
'TS' => [
|
||||||
|
'name' => '하단마감재 철재SUS',
|
||||||
|
'lengthCodes' => ['43'],
|
||||||
|
],
|
||||||
|
'GI' => [
|
||||||
|
'name' => '연기차단재',
|
||||||
|
'lengthCodes' => ['54', '84'],
|
||||||
|
'nameOverrides' => [
|
||||||
|
'54' => '연기차단재 W50 4000mm',
|
||||||
|
'84' => '연기차단재 W80 4000mm',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$definitions = $this->getItemDefinitions();
|
||||||
|
$created = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($definitions as $prefix => $def) {
|
||||||
|
foreach ($def['lengthCodes'] as $lengthCode) {
|
||||||
|
$code = "BD-{$prefix}-{$lengthCode}";
|
||||||
|
$lengthMm = self::LENGTH_MAP[$lengthCode];
|
||||||
|
$name = ($def['nameOverrides'][$lengthCode] ?? null)
|
||||||
|
?: "{$def['name']} {$lengthMm}mm";
|
||||||
|
|
||||||
|
$exists = DB::table('items')
|
||||||
|
->where('tenant_id', $this->tenantId)
|
||||||
|
->where('code', $code)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->command?->line(" ⏭️ skip (exists): {$code}");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('items')->insert([
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $name,
|
||||||
|
'item_type' => 'PT',
|
||||||
|
'item_category' => 'BENDING',
|
||||||
|
'unit' => 'EA',
|
||||||
|
'bom' => null,
|
||||||
|
'attributes' => json_encode([]),
|
||||||
|
'attributes_archive' => json_encode([]),
|
||||||
|
'options' => json_encode([
|
||||||
|
'source' => 'bending_item_seeder',
|
||||||
|
'lot_managed' => true,
|
||||||
|
'consumption_method' => 'auto',
|
||||||
|
'production_source' => 'self_produced',
|
||||||
|
'input_tracking' => true,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'length_code' => $lengthCode,
|
||||||
|
'length_mm' => $lengthMm,
|
||||||
|
]),
|
||||||
|
'is_active' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->command?->line(" ✅ created: {$code} ({$name})");
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command?->info("BD-* 누락 품목 등록 완료: 생성 {$created}건, 스킵 {$skipped}건");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -416,6 +416,8 @@
|
|||||||
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
|
'production_order_already_exists' => '이미 생산지시가 존재합니다.',
|
||||||
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
|
'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.',
|
||||||
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
|
'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.',
|
||||||
|
'cannot_revert_work_order_completed' => '완료 또는 출하된 작업지시는 취소할 수 없습니다.',
|
||||||
|
'cancel_reason_required' => '취소 사유를 입력해주세요.',
|
||||||
'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.',
|
'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -468,6 +468,7 @@
|
|||||||
'created' => '수주가 등록되었습니다.',
|
'created' => '수주가 등록되었습니다.',
|
||||||
'updated' => '수주가 수정되었습니다.',
|
'updated' => '수주가 수정되었습니다.',
|
||||||
'deleted' => '수주가 삭제되었습니다.',
|
'deleted' => '수주가 삭제되었습니다.',
|
||||||
|
'bulk_deleted' => '수주가 일괄 삭제되었습니다.',
|
||||||
'status_updated' => '수주 상태가 변경되었습니다.',
|
'status_updated' => '수주 상태가 변경되었습니다.',
|
||||||
'created_from_quote' => '견적에서 수주가 생성되었습니다.',
|
'created_from_quote' => '견적에서 수주가 생성되었습니다.',
|
||||||
'production_order_created' => '생산지시가 생성되었습니다.',
|
'production_order_created' => '생산지시가 생성되었습니다.',
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록
|
Route::get('', [OrderController::class, 'index'])->name('v1.orders.index'); // 목록
|
||||||
Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계
|
Route::get('/stats', [OrderController::class, 'stats'])->name('v1.orders.stats'); // 통계
|
||||||
Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성
|
Route::post('', [OrderController::class, 'store'])->name('v1.orders.store'); // 생성
|
||||||
|
Route::delete('/bulk', [OrderController::class, 'bulkDestroy'])->name('v1.orders.bulk-destroy'); // 일괄 삭제
|
||||||
Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세
|
Route::get('/{id}', [OrderController::class, 'show'])->whereNumber('id')->name('v1.orders.show'); // 상세
|
||||||
Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정
|
Route::put('/{id}', [OrderController::class, 'update'])->whereNumber('id')->name('v1.orders.update'); // 수정
|
||||||
Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제
|
Route::delete('/{id}', [OrderController::class, 'destroy'])->whereNumber('id')->name('v1.orders.destroy'); // 삭제
|
||||||
@@ -162,6 +163,9 @@
|
|||||||
// 견적에서 수주 생성
|
// 견적에서 수주 생성
|
||||||
Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote');
|
Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote');
|
||||||
|
|
||||||
|
// 절곡 재고 현황 확인
|
||||||
|
Route::get('/{id}/bending-stock', [OrderController::class, 'checkBendingStock'])->whereNumber('id')->name('v1.orders.bending-stock');
|
||||||
|
|
||||||
// 생산지시 생성
|
// 생산지시 생성
|
||||||
Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order');
|
Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order');
|
||||||
|
|
||||||
|
|||||||
@@ -35625,6 +35625,191 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/orders/bulk": {
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"Order"
|
||||||
|
],
|
||||||
|
"summary": "수주 일괄 삭제",
|
||||||
|
"description": "여러 수주를 일괄 삭제합니다 (Soft Delete). 진행중/완료 수주는 건너뜁니다.",
|
||||||
|
"operationId": "84fb75f391c22af2356913507ddb98d1",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OrderBulkDeleteRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "성공",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"properties": {
|
||||||
|
"deleted_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 3
|
||||||
|
},
|
||||||
|
"skipped_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"skipped_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
5
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "유효성 검증 실패"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/orders/{id}/revert-production": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Order"
|
||||||
|
],
|
||||||
|
"summary": "생산지시 되돌리기",
|
||||||
|
"description": "생산지시를 되돌립니다. 기본 모드(force=false)에서는 작업지시를 취소 처리하며, 강제 모드(force=true)에서는 물리 삭제합니다.",
|
||||||
|
"operationId": "73634a58fa6ef750ab26e38747d03f65",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": false,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/OrderRevertProductionRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "성공",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"properties": {
|
||||||
|
"order": {
|
||||||
|
"$ref": "#/components/schemas/Order"
|
||||||
|
},
|
||||||
|
"deleted_counts": {
|
||||||
|
"properties": {
|
||||||
|
"work_results": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 0
|
||||||
|
},
|
||||||
|
"work_order_items": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
},
|
||||||
|
"work_orders": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"cancelled_counts": {
|
||||||
|
"properties": {
|
||||||
|
"work_orders": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"work_order_items": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"previous_status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "IN_PROGRESS"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "되돌리기 불가 상태 (수주확정/수주등록 상태)"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "수주를 찾을 수 없음"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "운영 모드에서 사유 미입력"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"ApiKeyAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/payments": {
|
"/api/v1/payments": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -79288,6 +79473,45 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"OrderBulkDeleteRequest": {
|
||||||
|
"required": [
|
||||||
|
"ids"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"force": {
|
||||||
|
"description": "강제 삭제 여부 (진행중 수주 포함)",
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"OrderRevertProductionRequest": {
|
||||||
|
"properties": {
|
||||||
|
"force": {
|
||||||
|
"description": "강제 되돌리기 (물리 삭제, 기본값 false)",
|
||||||
|
"type": "boolean",
|
||||||
|
"example": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"description": "되돌리기 사유 (운영 모드 시 필수)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "고객 요청에 의한 생산지시 취소"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"Payment": {
|
"Payment": {
|
||||||
"description": "결제 정보",
|
"description": "결제 정보",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
278
tests/Feature/Production/BendingLotPipelineTest.php
Normal file
278
tests/Feature/Production/BendingLotPipelineTest.php
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Production;
|
||||||
|
|
||||||
|
use App\DTOs\Production\DynamicBomEntry;
|
||||||
|
use App\Services\Production\PrefixResolver;
|
||||||
|
use App\Services\WorkOrderService;
|
||||||
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 절곡 자재투입 LOT 매핑 파이프라인 통합 테스트
|
||||||
|
*
|
||||||
|
* getMaterials() → dynamic_bom 우선 체크 → 세부품목 반환 → 자재투입 플로우 검증
|
||||||
|
*
|
||||||
|
* 실행 조건: Docker 환경 + 로컬 DB 접속 필요
|
||||||
|
*/
|
||||||
|
class BendingLotPipelineTest extends TestCase
|
||||||
|
{
|
||||||
|
use DatabaseTransactions;
|
||||||
|
|
||||||
|
private const TENANT_ID = 287;
|
||||||
|
|
||||||
|
private PrefixResolver $resolver;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->resolver = new PrefixResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// PrefixResolver → items.id 조회 통합
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BD-* 품목이 items 테이블에 실제 존재하는지 확인
|
||||||
|
*/
|
||||||
|
public function test_prefix_resolver_resolves_existing_bd_items(): void
|
||||||
|
{
|
||||||
|
$testCodes = [
|
||||||
|
'BD-RS-43', 'BD-RM-30', 'BD-RC-35', 'BD-RD-40',
|
||||||
|
'BD-SS-43', 'BD-SM-30', 'BD-SC-35', 'BD-SD-40',
|
||||||
|
'BD-BE-30', 'BD-BS-40', 'BD-LA-30',
|
||||||
|
'BD-CF-30', 'BD-CL-24', 'BD-CP-30', 'BD-CB-30',
|
||||||
|
'BD-GI-53', 'BD-GI-84',
|
||||||
|
'BD-XX-30', 'BD-YY-43', 'BD-HH-30',
|
||||||
|
];
|
||||||
|
|
||||||
|
$foundCount = 0;
|
||||||
|
$missingCodes = [];
|
||||||
|
|
||||||
|
foreach ($testCodes as $code) {
|
||||||
|
$id = $this->resolver->resolveItemId($code, self::TENANT_ID);
|
||||||
|
if ($id !== null) {
|
||||||
|
$foundCount++;
|
||||||
|
$this->assertGreaterThan(0, $id, "Item ID for {$code} must be positive");
|
||||||
|
} else {
|
||||||
|
$missingCodes[] = $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 0에서 전부 등록했으므로 모두 존재해야 함
|
||||||
|
$this->assertEmpty(
|
||||||
|
$missingCodes,
|
||||||
|
'Missing BD items: '.implode(', ', $missingCodes)
|
||||||
|
);
|
||||||
|
$this->assertCount(count($testCodes), array_diff($testCodes, $missingCodes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolveItemId 캐시 동작 확인
|
||||||
|
*/
|
||||||
|
public function test_resolve_item_id_uses_cache(): void
|
||||||
|
{
|
||||||
|
$code = 'BD-RS-43';
|
||||||
|
$id1 = $this->resolver->resolveItemId($code, self::TENANT_ID);
|
||||||
|
$id2 = $this->resolver->resolveItemId($code, self::TENANT_ID);
|
||||||
|
|
||||||
|
$this->assertNotNull($id1);
|
||||||
|
$this->assertSame($id1, $id2, 'Cached result should be identical');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// dynamic_bom 생성 → JSON 구조 검증
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicBomEntry 배열이 올바른 JSON 구조로 변환되는지 확인
|
||||||
|
*/
|
||||||
|
public function test_dynamic_bom_entries_produce_valid_json_structure(): void
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
// 가이드레일 벽면형 KSS01 (SUS) 4300mm
|
||||||
|
$testCombinations = [
|
||||||
|
['finish', 'wall', 'KSS01', 4300, 'guideRail', 'SUS'],
|
||||||
|
['body', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'],
|
||||||
|
['c_type', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'],
|
||||||
|
['d_type', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'],
|
||||||
|
['base', 'wall', 'KSS01', 4300, 'guideRail', 'EGI'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testCombinations as [$partType, $guideType, $productCode, $lengthMm, $category, $materialType]) {
|
||||||
|
$prefix = $this->resolver->resolveGuideRailPrefix($partType, $guideType, $productCode);
|
||||||
|
$itemCode = $this->resolver->buildItemCode($prefix, $lengthMm);
|
||||||
|
$this->assertNotNull($itemCode, "buildItemCode failed for {$prefix}/{$lengthMm}");
|
||||||
|
|
||||||
|
$itemId = $this->resolver->resolveItemId($itemCode, self::TENANT_ID);
|
||||||
|
if ($itemId === null) {
|
||||||
|
$this->markTestSkipped("Item {$itemCode} not found in DB — run Phase 0 first");
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = DynamicBomEntry::fromArray([
|
||||||
|
'child_item_id' => $itemId,
|
||||||
|
'child_item_code' => $itemCode,
|
||||||
|
'lot_prefix' => $prefix,
|
||||||
|
'part_type' => PrefixResolver::partTypeName($partType),
|
||||||
|
'category' => $category,
|
||||||
|
'material_type' => $materialType,
|
||||||
|
'length_mm' => $lengthMm,
|
||||||
|
'qty' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = DynamicBomEntry::toArrayList($entries);
|
||||||
|
|
||||||
|
$this->assertCount(5, $json);
|
||||||
|
$this->assertEquals('BD-RS-43', $json[0]['child_item_code']);
|
||||||
|
$this->assertEquals('BD-RM-43', $json[1]['child_item_code']);
|
||||||
|
$this->assertEquals('BD-RC-43', $json[2]['child_item_code']);
|
||||||
|
$this->assertEquals('BD-RD-43', $json[3]['child_item_code']);
|
||||||
|
$this->assertEquals('BD-XX-43', $json[4]['child_item_code']);
|
||||||
|
|
||||||
|
// JSON 인코딩/디코딩 정합성
|
||||||
|
$encoded = json_encode($json, JSON_UNESCAPED_UNICODE);
|
||||||
|
$decoded = json_decode($encoded, true);
|
||||||
|
$this->assertEquals($json, $decoded, 'JSON round-trip should be identical');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// getMaterials dynamic_bom 우선 체크
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* work_order_items.options.dynamic_bom이 있는 경우
|
||||||
|
* getMaterials가 세부품목을 반환하는지 확인
|
||||||
|
*/
|
||||||
|
public function test_get_materials_returns_dynamic_bom_items(): void
|
||||||
|
{
|
||||||
|
// 절곡 작업지시 찾기 (dynamic_bom이 있는)
|
||||||
|
$woItem = DB::table('work_order_items')
|
||||||
|
->where('tenant_id', self::TENANT_ID)
|
||||||
|
->whereNotNull('options')
|
||||||
|
->whereRaw("JSON_EXTRACT(options, '$.dynamic_bom') IS NOT NULL")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $woItem) {
|
||||||
|
$this->markTestSkipped('No work_order_items with dynamic_bom found — create a bending work order first');
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = is_string($woItem->options) ? json_decode($woItem->options, true) : ($woItem->options ?? []);
|
||||||
|
$dynamicBom = $options['dynamic_bom'] ?? [];
|
||||||
|
|
||||||
|
$this->assertNotEmpty($dynamicBom, 'dynamic_bom should not be empty');
|
||||||
|
|
||||||
|
// dynamic_bom 각 항목 구조 검증
|
||||||
|
foreach ($dynamicBom as $entry) {
|
||||||
|
$this->assertArrayHasKey('child_item_id', $entry);
|
||||||
|
$this->assertArrayHasKey('child_item_code', $entry);
|
||||||
|
$this->assertArrayHasKey('lot_prefix', $entry);
|
||||||
|
$this->assertArrayHasKey('part_type', $entry);
|
||||||
|
$this->assertArrayHasKey('category', $entry);
|
||||||
|
$this->assertGreaterThan(0, $entry['child_item_id']);
|
||||||
|
$this->assertMatchesRegularExpression('/^BD-[A-Z]{2}-\d{2}$/', $entry['child_item_code']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getMaterials API 응답에 work_order_item_id 필드가 포함되는지 확인
|
||||||
|
*/
|
||||||
|
public function test_get_materials_api_includes_work_order_item_id(): void
|
||||||
|
{
|
||||||
|
// 절곡 작업지시 찾기
|
||||||
|
$wo = DB::table('work_orders')
|
||||||
|
->where('tenant_id', self::TENANT_ID)
|
||||||
|
->whereExists(function ($query) {
|
||||||
|
$query->select(DB::raw(1))
|
||||||
|
->from('work_order_items')
|
||||||
|
->whereColumn('work_order_items.work_order_id', 'work_orders.id')
|
||||||
|
->whereRaw("JSON_EXTRACT(options, '$.dynamic_bom') IS NOT NULL");
|
||||||
|
})
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $wo) {
|
||||||
|
$this->markTestSkipped('No work order with dynamic_bom items found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkOrderService 직접 호출로 getMaterials 검증
|
||||||
|
$service = app(WorkOrderService::class);
|
||||||
|
$service->setContext(self::TENANT_ID, 1);
|
||||||
|
|
||||||
|
$materials = $service->getMaterials($wo->id);
|
||||||
|
|
||||||
|
// dynamic_bom 품목에는 work_order_item_id가 포함되어야 함
|
||||||
|
$dynamicBomMaterials = array_filter($materials, fn ($m) => isset($m['work_order_item_id']));
|
||||||
|
|
||||||
|
if (empty($dynamicBomMaterials)) {
|
||||||
|
$this->markTestSkipped('getMaterials returned no dynamic_bom materials');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($dynamicBomMaterials as $material) {
|
||||||
|
$this->assertArrayHasKey('work_order_item_id', $material);
|
||||||
|
$this->assertArrayHasKey('lot_prefix', $material);
|
||||||
|
$this->assertArrayHasKey('category', $material);
|
||||||
|
$this->assertGreaterThan(0, $material['work_order_item_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 전체 prefix × lengthCode 마스터 검증 (Phase 0 검증 재확인)
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 19종 prefix × 해당 lengthCode 조합이 모두 items 테이블에 존재하는지 확인
|
||||||
|
*/
|
||||||
|
public function test_all_prefix_length_combinations_exist_in_items(): void
|
||||||
|
{
|
||||||
|
$standardLengths = [30, 35, 40, 43];
|
||||||
|
$boxLengths = [12, 24, 30, 35, 40, 41];
|
||||||
|
|
||||||
|
$prefixLengthMap = [
|
||||||
|
// 가이드레일 벽면형
|
||||||
|
'RS' => $standardLengths, 'RM' => array_merge($standardLengths, [24, 35]),
|
||||||
|
'RC' => array_merge($standardLengths, [24, 35]), 'RD' => array_merge($standardLengths, [24, 35]),
|
||||||
|
'RT' => [30, 43],
|
||||||
|
// 가이드레일 측면형
|
||||||
|
'SS' => [30, 35, 40, 43], 'SM' => [30, 35, 40, 43, 24],
|
||||||
|
'SC' => [30, 35, 40, 43, 24], 'SD' => [30, 35, 40, 43, 24],
|
||||||
|
'ST' => [43], 'SU' => [30, 35, 40, 43],
|
||||||
|
// 하단마감재
|
||||||
|
'BE' => [30, 40], 'BS' => [30, 35, 40, 43, 24],
|
||||||
|
'TS' => [40, 43],
|
||||||
|
'LA' => [30, 40],
|
||||||
|
// 셔터박스 (표준 길이: 43 제외 — 4300mm는 가이드레일 전용)
|
||||||
|
'CF' => $boxLengths, 'CL' => $boxLengths,
|
||||||
|
'CP' => $boxLengths, 'CB' => $boxLengths,
|
||||||
|
// 연기차단재
|
||||||
|
'GI' => [53, 54, 83, 84, 30, 35, 40],
|
||||||
|
// 공통
|
||||||
|
'XX' => array_merge($boxLengths, [43]), 'YY' => $standardLengths,
|
||||||
|
'HH' => [30, 40],
|
||||||
|
];
|
||||||
|
|
||||||
|
$missing = [];
|
||||||
|
|
||||||
|
foreach ($prefixLengthMap as $prefix => $codes) {
|
||||||
|
foreach ($codes as $code) {
|
||||||
|
$itemCode = "BD-{$prefix}-{$code}";
|
||||||
|
$exists = DB::table('items')
|
||||||
|
->where('tenant_id', self::TENANT_ID)
|
||||||
|
->where('code', $itemCode)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $exists) {
|
||||||
|
$missing[] = $itemCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEmpty(
|
||||||
|
$missing,
|
||||||
|
'Missing BD items in items table: '.implode(', ', $missing)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
tests/Unit/Production/DynamicBomEntryTest.php
Normal file
173
tests/Unit/Production/DynamicBomEntryTest.php
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Production;
|
||||||
|
|
||||||
|
use App\DTOs\Production\DynamicBomEntry;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class DynamicBomEntryTest extends TestCase
|
||||||
|
{
|
||||||
|
private function validData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'child_item_id' => 15812,
|
||||||
|
'child_item_code' => 'BD-RS-43',
|
||||||
|
'lot_prefix' => 'RS',
|
||||||
|
'part_type' => '마감재',
|
||||||
|
'category' => 'guideRail',
|
||||||
|
'material_type' => 'SUS',
|
||||||
|
'length_mm' => 4300,
|
||||||
|
'qty' => 2,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// fromArray + toArray 라운드트립
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_from_array_creates_dto(): void
|
||||||
|
{
|
||||||
|
$entry = DynamicBomEntry::fromArray($this->validData());
|
||||||
|
|
||||||
|
$this->assertEquals(15812, $entry->child_item_id);
|
||||||
|
$this->assertEquals('BD-RS-43', $entry->child_item_code);
|
||||||
|
$this->assertEquals('RS', $entry->lot_prefix);
|
||||||
|
$this->assertEquals('마감재', $entry->part_type);
|
||||||
|
$this->assertEquals('guideRail', $entry->category);
|
||||||
|
$this->assertEquals('SUS', $entry->material_type);
|
||||||
|
$this->assertEquals(4300, $entry->length_mm);
|
||||||
|
$this->assertEquals(2, $entry->qty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_to_array_round_trip(): void
|
||||||
|
{
|
||||||
|
$data = $this->validData();
|
||||||
|
$entry = DynamicBomEntry::fromArray($data);
|
||||||
|
$this->assertEquals($data, $entry->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_to_array_list(): void
|
||||||
|
{
|
||||||
|
$entries = [
|
||||||
|
DynamicBomEntry::fromArray($this->validData()),
|
||||||
|
DynamicBomEntry::fromArray(array_merge($this->validData(), [
|
||||||
|
'child_item_id' => 15813,
|
||||||
|
'child_item_code' => 'BD-RM-43',
|
||||||
|
'lot_prefix' => 'RM',
|
||||||
|
'part_type' => '본체',
|
||||||
|
])),
|
||||||
|
];
|
||||||
|
|
||||||
|
$list = DynamicBomEntry::toArrayList($entries);
|
||||||
|
$this->assertCount(2, $list);
|
||||||
|
$this->assertEquals('BD-RS-43', $list[0]['child_item_code']);
|
||||||
|
$this->assertEquals('BD-RM-43', $list[1]['child_item_code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 유효한 카테고리
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider validCategoryProvider
|
||||||
|
*/
|
||||||
|
public function test_valid_categories(string $category): void
|
||||||
|
{
|
||||||
|
$data = array_merge($this->validData(), ['category' => $category]);
|
||||||
|
$entry = DynamicBomEntry::fromArray($data);
|
||||||
|
$this->assertEquals($category, $entry->category);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validCategoryProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'guideRail' => ['guideRail'],
|
||||||
|
'bottomBar' => ['bottomBar'],
|
||||||
|
'shutterBox' => ['shutterBox'],
|
||||||
|
'smokeBarrier' => ['smokeBarrier'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 필수 필드 누락 검증
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider requiredFieldProvider
|
||||||
|
*/
|
||||||
|
public function test_missing_required_field_throws(string $field): void
|
||||||
|
{
|
||||||
|
$data = $this->validData();
|
||||||
|
unset($data[$field]);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage("'{$field}' is required");
|
||||||
|
DynamicBomEntry::fromArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function requiredFieldProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'child_item_id' => ['child_item_id'],
|
||||||
|
'child_item_code' => ['child_item_code'],
|
||||||
|
'lot_prefix' => ['lot_prefix'],
|
||||||
|
'part_type' => ['part_type'],
|
||||||
|
'category' => ['category'],
|
||||||
|
'material_type' => ['material_type'],
|
||||||
|
'length_mm' => ['length_mm'],
|
||||||
|
'qty' => ['qty'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 값 제약 검증
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_invalid_child_item_id_throws(): void
|
||||||
|
{
|
||||||
|
$data = array_merge($this->validData(), ['child_item_id' => 0]);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('child_item_id must be positive');
|
||||||
|
DynamicBomEntry::fromArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_category_throws(): void
|
||||||
|
{
|
||||||
|
$data = array_merge($this->validData(), ['category' => 'invalidCategory']);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('category must be one of');
|
||||||
|
DynamicBomEntry::fromArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_zero_qty_throws(): void
|
||||||
|
{
|
||||||
|
$data = array_merge($this->validData(), ['qty' => 0]);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('qty must be positive');
|
||||||
|
DynamicBomEntry::fromArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_negative_qty_throws(): void
|
||||||
|
{
|
||||||
|
$data = array_merge($this->validData(), ['qty' => -1]);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('qty must be positive');
|
||||||
|
DynamicBomEntry::fromArray($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// float qty 허용
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_float_qty_allowed(): void
|
||||||
|
{
|
||||||
|
$data = array_merge($this->validData(), ['qty' => 1.5]);
|
||||||
|
$entry = DynamicBomEntry::fromArray($data);
|
||||||
|
$this->assertEquals(1.5, $entry->qty);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
tests/Unit/Production/PrefixResolverTest.php
Normal file
263
tests/Unit/Production/PrefixResolverTest.php
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Production;
|
||||||
|
|
||||||
|
use App\Services\Production\PrefixResolver;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class PrefixResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
private PrefixResolver $resolver;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->resolver = new PrefixResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 가이드레일 벽면형(Wall) Prefix
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider wallFinishProvider
|
||||||
|
*/
|
||||||
|
public function test_wall_finish_prefix(string $productCode, string $expected): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(
|
||||||
|
$expected,
|
||||||
|
$this->resolver->resolveGuideRailPrefix('finish', 'wall', $productCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function wallFinishProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'KSS01 → RS' => ['KSS01', 'RS'],
|
||||||
|
'KQTS01 → RS' => ['KQTS01', 'RS'],
|
||||||
|
'KSE01 → RE' => ['KSE01', 'RE'],
|
||||||
|
'KWE01 → RE' => ['KWE01', 'RE'],
|
||||||
|
'KTE01 → RS' => ['KTE01', 'RS'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wall_body_prefix(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KSS01'));
|
||||||
|
$this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KSE01'));
|
||||||
|
$this->assertEquals('RM', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KWE01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wall_body_steel_override(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('RT', $this->resolver->resolveGuideRailPrefix('body', 'wall', 'KTE01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_wall_fixed_prefixes(): void
|
||||||
|
{
|
||||||
|
foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'] as $code) {
|
||||||
|
$this->assertEquals('RC', $this->resolver->resolveGuideRailPrefix('c_type', 'wall', $code));
|
||||||
|
$this->assertEquals('RD', $this->resolver->resolveGuideRailPrefix('d_type', 'wall', $code));
|
||||||
|
$this->assertEquals('YY', $this->resolver->resolveGuideRailPrefix('extra_finish', 'wall', $code));
|
||||||
|
$this->assertEquals('XX', $this->resolver->resolveGuideRailPrefix('base', 'wall', $code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 가이드레일 측면형(Side) Prefix
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider sideFinishProvider
|
||||||
|
*/
|
||||||
|
public function test_side_finish_prefix(string $productCode, string $expected): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(
|
||||||
|
$expected,
|
||||||
|
$this->resolver->resolveGuideRailPrefix('finish', 'side', $productCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sideFinishProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'KSS01 → SS' => ['KSS01', 'SS'],
|
||||||
|
'KQTS01 → SS' => ['KQTS01', 'SS'],
|
||||||
|
'KSE01 → SE' => ['KSE01', 'SE'],
|
||||||
|
'KWE01 → SE' => ['KWE01', 'SE'],
|
||||||
|
'KTE01 → SS' => ['KTE01', 'SS'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_side_body_prefix(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('SM', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KSS01'));
|
||||||
|
$this->assertEquals('SM', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KSE01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_side_body_steel_override(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('ST', $this->resolver->resolveGuideRailPrefix('body', 'side', 'KTE01'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_side_fixed_prefixes(): void
|
||||||
|
{
|
||||||
|
foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'] as $code) {
|
||||||
|
$this->assertEquals('SC', $this->resolver->resolveGuideRailPrefix('c_type', 'side', $code));
|
||||||
|
$this->assertEquals('SD', $this->resolver->resolveGuideRailPrefix('d_type', 'side', $code));
|
||||||
|
$this->assertEquals('YY', $this->resolver->resolveGuideRailPrefix('extra_finish', 'side', $code));
|
||||||
|
$this->assertEquals('XX', $this->resolver->resolveGuideRailPrefix('base', 'side', $code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 하단마감재 Prefix
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_bottom_bar_main_prefix(): void
|
||||||
|
{
|
||||||
|
// EGI 제품
|
||||||
|
$this->assertEquals('BE', $this->resolver->resolveBottomBarPrefix('main', 'KSE01', 'EGI마감'));
|
||||||
|
$this->assertEquals('BE', $this->resolver->resolveBottomBarPrefix('main', 'KWE01', 'EGI마감'));
|
||||||
|
|
||||||
|
// SUS 제품
|
||||||
|
$this->assertEquals('BS', $this->resolver->resolveBottomBarPrefix('main', 'KSS01', 'SUS마감'));
|
||||||
|
$this->assertEquals('BS', $this->resolver->resolveBottomBarPrefix('main', 'KQTS01', 'SUS마감'));
|
||||||
|
|
||||||
|
// 철재
|
||||||
|
$this->assertEquals('TS', $this->resolver->resolveBottomBarPrefix('main', 'KTE01', 'EGI마감'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_bottom_bar_fixed_prefixes(): void
|
||||||
|
{
|
||||||
|
foreach (['KSS01', 'KSE01', 'KWE01', 'KTE01'] as $code) {
|
||||||
|
$this->assertEquals('LA', $this->resolver->resolveBottomBarPrefix('lbar', $code, 'EGI마감'));
|
||||||
|
$this->assertEquals('HH', $this->resolver->resolveBottomBarPrefix('reinforce', $code, 'EGI마감'));
|
||||||
|
$this->assertEquals('YY', $this->resolver->resolveBottomBarPrefix('extra', $code, 'SUS마감'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 셔터박스 Prefix
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_shutter_box_standard_prefixes(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('CF', $this->resolver->resolveShutterBoxPrefix('front', true));
|
||||||
|
$this->assertEquals('CL', $this->resolver->resolveShutterBoxPrefix('lintel', true));
|
||||||
|
$this->assertEquals('CP', $this->resolver->resolveShutterBoxPrefix('inspection', true));
|
||||||
|
$this->assertEquals('CB', $this->resolver->resolveShutterBoxPrefix('rear_corner', true));
|
||||||
|
$this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix('top_cover', true));
|
||||||
|
$this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix('fin_cover', true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shutter_box_nonstandard_all_xx(): void
|
||||||
|
{
|
||||||
|
foreach (['front', 'lintel', 'inspection', 'rear_corner', 'top_cover', 'fin_cover'] as $part) {
|
||||||
|
$this->assertEquals('XX', $this->resolver->resolveShutterBoxPrefix($part, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 연기차단재 Prefix
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_smoke_barrier_always_gi(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('GI', $this->resolver->resolveSmokeBarrierPrefix());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// lengthToCode 변환
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider lengthCodeProvider
|
||||||
|
*/
|
||||||
|
public function test_length_to_code(int $lengthMm, ?string $smokeCategory, ?string $expected): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, PrefixResolver::lengthToCode($lengthMm, $smokeCategory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lengthCodeProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'1219 → 12' => [1219, null, '12'],
|
||||||
|
'2438 → 24' => [2438, null, '24'],
|
||||||
|
'3000 → 30' => [3000, null, '30'],
|
||||||
|
'3500 → 35' => [3500, null, '35'],
|
||||||
|
'4000 → 40' => [4000, null, '40'],
|
||||||
|
'4150 → 41' => [4150, null, '41'],
|
||||||
|
'4200 → 42' => [4200, null, '42'],
|
||||||
|
'4300 → 43' => [4300, null, '43'],
|
||||||
|
'smoke w50 3000 → 53' => [3000, 'w50', '53'],
|
||||||
|
'smoke w50 4000 → 54' => [4000, 'w50', '54'],
|
||||||
|
'smoke w80 3000 → 83' => [3000, 'w80', '83'],
|
||||||
|
'smoke w80 4000 → 84' => [4000, 'w80', '84'],
|
||||||
|
'unknown length → null' => [9999, null, null],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// buildItemCode
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_build_item_code(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('BD-RS-43', $this->resolver->buildItemCode('RS', 4300));
|
||||||
|
$this->assertEquals('BD-RM-30', $this->resolver->buildItemCode('RM', 3000));
|
||||||
|
$this->assertEquals('BD-GI-53', $this->resolver->buildItemCode('GI', 3000, 'w50'));
|
||||||
|
$this->assertEquals('BD-GI-84', $this->resolver->buildItemCode('GI', 4000, 'w80'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_build_item_code_invalid_length_returns_null(): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->resolver->buildItemCode('RS', 9999));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// partTypeName
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_part_type_name(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals('마감재', PrefixResolver::partTypeName('finish'));
|
||||||
|
$this->assertEquals('본체', PrefixResolver::partTypeName('body'));
|
||||||
|
$this->assertEquals('C형', PrefixResolver::partTypeName('c_type'));
|
||||||
|
$this->assertEquals('D형', PrefixResolver::partTypeName('d_type'));
|
||||||
|
$this->assertEquals('별도마감', PrefixResolver::partTypeName('extra_finish'));
|
||||||
|
$this->assertEquals('하부BASE', PrefixResolver::partTypeName('base'));
|
||||||
|
$this->assertEquals('L-Bar', PrefixResolver::partTypeName('lbar'));
|
||||||
|
$this->assertEquals('보강평철', PrefixResolver::partTypeName('reinforce'));
|
||||||
|
$this->assertEquals('전면부', PrefixResolver::partTypeName('front'));
|
||||||
|
$this->assertEquals('unknown_type', PrefixResolver::partTypeName('unknown_type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// 전체 조합 커버리지 (productCode × guideType × partType)
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_all_product_code_guide_type_combinations_produce_non_empty_prefix(): void
|
||||||
|
{
|
||||||
|
$productCodes = ['KSS01', 'KSE01', 'KWE01', 'KTE01', 'KQTS01'];
|
||||||
|
$guideTypes = ['wall', 'side'];
|
||||||
|
$partTypes = ['finish', 'body', 'c_type', 'd_type', 'base'];
|
||||||
|
|
||||||
|
foreach ($productCodes as $code) {
|
||||||
|
foreach ($guideTypes as $guide) {
|
||||||
|
foreach ($partTypes as $part) {
|
||||||
|
$prefix = $this->resolver->resolveGuideRailPrefix($part, $guide, $code);
|
||||||
|
$this->assertNotEmpty(
|
||||||
|
$prefix,
|
||||||
|
"Empty prefix for {$code}/{$guide}/{$part}"
|
||||||
|
);
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/^[A-Z]{2}$/',
|
||||||
|
$prefix,
|
||||||
|
"Invalid prefix '{$prefix}' for {$code}/{$guide}/{$part}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user