Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop
This commit is contained in:
130
Jenkinsfile
vendored
130
Jenkinsfile
vendored
@@ -1,6 +1,12 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
parameters {
|
||||
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
|
||||
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
|
||||
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
@@ -8,10 +14,73 @@ pipeline {
|
||||
environment {
|
||||
DEPLOY_USER = 'hskwon'
|
||||
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
||||
PROD_SERVER = '211.117.60.189'
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
// ── 롤백: 릴리스 목록 조회 ──
|
||||
stage('Rollback: List Releases') {
|
||||
when { expression { params.ACTION == 'rollback' } }
|
||||
steps {
|
||||
script {
|
||||
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
|
||||
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
|
||||
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
|
||||
echo "현재 활성: ${current}"
|
||||
echo "사용 가능:\n${releases}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 롤백: symlink 전환 ──
|
||||
stage('Rollback: Switch Release') {
|
||||
when { expression { params.ACTION == 'rollback' } }
|
||||
steps {
|
||||
script {
|
||||
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
|
||||
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
def targetRelease = params.ROLLBACK_RELEASE
|
||||
if (!targetRelease?.trim()) {
|
||||
// 비워두면 직전 릴리스로 롤백
|
||||
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
|
||||
}
|
||||
|
||||
// 릴리스 존재 여부 확인
|
||||
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
|
||||
|
||||
slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token',
|
||||
message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
|
||||
cd ${basePath}/current &&
|
||||
php artisan config:cache &&
|
||||
php artisan route:cache &&
|
||||
php artisan view:cache &&
|
||||
sudo systemctl reload php8.4-fpm
|
||||
'
|
||||
"""
|
||||
|
||||
if (params.ROLLBACK_TARGET == 'production') {
|
||||
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'"
|
||||
}
|
||||
|
||||
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 일반 배포: Checkout ──
|
||||
stage('Checkout') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
@@ -24,17 +93,22 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Stage 배포 ──
|
||||
stage('Deploy Stage') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '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 '
|
||||
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage bootstrap/cache &&
|
||||
@@ -71,17 +145,22 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Production 배포 ──
|
||||
stage('Deploy Production') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '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 '
|
||||
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
cd /home/webservice/api/releases/${RELEASE_ID} &&
|
||||
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
|
||||
sudo chown -R www-data:webservice storage bootstrap/cache &&
|
||||
@@ -109,23 +188,32 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
script {
|
||||
if (params.ACTION == 'deploy') {
|
||||
slackSend channel: '#deploy_api', 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: '#deploy_api', 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
|
||||
"""
|
||||
if (params.ACTION == 'deploy') {
|
||||
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
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
|
||||
"""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ public function handle(): int
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
// Delete physical file
|
||||
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
if (Storage::disk('r2')->exists($file->file_path)) {
|
||||
Storage::disk('r2')->delete($file->file_path);
|
||||
}
|
||||
|
||||
// Force delete from DB
|
||||
|
||||
@@ -60,8 +60,8 @@ private function permanentDelete(File $file): void
|
||||
{
|
||||
DB::transaction(function () use ($file) {
|
||||
// Delete physical file
|
||||
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
if (Storage::disk('r2')->exists($file->file_path)) {
|
||||
Storage::disk('r2')->delete($file->file_path);
|
||||
}
|
||||
|
||||
// Update tenant storage usage
|
||||
|
||||
264
app/Console/Commands/UploadLocalFilesToR2.php
Normal file
264
app/Console/Commands/UploadLocalFilesToR2.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\File as FileFacade;
|
||||
|
||||
class UploadLocalFilesToR2 extends Command
|
||||
{
|
||||
protected $signature = 'r2:upload-local
|
||||
{--count=3 : Number of files to upload}
|
||||
{--source=db : Source: "db" (latest DB records) or "disk" (latest local files)}
|
||||
{--dry-run : Show files without uploading}
|
||||
{--fix : Delete wrong-path files from R2 before re-uploading}';
|
||||
|
||||
protected $description = 'Upload local files to Cloudflare R2 (by DB records or local disk)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = (int) $this->option('count');
|
||||
$source = $this->option('source');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info("=== R2 Upload Tool ===");
|
||||
$this->info("Source: {$source} | Count: {$count}");
|
||||
|
||||
return $source === 'db'
|
||||
? $this->uploadFromDb($count, $dryRun)
|
||||
: $this->uploadFromDisk($count, $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files based on DB records (latest by ID desc)
|
||||
*/
|
||||
private function uploadFromDb(int $count, bool $dryRun): int
|
||||
{
|
||||
$files = File::orderByDesc('id')->limit($count)->get();
|
||||
|
||||
if ($files->isEmpty()) {
|
||||
$this->warn('No files in DB.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($files as $f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
$r2Exists = Storage::disk('r2')->exists($f->file_path);
|
||||
$localExists = file_exists($localPath);
|
||||
|
||||
$rows[] = [
|
||||
$f->id,
|
||||
mb_strimwidth($f->display_name ?? '', 0, 25, '...'),
|
||||
$f->file_path,
|
||||
$r2Exists ? '✓ YES' : '✗ NO',
|
||||
$localExists ? '✓ YES' : '✗ NO',
|
||||
$f->file_size ? $this->formatSize($f->file_size) : '-',
|
||||
];
|
||||
}
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
// Filter: local exists but R2 doesn't
|
||||
$toUpload = $files->filter(function ($f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
|
||||
});
|
||||
|
||||
$alreadyInR2 = $files->filter(function ($f) {
|
||||
return Storage::disk('r2')->exists($f->file_path);
|
||||
});
|
||||
|
||||
if ($alreadyInR2->isNotEmpty()) {
|
||||
$this->info("Already in R2: {$alreadyInR2->count()} files (skipped)");
|
||||
}
|
||||
|
||||
if ($toUpload->isEmpty()) {
|
||||
$missingBoth = $files->filter(function ($f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
|
||||
});
|
||||
|
||||
if ($missingBoth->isNotEmpty()) {
|
||||
$this->warn("Missing both locally and in R2: {$missingBoth->count()} files");
|
||||
$this->warn("These files may exist on the dev server only.");
|
||||
}
|
||||
|
||||
$this->info('Nothing to upload.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("[DRY RUN] Would upload {$toUpload->count()} files.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Test R2 connection
|
||||
$this->info('Testing R2 connection...');
|
||||
try {
|
||||
Storage::disk('r2')->directories('/');
|
||||
$this->info('✓ R2 connection OK');
|
||||
} catch (\Exception $e) {
|
||||
$this->error('✗ R2 connection failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Upload
|
||||
$bar = $this->output->createProgressBar($toUpload->count());
|
||||
$bar->start();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($toUpload as $f) {
|
||||
$localPath = storage_path("app/tenants/{$f->file_path}");
|
||||
try {
|
||||
$content = FileFacade::get($localPath);
|
||||
$mimeType = $f->mime_type ?: FileFacade::mimeType($localPath);
|
||||
|
||||
Storage::disk('r2')->put($f->file_path, $content, [
|
||||
'ContentType' => $mimeType,
|
||||
]);
|
||||
$success++;
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$this->newLine();
|
||||
$this->error(" ✗ ID {$f->id}: {$e->getMessage()}");
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("=== Upload Complete ===");
|
||||
$this->info("✓ Success: {$success}");
|
||||
if ($failed > 0) {
|
||||
$this->error("✗ Failed: {$failed}");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files based on local disk (newest files by mtime)
|
||||
*/
|
||||
private function uploadFromDisk(int $count, bool $dryRun): int
|
||||
{
|
||||
$storagePath = storage_path('app/tenants');
|
||||
|
||||
if (!is_dir($storagePath)) {
|
||||
$this->error("Path not found: {$storagePath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$allFiles = $this->collectFiles($storagePath);
|
||||
if (empty($allFiles)) {
|
||||
$this->warn('No files found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a));
|
||||
$filesToUpload = array_slice($allFiles, 0, $count);
|
||||
|
||||
$this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:");
|
||||
$this->newLine();
|
||||
|
||||
$headers = ['#', 'File', 'Size', 'Modified', 'R2 Path'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($filesToUpload as $i => $filePath) {
|
||||
$r2Path = $this->toR2Path($filePath);
|
||||
$rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path];
|
||||
}
|
||||
|
||||
$this->table($headers, $rows);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('[DRY RUN] No files uploaded.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info('Testing R2 connection...');
|
||||
try {
|
||||
Storage::disk('r2')->directories('/');
|
||||
$this->info('✓ R2 connection OK');
|
||||
} catch (\Exception $e) {
|
||||
$this->error('✗ R2 connection failed: ' . $e->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
$bar = $this->output->createProgressBar(count($filesToUpload));
|
||||
$bar->start();
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$fix = $this->option('fix');
|
||||
|
||||
foreach ($filesToUpload as $filePath) {
|
||||
$r2Path = $this->toR2Path($filePath);
|
||||
try {
|
||||
if ($fix) {
|
||||
$wrongPath = $this->toRelativePath($filePath);
|
||||
if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) {
|
||||
Storage::disk('r2')->delete($wrongPath);
|
||||
}
|
||||
}
|
||||
|
||||
$content = FileFacade::get($filePath);
|
||||
$mimeType = FileFacade::mimeType($filePath);
|
||||
|
||||
Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]);
|
||||
$success++;
|
||||
} catch (\Exception $e) {
|
||||
$failed++;
|
||||
$this->newLine();
|
||||
$this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}");
|
||||
}
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
$this->info("=== Upload Complete ===");
|
||||
$this->info("✓ Success: {$success}");
|
||||
if ($failed > 0) {
|
||||
$this->error("✗ Failed: {$failed}");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function toR2Path(string $filePath): string
|
||||
{
|
||||
$relative = $this->toRelativePath($filePath);
|
||||
return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative;
|
||||
}
|
||||
|
||||
private function toRelativePath(string $filePath): string
|
||||
{
|
||||
return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath));
|
||||
}
|
||||
|
||||
private function collectFiles(string $dir): array
|
||||
{
|
||||
$files = [];
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->isFile() && $file->getFilename() !== '.gitignore') {
|
||||
$files[] = $file->getPathname();
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function formatSize(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
}
|
||||
}
|
||||
82
app/Http/Controllers/Api/V1/ChecklistTemplateController.php
Normal file
82
app/Http/Controllers/Api/V1/ChecklistTemplateController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Helpers\ApiResponse;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Quality\SaveChecklistTemplateRequest;
|
||||
use App\Services\ChecklistTemplateService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ChecklistTemplateController extends Controller
|
||||
{
|
||||
public function __construct(private ChecklistTemplateService $service) {}
|
||||
|
||||
/**
|
||||
* 템플릿 조회 (type별)
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$type = $request->query('type', 'day1_audit');
|
||||
|
||||
return $this->service->getByType($type);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 저장 (전체 덮어쓰기)
|
||||
*/
|
||||
public function update(SaveChecklistTemplateRequest $request, int $id)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request, $id) {
|
||||
return $this->service->save($id, $request->validated());
|
||||
}, __('message.updated'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 파일 목록 조회
|
||||
*/
|
||||
public function documents(Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
$templateId = (int) $request->query('template_id');
|
||||
$subItemId = $request->query('sub_item_id');
|
||||
|
||||
return $this->service->getDocuments($templateId, $subItemId);
|
||||
}, __('message.fetched'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
*/
|
||||
public function uploadDocument(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'template_id' => ['required', 'integer'],
|
||||
'sub_item_id' => ['required', 'string', 'max:50'],
|
||||
'file' => ['required', 'file', 'max:10240'], // 10MB
|
||||
]);
|
||||
|
||||
return ApiResponse::handle(function () use ($request) {
|
||||
return $this->service->uploadDocument(
|
||||
(int) $request->input('template_id'),
|
||||
$request->input('sub_item_id'),
|
||||
$request->file('file')
|
||||
);
|
||||
}, __('message.created'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
*/
|
||||
public function deleteDocument(int $id, Request $request)
|
||||
{
|
||||
return ApiResponse::handle(function () use ($id, $request) {
|
||||
$replace = filter_var($request->query('replace', false), FILTER_VALIDATE_BOOLEAN);
|
||||
$this->service->deleteDocument($id, $replace);
|
||||
|
||||
return 'success';
|
||||
}, __('message.deleted'));
|
||||
}
|
||||
}
|
||||
@@ -83,14 +83,25 @@ public function trash()
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file
|
||||
* Download file (attachment)
|
||||
*/
|
||||
public function download(int $id)
|
||||
{
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
return $file->download();
|
||||
return $file->download(inline: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* View file inline (이미지/PDF 브라우저에서 바로 표시)
|
||||
*/
|
||||
public function view(int $id)
|
||||
{
|
||||
$service = new FileStorageService;
|
||||
$file = $service->getFile($id);
|
||||
|
||||
return $file->download(inline: true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,7 +109,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
|
||||
$filePath = $directory.'/'.$storedName;
|
||||
|
||||
// 파일 저장 (tenant 디스크)
|
||||
Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName);
|
||||
Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
|
||||
|
||||
// file_type 자동 분류 (MIME 타입 기반)
|
||||
$mimeType = $uploadedFile->getMimeType();
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
use App\Http\Requests\Shipment\ShipmentUpdateRequest;
|
||||
use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest;
|
||||
use App\Services\ShipmentService;
|
||||
use App\Services\WorkOrderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ShipmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ShipmentService $service
|
||||
private readonly ShipmentService $service,
|
||||
private readonly WorkOrderService $workOrderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse
|
||||
{
|
||||
$shipment = $this->service->store($request->validated());
|
||||
|
||||
return ApiResponse::success($shipment, __('message.created'), 201);
|
||||
return ApiResponse::success($shipment, __('message.created'), [], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 기반 출하 생성
|
||||
*/
|
||||
public function createFromOrder(int $orderId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$shipment = $this->workOrderService->createShipmentForOrder($orderId);
|
||||
|
||||
return ApiResponse::success($shipment, __('message.created'), [], 201);
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) {
|
||||
return ApiResponse::error($e->getMessage(), 400);
|
||||
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
||||
return ApiResponse::error(__('error.order.not_found'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LOT 옵션 조회
|
||||
*/
|
||||
|
||||
@@ -117,6 +117,7 @@ public function handle(Request $request, Closure $next)
|
||||
// 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
|
||||
$allowWithoutAuth = [
|
||||
'api/v1/login',
|
||||
'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요)
|
||||
'api/v1/signup',
|
||||
'api/v1/register',
|
||||
'api/v1/refresh',
|
||||
|
||||
40
app/Http/Requests/Quality/SaveChecklistTemplateRequest.php
Normal file
40
app/Http/Requests/Quality/SaveChecklistTemplateRequest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Quality;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SaveChecklistTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'categories' => ['required', 'array', 'min:1'],
|
||||
'categories.*.id' => ['required', 'string', 'max:50'],
|
||||
'categories.*.title' => ['required', 'string', 'max:255'],
|
||||
'categories.*.subItems' => ['required', 'array'],
|
||||
'categories.*.subItems.*.id' => ['required', 'string', 'max:50'],
|
||||
'categories.*.subItems.*.name' => ['required', 'string', 'max:255'],
|
||||
'options' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
|
||||
'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]),
|
||||
'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']),
|
||||
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
|
||||
'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']),
|
||||
'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']),
|
||||
'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public function rules(): array
|
||||
'items.*.productCategory' => 'nullable|string|in:SCREEN,STEEL',
|
||||
'items.*.guideRailType' => 'nullable|string|in:wall,ceiling,floor,mixed',
|
||||
'items.*.motorPower' => 'nullable|string|in:single,three',
|
||||
'items.*.controller' => 'nullable|string|in:basic,smart,premium',
|
||||
'items.*.controller' => 'nullable|string|in:exposed,embedded,embedded_no_box',
|
||||
'items.*.wingSize' => 'nullable|numeric|min:0|max:500',
|
||||
'items.*.inspectionFee' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -45,7 +45,7 @@ public function rules(): array
|
||||
'items.*.PC' => 'nullable|string|in:SCREEN,STEEL',
|
||||
'items.*.GT' => 'nullable|string|in:wall,ceiling,floor,mixed',
|
||||
'items.*.MP' => 'nullable|string|in:single,three',
|
||||
'items.*.CT' => 'nullable|string|in:basic,smart,premium',
|
||||
'items.*.CT' => 'nullable|string|in:exposed,embedded,embedded_no_box',
|
||||
'items.*.WS' => 'nullable|numeric|min:0|max:500',
|
||||
'items.*.INSP' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -128,7 +128,7 @@ private function normalizeInputVariables(array $item): array
|
||||
'PC' => $item['productCategory'] ?? $item['PC'] ?? 'SCREEN',
|
||||
'GT' => $item['guideRailType'] ?? $item['GT'] ?? 'wall',
|
||||
'MP' => $item['motorPower'] ?? $item['MP'] ?? 'single',
|
||||
'CT' => $item['controller'] ?? $item['CT'] ?? 'basic',
|
||||
'CT' => $item['controller'] ?? $item['CT'] ?? 'exposed',
|
||||
'WS' => (float) ($item['wingSize'] ?? $item['WS'] ?? 50),
|
||||
'INSP' => (float) ($item['inspectionFee'] ?? $item['INSP'] ?? 50000),
|
||||
];
|
||||
|
||||
@@ -30,7 +30,7 @@ public function rules(): array
|
||||
'PC' => 'nullable|string|in:SCREEN,STEEL',
|
||||
'GT' => 'nullable|string|in:wall,ceiling,floor,mixed',
|
||||
'MP' => 'nullable|string|in:single,three',
|
||||
'CT' => 'nullable|string|in:basic,smart,premium',
|
||||
'CT' => 'nullable|string|in:exposed,embedded,embedded_no_box',
|
||||
'WS' => 'nullable|numeric|min:0|max:500',
|
||||
'INSP' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -82,7 +82,7 @@ public function getInputVariables(): array
|
||||
'PC' => $validated['PC'] ?? 'SCREEN',
|
||||
'GT' => $validated['GT'] ?? 'wall',
|
||||
'MP' => $validated['MP'] ?? 'single',
|
||||
'CT' => $validated['CT'] ?? 'basic',
|
||||
'CT' => $validated['CT'] ?? 'exposed',
|
||||
'WS' => (float) ($validated['WS'] ?? 50),
|
||||
'INSP' => (float) ($validated['INSP'] ?? 50000),
|
||||
];
|
||||
|
||||
@@ -31,6 +31,7 @@ public function rules(): array
|
||||
'remark' => ['nullable', 'string', 'max:1000'],
|
||||
'manufacturer' => ['nullable', 'string', 'max:100'],
|
||||
'material_no' => ['nullable', 'string', 'max:50'],
|
||||
'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ public function rules(): array
|
||||
'inspection_result' => ['nullable', 'string', 'max:20'],
|
||||
'manufacturer' => ['nullable', 'string', 'max:100'],
|
||||
'material_no' => ['nullable', 'string', 'max:50'],
|
||||
'certificate_file_id' => ['nullable', 'integer', 'exists:files,id'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ public function fileable()
|
||||
*/
|
||||
public function getStoragePath(): string
|
||||
{
|
||||
return Storage::disk('tenant')->path($this->file_path);
|
||||
return $this->file_path;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,22 +111,38 @@ public function getStoragePath(): string
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return Storage::disk('tenant')->exists($this->file_path);
|
||||
return Storage::disk('r2')->exists($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download response
|
||||
* Get download response (streaming from R2)
|
||||
*
|
||||
* @param bool $inline true = 브라우저에서 바로 표시 (이미지/PDF), false = 다운로드
|
||||
*/
|
||||
public function download()
|
||||
public function download(bool $inline = false)
|
||||
{
|
||||
if (! $this->exists()) {
|
||||
abort(404, 'File not found in storage');
|
||||
}
|
||||
|
||||
return response()->download(
|
||||
$this->getStoragePath(),
|
||||
$this->display_name ?? $this->original_name
|
||||
);
|
||||
$fileName = $this->display_name ?? $this->original_name;
|
||||
$mimeType = $this->mime_type ?? 'application/octet-stream';
|
||||
$disposition = $inline ? 'inline' : 'attachment';
|
||||
|
||||
// Stream from R2 (메모리에 전체 로드하지 않음)
|
||||
$stream = Storage::disk('r2')->readStream($this->file_path);
|
||||
|
||||
return response()->stream(function () use ($stream) {
|
||||
fpassthru($stream);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => $disposition . '; filename="' . $fileName . '"',
|
||||
'Content-Length' => $this->file_size,
|
||||
'Cache-Control' => 'private, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,9 +165,9 @@ public function moveToFolder(Folder $folder): bool
|
||||
$this->stored_name ?? $this->file_name
|
||||
);
|
||||
|
||||
// Move physical file
|
||||
if (Storage::disk('tenant')->exists($this->file_path)) {
|
||||
Storage::disk('tenant')->move($this->file_path, $newPath);
|
||||
// Move physical file in R2
|
||||
if (Storage::disk('r2')->exists($this->file_path)) {
|
||||
Storage::disk('r2')->move($this->file_path, $newPath);
|
||||
}
|
||||
|
||||
// Update DB
|
||||
@@ -182,9 +198,9 @@ public function softDeleteFile(int $userId): void
|
||||
*/
|
||||
public function permanentDelete(): void
|
||||
{
|
||||
// Delete physical file
|
||||
// Delete physical file from R2
|
||||
if ($this->exists()) {
|
||||
Storage::disk('tenant')->delete($this->file_path);
|
||||
Storage::disk('r2')->delete($this->file_path);
|
||||
}
|
||||
|
||||
// Decrement tenant storage
|
||||
|
||||
76
app/Models/Qualitys/ChecklistTemplate.php
Normal file
76
app/Models/Qualitys/ChecklistTemplate.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Qualitys;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ChecklistTemplate extends Model
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $table = 'checklist_templates';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'name',
|
||||
'type',
|
||||
'categories',
|
||||
'options',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'categories' => 'array',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'updated_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 점검항목별 연결 파일 (files 테이블 polymorphic)
|
||||
* document_type = 'checklist_template', document_id = this.id
|
||||
* field_key = sub_item_id (e.g. 'cat-1-1')
|
||||
*/
|
||||
public function documents(): MorphMany
|
||||
{
|
||||
return $this->morphMany(File::class, 'document', 'document_type', 'document_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 항목의 파일 조회
|
||||
*/
|
||||
public function documentsForItem(string $subItemId)
|
||||
{
|
||||
return $this->documents()->where('field_key', $subItemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* categories JSON에서 모든 sub_item_id 추출
|
||||
*/
|
||||
public function getAllSubItemIds(): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($this->categories ?? [] as $category) {
|
||||
foreach ($category['subItems'] ?? [] as $subItem) {
|
||||
$ids[] = $subItem['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class Receiving extends Model
|
||||
'status',
|
||||
'remark',
|
||||
'options',
|
||||
'certificate_file_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'deleted_by',
|
||||
@@ -47,6 +48,7 @@ class Receiving extends Model
|
||||
'receiving_qty' => 'decimal:2',
|
||||
'item_id' => 'integer',
|
||||
'options' => 'array',
|
||||
'certificate_file_id' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,14 @@ public function item(): BelongsTo
|
||||
return $this->belongsTo(\App\Models\Items\Item::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업체 제공 성적서 파일 관계
|
||||
*/
|
||||
public function certificateFile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(\App\Models\Commons\File::class, 'certificate_file_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성자 관계
|
||||
*/
|
||||
|
||||
@@ -283,6 +283,12 @@ public function getOrderContactAttribute(): ?string
|
||||
*/
|
||||
public function getOrderInfoAttribute(): array
|
||||
{
|
||||
$orderOptions = $this->order?->options;
|
||||
if (is_string($orderOptions)) {
|
||||
$orderOptions = json_decode($orderOptions, true) ?? [];
|
||||
}
|
||||
$orderOptions = $orderOptions ?? [];
|
||||
|
||||
return [
|
||||
'order_id' => $this->order_id,
|
||||
'order_no' => $this->order?->order_no,
|
||||
@@ -290,10 +296,16 @@ public function getOrderInfoAttribute(): array
|
||||
'client_id' => $this->order_client_id,
|
||||
'customer_name' => $this->order_customer_name,
|
||||
'site_name' => $this->order_site_name,
|
||||
'delivery_address' => $this->order_delivery_address,
|
||||
'delivery_address' => $orderOptions['shipping_address'] ?? $this->order_delivery_address,
|
||||
'delivery_address_detail' => $orderOptions['shipping_address_detail'] ?? null,
|
||||
'contact' => $this->order_contact,
|
||||
// 수신자 정보 (수주 options에서)
|
||||
'receiver' => $orderOptions['receiver'] ?? null,
|
||||
'receiver_contact' => $orderOptions['receiver_contact'] ?? $this->order_contact,
|
||||
// 추가 정보
|
||||
'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'), 'writer_id' => $this->order?->writer_id,
|
||||
'delivery_date' => $this->order?->delivery_date?->format('Y-m-d'),
|
||||
'delivery_method' => $this->order?->delivery_method_code,
|
||||
'writer_id' => $this->order?->writer_id,
|
||||
'writer_name' => $this->order?->writer?->name,
|
||||
];
|
||||
}
|
||||
|
||||
256
app/Services/ChecklistTemplateService.php
Normal file
256
app/Services/ChecklistTemplateService.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Commons\File;
|
||||
use App\Models\Qualitys\ChecklistTemplate;
|
||||
use App\Services\Audit\AuditLogger;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ChecklistTemplateService extends Service
|
||||
{
|
||||
private const AUDIT_TARGET = 'checklist_template';
|
||||
|
||||
private const DOCUMENT_TYPE = 'checklist_template';
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogger $auditLogger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 템플릿 조회 (type별)
|
||||
*/
|
||||
public function getByType(string $type): array
|
||||
{
|
||||
$template = ChecklistTemplate::query()
|
||||
->where('type', $type)
|
||||
->first();
|
||||
|
||||
if (! $template) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// 각 항목별 파일 수 포함
|
||||
$fileCounts = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $template->id)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw('field_key, COUNT(*) as count')
|
||||
->groupBy('field_key')
|
||||
->pluck('count', 'field_key')
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'type' => $template->type,
|
||||
'categories' => $template->categories,
|
||||
'options' => $template->options,
|
||||
'file_counts' => $fileCounts,
|
||||
'updated_at' => $template->updated_at?->toIso8601String(),
|
||||
'updated_by' => $template->updater?->name,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 저장 (전체 덮어쓰기)
|
||||
*/
|
||||
public function save(int $id, array $data): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($id);
|
||||
$before = $template->toArray();
|
||||
|
||||
// 삭제된 항목의 파일 처리
|
||||
$oldSubItemIds = $template->getAllSubItemIds();
|
||||
$newSubItemIds = $this->extractSubItemIds($data['categories']);
|
||||
$removedIds = array_diff($oldSubItemIds, $newSubItemIds);
|
||||
|
||||
DB::transaction(function () use ($template, $data, $removedIds) {
|
||||
// 템플릿 업데이트
|
||||
$template->update([
|
||||
'name' => $data['name'] ?? $template->name,
|
||||
'categories' => $data['categories'],
|
||||
'options' => $data['options'] ?? $template->options,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
// 삭제된 항목의 파일 → soft delete
|
||||
if (! empty($removedIds)) {
|
||||
$orphanFiles = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $template->id)
|
||||
->whereIn('field_key', $removedIds)
|
||||
->get();
|
||||
|
||||
foreach ($orphanFiles as $file) {
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$template->refresh();
|
||||
|
||||
$this->auditLogger->log(
|
||||
$this->tenantId(),
|
||||
self::AUDIT_TARGET,
|
||||
$template->id,
|
||||
'updated',
|
||||
$before,
|
||||
$template->toArray()
|
||||
);
|
||||
|
||||
return $this->getByType($template->type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목 완료 토글
|
||||
*/
|
||||
public function toggleItem(int $id, string $subItemId): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($id);
|
||||
$categories = $template->categories;
|
||||
$toggled = null;
|
||||
|
||||
foreach ($categories as &$category) {
|
||||
if (empty($category['subItems'])) {
|
||||
continue;
|
||||
}
|
||||
foreach ($category['subItems'] as &$subItem) {
|
||||
if ($subItem['id'] === $subItemId) {
|
||||
$subItem['is_completed'] = ! ($subItem['is_completed'] ?? false);
|
||||
$subItem['completed_at'] = $subItem['is_completed'] ? now()->toIso8601String() : null;
|
||||
$toggled = $subItem;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
unset($subItem);
|
||||
}
|
||||
unset($category);
|
||||
|
||||
if (! $toggled) {
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
$template->update([
|
||||
'categories' => $categories,
|
||||
'updated_by' => $this->apiUserId(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $toggled['id'],
|
||||
'name' => $toggled['name'],
|
||||
'is_completed' => $toggled['is_completed'],
|
||||
'completed_at' => $toggled['completed_at'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목별 파일 목록 조회
|
||||
*/
|
||||
public function getDocuments(int $templateId, ?string $subItemId = null): array
|
||||
{
|
||||
$query = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->where('document_id', $templateId)
|
||||
->with('uploader:id,name');
|
||||
|
||||
if ($subItemId) {
|
||||
$query->where('field_key', $subItemId);
|
||||
}
|
||||
|
||||
$files = $query->orderBy('field_key')->orderByDesc('id')->get();
|
||||
|
||||
return $files->map(fn (File $file) => [
|
||||
'id' => $file->id,
|
||||
'field_key' => $file->field_key,
|
||||
'display_name' => $file->display_name ?? $file->original_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'uploaded_by' => $file->uploader?->name,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 업로드 (polymorphic)
|
||||
*/
|
||||
public function uploadDocument(int $templateId, string $subItemId, $uploadedFile): array
|
||||
{
|
||||
$template = ChecklistTemplate::findOrFail($templateId);
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
// 저장 경로: {tenant_id}/checklist-templates/{year}/{month}/{stored_name}
|
||||
$date = now();
|
||||
$storedName = bin2hex(random_bytes(16)).'.'.$uploadedFile->getClientOriginalExtension();
|
||||
$filePath = sprintf(
|
||||
'%d/checklist-templates/%s/%s/%s',
|
||||
$tenantId,
|
||||
$date->format('Y'),
|
||||
$date->format('m'),
|
||||
$storedName
|
||||
);
|
||||
|
||||
// 파일 저장
|
||||
Storage::disk('r2')->put($filePath, file_get_contents($uploadedFile->getPathname()));
|
||||
|
||||
// DB 레코드 생성
|
||||
$file = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => self::DOCUMENT_TYPE,
|
||||
'document_id' => $template->id,
|
||||
'field_key' => $subItemId,
|
||||
'display_name' => $uploadedFile->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => $uploadedFile->getSize(),
|
||||
'mime_type' => $uploadedFile->getClientMimeType(),
|
||||
'uploaded_by' => $userId,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $file->id,
|
||||
'field_key' => $file->field_key,
|
||||
'display_name' => $file->display_name,
|
||||
'file_size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
'created_at' => $file->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 삭제
|
||||
* - 교체(replace=true): hard delete (물리 파일 + DB)
|
||||
* - 일반 삭제: soft delete (휴지통)
|
||||
*/
|
||||
public function deleteDocument(int $fileId, bool $replace = false): void
|
||||
{
|
||||
$file = File::query()
|
||||
->where('document_type', self::DOCUMENT_TYPE)
|
||||
->findOrFail($fileId);
|
||||
|
||||
if ($replace) {
|
||||
$file->permanentDelete();
|
||||
} else {
|
||||
$file->softDeleteFile($this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* categories JSON에서 sub_item_id 목록 추출
|
||||
*/
|
||||
private function extractSubItemIds(array $categories): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($categories as $category) {
|
||||
foreach ($category['subItems'] ?? [] as $subItem) {
|
||||
$ids[] = $subItem['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -54,18 +54,16 @@ public function upload(UploadedFile $file, ?string $description = null): File
|
||||
$storedName
|
||||
);
|
||||
|
||||
// Store file
|
||||
Storage::disk('tenant')->putFileAs(
|
||||
dirname($tempPath),
|
||||
$file,
|
||||
basename($tempPath)
|
||||
);
|
||||
// Store file to R2 (Cloudflare R2, S3 compatible)
|
||||
Storage::disk('r2')->put($tempPath, file_get_contents($file->getRealPath()), [
|
||||
'ContentType' => $file->getMimeType(),
|
||||
]);
|
||||
|
||||
// Determine file type
|
||||
$mimeType = $file->getMimeType();
|
||||
$fileType = $this->determineFileType($mimeType);
|
||||
|
||||
// Create DB record
|
||||
// Create DB record (file_path = R2 key path)
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'display_name' => $file->getClientOriginalName(),
|
||||
|
||||
@@ -23,8 +23,7 @@ class QmsLotAuditService extends Service
|
||||
public function index(array $params): array
|
||||
{
|
||||
$query = QualityDocument::with([
|
||||
'documentOrders.order.nodes' => fn ($q) => $q->whereNull('parent_id'),
|
||||
'documentOrders.order.nodes.items.item',
|
||||
'documentOrders.order.item',
|
||||
'locations',
|
||||
'performanceReport',
|
||||
])
|
||||
@@ -89,7 +88,7 @@ public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
{
|
||||
$docOrder = QualityDocumentOrder::with([
|
||||
'order.workOrders.process',
|
||||
'locations',
|
||||
'locations.orderItem',
|
||||
'qualityDocument',
|
||||
])->findOrFail($qualityDocumentOrderId);
|
||||
|
||||
@@ -119,17 +118,20 @@ public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
// 2. 수주서
|
||||
$documents[] = $this->formatDocument('order', '수주서', collect([$order]));
|
||||
|
||||
// 3. 작업일지 (subType: process.process_name 기반)
|
||||
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders);
|
||||
// 3. 작업일지 (공정별 1개씩 — 같은 공정의 WO는 그룹핑)
|
||||
$workOrdersByProcess = $workOrders->groupBy('process_id')->map(fn ($group) => $group->first());
|
||||
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrdersByProcess);
|
||||
|
||||
// 4. 중간검사 성적서 (PQC)
|
||||
// 4. 중간검사 성적서 (PQC — 공정별 1개씩)
|
||||
$pqcInspections = Inspection::where('inspection_type', 'PQC')
|
||||
->whereIn('work_order_id', $workOrders->pluck('id'))
|
||||
->where('status', 'completed')
|
||||
->with('workOrder.process')
|
||||
->get();
|
||||
|
||||
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder');
|
||||
// 공정별 그룹핑 (같은 공정의 PQC는 최신 1개만)
|
||||
$pqcByProcess = $pqcInspections->groupBy(fn ($insp) => $insp->workOrder?->process_id)
|
||||
->map(fn ($group) => $group->sortByDesc('inspection_date')->first());
|
||||
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcByProcess, 'workOrder');
|
||||
|
||||
// 5. 납품확인서
|
||||
$shipments = $order->shipments()->get();
|
||||
@@ -138,9 +140,11 @@ public function routeDocuments(int $qualityDocumentOrderId): array
|
||||
// 6. 출고증
|
||||
$documents[] = $this->formatDocument('shipping', '출고증', $shipments);
|
||||
|
||||
// 7. 제품검사 성적서
|
||||
$locationsWithDoc = $docOrder->locations->filter(fn ($loc) => $loc->document_id);
|
||||
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithDoc);
|
||||
// 7. 제품검사 성적서 (FQC 문서 또는 inspection_data 완료건)
|
||||
$locationsWithInspection = $docOrder->locations->filter(
|
||||
fn ($loc) => $loc->document_id || $loc->inspection_status === 'completed'
|
||||
);
|
||||
$documents[] = $this->formatDocument('product', '제품검사 성적서', $locationsWithInspection);
|
||||
|
||||
// 8. 품질관리서
|
||||
$documents[] = $this->formatDocument('quality', '품질관리서', collect([$qualityDoc]));
|
||||
@@ -220,30 +224,14 @@ private function transformReportToFrontend(QualityDocument $doc): array
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 최상위(FG) 제품명 추출
|
||||
* Order → root OrderNode(parent_id=null) → 대표 OrderItem → Item(FG).name
|
||||
* 수주 대표 제품명 추출
|
||||
* Order.item_id → Item.name
|
||||
*/
|
||||
private function getFgProductName(QualityDocument $doc): string
|
||||
{
|
||||
$firstDocOrder = $doc->documentOrders->first();
|
||||
if (! $firstDocOrder) {
|
||||
return '';
|
||||
}
|
||||
$order = $doc->documentOrders->first()?->order;
|
||||
|
||||
$order = $firstDocOrder->order;
|
||||
if (! $order) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// eager loaded with whereNull('parent_id') filter
|
||||
$rootNode = $order->nodes->first();
|
||||
if (! $rootNode) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$representativeItem = $rootNode->items->first();
|
||||
|
||||
return $representativeItem?->item?->name ?? '';
|
||||
return $order?->item?->name ?? '';
|
||||
}
|
||||
|
||||
private function transformRouteToFrontend(QualityDocumentOrder $docOrder, QualityDocument $qualityDoc): array
|
||||
@@ -313,11 +301,12 @@ private function formatDocumentWithSubType(string $type, string $title, $collect
|
||||
'items' => $collection->values()->map(function ($item) use ($type, $workOrderRelation) {
|
||||
$formatted = $this->formatDocumentItem($type, $item);
|
||||
|
||||
// subType: process.process_name 기반
|
||||
// subType: process.process_name 기반 + work_order_id 전달
|
||||
$workOrder = $workOrderRelation ? $item->{$workOrderRelation} : $item;
|
||||
if ($workOrder instanceof WorkOrder) {
|
||||
$processName = $workOrder->process?->process_name;
|
||||
$formatted['sub_type'] = $this->mapProcessToSubType($processName);
|
||||
$formatted['work_order_id'] = $workOrder->id;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
@@ -354,7 +343,7 @@ private function formatDocumentItem(string $type, $item): array
|
||||
],
|
||||
'product' => [
|
||||
'id' => (string) $item->id,
|
||||
'title' => '제품검사 성적서',
|
||||
'title' => trim(($item->orderItem?->floor_code ?? '').' '.($item->orderItem?->symbol_code ?? '')) ?: '제품검사 성적서',
|
||||
'date' => $item->updated_at?->toDateString() ?? '',
|
||||
'code' => '',
|
||||
],
|
||||
@@ -479,9 +468,16 @@ private function getShipmentDetail(int $id): array
|
||||
|
||||
private function getLocationDetail(int $id): array
|
||||
{
|
||||
$location = QualityDocumentLocation::with(['orderItem', 'document'])->findOrFail($id);
|
||||
$location = QualityDocumentLocation::with([
|
||||
'orderItem',
|
||||
'document.template.sections.items',
|
||||
'document.template.columns',
|
||||
'document.template.approvalLines',
|
||||
'document.template.basicFields',
|
||||
'document.data',
|
||||
])->findOrFail($id);
|
||||
|
||||
return [
|
||||
$result = [
|
||||
'type' => 'product',
|
||||
'data' => [
|
||||
'id' => $location->id,
|
||||
@@ -494,6 +490,86 @@ private function getLocationDetail(int $id): array
|
||||
'document_id' => $location->document_id,
|
||||
],
|
||||
];
|
||||
|
||||
// FQC 문서가 있으면 template + data 포함
|
||||
if ($location->document) {
|
||||
$doc = $location->document;
|
||||
$result['data']['fqc_document'] = [
|
||||
'id' => $doc->id,
|
||||
'template_id' => $doc->template_id,
|
||||
'document_no' => $doc->document_no,
|
||||
'title' => $doc->title,
|
||||
'status' => $doc->status,
|
||||
'created_at' => $doc->created_at?->toIso8601String(),
|
||||
'template' => $this->formatFqcTemplate($doc->template),
|
||||
'data' => $doc->data->map(fn ($d) => [
|
||||
'section_id' => $d->section_id,
|
||||
'column_id' => $d->column_id,
|
||||
'row_index' => $d->row_index,
|
||||
'field_key' => $d->field_key,
|
||||
'field_value' => $d->field_value,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function formatFqcTemplate($template): ?array
|
||||
{
|
||||
if (! $template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $template->id,
|
||||
'name' => $template->name,
|
||||
'category' => $template->category,
|
||||
'title' => $template->title,
|
||||
'approval_lines' => $template->approvalLines->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'name' => $a->name,
|
||||
'department' => $a->department,
|
||||
'sort_order' => $a->sort_order,
|
||||
])->all(),
|
||||
'basic_fields' => $template->basicFields->map(fn ($f) => [
|
||||
'id' => $f->id,
|
||||
'label' => $f->label,
|
||||
'field_key' => $f->field_key,
|
||||
'field_type' => $f->field_type,
|
||||
'default_value' => $f->default_value,
|
||||
'is_required' => $f->is_required,
|
||||
'sort_order' => $f->sort_order,
|
||||
])->all(),
|
||||
'sections' => $template->sections->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
'title' => $s->title,
|
||||
'description' => $s->description,
|
||||
'image_path' => $s->image_path,
|
||||
'sort_order' => $s->sort_order,
|
||||
'items' => $s->items->map(fn ($i) => [
|
||||
'id' => $i->id,
|
||||
'section_id' => $i->section_id,
|
||||
'item_name' => $i->item ?? '',
|
||||
'standard' => $i->standard,
|
||||
'tolerance' => $i->tolerance,
|
||||
'measurement_type' => $i->measurement_type,
|
||||
'frequency' => $i->frequency,
|
||||
'sort_order' => $i->sort_order,
|
||||
'category' => $i->category,
|
||||
'method' => $i->method,
|
||||
])->all(),
|
||||
])->all(),
|
||||
'columns' => $template->columns->map(fn ($c) => [
|
||||
'id' => $c->id,
|
||||
'label' => $c->label,
|
||||
'column_type' => $c->column_type,
|
||||
'width' => $c->width,
|
||||
'group_name' => $c->group_name,
|
||||
'sort_order' => $c->sort_order,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getQualityDocDetail(int $id): array
|
||||
|
||||
@@ -633,7 +633,7 @@ public function calculateBomWithDebug(
|
||||
'PC' => $inputVariables['PC'] ?? '',
|
||||
'GT' => $inputVariables['GT'] ?? 'wall',
|
||||
'MP' => $inputVariables['MP'] ?? 'single',
|
||||
'CT' => $inputVariables['CT'] ?? 'basic',
|
||||
'CT' => $inputVariables['CT'] ?? 'exposed',
|
||||
'WS' => $inputVariables['WS'] ?? 50,
|
||||
'INSP' => $inputVariables['INSP'] ?? 50000,
|
||||
'finished_goods' => $finishedGoodsCode,
|
||||
@@ -1708,6 +1708,22 @@ private function calculateTenantBom(
|
||||
default => '220V',
|
||||
};
|
||||
|
||||
// 제어기 타입: 프론트 CT(exposed/embedded/embedded_no_box) → controller_type(노출형/매립형) 매핑
|
||||
// - exposed: 노출형 (뒷박스 불필요)
|
||||
// - embedded: 매립형 (뒷박스 포함)
|
||||
// - embedded_no_box: 매립형 (뒷박스 제외 — 업체 자체 보유)
|
||||
$ctValue = $inputVariables['CT'] ?? 'exposed';
|
||||
$controllerType = $inputVariables['controller_type'] ?? match ($ctValue) {
|
||||
'embedded', 'embedded_no_box' => '매립형',
|
||||
'exposed' => '노출형',
|
||||
default => '노출형',
|
||||
};
|
||||
// 뒷박스: embedded만 포함, exposed/embedded_no_box는 제외
|
||||
$backboxQty = (int) ($inputVariables['backbox_qty'] ?? match ($ctValue) {
|
||||
'embedded' => 1,
|
||||
default => 0,
|
||||
});
|
||||
|
||||
$calculatedVariables = array_merge($inputVariables, [
|
||||
'W0' => $W0,
|
||||
'H0' => $H0,
|
||||
@@ -1724,6 +1740,8 @@ private function calculateTenantBom(
|
||||
'finishing_type' => $finishingType,
|
||||
'installation_type' => $installationType,
|
||||
'motor_voltage' => $motorVoltage,
|
||||
'controller_type' => $controllerType,
|
||||
'backbox_qty' => $backboxQty,
|
||||
]);
|
||||
|
||||
$this->addDebugStep(3, '변수계산', [
|
||||
|
||||
@@ -237,7 +237,7 @@ private function calculateBomMaterials(Quote $quote): array
|
||||
'PC' => $input['productCategory'] ?? 'SCREEN',
|
||||
'GT' => $input['guideRailType'] ?? 'wall',
|
||||
'MP' => $input['motorPower'] ?? 'single',
|
||||
'CT' => $input['controller'] ?? 'basic',
|
||||
'CT' => $input['controller'] ?? 'exposed',
|
||||
'WS' => (float) ($input['wingSize'] ?? 50),
|
||||
'INSP' => (float) ($input['inspectionFee'] ?? 50000),
|
||||
];
|
||||
|
||||
@@ -16,7 +16,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$query = Receiving::query()
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name'])
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path'])
|
||||
->where('tenant_id', $tenantId);
|
||||
|
||||
// 검색어 필터
|
||||
@@ -162,7 +162,7 @@ public function show(int $id): Receiving
|
||||
|
||||
return Receiving::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name'])
|
||||
->with(['creator:id,name', 'item:id,item_type,code,name', 'certificateFile:id,display_name,file_path'])
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,11 @@ public function store(array $data): Receiving
|
||||
$receiving->status = $data['status'] ?? 'receiving_pending';
|
||||
$receiving->remark = $data['remark'] ?? null;
|
||||
|
||||
// 성적서 파일 ID
|
||||
if (isset($data['certificate_file_id'])) {
|
||||
$receiving->certificate_file_id = $data['certificate_file_id'];
|
||||
}
|
||||
|
||||
// options 필드 처리 (제조사, 수입검사 등 확장 필드)
|
||||
$receiving->options = $this->buildOptions($data);
|
||||
|
||||
@@ -299,6 +304,11 @@ public function update(int $id, array $data): Receiving
|
||||
}
|
||||
}
|
||||
|
||||
// 성적서 파일 ID
|
||||
if (array_key_exists('certificate_file_id', $data)) {
|
||||
$receiving->certificate_file_id = $data['certificate_file_id'];
|
||||
}
|
||||
|
||||
// options 필드 업데이트 (제조사, 수입검사 등 확장 필드)
|
||||
$receiving->options = $this->mergeOptions($receiving->options, $data);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public function index(array $params): LengthAwarePaginator
|
||||
|
||||
$query = Shipment::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder']);
|
||||
->with(['items', 'vehicleDispatches', 'order.client', 'order.writer', 'workOrder', 'creator']);
|
||||
|
||||
// 검색어 필터
|
||||
if (! empty($params['search'])) {
|
||||
|
||||
@@ -602,13 +602,9 @@ public function updateStatus(int $id, string $status, ?array $resultData = null)
|
||||
// 연결된 수주(Order) 상태 동기화
|
||||
$this->syncOrderStatus($workOrder, $tenantId);
|
||||
|
||||
// 작업완료 시: 수주 연동 → 출하 생성 / 선생산 → 재고 입고
|
||||
if ($status === WorkOrder::STATUS_COMPLETED) {
|
||||
if ($workOrder->sales_order_id) {
|
||||
$this->createShipmentFromWorkOrder($workOrder, $tenantId, $userId);
|
||||
} else {
|
||||
$this->stockInFromProduction($workOrder);
|
||||
}
|
||||
// 작업완료 시: 선생산(수주 없음) → 재고 입고
|
||||
if ($status === WorkOrder::STATUS_COMPLETED && ! $workOrder->sales_order_id) {
|
||||
$this->stockInFromProduction($workOrder);
|
||||
}
|
||||
|
||||
return $workOrder->load(['assignee:id,name', 'assignees.user:id,name', 'team:id,name', 'process:id,process_name,process_code']);
|
||||
@@ -659,11 +655,167 @@ private function shouldStockIn(WorkOrderItem $woItem): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시 완료 시 자동 출하 생성
|
||||
* PRODUCED 수주에 출하가 없으면 재생성
|
||||
*
|
||||
* 작업지시가 완료(completed) 상태가 되면 출하(Shipment)를 자동 생성하여 출하관리로 넘깁니다.
|
||||
* 발주처/배송 정보는 출하에 복사하지 않고, 수주(Order)를 참조합니다.
|
||||
* (Shipment 모델의 accessor 메서드로 수주 정보 참조)
|
||||
* syncOrderStatus에서 이미 PRODUCED인데 출하가 삭제된 경우 호출됩니다.
|
||||
*/
|
||||
private function ensureShipmentExists(Order $order, $mainWorkOrders, int $tenantId): void
|
||||
{
|
||||
$hasShipment = Shipment::where('tenant_id', $tenantId)
|
||||
->where('order_id', $order->id)
|
||||
->exists();
|
||||
|
||||
if (! $hasShipment) {
|
||||
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 기반 출하 수동 생성 (API 엔드포인트용)
|
||||
*
|
||||
* 출하관리 UI에서 수주를 선택하여 출하를 수동 생성할 때 사용합니다.
|
||||
* PRODUCED 이상 상태의 수주만 가능합니다.
|
||||
*/
|
||||
public function createShipmentForOrder(int $orderId): Shipment
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
|
||||
$order = Order::where('tenant_id', $tenantId)->findOrFail($orderId);
|
||||
|
||||
// PRODUCED 또는 SHIPPED 상태만 출하 생성 가능
|
||||
$allowedStatuses = [Order::STATUS_PRODUCED, Order::STATUS_SHIPPED];
|
||||
if (! in_array($order->status_code, $allowedStatuses)) {
|
||||
throw new BadRequestHttpException(__('error.shipment.order_not_produced'));
|
||||
}
|
||||
|
||||
// 메인 작업지시 조회
|
||||
$allWorkOrders = WorkOrder::where('tenant_id', $tenantId)
|
||||
->where('sales_order_id', $orderId)
|
||||
->where('status', '!=', WorkOrder::STATUS_CANCELLED)
|
||||
->get();
|
||||
|
||||
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null);
|
||||
|
||||
if ($mainWorkOrders->isEmpty()) {
|
||||
throw new BadRequestHttpException(__('error.shipment.no_work_orders'));
|
||||
}
|
||||
|
||||
$shipment = $this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $userId);
|
||||
|
||||
if (! $shipment) {
|
||||
throw new BadRequestHttpException(__('error.shipment.already_exists'));
|
||||
}
|
||||
|
||||
return $shipment->load('items');
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 단위 자동 출하 생성 (생산완료 시)
|
||||
*
|
||||
* 수주의 모든 메인 작업지시가 완료되면, 전체 WO 품목을 합쳐서 출하 1건을 생성합니다.
|
||||
* - 이미 수주에 연결된 출하가 있으면 스킵 (중복 방지)
|
||||
* - 부분 출고는 출하관리 UI에서 수동 생성
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $mainWorkOrders 메인 작업지시 컬렉션
|
||||
*/
|
||||
private function createShipmentFromOrder(Order $order, $mainWorkOrders, int $tenantId, int $userId): ?Shipment
|
||||
{
|
||||
// 이미 이 수주에 연결된 출하가 있으면 스킵
|
||||
$existingShipment = Shipment::where('tenant_id', $tenantId)
|
||||
->where('order_id', $order->id)
|
||||
->first();
|
||||
|
||||
if ($existingShipment) {
|
||||
return $existingShipment;
|
||||
}
|
||||
|
||||
$shipmentNo = Shipment::generateShipmentNo($tenantId);
|
||||
|
||||
$shipment = Shipment::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_no' => $shipmentNo,
|
||||
'work_order_id' => null, // 수주 단위이므로 개별 WO 연결 안함
|
||||
'order_id' => $order->id,
|
||||
'scheduled_date' => $order->delivery_date ?? now()->toDateString(),
|
||||
'status' => 'scheduled',
|
||||
'priority' => 'normal',
|
||||
'delivery_method' => $order->delivery_method_code ?? 'pickup',
|
||||
'can_ship' => true,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId,
|
||||
]);
|
||||
|
||||
// 모든 메인 작업지시의 품목을 출하 품목으로 복사
|
||||
$seq = 0;
|
||||
foreach ($mainWorkOrders as $wo) {
|
||||
$workOrderItems = $wo->items()->get();
|
||||
|
||||
foreach ($workOrderItems as $woItem) {
|
||||
$result = $woItem->options['result'] ?? [];
|
||||
$lotNo = $result['lot_no'] ?? null;
|
||||
$floorUnit = $this->getFloorUnitFromOrderItem($woItem->source_order_item_id, $tenantId);
|
||||
|
||||
ShipmentItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_id' => $shipment->id,
|
||||
'seq' => ++$seq,
|
||||
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
|
||||
'item_name' => $woItem->item_name,
|
||||
'floor_unit' => $floorUnit,
|
||||
'specification' => $woItem->specification,
|
||||
'quantity' => $result['good_qty'] ?? $woItem->quantity,
|
||||
'unit' => $woItem->unit,
|
||||
'lot_no' => $lotNo,
|
||||
'remarks' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// WO에 품목이 없으면 수주 품목에서 fallback (해당 WO의 공정에 매칭되는 품목)
|
||||
if ($workOrderItems->isEmpty() && $wo->salesOrder) {
|
||||
$orderItems = $wo->salesOrder->items()->get();
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$floorUnit = $this->getFloorUnitFromOrderItem($orderItem->id, $tenantId);
|
||||
ShipmentItem::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_id' => $shipment->id,
|
||||
'seq' => ++$seq,
|
||||
'item_code' => $orderItem->item_id ? "ITEM-{$orderItem->item_id}" : null,
|
||||
'item_name' => $orderItem->item_name,
|
||||
'floor_unit' => $floorUnit,
|
||||
'specification' => $orderItem->specification,
|
||||
'quantity' => $orderItem->quantity,
|
||||
'unit' => $orderItem->unit,
|
||||
'lot_no' => null,
|
||||
'remarks' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->auditLogger->log(
|
||||
$tenantId,
|
||||
'shipment',
|
||||
$shipment->id,
|
||||
'auto_created_from_order',
|
||||
null,
|
||||
[
|
||||
'order_id' => $order->id,
|
||||
'order_no' => $order->order_no,
|
||||
'shipment_no' => $shipmentNo,
|
||||
'work_order_count' => $mainWorkOrders->count(),
|
||||
'items_count' => $shipment->items()->count(),
|
||||
]
|
||||
);
|
||||
|
||||
return $shipment;
|
||||
}
|
||||
|
||||
/**
|
||||
* [DEPRECATED] 작업지시 단위 자동 출하 생성
|
||||
*
|
||||
* 수주 단위 출하(createShipmentFromOrder)로 대체됨.
|
||||
* 부분 출고 등 특수 케이스에서 개별 WO 기반 출하가 필요할 경우를 위해 유지.
|
||||
*/
|
||||
private function createShipmentFromWorkOrder(WorkOrder $workOrder, int $tenantId, int $userId): ?Shipment
|
||||
{
|
||||
@@ -836,17 +988,50 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
|
||||
return;
|
||||
}
|
||||
|
||||
// 작업지시 상태 → 수주 상태 매핑
|
||||
$statusMap = [
|
||||
WorkOrder::STATUS_IN_PROGRESS => Order::STATUS_IN_PRODUCTION,
|
||||
WorkOrder::STATUS_COMPLETED => Order::STATUS_PRODUCED,
|
||||
WorkOrder::STATUS_SHIPPED => Order::STATUS_SHIPPED,
|
||||
];
|
||||
// 해당 수주의 모든 비보조 작업지시 상태 집계
|
||||
$allWorkOrders = WorkOrder::where('tenant_id', $tenantId)
|
||||
->where('sales_order_id', $workOrder->sales_order_id)
|
||||
->where('status', '!=', WorkOrder::STATUS_CANCELLED)
|
||||
->get();
|
||||
|
||||
$newOrderStatus = $statusMap[$workOrder->status] ?? null;
|
||||
// 보조 공정 및 공정 미지정 작업지시 제외
|
||||
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) => ! $this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null);
|
||||
|
||||
if ($mainWorkOrders->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$totalCount = $mainWorkOrders->count();
|
||||
$statusCounts = $mainWorkOrders->groupBy('status')->map->count();
|
||||
|
||||
$shippedCount = $statusCounts->get(WorkOrder::STATUS_SHIPPED, 0);
|
||||
$completedCount = $statusCounts->get(WorkOrder::STATUS_COMPLETED, 0);
|
||||
$inProgressCount = $statusCounts->get(WorkOrder::STATUS_IN_PROGRESS, 0);
|
||||
|
||||
// 집계 기반 수주 상태 결정
|
||||
// 전부 출하 → SHIPPED
|
||||
// 전부 완료(또는 완료+출하) → PRODUCED
|
||||
// 하나라도 진행중/완료/출하 → IN_PRODUCTION
|
||||
$newOrderStatus = null;
|
||||
if ($shippedCount === $totalCount) {
|
||||
$newOrderStatus = Order::STATUS_SHIPPED;
|
||||
} elseif (($completedCount + $shippedCount) === $totalCount) {
|
||||
$newOrderStatus = Order::STATUS_PRODUCED;
|
||||
} elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) {
|
||||
$newOrderStatus = Order::STATUS_IN_PRODUCTION;
|
||||
}
|
||||
|
||||
// 매핑되는 상태가 없으면 스킵
|
||||
if (! $newOrderStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 동일한 상태면 상태 변경은 스킵하되, PRODUCED인데 출하 없으면 재생성
|
||||
if ($order->status_code === $newOrderStatus) {
|
||||
if ($newOrderStatus === Order::STATUS_PRODUCED) {
|
||||
$this->ensureShipmentExists($order, $mainWorkOrders, $tenantId);
|
||||
}
|
||||
|
||||
// 매핑되는 상태가 없거나 이미 동일한 상태면 스킵
|
||||
if (! $newOrderStatus || $order->status_code === $newOrderStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -861,9 +1046,19 @@ private function syncOrderStatus(WorkOrder $workOrder, int $tenantId): void
|
||||
'order',
|
||||
$order->id,
|
||||
'status_synced_from_work_order',
|
||||
['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id],
|
||||
['status_code' => $newOrderStatus, 'work_order_id' => $workOrder->id]
|
||||
['status_code' => $oldOrderStatus, 'work_order_id' => $workOrder->id, 'aggregated' => true],
|
||||
['status_code' => $newOrderStatus, 'work_order_counts' => [
|
||||
'total' => $totalCount,
|
||||
'shipped' => $shippedCount,
|
||||
'completed' => $completedCount,
|
||||
'in_progress' => $inProgressCount,
|
||||
]]
|
||||
);
|
||||
|
||||
// 생산완료(PRODUCED) 전환 시 → 수주 단위 출하 자동 생성
|
||||
if ($newOrderStatus === Order::STATUS_PRODUCED) {
|
||||
$this->createShipmentFromOrder($order, $mainWorkOrders, $tenantId, $this->apiUserId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -952,7 +1147,7 @@ private function saveItemResults(WorkOrder $workOrder, ?array $resultData, int $
|
||||
*/
|
||||
private function isAuxiliaryWorkOrder(WorkOrder $workOrder): bool
|
||||
{
|
||||
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options, true) ?? []);
|
||||
$options = is_array($workOrder->options) ? $workOrder->options : (json_decode($workOrder->options ?? '{}', true) ?? []);
|
||||
|
||||
return ! empty($options['is_auxiliary']);
|
||||
}
|
||||
@@ -1846,15 +2041,92 @@ public function toggleStepProgress(int $workOrderId, int $progressId): array
|
||||
$after
|
||||
);
|
||||
|
||||
// 모든 공정 단계 완료 시 → 작업지시 자동 완료
|
||||
$workOrderStatusChanged = false;
|
||||
if ($progress->isCompleted()) {
|
||||
$workOrderStatusChanged = $this->autoCompleteWorkOrderIfAllStepsDone($workOrder, $tenantId, $userId);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $progress->id,
|
||||
'status' => $progress->status,
|
||||
'is_completed' => $progress->isCompleted(),
|
||||
'completed_at' => $progress->completed_at?->toDateTimeString(),
|
||||
'completed_by' => $progress->completed_by,
|
||||
'work_order_status_changed' => $workOrderStatusChanged,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 공정 단계 완료 시 작업지시를 자동으로 완료 처리
|
||||
*
|
||||
* 트리거: 마지막 공정 단계(포장 등) 완료 체크 시
|
||||
* 흐름: 전 단계 완료 → 작업지시 completed → 수주 상태 동기화 → 출하 자동 생성
|
||||
*/
|
||||
private function autoCompleteWorkOrderIfAllStepsDone(WorkOrder $workOrder, int $tenantId, int $userId): bool
|
||||
{
|
||||
// 이미 완료/출하 상태면 스킵
|
||||
if (in_array($workOrder->status, [WorkOrder::STATUS_COMPLETED, WorkOrder::STATUS_SHIPPED])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 해당 작업지시의 모든 공정 단계 조회
|
||||
$allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get();
|
||||
|
||||
if ($allSteps->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 미완료 step 자동 보정: 같은 개소(work_order_item)의 다른 step이 모두 완료된 경우
|
||||
// 자재투입 등 모달 방식 step이 DB에 waiting으로 남아있을 수 있음
|
||||
$incompleteSteps = $allSteps->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED);
|
||||
if ($incompleteSteps->isNotEmpty()) {
|
||||
$this->autoCompleteOrphanedSteps($allSteps, $incompleteSteps, $userId);
|
||||
|
||||
// 보정 후 다시 확인
|
||||
$allSteps = WorkOrderStepProgress::where('work_order_id', $workOrder->id)->get();
|
||||
$allCompleted = $allSteps->every(fn ($step) => $step->status === WorkOrderStepProgress::STATUS_COMPLETED);
|
||||
|
||||
if (! $allCompleted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 작업지시 완료 처리 (updateStatus 재사용으로 출하 생성/수주 동기화 모두 트리거)
|
||||
$this->updateStatus($workOrder->id, WorkOrder::STATUS_COMPLETED);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 개소(work_order_item)의 나머지 step이 모두 완료되었으면
|
||||
* 남은 미완료 step(자재투입 등)도 자동 완료 처리
|
||||
*/
|
||||
private function autoCompleteOrphanedSteps($allSteps, $incompleteSteps, int $userId): void
|
||||
{
|
||||
// 개소(item)별로 그룹핑
|
||||
$stepsByItem = $allSteps->groupBy('work_order_item_id');
|
||||
|
||||
foreach ($incompleteSteps as $incomplete) {
|
||||
$itemSteps = $stepsByItem->get($incomplete->work_order_item_id);
|
||||
if (! $itemSteps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이 개소에서 이 step만 미완료인지 확인
|
||||
$otherIncomplete = $itemSteps->where('id', '!=', $incomplete->id)
|
||||
->where('status', '!=', WorkOrderStepProgress::STATUS_COMPLETED);
|
||||
|
||||
if ($otherIncomplete->isEmpty()) {
|
||||
// 이 step만 남았으면 자동 완료
|
||||
$incomplete->status = WorkOrderStepProgress::STATUS_COMPLETED;
|
||||
$incomplete->completed_at = now();
|
||||
$incomplete->completed_by = $userId;
|
||||
$incomplete->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 이력 조회
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"laravel/mcp": "^0.1.1",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"league/flysystem-aws-s3-v3": "^3.32",
|
||||
"livewire/livewire": "^3.0",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
"spatie/laravel-permission": "^6.21"
|
||||
|
||||
346
composer.lock
generated
346
composer.lock
generated
@@ -4,8 +4,159 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "340d586f1c4e3f7bd0728229300967da",
|
||||
"content-hash": "f39a7807cc0a6aa991e31a6acffc9508",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
"version": "v1.2.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/awslabs/aws-crt-php.git",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "AWS SDK Common Runtime Team",
|
||||
"email": "aws-sdk-common-runtime@amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS Common Runtime for PHP",
|
||||
"homepage": "https://github.com/awslabs/aws-crt-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"crt",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/awslabs/aws-crt-php/issues",
|
||||
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
|
||||
},
|
||||
"time": "2024-10-18T22:15:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-sdk-php",
|
||||
"version": "3.372.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/aws/aws-sdk-php.git",
|
||||
"reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d207d2ca972c9b10674e535dacd4a5d956a80bad",
|
||||
"reference": "d207d2ca972c9b10674e535dacd4a5d956a80bad",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-crt-php": "^1.2.3",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/promises": "^2.0",
|
||||
"guzzlehttp/psr7": "^2.4.5",
|
||||
"mtdowling/jmespath.php": "^2.8.0",
|
||||
"php": ">=8.1",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"andrewsville/php-token-reflection": "^1.4",
|
||||
"aws/aws-php-sns-message-validator": "~1.0",
|
||||
"behat/behat": "~3.0",
|
||||
"composer/composer": "^2.7.8",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.4.0",
|
||||
"doctrine/cache": "~1.4",
|
||||
"ext-dom": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-sockets": "*",
|
||||
"phpunit/phpunit": "^9.6",
|
||||
"psr/cache": "^2.0 || ^3.0",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
|
||||
"yoast/phpunit-polyfills": "^2.0"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
|
||||
"doctrine/cache": "To use the DoctrineCacheAdapter",
|
||||
"ext-curl": "To send requests using cURL",
|
||||
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
|
||||
"ext-pcntl": "To use client-side monitoring",
|
||||
"ext-sockets": "To use client-side monitoring"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Aws\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/data/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"homepage": "https://aws.amazon.com"
|
||||
}
|
||||
],
|
||||
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
|
||||
"homepage": "https://aws.amazon.com/sdk-for-php",
|
||||
"keywords": [
|
||||
"amazon",
|
||||
"aws",
|
||||
"cloud",
|
||||
"dynamodb",
|
||||
"ec2",
|
||||
"glacier",
|
||||
"s3",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://github.com/aws/aws-sdk-php/discussions",
|
||||
"issues": "https://github.com/aws/aws-sdk-php/issues",
|
||||
"source": "https://github.com/aws/aws-sdk-php/tree/3.372.3"
|
||||
},
|
||||
"time": "2026-03-10T18:07:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.13.1",
|
||||
@@ -2512,6 +2663,61 @@
|
||||
},
|
||||
"time": "2025-06-25T13:29:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-aws-s3-v3",
|
||||
"version": "3.32.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
|
||||
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||
"reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"aws/aws-sdk-php": "^3.295.10",
|
||||
"league/flysystem": "^3.10.0",
|
||||
"league/mime-type-detection": "^1.0.0",
|
||||
"php": "^8.0.2"
|
||||
},
|
||||
"conflict": {
|
||||
"guzzlehttp/guzzle": "<7.0",
|
||||
"guzzlehttp/ringphp": "<1.1.1"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Flysystem\\AwsS3V3\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Frank de Jonge",
|
||||
"email": "info@frankdejonge.nl"
|
||||
}
|
||||
],
|
||||
"description": "AWS S3 filesystem adapter for Flysystem.",
|
||||
"keywords": [
|
||||
"Flysystem",
|
||||
"aws",
|
||||
"file",
|
||||
"files",
|
||||
"filesystem",
|
||||
"s3",
|
||||
"storage"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0"
|
||||
},
|
||||
"time": "2026-02-25T16:46:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/flysystem-local",
|
||||
"version": "3.30.0",
|
||||
@@ -3236,6 +3442,72 @@
|
||||
],
|
||||
"time": "2025-03-24T10:02:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mtdowling/jmespath.php",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/jmespath/jmespath.php.git",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.17"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/xdebug-handler": "^3.0.3",
|
||||
"phpunit/phpunit": "^8.5.33"
|
||||
},
|
||||
"bin": [
|
||||
"bin/jp.php"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/JmesPath.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"JmesPath\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
}
|
||||
],
|
||||
"description": "Declaratively specify how to extract elements from a JSON document",
|
||||
"keywords": [
|
||||
"json",
|
||||
"jsonpath"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/jmespath/jmespath.php/issues",
|
||||
"source": "https://github.com/jmespath/jmespath.php/tree/2.8.0"
|
||||
},
|
||||
"time": "2024-09-04T18:46:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.10.1",
|
||||
@@ -5230,6 +5502,76 @@
|
||||
],
|
||||
"time": "2024-09-25T14:21:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/filesystem",
|
||||
"version": "v8.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/filesystem.git",
|
||||
"reference": "7bf9162d7a0dff98d079b72948508fa48018a770"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770",
|
||||
"reference": "7bf9162d7a0dff98d079b72948508fa48018a770",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/polyfill-ctype": "~1.8",
|
||||
"symfony/polyfill-mbstring": "~1.8"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/process": "^7.4|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Filesystem\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides basic utilities for the filesystem",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/filesystem/tree/v8.0.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-25T16:59:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/finder",
|
||||
"version": "v7.3.0",
|
||||
@@ -10773,5 +11115,5 @@
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -76,6 +76,18 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'r2' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('R2_ACCESS_KEY_ID'),
|
||||
'secret' => env('R2_SECRET_ACCESS_KEY'),
|
||||
'region' => env('R2_REGION', 'auto'),
|
||||
'bucket' => env('R2_BUCKET'),
|
||||
'endpoint' => env('R2_ENDPOINT'),
|
||||
'use_path_style_endpoint' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('receivings', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('certificate_file_id')
|
||||
->nullable()
|
||||
->after('options')
|
||||
->comment('업체 제공 성적서 파일 ID');
|
||||
|
||||
$table->foreign('certificate_file_id')
|
||||
->references('id')
|
||||
->on('files')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('receivings', function (Blueprint $table) {
|
||||
$table->dropForeign(['certificate_file_id']);
|
||||
$table->dropColumn('certificate_file_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('checklist_templates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
|
||||
$table->string('name', 255)->default('품질인정심사 점검표')->comment('템플릿명');
|
||||
$table->string('type', 50)->default('day1_audit')->comment('심사유형: day1_audit, day2_lot 등');
|
||||
$table->json('categories')->comment('카테고리/항목 JSON');
|
||||
$table->json('options')->nullable()->comment('확장 속성');
|
||||
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
|
||||
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['tenant_id', 'type'], 'uq_checklist_templates_tenant_type');
|
||||
$table->index(['tenant_id', 'type'], 'idx_checklist_templates_tenant_type');
|
||||
$table->foreign('tenant_id')->references('id')->on('tenants');
|
||||
$table->foreign('created_by')->references('id')->on('users')->onDelete('set null');
|
||||
$table->foreign('updated_by')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
|
||||
// 기존 테넌트에 기본 템플릿 시딩
|
||||
$this->seedDefaultTemplates();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('checklist_templates');
|
||||
}
|
||||
|
||||
private function seedDefaultTemplates(): void
|
||||
{
|
||||
$defaultCategories = json_encode([
|
||||
[
|
||||
'id' => 'cat-1',
|
||||
'title' => '원재료 품질관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-1-1', 'name' => '수입검사 기준 확인'],
|
||||
['id' => 'cat-1-2', 'name' => '불합격품 처리 기준 확인'],
|
||||
['id' => 'cat-1-3', 'name' => '자재 보관 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-2',
|
||||
'title' => '제조공정 관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-2-1', 'name' => '작업표준서 확인'],
|
||||
['id' => 'cat-2-2', 'name' => '공정검사 기준 확인'],
|
||||
['id' => 'cat-2-3', 'name' => '부적합품 처리 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-3',
|
||||
'title' => '제품 품질관리 기준',
|
||||
'subItems' => [
|
||||
['id' => 'cat-3-1', 'name' => '제품검사 기준 확인'],
|
||||
['id' => 'cat-3-2', 'name' => '출하검사 기준 확인'],
|
||||
['id' => 'cat-3-3', 'name' => '클레임 처리 기준 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-4',
|
||||
'title' => '제조설비 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-4-1', 'name' => '설비관리 기준 확인'],
|
||||
['id' => 'cat-4-2', 'name' => '설비점검 이력 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-5',
|
||||
'title' => '검사설비 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-5-1', 'name' => '검사설비 관리 기준 확인'],
|
||||
['id' => 'cat-5-2', 'name' => '교정 이력 확인'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'cat-6',
|
||||
'title' => '문서 및 인증 관리',
|
||||
'subItems' => [
|
||||
['id' => 'cat-6-1', 'name' => '문서관리 기준 확인'],
|
||||
['id' => 'cat-6-2', 'name' => 'KS/인증 관리 현황 확인'],
|
||||
],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$tenantIds = DB::table('tenants')->pluck('id');
|
||||
$now = now();
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::table('checklist_templates')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => '품질인정심사 점검표',
|
||||
'type' => 'day1_audit',
|
||||
'categories' => $defaultCategories,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -99,6 +99,9 @@
|
||||
'cannot_delete' => '현재 상태에서는 삭제할 수 없습니다.',
|
||||
'invalid_status' => '유효하지 않은 상태입니다.',
|
||||
'cannot_ship' => '출하 가능 상태가 아닙니다.',
|
||||
'order_not_produced' => '생산완료 상태의 수주만 출하를 생성할 수 있습니다.',
|
||||
'no_work_orders' => '해당 수주에 유효한 작업지시가 없습니다.',
|
||||
'already_exists' => '이미 해당 수주에 출하가 존재합니다.',
|
||||
],
|
||||
|
||||
// 파일 관리 관련
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
Route::get('/trash', [FileStorageController::class, 'trash'])->name('v1.files.trash'); // 휴지통 목록
|
||||
Route::get('/{id}', [FileStorageController::class, 'show'])->name('v1.files.show'); // 파일 상세
|
||||
Route::get('/{id}/download', [FileStorageController::class, 'download'])->name('v1.files.download'); // 파일 다운로드
|
||||
Route::get('/{id}/view', [FileStorageController::class, 'view'])->name('v1.files.view'); // 파일 인라인 보기 (이미지/PDF)
|
||||
Route::delete('/{id}', [FileStorageController::class, 'destroy'])->name('v1.files.destroy'); // 파일 삭제 (soft)
|
||||
Route::post('/{id}/restore', [FileStorageController::class, 'restore'])->name('v1.files.restore'); // 파일 복구
|
||||
Route::delete('/{id}/permanent', [FileStorageController::class, 'permanentDelete'])->name('v1.files.permanent'); // 파일 영구 삭제
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
Route::get('/options/logistics', [ShipmentController::class, 'logisticsOptions'])->name('v1.shipments.options.logistics');
|
||||
Route::get('/options/vehicle-tonnage', [ShipmentController::class, 'vehicleTonnageOptions'])->name('v1.shipments.options.vehicle-tonnage');
|
||||
Route::post('', [ShipmentController::class, 'store'])->name('v1.shipments.store');
|
||||
Route::post('/from-order/{orderId}', [ShipmentController::class, 'createFromOrder'])->whereNumber('orderId')->name('v1.shipments.from-order');
|
||||
Route::get('/{id}', [ShipmentController::class, 'show'])->whereNumber('id')->name('v1.shipments.show');
|
||||
Route::put('/{id}', [ShipmentController::class, 'update'])->whereNumber('id')->name('v1.shipments.update');
|
||||
Route::patch('/{id}/status', [ShipmentController::class, 'updateStatus'])->whereNumber('id')->name('v1.shipments.status');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuditChecklistController;
|
||||
use App\Http\Controllers\Api\V1\ChecklistTemplateController;
|
||||
use App\Http\Controllers\Api\V1\PerformanceReportController;
|
||||
use App\Http\Controllers\Api\V1\QmsLotAuditController;
|
||||
use App\Http\Controllers\Api\V1\QualityDocumentController;
|
||||
@@ -50,6 +51,19 @@
|
||||
Route::patch('/units/{id}/confirm', [QmsLotAuditController::class, 'confirm'])->whereNumber('id')->name('v1.qms.lot-audit.units.confirm');
|
||||
});
|
||||
|
||||
// QMS 점검표 템플릿 관리
|
||||
Route::prefix('quality/checklist-templates')->group(function () {
|
||||
Route::get('', [ChecklistTemplateController::class, 'show'])->name('v1.quality.checklist-templates.show');
|
||||
Route::put('/{id}', [ChecklistTemplateController::class, 'update'])->whereNumber('id')->name('v1.quality.checklist-templates.update');
|
||||
});
|
||||
|
||||
// QMS 점검표 문서 (파일) 관리
|
||||
Route::prefix('quality/qms-documents')->group(function () {
|
||||
Route::get('', [ChecklistTemplateController::class, 'documents'])->name('v1.quality.qms-documents.index');
|
||||
Route::post('', [ChecklistTemplateController::class, 'uploadDocument'])->name('v1.quality.qms-documents.store');
|
||||
Route::delete('/{id}', [ChecklistTemplateController::class, 'deleteDocument'])->whereNumber('id')->name('v1.quality.qms-documents.destroy');
|
||||
});
|
||||
|
||||
// QMS 기준/매뉴얼 심사 (1일차)
|
||||
Route::prefix('qms')->group(function () {
|
||||
Route::get('/checklists', [AuditChecklistController::class, 'index'])->name('v1.qms.checklists.index');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user