deploy: 2026-03-12 배포

- feat: [barobill] 바로빌 카드/은행/홈택스 REST API 구현
- feat: [equipment] 설비관리 API 백엔드 구현
- feat: [payroll] 급여관리 계산 엔진 및 일괄 처리 API
- feat: [QMS] 점검표 템플릿 관리 + 로트심사 개선
- feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선
- feat: [receiving] 입고 성적서 파일 연결
- feat: [견적] 제어기 타입 체계 변경
- feat: [email] 테넌트 메일 설정 마이그레이션 및 모델
- feat: [pmis] 시공관리 테이블 마이그레이션
- feat: [R2] 파일 업로드 커맨드 + filesystems 설정
- feat: [배포] Jenkinsfile 롤백 기능 추가
- fix: [approval] SAM API 규칙 준수 코드 개선
- fix: [account-codes] 계정과목 중복 데이터 정리
- fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기
- fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 제거
- refactor: [barobill] 바로빌 연동 코드 전면 개선
This commit is contained in:
2026-03-12 15:20:20 +09:00
parent 4e19e3ed67
commit 5448f0e57d
103 changed files with 8971 additions and 1289 deletions

130
Jenkinsfile vendored
View File

@@ -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}>"
}
}
}

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-03-07 02:57:21
> **자동 생성**: 2026-03-12 13:58:25
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -26,6 +26,68 @@ ### bad_debt_memos
- **badDebt()**: belongsTo → `bad_debts`
- **creator()**: belongsTo → `users`
### barobill_bank_sync_status
**모델**: `App\Models\Barobill\BarobillBankSyncStatus`
- **tenant()**: belongsTo → `tenants`
### barobill_bank_transactions
**모델**: `App\Models\Barobill\BarobillBankTransaction`
- **tenant()**: belongsTo → `tenants`
### barobill_bank_transaction_splits
**모델**: `App\Models\Barobill\BarobillBankTransactionSplit`
- **tenant()**: belongsTo → `tenants`
### barobill_billing_records
**모델**: `App\Models\Barobill\BarobillBillingRecord`
- **member()**: belongsTo → `barobill_members`
### barobill_card_transactions
**모델**: `App\Models\Barobill\BarobillCardTransaction`
- **tenant()**: belongsTo → `tenants`
### barobill_card_transaction_amount_logs
**모델**: `App\Models\Barobill\BarobillCardTransactionAmountLog`
- **cardTransaction()**: belongsTo → `barobill_card_transactions`
### barobill_card_transaction_splits
**모델**: `App\Models\Barobill\BarobillCardTransactionSplit`
- **tenant()**: belongsTo → `tenants`
### barobill_members
**모델**: `App\Models\Barobill\BarobillMember`
- **tenant()**: belongsTo → `tenants`
### barobill_monthly_summarys
**모델**: `App\Models\Barobill\BarobillMonthlySummary`
- **member()**: belongsTo → `barobill_members`
### barobill_subscriptions
**모델**: `App\Models\Barobill\BarobillSubscription`
- **member()**: belongsTo → `barobill_members`
### hometax_invoices
**모델**: `App\Models\Barobill\HometaxInvoice`
- **tenant()**: belongsTo → `tenants`
- **journals()**: hasMany → `hometax_invoice_journals`
### hometax_invoice_journals
**모델**: `App\Models\Barobill\HometaxInvoiceJournal`
- **tenant()**: belongsTo → `tenants`
- **invoice()**: belongsTo → `hometax_invoices`
### biddings
**모델**: `App\Models\Bidding\Bidding`
@@ -309,6 +371,43 @@ ### esign_signers
- **contract()**: belongsTo → `esign_contracts`
- **signFields()**: hasMany → `esign_sign_fields`
### equipments
**모델**: `App\Models\Equipment\Equipment`
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
- **inspections()**: hasMany → `equipment_inspections`
- **repairs()**: hasMany → `equipment_repairs`
- **photos()**: hasMany → `files`
- **processes()**: belongsToMany → `processes`
### equipment_inspections
**모델**: `App\Models\Equipment\EquipmentInspection`
- **equipment()**: belongsTo → `equipments`
- **details()**: hasMany → `equipment_inspection_details`
### equipment_inspection_details
**모델**: `App\Models\Equipment\EquipmentInspectionDetail`
- **inspection()**: belongsTo → `equipment_inspections`
- **templateItem()**: belongsTo → `equipment_inspection_templates`
### equipment_inspection_templates
**모델**: `App\Models\Equipment\EquipmentInspectionTemplate`
- **equipment()**: belongsTo → `equipments`
### equipment_process
**모델**: `App\Models\Equipment\EquipmentProcess`
- **equipment()**: belongsTo → `equipments`
- **process()**: belongsTo → `processes`
### equipment_repairs
**모델**: `App\Models\Equipment\EquipmentRepair`
- **equipment()**: belongsTo → `equipments`
### estimates
**모델**: `App\Models\Estimate\Estimate`
@@ -734,6 +833,36 @@ ### push_notification_settings
**모델**: `App\Models\PushNotificationSetting`
### audit_checklists
**모델**: `App\Models\Qualitys\AuditChecklist`
- **categories()**: hasMany → `audit_checklist_categories`
### audit_checklist_categorys
**모델**: `App\Models\Qualitys\AuditChecklistCategory`
- **checklist()**: belongsTo → `audit_checklists`
- **items()**: hasMany → `audit_checklist_items`
### audit_checklist_items
**모델**: `App\Models\Qualitys\AuditChecklistItem`
- **category()**: belongsTo → `audit_checklist_categories`
- **standardDocuments()**: hasMany → `audit_standard_documents`
### audit_standard_documents
**모델**: `App\Models\Qualitys\AuditStandardDocument`
- **checklistItem()**: belongsTo → `audit_checklist_items`
- **document()**: belongsTo → `documents`
### checklist_templates
**모델**: `App\Models\Qualitys\ChecklistTemplate`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **documents()**: morphMany → `files`
### inspections
**모델**: `App\Models\Qualitys\Inspection`
@@ -838,6 +967,11 @@ ### quote_revisions
- **quote()**: belongsTo → `quotes`
- **reviser()**: belongsTo → `users`
### account_codes
**모델**: `App\Models\Tenants\AccountCode`
- **children()**: hasMany → `account_codes`
### ai_reports
**모델**: `App\Models\Tenants\AiReport`
@@ -857,14 +991,24 @@ ### approvals
**모델**: `App\Models\Tenants\Approval`
- **form()**: belongsTo → `approval_forms`
- **line()**: belongsTo → `approval_lines`
- **drafter()**: belongsTo → `users`
- **department()**: belongsTo → `departments`
- **parentDocument()**: belongsTo → `approvals`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **childDocuments()**: hasMany → `approvals`
- **steps()**: hasMany → `approval_steps`
- **approverSteps()**: hasMany → `approval_steps`
- **referenceSteps()**: hasMany → `approval_steps`
- **linkable()**: morphTo → `(Polymorphic)`
### approval_delegations
**모델**: `App\Models\Tenants\ApprovalDelegation`
- **delegator()**: belongsTo → `users`
- **delegate()**: belongsTo → `users`
### approval_forms
**모델**: `App\Models\Tenants\ApprovalForm`
@@ -883,6 +1027,7 @@ ### approval_steps
- **approval()**: belongsTo → `approvals`
- **approver()**: belongsTo → `users`
- **actedBy()**: belongsTo → `users`
### attendances
**모델**: `App\Models\Tenants\Attendance`
@@ -1004,6 +1149,11 @@ ### loans
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### mail_logs
**모델**: `App\Models\Tenants\MailLog`
- **tenant()**: belongsTo → `tenants`
### payments
**모델**: `App\Models\Tenants\Payment`
@@ -1046,6 +1196,7 @@ ### receivings
**모델**: `App\Models\Tenants\Receiving`
- **item()**: belongsTo → `items`
- **certificateFile()**: belongsTo → `files`
- **creator()**: belongsTo → `users`
### salarys
@@ -1167,6 +1318,11 @@ ### tenant_field_settings
- **fieldDef()**: belongsTo → `setting_field_defs`
- **optionGroup()**: belongsTo → `tenant_option_groups`
### tenant_mail_configs
**모델**: `App\Models\Tenants\TenantMailConfig`
- **tenant()**: belongsTo → `tenants`
### tenant_option_groups
**모델**: `App\Models\Tenants\TenantOptionGroup`

View File

@@ -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

View File

@@ -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

View 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';
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace App\Enums;
use App\Models\Commons\Holiday;
use Carbon\Carbon;
class InspectionCycle
{
const DAILY = 'daily';
const WEEKLY = 'weekly';
const MONTHLY = 'monthly';
const BIMONTHLY = 'bimonthly';
const QUARTERLY = 'quarterly';
const SEMIANNUAL = 'semiannual';
public static function all(): array
{
return [
self::DAILY => '일일',
self::WEEKLY => '주간',
self::MONTHLY => '월간',
self::BIMONTHLY => '2개월',
self::QUARTERLY => '분기',
self::SEMIANNUAL => '반년',
];
}
public static function label(string $cycle): string
{
return self::all()[$cycle] ?? $cycle;
}
public static function periodType(string $cycle): string
{
return $cycle === self::DAILY ? 'month' : 'year';
}
public static function columnLabels(string $cycle, ?string $period = null): array
{
return match ($cycle) {
self::DAILY => self::dailyLabels($period),
self::WEEKLY => self::weeklyLabels(),
self::MONTHLY => self::monthlyLabels(),
self::BIMONTHLY => self::bimonthlyLabels(),
self::QUARTERLY => self::quarterlyLabels(),
self::SEMIANNUAL => self::semiannualLabels(),
default => self::dailyLabels($period),
};
}
public static function resolveCheckDate(string $cycle, string $period, int $colIndex): string
{
return match ($cycle) {
self::DAILY => self::dailyCheckDate($period, $colIndex),
self::WEEKLY => self::weeklyCheckDate($period, $colIndex),
self::MONTHLY => self::monthlyCheckDate($period, $colIndex),
self::BIMONTHLY => self::bimonthlyCheckDate($period, $colIndex),
self::QUARTERLY => self::quarterlyCheckDate($period, $colIndex),
self::SEMIANNUAL => self::semiannualCheckDate($period, $colIndex),
default => self::dailyCheckDate($period, $colIndex),
};
}
public static function resolvePeriod(string $cycle, string $checkDate): string
{
$date = Carbon::parse($checkDate);
return match ($cycle) {
self::DAILY => $date->format('Y-m'),
self::WEEKLY => (string) $date->isoWeekYear,
default => $date->format('Y'),
};
}
public static function columnCount(string $cycle, ?string $period = null): int
{
return count(self::columnLabels($cycle, $period));
}
public static function isWeekend(string $period, int $colIndex): bool
{
$date = Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1);
return in_array($date->dayOfWeek, [0, 6]);
}
public static function getHolidayDates(string $cycle, string $period, int $tenantId): array
{
if ($cycle === self::DAILY) {
$start = Carbon::createFromFormat('Y-m', $period)->startOfMonth();
$end = $start->copy()->endOfMonth();
} else {
$start = Carbon::create((int) $period, 1, 1);
$end = Carbon::create((int) $period, 12, 31);
}
$holidays = Holiday::where('tenant_id', $tenantId)
->where('start_date', '<=', $end->toDateString())
->where('end_date', '>=', $start->toDateString())
->get();
$dates = [];
foreach ($holidays as $holiday) {
$hStart = $holiday->start_date->copy()->max($start);
$hEnd = $holiday->end_date->copy()->min($end);
$current = $hStart->copy();
while ($current->lte($hEnd)) {
$dates[$current->format('Y-m-d')] = true;
$current->addDay();
}
}
return $dates;
}
public static function isNonWorkingDay(string $checkDate, array $holidayDates = []): bool
{
$date = Carbon::parse($checkDate);
return $date->isWeekend() || isset($holidayDates[$checkDate]);
}
// --- Daily ---
private static function dailyLabels(?string $period): array
{
$date = Carbon::createFromFormat('Y-m', $period ?? now()->format('Y-m'));
$days = $date->daysInMonth;
$labels = [];
for ($d = 1; $d <= $days; $d++) {
$labels[$d] = (string) $d;
}
return $labels;
}
private static function dailyCheckDate(string $period, int $colIndex): string
{
return Carbon::createFromFormat('Y-m', $period)->startOfMonth()->addDays($colIndex - 1)->format('Y-m-d');
}
// --- Weekly ---
private static function weeklyLabels(): array
{
$labels = [];
for ($w = 1; $w <= 52; $w++) {
$labels[$w] = $w.'주';
}
return $labels;
}
private static function weeklyCheckDate(string $year, int $colIndex): string
{
return Carbon::create((int) $year)->setISODate((int) $year, $colIndex, 1)->format('Y-m-d');
}
// --- Monthly ---
private static function monthlyLabels(): array
{
$labels = [];
for ($m = 1; $m <= 12; $m++) {
$labels[$m] = $m.'월';
}
return $labels;
}
private static function monthlyCheckDate(string $year, int $colIndex): string
{
return Carbon::create((int) $year, $colIndex, 1)->format('Y-m-d');
}
// --- Bimonthly ---
private static function bimonthlyLabels(): array
{
return [
1 => '1~2월',
2 => '3~4월',
3 => '5~6월',
4 => '7~8월',
5 => '9~10월',
6 => '11~12월',
];
}
private static function bimonthlyCheckDate(string $year, int $colIndex): string
{
$month = ($colIndex - 1) * 2 + 1;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
// --- Quarterly ---
private static function quarterlyLabels(): array
{
return [
1 => '1분기',
2 => '2분기',
3 => '3분기',
4 => '4분기',
];
}
private static function quarterlyCheckDate(string $year, int $colIndex): string
{
$month = ($colIndex - 1) * 3 + 1;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
// --- Semiannual ---
private static function semiannualLabels(): array
{
return [
1 => '상반기',
2 => '하반기',
];
}
private static function semiannualCheckDate(string $year, int $colIndex): string
{
$month = $colIndex === 1 ? 1 : 7;
return Carbon::create((int) $year, $month, 1)->format('Y-m-d');
}
}

View File

@@ -0,0 +1,287 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\BarobillBankTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 바로빌 은행 거래 API 컨트롤러 (React 연동용)
*
* MNG에서 동기화된 은행 거래 데이터를 React에서 조회/관리
*/
class BarobillBankTransactionController extends Controller
{
public function __construct(
protected BarobillBankTransactionService $service,
protected JournalSyncService $journalSyncService,
) {}
/**
* 은행 거래 목록 조회
*/
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'bank_account_num' => 'nullable|string|max:50',
'search' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->index($params);
}, __('message.fetched'));
}
/**
* 계좌 목록 (필터용)
*/
public function accounts(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->accounts();
}, __('message.fetched'));
}
/**
* 잔액 요약
*/
public function balanceSummary(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'date' => 'nullable|date',
]);
return $this->service->balanceSummary($params);
}, __('message.fetched'));
}
// =========================================================================
// 분할 (Splits)
// =========================================================================
/**
* 거래 분할 조회
*/
public function getSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->getSplits($validated['unique_key']);
}, __('message.fetched'));
}
/**
* 거래 분할 저장
*/
public function saveSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
'items' => 'required|array|min:1',
'items.*.split_amount' => 'required|numeric',
'items.*.account_code' => 'nullable|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.deduction_type' => 'nullable|string|max:20',
'items.*.evidence_name' => 'nullable|string|max:100',
'items.*.description' => 'nullable|string|max:500',
'items.*.memo' => 'nullable|string|max:500',
'items.*.bank_account_num' => 'nullable|string|max:50',
'items.*.trans_dt' => 'nullable|string|max:20',
'items.*.trans_date' => 'nullable|date',
'items.*.original_deposit' => 'nullable|numeric',
'items.*.original_withdraw' => 'nullable|numeric',
'items.*.summary' => 'nullable|string|max:500',
]);
return $this->service->saveSplits($validated['unique_key'], $validated['items']);
}, __('message.created'));
}
/**
* 거래 분할 삭제
*/
public function deleteSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->deleteSplits($validated['unique_key']);
}, __('message.deleted'));
}
// =========================================================================
// 오버라이드 (Override)
// =========================================================================
/**
* 적요/분류 오버라이드 저장
*/
public function saveOverride(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
'modified_summary' => 'nullable|string|max:500',
'modified_cast' => 'nullable|string|max:100',
]);
return $this->service->saveOverride(
$validated['unique_key'],
$validated['modified_summary'] ?? null,
$validated['modified_cast'] ?? null
);
}, __('message.updated'));
}
// =========================================================================
// 수동 입력 (Manual)
// =========================================================================
/**
* 수동 은행 거래 등록
*/
public function storeManual(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'bank_account_num' => 'required|string|max:50',
'bank_code' => 'nullable|string|max:10',
'bank_name' => 'nullable|string|max:50',
'trans_date' => 'required|date',
'trans_time' => 'nullable|string|max:10',
'trans_dt' => 'nullable|string|max:20',
'deposit' => 'nullable|numeric|min:0',
'withdraw' => 'nullable|numeric|min:0',
'balance' => 'nullable|numeric',
'summary' => 'nullable|string|max:500',
'cast' => 'nullable|string|max:100',
'memo' => 'nullable|string|max:500',
'trans_office' => 'nullable|string|max:100',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'client_code' => 'nullable|string|max:20',
'client_name' => 'nullable|string|max:200',
]);
return $this->service->storeManual($validated);
}, __('message.created'));
}
/**
* 수동 은행 거래 수정
*/
public function updateManual(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'deposit' => 'nullable|numeric|min:0',
'withdraw' => 'nullable|numeric|min:0',
'balance' => 'nullable|numeric',
'summary' => 'nullable|string|max:500',
'cast' => 'nullable|string|max:100',
'memo' => 'nullable|string|max:500',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'client_code' => 'nullable|string|max:20',
'client_name' => 'nullable|string|max:200',
]);
return $this->service->updateManual($id, $validated);
}, __('message.updated'));
}
/**
* 수동 은행 거래 삭제
*/
public function destroyManual(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyManual($id);
}, __('message.deleted'));
}
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 은행 거래 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_bank_{$id}";
return $this->journalSyncService->getForSource(
JournalEntry::SOURCE_BAROBILL_BANK,
$sourceKey
) ?? ['items' => []];
}, __('message.fetched'));
}
/**
* 은행 거래 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.side' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.memo' => 'nullable|string|max:500',
]);
$bankTx = \App\Models\Barobill\BarobillBankTransaction::find($id);
if (! $bankTx) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $bankTx->trans_date ?? now()->format('Y-m-d');
$sourceKey = "barobill_bank_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_BAROBILL_BANK,
$sourceKey,
$entryDate,
"바로빌 은행거래 분개 (#{$id})",
$validated['items'],
);
}, __('message.created'));
}
/**
* 은행 거래 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_bank_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_BAROBILL_BANK,
$sourceKey
);
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,326 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\BarobillCardTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 바로빌 카드 거래 API 컨트롤러 (React 연동용)
*
* MNG에서 동기화된 카드 거래 데이터를 React에서 조회/관리
*/
class BarobillCardTransactionController extends Controller
{
public function __construct(
protected BarobillCardTransactionService $service,
protected JournalSyncService $journalSyncService,
) {}
/**
* 카드 거래 목록 조회
*/
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'card_num' => 'nullable|string|max:50',
'search' => 'nullable|string|max:100',
'include_hidden' => 'nullable|boolean',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->index($params);
}, __('message.fetched'));
}
/**
* 단일 카드 거래 상세
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$tx = $this->service->show($id);
if (! $tx) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
return $tx;
}, __('message.fetched'));
}
/**
* 카드 번호 목록 (필터용)
*/
public function cardNumbers(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->cardNumbers();
}, __('message.fetched'));
}
// =========================================================================
// 분할 (Splits)
// =========================================================================
/**
* 카드 거래 분할 조회
*/
public function getSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->getSplits($validated['unique_key']);
}, __('message.fetched'));
}
/**
* 카드 거래 분할 저장
*/
public function saveSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
'items' => 'required|array|min:1',
'items.*.split_amount' => 'required|numeric',
'items.*.split_supply_amount' => 'nullable|numeric',
'items.*.split_tax' => 'nullable|numeric',
'items.*.account_code' => 'nullable|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.deduction_type' => 'nullable|string|max:20',
'items.*.evidence_name' => 'nullable|string|max:100',
'items.*.description' => 'nullable|string|max:500',
'items.*.memo' => 'nullable|string|max:500',
'items.*.card_num' => 'nullable|string|max:50',
'items.*.use_dt' => 'nullable|string|max:20',
'items.*.use_date' => 'nullable|date',
'items.*.approval_num' => 'nullable|string|max:50',
'items.*.original_amount' => 'nullable|numeric',
'items.*.merchant_name' => 'nullable|string|max:200',
]);
return $this->service->saveSplits($validated['unique_key'], $validated['items']);
}, __('message.created'));
}
/**
* 카드 거래 분할 삭제
*/
public function deleteSplits(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'unique_key' => 'required|string|max:500',
]);
return $this->service->deleteSplits($validated['unique_key']);
}, __('message.deleted'));
}
// =========================================================================
// 수동 입력 (Manual)
// =========================================================================
/**
* 수동 카드 거래 등록
*/
public function storeManual(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'card_num' => 'required|string|max:50',
'card_company' => 'nullable|string|max:10',
'card_company_name' => 'nullable|string|max:50',
'use_dt' => 'required|string|max:20',
'use_date' => 'required|date',
'use_time' => 'nullable|string|max:10',
'approval_num' => 'nullable|string|max:50',
'approval_type' => 'nullable|string|max:10',
'approval_amount' => 'required|numeric',
'tax' => 'nullable|numeric',
'service_charge' => 'nullable|numeric',
'merchant_name' => 'required|string|max:200',
'merchant_biz_num' => 'nullable|string|max:20',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'evidence_name' => 'nullable|string|max:100',
'description' => 'nullable|string|max:500',
'memo' => 'nullable|string|max:500',
]);
return $this->service->storeManual($validated);
}, __('message.created'));
}
/**
* 수동 카드 거래 수정
*/
public function updateManual(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'approval_amount' => 'nullable|numeric',
'tax' => 'nullable|numeric',
'merchant_name' => 'nullable|string|max:200',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'description' => 'nullable|string|max:500',
'memo' => 'nullable|string|max:500',
]);
return $this->service->updateManual($id, $validated);
}, __('message.updated'));
}
/**
* 수동 카드 거래 삭제
*/
public function destroyManual(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyManual($id);
}, __('message.deleted'));
}
// =========================================================================
// 숨김/복원 (Hide/Restore)
// =========================================================================
/**
* 카드 거래 숨김
*/
public function hide(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->hide($id);
}, __('message.updated'));
}
/**
* 카드 거래 숨김 복원
*/
public function restore(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->restore($id);
}, __('message.updated'));
}
/**
* 숨겨진 거래 목록
*/
public function hiddenList(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
return $this->service->hiddenList($params);
}, __('message.fetched'));
}
// =========================================================================
// 금액 수정
// =========================================================================
/**
* 공급가액/세액 수정
*/
public function updateAmount(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'supply_amount' => 'required|numeric',
'tax' => 'required|numeric',
'modified_by_name' => 'nullable|string|max:50',
]);
return $this->service->updateAmount($id, $validated);
}, __('message.updated'));
}
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 카드 거래 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_card_{$id}";
return $this->journalSyncService->getForSource(
JournalEntry::SOURCE_BAROBILL_CARD,
$sourceKey
) ?? ['items' => []];
}, __('message.fetched'));
}
/**
* 카드 거래 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.side' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.memo' => 'nullable|string|max:500',
]);
$tx = $this->service->show($id);
if (! $tx) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $tx->use_date ?? now()->format('Y-m-d');
$sourceKey = "barobill_card_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_BAROBILL_CARD,
$sourceKey,
$entryDate,
"바로빌 카드거래 분개 (#{$id})",
$validated['items'],
);
}, __('message.created'));
}
/**
* 카드 거래 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "barobill_card_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_BAROBILL_CARD,
$sourceKey
);
}, __('message.deleted'));
}
}

View File

@@ -28,7 +28,7 @@ public function status()
'barobill_id' => $setting->barobill_id,
'biz_no' => $setting->corp_num,
'status' => $setting->isVerified() ? 'active' : 'inactive',
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production',
] : null,
];
}, __('message.fetched'));
@@ -86,17 +86,21 @@ public function signup(Request $request)
}, __('message.saved'));
}
/**
* 바로빌 서비스 URL 조회 (공통)
*/
private function getServiceUrl(string $path): array
{
return ['url' => $this->barobillService->getBaseUrl().$path];
}
/**
* 은행 빠른조회 서비스 URL 조회
*/
public function bankServiceUrl(Request $request)
public function bankServiceUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/BankAccountService'];
return $this->getServiceUrl('/BANKACCOUNT.asmx');
}, __('message.fetched'));
}
@@ -106,11 +110,7 @@ public function bankServiceUrl(Request $request)
public function accountLinkUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/AccountLink'];
return $this->getServiceUrl('/BANKACCOUNT.asmx');
}, __('message.fetched'));
}
@@ -120,11 +120,7 @@ public function accountLinkUrl()
public function cardLinkUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Card/CardLink'];
return $this->getServiceUrl('/CARD.asmx');
}, __('message.fetched'));
}
@@ -134,11 +130,7 @@ public function cardLinkUrl()
public function certificateUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Certificate/Register'];
return $this->getServiceUrl('/CORPSTATE.asmx');
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,92 @@
<?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 toggleItem(int $id, string $subItemId)
{
return ApiResponse::handle(function () use ($id, $subItemId) {
return $this->service->toggleItem($id, $subItemId);
}, __('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'));
}
}

View File

@@ -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);
}
/**

View File

@@ -0,0 +1,278 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\HometaxInvoiceService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* 홈택스 세금계산서 API 컨트롤러 (React 연동용)
*
* MNG에서 동기화된 홈택스 세금계산서를 React에서 조회/관리
*/
class HometaxInvoiceController extends Controller
{
public function __construct(
protected HometaxInvoiceService $service,
protected JournalSyncService $journalSyncService,
) {}
/**
* 매출 세금계산서 목록
*/
public function sales(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'search' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->sales($params);
}, __('message.fetched'));
}
/**
* 매입 세금계산서 목록
*/
public function purchases(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'search' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
return $this->service->purchases($params);
}, __('message.fetched'));
}
/**
* 세금계산서 상세 조회
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$invoice = $this->service->show($id);
if (! $invoice) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
return $invoice;
}, __('message.fetched'));
}
/**
* 요약 통계 (매출/매입 합계)
*/
public function summary(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$params = $request->validate([
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
return $this->service->summary($params);
}, __('message.fetched'));
}
// =========================================================================
// 수동 입력 (Manual)
// =========================================================================
/**
* 수동 세금계산서 등록
*/
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
$validated = $request->validate([
'invoice_type' => 'required|in:sales,purchase',
'nts_confirm_num' => 'nullable|string|max:50',
'write_date' => 'required|date',
'issue_date' => 'nullable|date',
'invoicer_corp_num' => 'nullable|string|max:20',
'invoicer_corp_name' => 'nullable|string|max:200',
'invoicer_ceo_name' => 'nullable|string|max:100',
'invoicee_corp_num' => 'nullable|string|max:20',
'invoicee_corp_name' => 'nullable|string|max:200',
'invoicee_ceo_name' => 'nullable|string|max:100',
'supply_amount' => 'required|integer',
'tax_amount' => 'required|integer',
'total_amount' => 'required|integer',
'tax_type' => 'nullable|string|max:10',
'purpose_type' => 'nullable|string|max:10',
'issue_type' => 'nullable|string|max:10',
'item_name' => 'nullable|string|max:200',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'remark1' => 'nullable|string|max:500',
]);
return $this->service->storeManual($validated);
}, __('message.created'));
}
/**
* 수동 세금계산서 수정
*/
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'write_date' => 'nullable|date',
'issue_date' => 'nullable|date',
'invoicer_corp_num' => 'nullable|string|max:20',
'invoicer_corp_name' => 'nullable|string|max:200',
'invoicee_corp_num' => 'nullable|string|max:20',
'invoicee_corp_name' => 'nullable|string|max:200',
'supply_amount' => 'nullable|integer',
'tax_amount' => 'nullable|integer',
'total_amount' => 'nullable|integer',
'item_name' => 'nullable|string|max:200',
'account_code' => 'nullable|string|max:20',
'account_name' => 'nullable|string|max:100',
'deduction_type' => 'nullable|string|max:20',
'remark1' => 'nullable|string|max:500',
]);
return $this->service->updateManual($id, $validated);
}, __('message.updated'));
}
/**
* 수동 세금계산서 삭제
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroyManual($id);
}, __('message.deleted'));
}
// =========================================================================
// 분개 (자체 테이블: hometax_invoice_journals)
// =========================================================================
/**
* 분개 조회
*/
public function getJournals(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getJournals($id);
}, __('message.fetched'));
}
/**
* 분개 저장
*/
public function saveJournals(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.dc_type' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.description' => 'nullable|string|max:500',
]);
return $this->service->saveJournals($id, $validated['items']);
}, __('message.created'));
}
/**
* 분개 삭제
*/
public function deleteJournals(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->deleteJournals($id);
}, __('message.deleted'));
}
// =========================================================================
// 통합 분개 (JournalSyncService - CEO 대시보드 연동)
// =========================================================================
/**
* 통합 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "hometax_{$id}";
return $this->journalSyncService->getForSource(
JournalEntry::SOURCE_HOMETAX_INVOICE,
$sourceKey
) ?? ['items' => []];
}, __('message.fetched'));
}
/**
* 통합 분개 저장
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'items' => 'required|array|min:1',
'items.*.side' => 'required|in:debit,credit',
'items.*.account_code' => 'required|string|max:20',
'items.*.account_name' => 'nullable|string|max:100',
'items.*.debit_amount' => 'required|integer|min:0',
'items.*.credit_amount' => 'required|integer|min:0',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.memo' => 'nullable|string|max:500',
]);
$invoice = $this->service->show($id);
if (! $invoice) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $invoice->write_date?->format('Y-m-d') ?? now()->format('Y-m-d');
$sourceKey = "hometax_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_HOMETAX_INVOICE,
$sourceKey,
$entryDate,
"홈택스 세금계산서 분개 (#{$id})",
$validated['items'],
);
}, __('message.created'));
}
/**
* 통합 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "hometax_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_HOMETAX_INVOICE,
$sourceKey
);
}, __('message.deleted'));
}
}

View File

@@ -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();

View File

@@ -4,18 +4,24 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Payroll\BulkGeneratePayrollRequest;
use App\Http\Requests\V1\Payroll\CalculatePayrollRequest;
use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest;
use App\Http\Requests\V1\Payroll\PayPayrollRequest;
use App\Http\Requests\V1\Payroll\StorePayrollJournalRequest;
use App\Http\Requests\V1\Payroll\StorePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest;
use App\Services\ExportService;
use App\Services\PayrollService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PayrollController extends Controller
{
public function __construct(
private readonly PayrollService $service
private readonly PayrollService $service,
private readonly ExportService $exportService
) {}
/**
@@ -28,6 +34,7 @@ public function index(Request $request)
'month',
'user_id',
'status',
'department_id',
'search',
'sort_by',
'sort_dir',
@@ -103,6 +110,16 @@ public function confirm(int $id)
return ApiResponse::success($payroll, __('message.payroll.confirmed'));
}
/**
* 급여 확정 취소
*/
public function unconfirm(int $id)
{
$payroll = $this->service->unconfirm($id);
return ApiResponse::success($payroll, __('message.payroll.unconfirmed'));
}
/**
* 급여 지급 처리
*/
@@ -113,6 +130,16 @@ public function pay(int $id, PayPayrollRequest $request)
return ApiResponse::success($payroll, __('message.payroll.paid'));
}
/**
* 급여 지급 취소 (슈퍼관리자)
*/
public function unpay(int $id)
{
$payroll = $this->service->unpay($id);
return ApiResponse::success($payroll, __('message.payroll.unpaid'));
}
/**
* 일괄 확정
*/
@@ -127,13 +154,29 @@ public function bulkConfirm(Request $request)
}
/**
* 급여명세서 조회
* 재직사원 일괄 생성
*/
public function payslip(int $id)
public function bulkGenerate(BulkGeneratePayrollRequest $request)
{
$payslip = $this->service->payslip($id);
$year = (int) $request->input('year');
$month = (int) $request->input('month');
return ApiResponse::success($payslip, __('message.fetched'));
$result = $this->service->bulkGenerate($year, $month);
return ApiResponse::success($result, __('message.payroll.bulk_generated'));
}
/**
* 전월 급여 복사
*/
public function copyFromPrevious(CopyFromPreviousPayrollRequest $request)
{
$year = (int) $request->input('year');
$month = (int) $request->input('month');
$result = $this->service->copyFromPreviousMonth($year, $month);
return ApiResponse::success($result, __('message.payroll.copied'));
}
/**
@@ -150,6 +193,76 @@ public function calculate(CalculatePayrollRequest $request)
return ApiResponse::success($payrolls, __('message.payroll.calculated'));
}
/**
* 급여 계산 미리보기
*/
public function calculatePreview(Request $request)
{
$data = $request->only([
'user_id',
'base_salary',
'overtime_pay',
'bonus',
'allowances',
'deductions',
]);
$result = $this->service->calculatePreview($data);
return ApiResponse::success($result, __('message.calculated'));
}
/**
* 급여명세서 조회
*/
public function payslip(int $id)
{
$payslip = $this->service->payslip($id);
return ApiResponse::success($payslip, __('message.fetched'));
}
/**
* 급여 엑셀 내보내기
*/
public function export(Request $request): BinaryFileResponse
{
$params = $request->only([
'year',
'month',
'status',
'user_id',
'department_id',
'search',
'sort_by',
'sort_dir',
]);
$exportData = $this->service->getExportData($params);
$filename = '급여현황_'.date('Ymd_His');
return $this->exportService->download(
$exportData['data'],
$exportData['headings'],
$filename,
'급여현황'
);
}
/**
* 급여 전표 생성
*/
public function journalEntries(StorePayrollJournalRequest $request)
{
$year = (int) $request->input('year');
$month = (int) $request->input('month');
$entryDate = $request->input('entry_date');
$entry = $this->service->createJournalEntries($year, $month, $entryDate);
return ApiResponse::success($entry, __('message.payroll.journal_created'));
}
/**
* 급여 설정 조회
*/

View File

@@ -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 옵션 조회
*/

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Equipment\StoreEquipmentRequest;
use App\Http\Requests\V1\Equipment\UpdateEquipmentRequest;
use App\Services\Equipment\EquipmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentController extends Controller
{
public function __construct(private readonly EquipmentService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'status', 'production_line', 'equipment_type',
'sort_by', 'sort_direction', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(StoreEquipmentRequest $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->validated()),
__('message.equipment.created')
);
}
public function update(UpdateEquipmentRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->validated()),
__('message.equipment.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.equipment.deleted')
);
}
public function restore(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->restore($id),
__('message.equipment.restored')
);
}
public function toggleActive(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->toggleActive($id),
__('message.toggled')
);
}
public function stats(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->stats(),
__('message.fetched')
);
}
public function options(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->options(),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Equipment\StoreInspectionTemplateRequest;
use App\Http\Requests\V1\Equipment\ToggleInspectionDetailRequest;
use App\Http\Requests\V1\Equipment\UpdateInspectionNotesRequest;
use App\Services\Equipment\EquipmentInspectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentInspectionController extends Controller
{
public function __construct(private readonly EquipmentInspectionService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->getInspections(
$request->input('cycle', 'daily'),
$request->input('period', now()->format('Y-m')),
$request->input('production_line'),
$request->input('equipment_id') ? (int) $request->input('equipment_id') : null
),
__('message.fetched')
);
}
public function toggleDetail(ToggleInspectionDetailRequest $request): JsonResponse
{
$data = $request->validated();
return ApiResponse::handle(
fn () => $this->service->toggleDetail(
$data['equipment_id'],
$data['template_item_id'],
$data['check_date'],
$data['cycle'] ?? 'daily'
),
__('message.equipment.inspection_saved')
);
}
public function setResult(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->setResult(
(int) $request->input('equipment_id'),
(int) $request->input('template_item_id'),
$request->input('check_date'),
$request->input('cycle', 'daily'),
$request->input('result')
),
__('message.equipment.inspection_saved')
);
}
public function updateNotes(UpdateInspectionNotesRequest $request): JsonResponse
{
$data = $request->validated();
return ApiResponse::handle(
fn () => $this->service->updateNotes(
$data['equipment_id'],
$data['year_month'],
collect($data)->only(['overall_judgment', 'inspector_id', 'repair_note', 'issue_note'])->toArray(),
$data['cycle'] ?? 'daily'
),
__('message.equipment.inspection_saved')
);
}
public function resetInspection(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->resetInspection(
(int) $request->input('equipment_id'),
$request->input('cycle', 'daily'),
$request->input('period')
),
__('message.equipment.inspection_reset')
);
}
public function templates(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->getActiveCycles($id),
__('message.fetched')
);
}
public function storeTemplate(StoreInspectionTemplateRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->saveTemplate($id, $request->validated()),
__('message.equipment.template_created')
);
}
public function updateTemplate(Request $request, int $templateId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->updateTemplate($templateId, $request->all()),
__('message.updated')
);
}
public function deleteTemplate(int $templateId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->deleteTemplate($templateId),
__('message.deleted')
);
}
public function copyTemplates(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->copyTemplates(
$id,
$request->input('source_cycle'),
$request->input('target_cycles', [])
),
__('message.equipment.template_copied')
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Equipment\StoreEquipmentRepairRequest;
use App\Services\Equipment\EquipmentRepairService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentRepairController extends Controller
{
public function __construct(private readonly EquipmentRepairService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'equipment_id', 'repair_type', 'date_from', 'date_to', 'search', 'per_page',
])),
__('message.fetched')
);
}
public function store(StoreEquipmentRepairRequest $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->validated()),
__('message.equipment.repair_created')
);
}
public function update(StoreEquipmentRepairRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->validated()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
}

View File

@@ -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',

View 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' => '항목명']),
];
}
}

View File

@@ -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),
];

View File

@@ -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),
];

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\V1\Equipment;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentRepairRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|integer|exists:equipments,id',
'repair_date' => 'required|date',
'repair_type' => 'nullable|string|in:internal,external',
'repair_hours' => 'nullable|numeric|min:0',
'description' => 'nullable|string',
'cost' => 'nullable|numeric|min:0',
'vendor' => 'nullable|string|max:100',
'repaired_by' => 'nullable|integer|exists:users,id',
'memo' => 'nullable|string',
'options' => 'nullable|array',
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\V1\Equipment;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_code' => 'required|string|max:50',
'name' => 'required|string|max:100',
'equipment_type' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:200',
'manufacturer' => 'nullable|string|max:100',
'model_name' => 'nullable|string|max:100',
'serial_no' => 'nullable|string|max:100',
'location' => 'nullable|string|max:100',
'production_line' => 'nullable|string|max:50',
'purchase_date' => 'nullable|date',
'install_date' => 'nullable|date',
'purchase_price' => 'nullable|numeric|min:0',
'useful_life' => 'nullable|integer|min:0',
'status' => 'nullable|in:active,idle,disposed',
'disposed_date' => 'nullable|date',
'manager_id' => 'nullable|integer|exists:users,id',
'sub_manager_id' => 'nullable|integer|exists:users,id',
'photo_path' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'options' => 'nullable|array',
'is_active' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\V1\Equipment;
use Illuminate\Foundation\Http\FormRequest;
class StoreInspectionTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'inspection_cycle' => 'required|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual',
'item_no' => 'required|string|max:20',
'check_point' => 'required|string|max:100',
'check_item' => 'required|string|max:200',
'check_timing' => 'nullable|string|max:50',
'check_frequency' => 'nullable|string|max:50',
'check_method' => 'nullable|string|max:200',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\V1\Equipment;
use Illuminate\Foundation\Http\FormRequest;
class ToggleInspectionDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|integer|exists:equipments,id',
'template_item_id' => 'required|integer|exists:equipment_inspection_templates,id',
'check_date' => 'required|date',
'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual',
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\V1\Equipment;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_code' => 'sometimes|string|max:50',
'name' => 'sometimes|string|max:100',
'equipment_type' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:200',
'manufacturer' => 'nullable|string|max:100',
'model_name' => 'nullable|string|max:100',
'serial_no' => 'nullable|string|max:100',
'location' => 'nullable|string|max:100',
'production_line' => 'nullable|string|max:50',
'purchase_date' => 'nullable|date',
'install_date' => 'nullable|date',
'purchase_price' => 'nullable|numeric|min:0',
'useful_life' => 'nullable|integer|min:0',
'status' => 'nullable|in:active,idle,disposed',
'disposed_date' => 'nullable|date',
'manager_id' => 'nullable|integer|exists:users,id',
'sub_manager_id' => 'nullable|integer|exists:users,id',
'photo_path' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'options' => 'nullable|array',
'is_active' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\V1\Equipment;
use Illuminate\Foundation\Http\FormRequest;
class UpdateInspectionNotesRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|integer|exists:equipments,id',
'year_month' => 'required|string',
'cycle' => 'nullable|string|in:daily,weekly,monthly,bimonthly,quarterly,semiannual',
'overall_judgment' => 'nullable|string|in:OK,NG',
'inspector_id' => 'nullable|integer|exists:users,id',
'repair_note' => 'nullable|string',
'issue_note' => 'nullable|string',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class BulkGeneratePayrollRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
'month' => ['required', 'integer', 'min:1', 'max:12'],
];
}
public function attributes(): array
{
return [
'year' => __('validation.attributes.pay_year'),
'month' => __('validation.attributes.pay_month'),
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class CopyFromPreviousPayrollRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
'month' => ['required', 'integer', 'min:1', 'max:12'],
];
}
public function attributes(): array
{
return [
'year' => __('validation.attributes.pay_year'),
'month' => __('validation.attributes.pay_month'),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class StorePayrollJournalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
'month' => ['required', 'integer', 'min:1', 'max:12'],
'entry_date' => ['nullable', 'date_format:Y-m-d'],
];
}
public function attributes(): array
{
return [
'year' => __('validation.attributes.pay_year'),
'month' => __('validation.attributes.pay_month'),
'entry_date' => __('validation.attributes.entry_date'),
];
}
}

View File

@@ -31,6 +31,14 @@ public function rules(): array
'deductions' => ['nullable', 'array'],
'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'],
'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'],
'deduction_overrides' => ['nullable', 'array'],
'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'],
'family_count' => ['nullable', 'integer', 'min:1', 'max:11'],
'note' => ['nullable', 'string', 'max:1000'],
];
}

View File

@@ -31,6 +31,14 @@ public function rules(): array
'deductions' => ['nullable', 'array'],
'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'],
'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'],
'deduction_overrides' => ['nullable', 'array'],
'deduction_overrides.income_tax' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.resident_tax' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.health_insurance' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.long_term_care' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.pension' => ['nullable', 'numeric', 'min:0'],
'deduction_overrides.employment_insurance' => ['nullable', 'numeric', 'min:0'],
'_is_super_admin' => ['nullable', 'boolean'],
'note' => ['nullable', 'string', 'max:1000'],
];
}

View File

@@ -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'],
];
}

View File

@@ -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'],
];
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBankSyncStatus extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_sync_status';
protected $fillable = [
'tenant_id',
'bank_account_num',
'synced_year_month',
'synced_at',
];
protected $casts = [
'synced_at' => 'datetime',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBankTransaction extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_transactions';
protected $fillable = [
'tenant_id',
'bank_account_num',
'bank_code',
'bank_name',
'trans_date',
'trans_time',
'trans_dt',
'deposit',
'withdraw',
'balance',
'summary',
'cast',
'memo',
'trans_office',
'account_code',
'account_name',
'is_manual',
'client_code',
'client_name',
];
protected $casts = [
'deposit' => 'decimal:2',
'withdraw' => 'decimal:2',
'balance' => 'decimal:2',
'is_manual' => 'boolean',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
// =========================================================================
// 접근자
// =========================================================================
/**
* 거래 고유 키 (계좌번호|거래일시|입금|출금|잔액)
*/
public function getUniqueKeyAttribute(): string
{
return static::generateUniqueKey([
'bank_account_num' => $this->bank_account_num,
'trans_dt' => $this->trans_dt,
'deposit' => $this->deposit,
'withdraw' => $this->withdraw,
'balance' => $this->balance,
]);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function generateUniqueKey(array $data): string
{
return implode('|', [
$data['bank_account_num'] ?? '',
$data['trans_dt'] ?? '',
$data['deposit'] ?? '0',
$data['withdraw'] ?? '0',
$data['balance'] ?? '0',
]);
}
public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $accountNum = null)
{
$query = static::where('tenant_id', $tenantId)
->whereBetween('trans_date', [$startDate, $endDate]);
if ($accountNum) {
$query->where('bank_account_num', $accountNum);
}
return $query->orderBy('trans_date')->orderBy('trans_dt')->get();
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class BarobillBankTransactionOverride extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_transaction_overrides';
protected $fillable = [
'tenant_id',
'unique_key',
'modified_summary',
'modified_cast',
];
// =========================================================================
// 스코프
// =========================================================================
public function scopeByUniqueKey($query, string $uniqueKey)
{
return $query->where('unique_key', $uniqueKey);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getByUniqueKeys(int $tenantId, array $uniqueKeys)
{
return static::where('tenant_id', $tenantId)
->whereIn('unique_key', $uniqueKeys)
->get()
->keyBy('unique_key');
}
public static function saveOverride(int $tenantId, string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): self
{
return static::updateOrCreate(
['tenant_id' => $tenantId, 'unique_key' => $uniqueKey],
['modified_summary' => $modifiedSummary, 'modified_cast' => $modifiedCast]
);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBankTransactionSplit extends Model
{
use BelongsToTenant;
protected $table = 'barobill_bank_transaction_splits';
protected $fillable = [
'tenant_id',
'original_unique_key',
'split_amount',
'account_code',
'account_name',
'description',
'memo',
'sort_order',
'bank_account_num',
'trans_dt',
'trans_date',
'original_deposit',
'original_withdraw',
'summary',
];
protected $casts = [
'split_amount' => 'decimal:2',
'original_deposit' => 'decimal:2',
'original_withdraw' => 'decimal:2',
'sort_order' => 'integer',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getByDateRange(int $tenantId, string $startDate, string $endDate)
{
return static::where('tenant_id', $tenantId)
->whereBetween('trans_date', [$startDate, $endDate])
->orderBy('original_unique_key')
->orderBy('sort_order')
->get()
->groupBy('original_unique_key');
}
public static function getByUniqueKey(int $tenantId, string $uniqueKey)
{
return static::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->orderBy('sort_order')
->get();
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillBillingRecord extends Model
{
protected $table = 'barobill_billing_records';
public const SERVICE_TYPES = ['tax_invoice', 'bank_account', 'card', 'hometax'];
public const BILLING_TYPES = ['subscription', 'usage'];
protected $fillable = [
'member_id',
'billing_month',
'service_type',
'billing_type',
'quantity',
'unit_price',
'total_amount',
'billed_at',
'description',
];
protected $casts = [
'quantity' => 'integer',
'unit_price' => 'integer',
'total_amount' => 'integer',
'billed_at' => 'date',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function member(): BelongsTo
{
return $this->belongsTo(BarobillMember::class, 'member_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeOfMonth($query, string $billingMonth)
{
return $query->where('billing_month', $billingMonth);
}
public function scopeSubscription($query)
{
return $query->where('billing_type', 'subscription');
}
public function scopeUsage($query)
{
return $query->where('billing_type', 'usage');
}
public function scopeOfService($query, string $serviceType)
{
return $query->where('service_type', $serviceType);
}
// =========================================================================
// 접근자
// =========================================================================
public function getServiceTypeLabelAttribute(): string
{
return match ($this->service_type) {
'tax_invoice' => '전자세금계산서',
'bank_account' => '계좌조회',
'card' => '카드조회',
'hometax' => '홈택스',
default => $this->service_type,
};
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillCardTransaction extends Model
{
use BelongsToTenant;
protected $table = 'barobill_card_transactions';
protected $fillable = [
'tenant_id',
'card_num',
'card_company',
'card_company_name',
'use_dt',
'use_date',
'use_time',
'approval_num',
'approval_type',
'approval_amount',
'tax',
'service_charge',
'payment_plan',
'currency_code',
'merchant_name',
'merchant_biz_num',
'merchant_addr',
'merchant_ceo',
'merchant_biz_type',
'merchant_tel',
'memo',
'use_key',
'account_code',
'account_name',
'deduction_type',
'evidence_name',
'description',
'modified_supply_amount',
'modified_tax',
'is_manual',
];
protected $casts = [
'approval_amount' => 'decimal:2',
'tax' => 'decimal:2',
'service_charge' => 'decimal:2',
'modified_supply_amount' => 'decimal:2',
'modified_tax' => 'decimal:2',
'is_manual' => 'boolean',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
// =========================================================================
// 접근자
// =========================================================================
/**
* 거래 고유 키 (cardNum|useDt|approvalNum|approvalAmount)
*/
public function getUniqueKeyAttribute(): string
{
return static::generateUniqueKey([
'card_num' => $this->card_num,
'use_dt' => $this->use_dt,
'approval_num' => $this->approval_num,
'approval_amount' => $this->approval_amount,
]);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function generateUniqueKey(array $data): string
{
return implode('|', [
$data['card_num'] ?? '',
$data['use_dt'] ?? '',
$data['approval_num'] ?? '',
$data['approval_amount'] ?? '0',
]);
}
public static function getByDateRange(int $tenantId, string $startDate, string $endDate, ?string $cardNum = null)
{
$query = static::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate]);
if ($cardNum) {
$query->where('card_num', $cardNum);
}
return $query->orderBy('use_date')->orderBy('use_dt')->get();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillCardTransactionAmountLog extends Model
{
protected $table = 'barobill_card_transaction_amount_logs';
public $timestamps = false;
protected $fillable = [
'card_transaction_id',
'original_unique_key',
'before_supply_amount',
'before_tax',
'after_supply_amount',
'after_tax',
'modified_by',
'modified_by_name',
'ip_address',
];
protected $casts = [
'before_supply_amount' => 'decimal:2',
'before_tax' => 'decimal:2',
'after_supply_amount' => 'decimal:2',
'after_tax' => 'decimal:2',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function cardTransaction(): BelongsTo
{
return $this->belongsTo(BarobillCardTransaction::class, 'card_transaction_id');
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class BarobillCardTransactionHide extends Model
{
use BelongsToTenant;
protected $table = 'barobill_card_transaction_hides';
protected $fillable = [
'tenant_id',
'original_unique_key',
'card_num',
'use_date',
'approval_num',
'original_amount',
'merchant_name',
'hidden_by',
];
protected $casts = [
'original_amount' => 'decimal:2',
];
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getHiddenKeys(int $tenantId, string $startDate, string $endDate)
{
return static::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate])
->pluck('original_unique_key')
->toArray();
}
public static function hideTransaction(int $tenantId, string $uniqueKey, array $originalData, int $userId): self
{
return static::create([
'tenant_id' => $tenantId,
'original_unique_key' => $uniqueKey,
'card_num' => $originalData['card_num'] ?? '',
'use_date' => $originalData['use_date'] ?? '',
'approval_num' => $originalData['approval_num'] ?? '',
'original_amount' => $originalData['approval_amount'] ?? 0,
'merchant_name' => $originalData['merchant_name'] ?? '',
'hidden_by' => $userId,
]);
}
public static function restoreTransaction(int $tenantId, string $uniqueKey): bool
{
return static::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->delete() > 0;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillCardTransactionSplit extends Model
{
use BelongsToTenant;
protected $table = 'barobill_card_transaction_splits';
protected $fillable = [
'tenant_id',
'original_unique_key',
'split_amount',
'split_supply_amount',
'split_tax',
'account_code',
'account_name',
'deduction_type',
'evidence_name',
'description',
'memo',
'sort_order',
'card_num',
'use_dt',
'use_date',
'approval_num',
'original_amount',
'merchant_name',
];
protected $casts = [
'split_amount' => 'decimal:2',
'split_supply_amount' => 'decimal:2',
'split_tax' => 'decimal:2',
'original_amount' => 'decimal:2',
'sort_order' => 'integer',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getByDateRange(int $tenantId, string $startDate, string $endDate)
{
return static::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate])
->orderBy('original_unique_key')
->orderBy('sort_order')
->get()
->groupBy('original_unique_key');
}
public static function getByUniqueKey(int $tenantId, string $uniqueKey)
{
return static::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->orderBy('sort_order')
->get();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillConfig extends Model
{
use SoftDeletes;
protected $table = 'barobill_configs';
protected $fillable = [
'name',
'environment',
'cert_key',
'corp_num',
'base_url',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getActiveTest(): ?self
{
return static::where('environment', 'test')->where('is_active', true)->first();
}
public static function getActiveProduction(): ?self
{
return static::where('environment', 'production')->where('is_active', true)->first();
}
public static function getActive(bool $isTestMode): ?self
{
return $isTestMode ? static::getActiveTest() : static::getActiveProduction();
}
public function getEnvironmentLabelAttribute(): string
{
return $this->environment === 'test' ? '테스트' : '운영';
}
public function getMaskedCertKeyAttribute(): string
{
$key = $this->cert_key;
if (strlen($key) <= 8) {
return str_repeat('*', strlen($key));
}
return substr($key, 0, 4).'****'.substr($key, -4);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillMember extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'barobill_members';
protected $fillable = [
'tenant_id',
'biz_no',
'corp_name',
'ceo_name',
'addr',
'biz_type',
'biz_class',
'barobill_id',
'barobill_pwd',
'manager_name',
'manager_email',
'manager_hp',
'status',
'server_mode',
'last_sales_fetch_at',
'last_purchases_fetch_at',
];
protected $casts = [
'barobill_pwd' => 'encrypted',
'last_sales_fetch_at' => 'datetime',
'last_purchases_fetch_at' => 'datetime',
];
protected $hidden = [
'barobill_pwd',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
// =========================================================================
// 접근자
// =========================================================================
public function getFormattedBizNoAttribute(): string
{
$num = $this->biz_no;
if (strlen($num) === 10) {
return substr($num, 0, 3).'-'.substr($num, 3, 2).'-'.substr($num, 5);
}
return $num ?? '';
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
'active' => '활성',
'inactive' => '비활성',
'pending' => '대기',
default => $this->status,
};
}
public function isTestMode(): bool
{
return $this->server_mode === 'test';
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BarobillMonthlySummary extends Model
{
protected $table = 'barobill_monthly_summaries';
protected $fillable = [
'member_id',
'billing_month',
'bank_account_fee',
'card_fee',
'hometax_fee',
'subscription_total',
'tax_invoice_count',
'tax_invoice_amount',
'usage_total',
'grand_total',
];
protected $casts = [
'bank_account_fee' => 'integer',
'card_fee' => 'integer',
'hometax_fee' => 'integer',
'subscription_total' => 'integer',
'tax_invoice_count' => 'integer',
'tax_invoice_amount' => 'integer',
'usage_total' => 'integer',
'grand_total' => 'integer',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function member(): BelongsTo
{
return $this->belongsTo(BarobillMember::class, 'member_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeOfMonth($query, string $billingMonth)
{
return $query->where('billing_month', $billingMonth);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
class BarobillPricingPolicy extends Model
{
protected $table = 'barobill_pricing_policies';
public const TYPE_CARD = 'card';
public const TYPE_TAX_INVOICE = 'tax_invoice';
public const TYPE_BANK_ACCOUNT = 'bank_account';
protected $fillable = [
'service_type',
'name',
'description',
'free_quota',
'free_quota_unit',
'additional_unit',
'additional_unit_label',
'additional_price',
'is_active',
'sort_order',
];
protected $casts = [
'free_quota' => 'integer',
'additional_unit' => 'integer',
'additional_price' => 'integer',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
// =========================================================================
// 스코프
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getByServiceType(string $serviceType): ?self
{
return static::active()->where('service_type', $serviceType)->first();
}
public static function getAllActive()
{
return static::active()->orderBy('sort_order')->get();
}
public function getServiceTypeLabelAttribute(): string
{
return match ($this->service_type) {
self::TYPE_CARD => '카드조회',
self::TYPE_TAX_INVOICE => '전자세금계산서',
self::TYPE_BANK_ACCOUNT => '계좌조회',
default => $this->service_type,
};
}
public function calculateBilling(int $usageCount): int
{
if ($usageCount <= $this->free_quota) {
return 0;
}
$excess = $usageCount - $this->free_quota;
$units = (int) ceil($excess / max($this->additional_unit, 1));
return $units * $this->additional_price;
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models\Barobill;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class BarobillSubscription extends Model
{
use SoftDeletes;
protected $table = 'barobill_subscriptions';
public const SERVICE_TYPES = ['bank_account', 'card', 'hometax'];
public const DEFAULT_MONTHLY_FEES = [
'bank_account' => 10000,
'card' => 10000,
'hometax' => 0,
];
protected $fillable = [
'member_id',
'service_type',
'monthly_fee',
'started_at',
'ended_at',
'is_active',
'memo',
];
protected $casts = [
'monthly_fee' => 'integer',
'started_at' => 'date',
'ended_at' => 'date',
'is_active' => 'boolean',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function member(): BelongsTo
{
return $this->belongsTo(BarobillMember::class, 'member_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOfService($query, string $serviceType)
{
return $query->where('service_type', $serviceType);
}
// =========================================================================
// 접근자
// =========================================================================
public function getServiceTypeLabelAttribute(): string
{
return match ($this->service_type) {
'bank_account' => '계좌조회',
'card' => '카드조회',
'hometax' => '홈택스',
default => $this->service_type,
};
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class HometaxInvoice extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'hometax_invoices';
// 과세유형
public const TAX_TYPE_TAXABLE = '01'; // 과세
public const TAX_TYPE_ZERO = '02'; // 영세
public const TAX_TYPE_EXEMPT = '03'; // 면세
// 영수/청구
public const PURPOSE_TYPE_RECEIPT = '01'; // 영수
public const PURPOSE_TYPE_CLAIM = '02'; // 청구
// 발급유형
public const ISSUE_TYPE_NORMAL = '01'; // 정발행
public const ISSUE_TYPE_REVERSE = '02'; // 역발행
protected $fillable = [
'tenant_id',
'nts_confirm_num',
'invoice_type',
'write_date',
'issue_date',
'send_date',
'invoicer_corp_num',
'invoicer_tax_reg_id',
'invoicer_corp_name',
'invoicer_ceo_name',
'invoicer_addr',
'invoicer_biz_type',
'invoicer_biz_class',
'invoicer_contact_id',
'invoicee_corp_num',
'invoicee_tax_reg_id',
'invoicee_corp_name',
'invoicee_ceo_name',
'invoicee_addr',
'invoicee_biz_type',
'invoicee_biz_class',
'invoicee_contact_id',
'supply_amount',
'tax_amount',
'total_amount',
'tax_type',
'purpose_type',
'issue_type',
'is_modified',
'original_nts_confirm_num',
'modify_code',
'remark1',
'remark2',
'remark3',
'item_name',
'item_count',
'item_unit_price',
'item_supply_amount',
'item_tax_amount',
'item_remark',
'account_code',
'account_name',
'deduction_type',
];
protected $casts = [
'supply_amount' => 'integer',
'tax_amount' => 'integer',
'total_amount' => 'integer',
'item_count' => 'integer',
'item_unit_price' => 'integer',
'item_supply_amount' => 'integer',
'item_tax_amount' => 'integer',
'is_modified' => 'boolean',
'write_date' => 'date',
'issue_date' => 'date',
'send_date' => 'date',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
public function journals(): HasMany
{
return $this->hasMany(HometaxInvoiceJournal::class, 'hometax_invoice_id');
}
// =========================================================================
// 스코프
// =========================================================================
public function scopeSales($query)
{
return $query->where('invoice_type', 'sales');
}
public function scopePurchase($query)
{
return $query->where('invoice_type', 'purchase');
}
public function scopePeriod($query, string $startDate, string $endDate)
{
return $query->whereBetween('write_date', [$startDate, $endDate]);
}
// =========================================================================
// 접근자
// =========================================================================
public function getTaxTypeNameAttribute(): string
{
return match ($this->tax_type) {
self::TAX_TYPE_TAXABLE => '과세',
self::TAX_TYPE_ZERO => '영세',
self::TAX_TYPE_EXEMPT => '면세',
default => $this->tax_type ?? '',
};
}
public function getPurposeTypeNameAttribute(): string
{
return match ($this->purpose_type) {
self::PURPOSE_TYPE_RECEIPT => '영수',
self::PURPOSE_TYPE_CLAIM => '청구',
default => $this->purpose_type ?? '',
};
}
public function getIssueTypeNameAttribute(): string
{
return match ($this->issue_type) {
self::ISSUE_TYPE_NORMAL => '정발행',
self::ISSUE_TYPE_REVERSE => '역발행',
default => $this->issue_type ?? '',
};
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models\Barobill;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class HometaxInvoiceJournal extends Model
{
use BelongsToTenant;
protected $table = 'hometax_invoice_journals';
protected $fillable = [
'tenant_id',
'hometax_invoice_id',
'nts_confirm_num',
'dc_type',
'account_code',
'account_name',
'debit_amount',
'credit_amount',
'description',
'sort_order',
'invoice_type',
'write_date',
'supply_amount',
'tax_amount',
'total_amount',
'trading_partner_name',
];
protected $casts = [
'debit_amount' => 'integer',
'credit_amount' => 'integer',
'sort_order' => 'integer',
'supply_amount' => 'integer',
'tax_amount' => 'integer',
'total_amount' => 'integer',
'write_date' => 'date',
];
// =========================================================================
// 관계 정의
// =========================================================================
public function tenant(): BelongsTo
{
return $this->belongsTo(\App\Models\Tenants\Tenant::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(HometaxInvoice::class, 'hometax_invoice_id');
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
public static function getByInvoiceId(int $tenantId, int $invoiceId)
{
return static::where('tenant_id', $tenantId)
->where('hometax_invoice_id', $invoiceId)
->orderBy('sort_order')
->get();
}
public static function getJournaledInvoiceIds(int $tenantId, array $invoiceIds): array
{
return static::where('tenant_id', $tenantId)
->whereIn('hometax_invoice_id', $invoiceIds)
->distinct()
->pluck('hometax_invoice_id')
->toArray();
}
}

View File

@@ -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

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Models\Equipment;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EquipmentInspection extends Model
{
use Auditable, BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',
'equipment_id',
'inspection_cycle',
'year_month',
'overall_judgment',
'inspector_id',
'repair_note',
'issue_note',
'created_by',
'updated_by',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function inspector(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'inspector_id');
}
public function details(): HasMany
{
return $this->hasMany(EquipmentInspectionDetail::class, 'inspection_id');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Models\Equipment;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EquipmentInspectionDetail extends Model
{
use ModelTrait;
protected $fillable = [
'inspection_id',
'template_item_id',
'check_date',
'result',
'note',
];
protected $casts = [
'check_date' => 'date',
];
public function inspection(): BelongsTo
{
return $this->belongsTo(EquipmentInspection::class, 'inspection_id');
}
public function templateItem(): BelongsTo
{
return $this->belongsTo(EquipmentInspectionTemplate::class, 'template_item_id');
}
public static function getNextResult(?string $current): ?string
{
return match ($current) {
null, '' => 'good',
'good' => 'bad',
'bad' => 'repaired',
'repaired' => null,
default => 'good',
};
}
public static function getResultSymbol(?string $result): string
{
return match ($result) {
'good' => '○',
'bad' => 'X',
'repaired' => '△',
default => '',
};
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models\Equipment;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EquipmentInspectionTemplate extends Model
{
use Auditable, BelongsToTenant, ModelTrait;
protected $fillable = [
'tenant_id',
'equipment_id',
'inspection_cycle',
'item_no',
'check_point',
'check_item',
'check_timing',
'check_frequency',
'check_method',
'sort_order',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function scopeByCycle($query, string $cycle)
{
return $query->where('inspection_cycle', $cycle);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Equipment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EquipmentProcess extends Model
{
protected $table = 'equipment_process';
protected $fillable = [
'equipment_id',
'process_id',
'is_primary',
];
protected $casts = [
'is_primary' => 'boolean',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function process(): BelongsTo
{
return $this->belongsTo(\App\Models\Process::class, 'process_id');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models\Equipment;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class EquipmentRepair extends Model
{
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $fillable = [
'tenant_id',
'equipment_id',
'repair_date',
'repair_type',
'repair_hours',
'description',
'cost',
'vendor',
'repaired_by',
'memo',
'options',
'created_by',
'updated_by',
];
protected $casts = [
'repair_date' => 'date',
'repair_hours' => 'decimal:1',
'cost' => 'decimal:2',
'options' => 'array',
];
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): self
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function repairer(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'repaired_by');
}
}

View 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\Members\User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\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;
}
}

View File

@@ -2,12 +2,15 @@
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
class BarobillSetting extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'corp_num',
@@ -23,6 +26,10 @@ class BarobillSetting extends Model
'contact_tel',
'is_active',
'auto_issue',
'use_tax_invoice',
'use_bank_account',
'use_card_usage',
'use_hometax',
'verified_at',
'created_by',
'updated_by',
@@ -31,6 +38,10 @@ class BarobillSetting extends Model
protected $casts = [
'is_active' => 'boolean',
'auto_issue' => 'boolean',
'use_tax_invoice' => 'boolean',
'use_bank_account' => 'boolean',
'use_card_usage' => 'boolean',
'use_hometax' => 'boolean',
'verified_at' => 'datetime',
];
@@ -129,4 +140,26 @@ public function getFormattedCorpNumAttribute(): string
return $num;
}
/**
* 활성화된 서비스 목록
*/
public function getActiveServicesAttribute(): array
{
$services = [];
if ($this->use_tax_invoice) {
$services[] = 'tax_invoice';
}
if ($this->use_bank_account) {
$services[] = 'bank_account';
}
if ($this->use_card_usage) {
$services[] = 'card_usage';
}
if ($this->use_hometax) {
$services[] = 'hometax';
}
return $services;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models\Tenants;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class IncomeTaxBracket extends Model
{
protected $table = 'income_tax_brackets';
protected $fillable = [
'tax_year',
'salary_from',
'salary_to',
'family_count',
'tax_amount',
];
protected $casts = [
'tax_year' => 'integer',
'salary_from' => 'integer',
'salary_to' => 'integer',
'family_count' => 'integer',
'tax_amount' => 'integer',
];
public function scopeForYear(Builder $query, int $year): Builder
{
return $query->where('tax_year', $year);
}
public function scopeForSalaryRange(Builder $query, int $salaryThousand): Builder
{
return $query->where('salary_from', '<=', $salaryThousand)
->where(function ($q) use ($salaryThousand) {
$q->where('salary_to', '>', $salaryThousand)
->orWhere(function ($q2) use ($salaryThousand) {
$q2->whereColumn('salary_from', 'salary_to')
->where('salary_from', $salaryThousand);
});
});
}
public function scopeForFamilyCount(Builder $query, int $count): Builder
{
return $query->where('family_count', $count);
}
/**
* 간이세액표에서 세액 조회
*/
public static function lookupTax(int $year, int $salaryThousand, int $familyCount): int
{
$familyCount = max(1, min(11, $familyCount));
$bracket = static::forYear($year)
->forSalaryRange($salaryThousand)
->forFamilyCount($familyCount)
->first();
return $bracket ? $bracket->tax_amount : 0;
}
}

View File

@@ -34,14 +34,26 @@ class JournalEntry extends Model
// Status
public const STATUS_DRAFT = 'draft';
public const STATUS_CONFIRMED = 'confirmed';
// Source type
public const SOURCE_MANUAL = 'manual';
public const SOURCE_BANK_TRANSACTION = 'bank_transaction';
public const SOURCE_TAX_INVOICE = 'tax_invoice';
public const SOURCE_CARD_TRANSACTION = 'card_transaction';
public const SOURCE_BAROBILL_CARD = 'barobill_card';
public const SOURCE_BAROBILL_BANK = 'barobill_bank';
public const SOURCE_HOMETAX_INVOICE = 'hometax_invoice';
public const SOURCE_PAYROLL = 'payroll';
// Entry type
public const TYPE_GENERAL = 'general';

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MailLog extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'mailable_type',
'to_address',
'from_address',
'subject',
'status',
'sent_at',
'options',
];
protected $casts = [
'sent_at' => 'datetime',
'options' => 'array',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): static
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
}

View File

@@ -49,17 +49,19 @@ class Payroll extends Model
protected $casts = [
'allowances' => 'array',
'deductions' => 'array',
'base_salary' => 'decimal:2',
'overtime_pay' => 'decimal:2',
'bonus' => 'decimal:2',
'gross_salary' => 'decimal:2',
'income_tax' => 'decimal:2',
'resident_tax' => 'decimal:2',
'health_insurance' => 'decimal:2',
'pension' => 'decimal:2',
'employment_insurance' => 'decimal:2',
'total_deductions' => 'decimal:2',
'net_salary' => 'decimal:2',
'options' => 'array',
'base_salary' => 'decimal:0',
'overtime_pay' => 'decimal:0',
'bonus' => 'decimal:0',
'gross_salary' => 'decimal:0',
'income_tax' => 'decimal:0',
'resident_tax' => 'decimal:0',
'health_insurance' => 'decimal:0',
'long_term_care' => 'decimal:0',
'pension' => 'decimal:0',
'employment_insurance' => 'decimal:0',
'total_deductions' => 'decimal:0',
'net_salary' => 'decimal:0',
'confirmed_at' => 'datetime',
'paid_at' => 'datetime',
'pay_year' => 'integer',
@@ -79,9 +81,11 @@ class Payroll extends Model
'income_tax',
'resident_tax',
'health_insurance',
'long_term_care',
'pension',
'employment_insurance',
'deductions',
'options',
'total_deductions',
'net_salary',
'status',
@@ -104,6 +108,7 @@ class Payroll extends Model
'income_tax' => 0,
'resident_tax' => 0,
'health_insurance' => 0,
'long_term_care' => 0,
'pension' => 0,
'employment_insurance' => 0,
'total_deductions' => 0,
@@ -227,13 +232,33 @@ public function scopeForUser($query, int $userId)
// =========================================================================
/**
* 수정 가능 여부 (작성중 상태)
* 수정 가능 여부 (작성중, 슈퍼관리자는 모든 상태)
*/
public function isEditable(): bool
public function isEditable(bool $isSuperAdmin = false): bool
{
if ($isSuperAdmin) {
return true;
}
return $this->status === self::STATUS_DRAFT;
}
/**
* 확정 취소 가능 여부
*/
public function isUnconfirmable(): bool
{
return $this->status === self::STATUS_CONFIRMED;
}
/**
* 지급 취소 가능 여부 (슈퍼관리자 전용)
*/
public function isUnpayable(): bool
{
return $this->status === self::STATUS_PAID;
}
/**
* 확정 가능 여부
*/
@@ -322,6 +347,7 @@ public function calculateTotalDeductions(): float
return $this->income_tax
+ $this->resident_tax
+ $this->health_insurance
+ $this->long_term_care
+ $this->pension
+ $this->employment_insurance
+ $this->deductions_total;

View File

@@ -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');
}
/**
* 생성자 관계
*/

View File

@@ -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,
];
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Models\Tenants;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class TenantMailConfig extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'provider',
'from_address',
'from_name',
'reply_to',
'is_verified',
'daily_limit',
'is_active',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'is_verified' => 'boolean',
'daily_limit' => 'integer',
'is_active' => 'boolean',
'options' => 'array',
];
// Options 키 상수
public const OPTION_SMTP = 'smtp';
public const OPTION_PRESET = 'preset';
public const OPTION_BRANDING = 'branding';
public const OPTION_CONNECTION_TEST = 'connection_test';
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function getOption(string $key, mixed $default = null): mixed
{
return data_get($this->options, $key, $default);
}
public function setOption(string $key, mixed $value): static
{
$options = $this->options ?? [];
data_set($options, $key, $value);
$this->options = $options;
return $this;
}
public function getSmtpHost(): ?string
{
return $this->getOption('smtp.host');
}
public function getSmtpPort(): int
{
return (int) $this->getOption('smtp.port', 587);
}
public function getSmtpEncryption(): string
{
return $this->getOption('smtp.encryption', 'tls');
}
public function getSmtpUsername(): ?string
{
return $this->getOption('smtp.username');
}
public function getSmtpPassword(): ?string
{
$encrypted = $this->getOption('smtp.password');
if (! $encrypted) {
return null;
}
try {
return decrypt($encrypted);
} catch (\Exception $e) {
return null;
}
}
public function getPreset(): ?string
{
return $this->getOption('preset');
}
}

View File

@@ -0,0 +1,249 @@
<?php
namespace App\Services;
use App\Models\Barobill\BarobillBankTransaction;
use App\Models\Barobill\BarobillBankTransactionOverride;
use App\Models\Barobill\BarobillBankTransactionSplit;
use Illuminate\Support\Facades\DB;
/**
* 바로빌 은행 거래 서비스 (React 연동용)
*
* MNG에서 동기화된 barobill_bank_transactions 데이터를
* React 프론트엔드에서 조회/분개/수정 등 처리
*/
class BarobillBankTransactionService extends Service
{
/**
* 은행 거래 목록 조회 (기간별, 계좌번호별)
*/
public function index(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
$accountNum = $params['bank_account_num'] ?? null;
$search = $params['search'] ?? null;
$perPage = $params['per_page'] ?? 50;
$query = BarobillBankTransaction::where('tenant_id', $tenantId)
->whereBetween('trans_date', [$startDate, $endDate]);
if ($accountNum) {
$query->where('bank_account_num', $accountNum);
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('summary', 'like', "%{$search}%")
->orWhere('memo', 'like', "%{$search}%")
->orWhere('client_name', 'like', "%{$search}%");
});
}
$query->orderByDesc('trans_date')->orderByDesc('trans_dt');
$transactions = $query->paginate($perPage);
// 분할/오버라이드 정보 로드
$uniqueKeys = $transactions->getCollection()->map->unique_key->toArray();
$splits = BarobillBankTransactionSplit::where('tenant_id', $tenantId)
->whereIn('original_unique_key', $uniqueKeys)
->orderBy('sort_order')
->get()
->groupBy('original_unique_key');
$overrides = BarobillBankTransactionOverride::getByUniqueKeys($tenantId, $uniqueKeys);
$transactions->getCollection()->transform(function ($tx) use ($splits, $overrides) {
$tx->splits = $splits->get($tx->unique_key, collect());
$tx->has_splits = $tx->splits->isNotEmpty();
$tx->override = $overrides->get($tx->unique_key);
return $tx;
});
return [
'data' => $transactions,
];
}
/**
* 계좌 목록 (필터용)
*/
public function accounts(): array
{
$tenantId = $this->tenantId();
$accounts = BarobillBankTransaction::where('tenant_id', $tenantId)
->select('bank_account_num', 'bank_name')
->distinct()
->orderBy('bank_account_num')
->get();
return ['items' => $accounts];
}
/**
* 거래 분할 조회
*/
public function getSplits(string $uniqueKey): array
{
$tenantId = $this->tenantId();
$splits = BarobillBankTransactionSplit::getByUniqueKey($tenantId, $uniqueKey);
return ['items' => $splits];
}
/**
* 거래 분할 저장
*/
public function saveSplits(string $uniqueKey, array $items): array
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($tenantId, $uniqueKey, $items) {
BarobillBankTransactionSplit::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->delete();
$created = [];
foreach ($items as $index => $item) {
$created[] = BarobillBankTransactionSplit::create([
'tenant_id' => $tenantId,
'original_unique_key' => $uniqueKey,
'split_amount' => $item['split_amount'],
'account_code' => $item['account_code'] ?? null,
'account_name' => $item['account_name'] ?? null,
'deduction_type' => $item['deduction_type'] ?? null,
'evidence_name' => $item['evidence_name'] ?? null,
'description' => $item['description'] ?? null,
'memo' => $item['memo'] ?? null,
'sort_order' => $index + 1,
'bank_account_num' => $item['bank_account_num'] ?? null,
'trans_dt' => $item['trans_dt'] ?? null,
'trans_date' => $item['trans_date'] ?? null,
'original_deposit' => $item['original_deposit'] ?? 0,
'original_withdraw' => $item['original_withdraw'] ?? 0,
'summary' => $item['summary'] ?? null,
]);
}
return ['items' => $created, 'count' => count($created)];
});
}
/**
* 거래 분할 삭제
*/
public function deleteSplits(string $uniqueKey): array
{
$tenantId = $this->tenantId();
$deleted = BarobillBankTransactionSplit::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->delete();
return ['deleted_count' => $deleted];
}
/**
* 적요/분류 오버라이드 저장
*/
public function saveOverride(string $uniqueKey, ?string $modifiedSummary, ?string $modifiedCast): BarobillBankTransactionOverride
{
$tenantId = $this->tenantId();
return BarobillBankTransactionOverride::saveOverride($tenantId, $uniqueKey, $modifiedSummary, $modifiedCast);
}
/**
* 수동 은행 거래 등록
*/
public function storeManual(array $data): BarobillBankTransaction
{
$tenantId = $this->tenantId();
return BarobillBankTransaction::create([
'tenant_id' => $tenantId,
'bank_account_num' => $data['bank_account_num'],
'bank_code' => $data['bank_code'] ?? null,
'bank_name' => $data['bank_name'] ?? null,
'trans_date' => $data['trans_date'],
'trans_time' => $data['trans_time'] ?? null,
'trans_dt' => $data['trans_dt'] ?? $data['trans_date'].($data['trans_time'] ?? '000000'),
'deposit' => $data['deposit'] ?? 0,
'withdraw' => $data['withdraw'] ?? 0,
'balance' => $data['balance'] ?? 0,
'summary' => $data['summary'] ?? null,
'cast' => $data['cast'] ?? null,
'memo' => $data['memo'] ?? null,
'trans_office' => $data['trans_office'] ?? null,
'account_code' => $data['account_code'] ?? null,
'account_name' => $data['account_name'] ?? null,
'client_code' => $data['client_code'] ?? null,
'client_name' => $data['client_name'] ?? null,
'is_manual' => true,
]);
}
/**
* 수동 은행 거래 수정
*/
public function updateManual(int $id, array $data): BarobillBankTransaction
{
$tx = BarobillBankTransaction::where('tenant_id', $this->tenantId())
->where('is_manual', true)
->findOrFail($id);
$tx->update($data);
return $tx->fresh();
}
/**
* 수동 은행 거래 삭제
*/
public function destroyManual(int $id): bool
{
$tx = BarobillBankTransaction::where('tenant_id', $this->tenantId())
->where('is_manual', true)
->findOrFail($id);
return $tx->delete();
}
/**
* 잔액 요약
*/
public function balanceSummary(array $params): array
{
$tenantId = $this->tenantId();
$date = $params['date'] ?? now()->format('Y-m-d');
$accounts = BarobillBankTransaction::where('tenant_id', $tenantId)
->select('bank_account_num', 'bank_name')
->distinct()
->get();
$summary = [];
foreach ($accounts as $account) {
$lastTx = BarobillBankTransaction::where('tenant_id', $tenantId)
->where('bank_account_num', $account->bank_account_num)
->where('trans_date', '<=', $date)
->orderByDesc('trans_date')
->orderByDesc('trans_dt')
->first();
$summary[] = [
'bank_account_num' => $account->bank_account_num,
'bank_name' => $account->bank_name,
'balance' => $lastTx ? $lastTx->balance : 0,
'last_trans_date' => $lastTx?->trans_date,
];
}
return ['items' => $summary];
}
}

View File

@@ -0,0 +1,308 @@
<?php
namespace App\Services;
use App\Models\Barobill\BarobillCardTransaction;
use App\Models\Barobill\BarobillCardTransactionAmountLog;
use App\Models\Barobill\BarobillCardTransactionHide;
use App\Models\Barobill\BarobillCardTransactionSplit;
use Illuminate\Support\Facades\DB;
/**
* 바로빌 카드 거래 서비스 (React 연동용)
*
* MNG에서 동기화된 barobill_card_transactions 데이터를
* React 프론트엔드에서 조회/분개/숨김 등 처리
*/
class BarobillCardTransactionService extends Service
{
/**
* 카드 거래 목록 조회 (기간별, 카드번호별)
*/
public function index(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
$cardNum = $params['card_num'] ?? null;
$search = $params['search'] ?? null;
$includeHidden = $params['include_hidden'] ?? false;
$perPage = $params['per_page'] ?? 50;
$query = BarobillCardTransaction::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate]);
if ($cardNum) {
$query->where('card_num', $cardNum);
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('merchant_name', 'like', "%{$search}%")
->orWhere('memo', 'like', "%{$search}%")
->orWhere('approval_num', 'like', "%{$search}%");
});
}
// 숨김 거래 필터링
if (! $includeHidden) {
$hiddenKeys = BarobillCardTransactionHide::getHiddenKeys($tenantId, $startDate, $endDate);
if (! empty($hiddenKeys)) {
$query->whereNotIn(
DB::raw("CONCAT(card_num, '|', use_dt, '|', approval_num, '|', approval_amount)"),
$hiddenKeys
);
}
}
$query->orderByDesc('use_date')->orderByDesc('use_dt');
$transactions = $query->paginate($perPage);
// 분할 거래 정보 로드
$uniqueKeys = $transactions->getCollection()->map->unique_key->toArray();
$splits = BarobillCardTransactionSplit::where('tenant_id', $tenantId)
->whereIn('original_unique_key', $uniqueKeys)
->orderBy('sort_order')
->get()
->groupBy('original_unique_key');
$transactions->getCollection()->transform(function ($tx) use ($splits) {
$tx->splits = $splits->get($tx->unique_key, collect());
$tx->has_splits = $tx->splits->isNotEmpty();
return $tx;
});
return [
'data' => $transactions,
];
}
/**
* 단일 카드 거래 상세
*/
public function show(int $id): ?BarobillCardTransaction
{
return BarobillCardTransaction::where('tenant_id', $this->tenantId())
->find($id);
}
/**
* 카드 거래 분할 조회
*/
public function getSplits(string $uniqueKey): array
{
$tenantId = $this->tenantId();
$splits = BarobillCardTransactionSplit::getByUniqueKey($tenantId, $uniqueKey);
return ['items' => $splits];
}
/**
* 카드 거래 분할 저장
*/
public function saveSplits(string $uniqueKey, array $items): array
{
$tenantId = $this->tenantId();
return DB::transaction(function () use ($tenantId, $uniqueKey, $items) {
// 기존 분할 삭제
BarobillCardTransactionSplit::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->delete();
$created = [];
foreach ($items as $index => $item) {
$created[] = BarobillCardTransactionSplit::create([
'tenant_id' => $tenantId,
'original_unique_key' => $uniqueKey,
'split_amount' => $item['split_amount'],
'split_supply_amount' => $item['split_supply_amount'] ?? 0,
'split_tax' => $item['split_tax'] ?? 0,
'account_code' => $item['account_code'] ?? null,
'account_name' => $item['account_name'] ?? null,
'deduction_type' => $item['deduction_type'] ?? null,
'evidence_name' => $item['evidence_name'] ?? null,
'description' => $item['description'] ?? null,
'memo' => $item['memo'] ?? null,
'sort_order' => $index + 1,
'card_num' => $item['card_num'] ?? null,
'use_dt' => $item['use_dt'] ?? null,
'use_date' => $item['use_date'] ?? null,
'approval_num' => $item['approval_num'] ?? null,
'original_amount' => $item['original_amount'] ?? 0,
'merchant_name' => $item['merchant_name'] ?? null,
]);
}
return ['items' => $created, 'count' => count($created)];
});
}
/**
* 카드 거래 분할 삭제
*/
public function deleteSplits(string $uniqueKey): array
{
$tenantId = $this->tenantId();
$deleted = BarobillCardTransactionSplit::where('tenant_id', $tenantId)
->where('original_unique_key', $uniqueKey)
->delete();
return ['deleted_count' => $deleted];
}
/**
* 수동 카드 거래 등록
*/
public function storeManual(array $data): BarobillCardTransaction
{
$tenantId = $this->tenantId();
return BarobillCardTransaction::create([
'tenant_id' => $tenantId,
'card_num' => $data['card_num'],
'card_company' => $data['card_company'] ?? null,
'card_company_name' => $data['card_company_name'] ?? null,
'use_dt' => $data['use_dt'],
'use_date' => $data['use_date'],
'use_time' => $data['use_time'] ?? null,
'approval_num' => $data['approval_num'] ?? 'MANUAL-'.now()->format('YmdHis'),
'approval_type' => $data['approval_type'] ?? '1',
'approval_amount' => $data['approval_amount'],
'tax' => $data['tax'] ?? 0,
'service_charge' => $data['service_charge'] ?? 0,
'payment_plan' => $data['payment_plan'] ?? null,
'merchant_name' => $data['merchant_name'],
'merchant_biz_num' => $data['merchant_biz_num'] ?? null,
'account_code' => $data['account_code'] ?? null,
'account_name' => $data['account_name'] ?? null,
'deduction_type' => $data['deduction_type'] ?? null,
'evidence_name' => $data['evidence_name'] ?? null,
'description' => $data['description'] ?? null,
'memo' => $data['memo'] ?? null,
'is_manual' => true,
]);
}
/**
* 수동 카드 거래 수정
*/
public function updateManual(int $id, array $data): BarobillCardTransaction
{
$tx = BarobillCardTransaction::where('tenant_id', $this->tenantId())
->where('is_manual', true)
->findOrFail($id);
$tx->update($data);
return $tx->fresh();
}
/**
* 수동 카드 거래 삭제
*/
public function destroyManual(int $id): bool
{
$tx = BarobillCardTransaction::where('tenant_id', $this->tenantId())
->where('is_manual', true)
->findOrFail($id);
return $tx->delete();
}
/**
* 카드 거래 숨김
*/
public function hide(int $id): BarobillCardTransactionHide
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id);
return BarobillCardTransactionHide::hideTransaction($tenantId, $tx->unique_key, [
'card_num' => $tx->card_num,
'use_date' => $tx->use_date,
'approval_num' => $tx->approval_num,
'approval_amount' => $tx->approval_amount,
'merchant_name' => $tx->merchant_name,
], $userId);
}
/**
* 카드 거래 숨김 복원
*/
public function restore(int $id): bool
{
$tenantId = $this->tenantId();
$tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id);
return BarobillCardTransactionHide::restoreTransaction($tenantId, $tx->unique_key);
}
/**
* 숨겨진 거래 목록
*/
public function hiddenList(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
$hiddenItems = BarobillCardTransactionHide::where('tenant_id', $tenantId)
->whereBetween('use_date', [$startDate, $endDate])
->orderByDesc('created_at')
->get();
return ['items' => $hiddenItems];
}
/**
* 금액 수정 (공급가액/세액 수정)
*/
public function updateAmount(int $id, array $data): BarobillCardTransaction
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$tx = BarobillCardTransaction::where('tenant_id', $tenantId)->findOrFail($id);
// 변경 이력 기록
BarobillCardTransactionAmountLog::create([
'card_transaction_id' => $tx->id,
'original_unique_key' => $tx->unique_key,
'before_supply_amount' => $tx->modified_supply_amount ?? $tx->approval_amount,
'before_tax' => $tx->modified_tax ?? $tx->tax,
'after_supply_amount' => $data['supply_amount'],
'after_tax' => $data['tax'],
'modified_by' => $userId,
'modified_by_name' => $data['modified_by_name'] ?? '',
'ip_address' => request()->ip(),
]);
$tx->update([
'modified_supply_amount' => $data['supply_amount'],
'modified_tax' => $data['tax'],
]);
return $tx->fresh();
}
/**
* 카드 번호 목록 (필터용)
*/
public function cardNumbers(): array
{
$tenantId = $this->tenantId();
$cards = BarobillCardTransaction::where('tenant_id', $tenantId)
->select('card_num', 'card_company_name')
->distinct()
->orderBy('card_num')
->get();
return ['items' => $cards];
}
}

View File

@@ -12,18 +12,41 @@
* 바로빌 API 연동 서비스
*
* 바로빌 개발자센터: https://dev.barobill.co.kr/
* SOAP 서비스 URL: https://ws.baroservice.com/ (운영) / https://testws.baroservice.com/ (테스트)
*/
class BarobillService extends Service
{
/**
* 바로빌 API 기본 URL
* 바로빌 SOAP 서비스 기본 URL (운영)
*/
private const API_BASE_URL = 'https://ws.barobill.co.kr';
private const API_BASE_URL = 'https://ws.baroservice.com';
/**
* 바로빌 API 테스트 URL
* 바로빌 SOAP 서비스 테스트 URL
*/
private const API_TEST_URL = 'https://testws.barobill.co.kr';
private const API_TEST_URL = 'https://testws.baroservice.com';
/**
* API 서비스 경로 매핑
*/
private const SERVICE_PATHS = [
'TI' => '/TI.asmx', // 세금계산서
'CORPSTATE' => '/CORPSTATE.asmx', // 회원/사업자 관리
'BANKACCOUNT' => '/BANKACCOUNT.asmx', // 계좌 조회
'CARD' => '/CARD.asmx', // 카드 조회
];
/**
* 메서드별 서비스 매핑
*/
private const METHOD_SERVICE_MAP = [
'GetAccessToken' => 'CORPSTATE',
'CheckCorpNum' => 'CORPSTATE',
'RegistCorp' => 'CORPSTATE',
'RegistAndIssueTaxInvoice' => 'TI',
'CancelTaxInvoice' => 'TI',
'GetTaxInvoiceState' => 'TI',
];
/**
* 테스트 모드 여부
@@ -32,7 +55,7 @@ class BarobillService extends Service
public function __construct()
{
$this->testMode = config('services.barobill.test_mode', true);
$this->testMode = (bool) config('services.barobill.test_mode', true);
}
// =========================================================================
@@ -44,11 +67,7 @@ public function __construct()
*/
public function getSetting(): ?BarobillSetting
{
$tenantId = $this->tenantId();
return BarobillSetting::query()
->where('tenant_id', $tenantId)
->first();
return BarobillSetting::query()->first();
}
/**
@@ -59,9 +78,7 @@ public function saveSetting(array $data): BarobillSetting
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$setting = BarobillSetting::query()
->where('tenant_id', $tenantId)
->first();
$setting = BarobillSetting::query()->first();
if ($setting) {
$setting->fill(array_merge($data, ['updated_by' => $userId]));
@@ -89,7 +106,6 @@ public function testConnection(): array
}
try {
// 바로빌 API 토큰 조회로 연동 테스트
$response = $this->callApi('GetAccessToken', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
@@ -97,7 +113,6 @@ public function testConnection(): array
]);
if (! empty($response['AccessToken'])) {
// 검증 성공 시 verified_at 업데이트
$setting->verified_at = now();
$setting->save();
@@ -108,7 +123,10 @@ public function testConnection(): array
];
}
throw new BadRequestHttpException($response['Message'] ?? __('error.barobill.connection_failed'));
return [
'success' => false,
'message' => $response['Message'] ?? __('error.barobill.connection_failed'),
];
} catch (\Exception $e) {
Log::error('바로빌 연동 테스트 실패', [
'tenant_id' => $this->tenantId(),
@@ -125,19 +143,11 @@ public function testConnection(): array
/**
* 사업자등록번호 유효성 검사 (휴폐업 조회)
*
* 바로빌 API를 통해 사업자등록번호의 유효성을 검증합니다.
* 바로빌 설정이 없는 경우 기본 형식 검증만 수행합니다.
*
* @param string $businessNumber 사업자등록번호 (10자리, 하이픈 제거)
* @return array{valid: bool, status: string, status_label: string, corp_name: ?string, ceo_name: ?string, message: string}
*/
public function checkBusinessNumber(string $businessNumber): array
{
// 하이픈 제거 및 숫자만 추출
$businessNumber = preg_replace('/[^0-9]/', '', $businessNumber);
// 기본 형식 검증 (10자리)
if (strlen($businessNumber) !== 10) {
return [
'valid' => false,
@@ -149,7 +159,6 @@ public function checkBusinessNumber(string $businessNumber): array
];
}
// 체크섬 검증 (사업자등록번호 자체 유효성)
if (! $this->validateBusinessNumberChecksum($businessNumber)) {
return [
'valid' => false,
@@ -161,16 +170,14 @@ public function checkBusinessNumber(string $businessNumber): array
];
}
// 바로빌 API 조회 시도
try {
$response = $this->callApi('CheckCorpNum', [
'CorpNum' => $businessNumber,
]);
// 바로빌 응답 해석
if (isset($response['CorpState'])) {
$state = $response['CorpState'];
$isValid = in_array($state, ['01', '02']); // 01: 사업중, 02: 휴업
$isValid = in_array($state, ['01', '02']);
$statusLabel = match ($state) {
'01' => '사업중',
'02' => '휴업',
@@ -190,7 +197,6 @@ public function checkBusinessNumber(string $businessNumber): array
];
}
// 응답 형식이 다른 경우 (결과 코드 방식)
if (isset($response['Result'])) {
$isValid = $response['Result'] >= 0;
@@ -206,7 +212,6 @@ public function checkBusinessNumber(string $businessNumber): array
];
}
// 기본 응답 (체크섬만 통과한 경우)
return [
'valid' => true,
'status' => 'format_valid',
@@ -216,7 +221,6 @@ public function checkBusinessNumber(string $businessNumber): array
'message' => __('message.company.business_number_format_valid'),
];
} catch (\Exception $e) {
// API 호출 실패 시 형식 검증 결과만 반환
Log::warning('바로빌 사업자번호 조회 실패', [
'business_number' => $businessNumber,
'error' => $e->getMessage(),
@@ -235,8 +239,6 @@ public function checkBusinessNumber(string $businessNumber): array
/**
* 사업자등록번호 체크섬 검증
*
* @param string $businessNumber 10자리 사업자등록번호
*/
private function validateBusinessNumberChecksum(string $businessNumber): bool
{
@@ -252,7 +254,6 @@ private function validateBusinessNumberChecksum(string $businessNumber): bool
$sum += intval($digits[$i]) * $multipliers[$i];
}
// 8번째 자리 (인덱스 8)에 대한 추가 처리
$sum += intval(floor(intval($digits[8]) * 5 / 10));
$remainder = $sum % 10;
@@ -277,14 +278,11 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
}
try {
// 바로빌 API 호출을 위한 데이터 구성
$apiData = $this->buildTaxInvoiceData($taxInvoice, $setting);
// 세금계산서 발행 API 호출
$response = $this->callApi('RegistAndIssueTaxInvoice', $apiData);
if (! empty($response['InvoiceID'])) {
// 발행 성공
$taxInvoice->barobill_invoice_id = $response['InvoiceID'];
$taxInvoice->nts_confirm_num = $response['NTSConfirmNum'] ?? null;
$taxInvoice->status = TaxInvoice::STATUS_ISSUED;
@@ -301,9 +299,10 @@ public function issueTaxInvoice(TaxInvoice $taxInvoice): TaxInvoice
return $taxInvoice->fresh();
}
throw new \Exception($response['Message'] ?? '발행 실패');
throw new \RuntimeException($response['Message'] ?? '발행 실패');
} catch (BadRequestHttpException $e) {
throw $e;
} catch (\Exception $e) {
// 발행 실패
$taxInvoice->status = TaxInvoice::STATUS_FAILED;
$taxInvoice->error_message = $e->getMessage();
$taxInvoice->save();
@@ -334,7 +333,6 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
}
try {
// 세금계산서 취소 API 호출
$response = $this->callApi('CancelTaxInvoice', [
'CERTKEY' => $setting->cert_key,
'CorpNum' => $setting->corp_num,
@@ -358,7 +356,9 @@ public function cancelTaxInvoice(TaxInvoice $taxInvoice, string $reason): TaxInv
return $taxInvoice->fresh();
}
throw new \Exception($response['Message'] ?? '취소 실패');
throw new \RuntimeException($response['Message'] ?? '취소 실패');
} catch (BadRequestHttpException $e) {
throw $e;
} catch (\Exception $e) {
Log::error('세금계산서 취소 실패', [
'tenant_id' => $this->tenantId(),
@@ -396,7 +396,6 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
if (! empty($response['State'])) {
$taxInvoice->nts_send_status = $response['State'];
// 국세청 전송 완료 시 상태 업데이트
if ($response['State'] === '전송완료' && ! $taxInvoice->sent_at) {
$taxInvoice->status = TaxInvoice::STATUS_SENT;
$taxInvoice->sent_at = now();
@@ -418,6 +417,26 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
}
}
// =========================================================================
// URL 헬퍼
// =========================================================================
/**
* 바로빌 API base URL 반환
*/
public function getBaseUrl(): string
{
return $this->testMode ? self::API_TEST_URL : self::API_BASE_URL;
}
/**
* 테스트 모드 여부
*/
public function isTestMode(): bool
{
return $this->testMode;
}
// =========================================================================
// Private 메서드
// =========================================================================
@@ -427,8 +446,10 @@ public function checkNtsSendStatus(TaxInvoice $taxInvoice): TaxInvoice
*/
private function callApi(string $method, array $data): array
{
$baseUrl = $this->testMode ? self::API_TEST_URL : self::API_BASE_URL;
$url = $baseUrl.'/TI/'.$method;
$baseUrl = $this->getBaseUrl();
$servicePath = self::METHOD_SERVICE_MAP[$method] ?? 'TI';
$path = self::SERVICE_PATHS[$servicePath] ?? '/TI.asmx';
$url = $baseUrl.$path.'/'.$method;
$response = Http::timeout(30)
->withHeaders([
@@ -437,7 +458,7 @@ private function callApi(string $method, array $data): array
->post($url, $data);
if ($response->failed()) {
throw new \Exception('API 호출 실패: '.$response->status());
throw new \RuntimeException('API 호출 실패: '.$response->status());
}
return $response->json() ?? [];
@@ -448,7 +469,6 @@ private function callApi(string $method, array $data): array
*/
private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $setting): array
{
// 품목 데이터 구성
$items = [];
foreach ($taxInvoice->items ?? [] as $index => $item) {
$items[] = [
@@ -463,7 +483,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
];
}
// 품목이 없는 경우 기본 품목 추가
if (empty($items)) {
$items[] = [
'PurchaseDT' => $taxInvoice->issue_date->format('Ymd'),
@@ -487,8 +506,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
'TaxType' => '과세',
'PurposeType' => '영수',
'WriteDate' => $taxInvoice->issue_date->format('Ymd'),
// 공급자 정보
'InvoicerCorpNum' => $taxInvoice->supplier_corp_num,
'InvoicerCorpName' => $taxInvoice->supplier_corp_name,
'InvoicerCEOName' => $taxInvoice->supplier_ceo_name,
@@ -496,8 +513,6 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
'InvoicerBizType' => $taxInvoice->supplier_biz_type,
'InvoicerBizClass' => $taxInvoice->supplier_biz_class,
'InvoicerContactID' => $taxInvoice->supplier_contact_id,
// 공급받는자 정보
'InvoiceeCorpNum' => $taxInvoice->buyer_corp_num,
'InvoiceeCorpName' => $taxInvoice->buyer_corp_name,
'InvoiceeCEOName' => $taxInvoice->buyer_ceo_name,
@@ -505,16 +520,10 @@ private function buildTaxInvoiceData(TaxInvoice $taxInvoice, BarobillSetting $se
'InvoiceeBizType' => $taxInvoice->buyer_biz_type,
'InvoiceeBizClass' => $taxInvoice->buyer_biz_class,
'InvoiceeContactID' => $taxInvoice->buyer_contact_id,
// 금액 정보
'SupplyCostTotal' => (int) $taxInvoice->supply_amount,
'TaxTotal' => (int) $taxInvoice->tax_amount,
'TotalAmount' => (int) $taxInvoice->total_amount,
// 품목 정보
'TaxInvoiceTradeLineItems' => $items,
// 비고
'Remark1' => $taxInvoice->description ?? '',
],
];

View 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;
}
}

View File

@@ -0,0 +1,376 @@
<?php
namespace App\Services\Equipment;
use App\Enums\InspectionCycle;
use App\Models\Equipment\Equipment;
use App\Models\Equipment\EquipmentInspection;
use App\Models\Equipment\EquipmentInspectionDetail;
use App\Models\Equipment\EquipmentInspectionTemplate;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EquipmentInspectionService extends Service
{
public function getInspections(string $cycle, string $period, ?string $productionLine = null, ?int $equipmentId = null): array
{
$equipmentQuery = Equipment::active()
->where('status', '!=', 'disposed')
->with(['manager', 'subManager']);
if ($productionLine) {
$equipmentQuery->byLine($productionLine);
}
if ($equipmentId) {
$equipmentQuery->where('id', $equipmentId);
}
$equipments = $equipmentQuery->orderBy('sort_order')->orderBy('name')->get();
$labels = InspectionCycle::columnLabels($cycle, $period);
$userId = $this->apiUserId();
$result = [];
foreach ($equipments as $equipment) {
$templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
->byCycle($cycle)
->active()
->orderBy('sort_order')
->get();
if ($templates->isEmpty()) {
continue;
}
$inspection = EquipmentInspection::where('equipment_id', $equipment->id)
->where('inspection_cycle', $cycle)
->where('year_month', $period)
->first();
$details = [];
if ($inspection) {
$details = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->get()
->groupBy(function ($d) {
return $d->template_item_id.'_'.$d->check_date->format('Y-m-d');
});
}
$result[] = [
'equipment' => $equipment,
'templates' => $templates,
'inspection' => $inspection,
'details' => $details,
'labels' => $labels,
'can_inspect' => $equipment->canInspect($userId),
];
}
return $result;
}
public function toggleDetail(int $equipmentId, int $templateItemId, string $checkDate, string $cycle = 'daily'): array
{
return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle) {
$equipment = Equipment::find($equipmentId);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
$userId = $this->apiUserId();
if (! $equipment->canInspect($userId)) {
throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission'));
}
$period = InspectionCycle::resolvePeriod($cycle, $checkDate);
if ($cycle === InspectionCycle::DAILY) {
$tenantId = $this->tenantId();
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId);
if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) {
throw new BadRequestHttpException(__('error.equipment.non_working_day'));
}
}
$tenantId = $this->tenantId();
$inspection = EquipmentInspection::firstOrCreate(
[
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'inspection_cycle' => $cycle,
'year_month' => $period,
],
[
'created_by' => $userId,
]
);
$detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->where('template_item_id', $templateItemId)
->where('check_date', $checkDate)
->first();
if ($detail) {
$nextResult = EquipmentInspectionDetail::getNextResult($detail->result);
if ($nextResult === null) {
$detail->delete();
return ['result' => null, 'symbol' => ''];
}
$detail->update(['result' => $nextResult]);
} else {
$detail = EquipmentInspectionDetail::create([
'inspection_id' => $inspection->id,
'template_item_id' => $templateItemId,
'check_date' => $checkDate,
'result' => 'good',
]);
$nextResult = 'good';
}
return [
'result' => $nextResult,
'symbol' => EquipmentInspectionDetail::getResultSymbol($nextResult),
];
});
}
public function setResult(int $equipmentId, int $templateItemId, string $checkDate, string $cycle, ?string $result): array
{
return DB::transaction(function () use ($equipmentId, $templateItemId, $checkDate, $cycle, $result) {
$equipment = Equipment::find($equipmentId);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
$userId = $this->apiUserId();
if (! $equipment->canInspect($userId)) {
throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission'));
}
$period = InspectionCycle::resolvePeriod($cycle, $checkDate);
if ($cycle === InspectionCycle::DAILY) {
$tenantId = $this->tenantId();
$holidayDates = InspectionCycle::getHolidayDates($cycle, $period, $tenantId);
if (InspectionCycle::isNonWorkingDay($checkDate, $holidayDates)) {
throw new BadRequestHttpException(__('error.equipment.non_working_day'));
}
}
$tenantId = $this->tenantId();
$inspection = EquipmentInspection::firstOrCreate(
[
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'inspection_cycle' => $cycle,
'year_month' => $period,
],
[
'created_by' => $userId,
]
);
$detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->where('template_item_id', $templateItemId)
->where('check_date', $checkDate)
->first();
if ($result === null) {
if ($detail) {
$detail->delete();
}
return ['result' => null, 'symbol' => ''];
}
if ($detail) {
$detail->update(['result' => $result]);
} else {
$detail = EquipmentInspectionDetail::create([
'inspection_id' => $inspection->id,
'template_item_id' => $templateItemId,
'check_date' => $checkDate,
'result' => $result,
]);
}
return [
'result' => $result,
'symbol' => EquipmentInspectionDetail::getResultSymbol($result),
];
});
}
public function updateNotes(int $equipmentId, string $yearMonth, array $data, string $cycle = 'daily'): EquipmentInspection
{
return DB::transaction(function () use ($equipmentId, $yearMonth, $data, $cycle) {
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$inspection = EquipmentInspection::firstOrCreate(
[
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'inspection_cycle' => $cycle,
'year_month' => $yearMonth,
],
[
'created_by' => $userId,
]
);
$inspection->update($data);
return $inspection->fresh();
});
}
public function resetInspection(int $equipmentId, string $cycle, string $period): int
{
return DB::transaction(function () use ($equipmentId, $cycle, $period) {
$equipment = Equipment::find($equipmentId);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
$userId = $this->apiUserId();
if (! $equipment->canInspect($userId)) {
throw new AccessDeniedHttpException(__('error.equipment.no_inspect_permission'));
}
$tenantId = $this->tenantId();
$inspection = EquipmentInspection::where('tenant_id', $tenantId)
->where('equipment_id', $equipmentId)
->where('inspection_cycle', $cycle)
->where('year_month', $period)
->first();
if (! $inspection) {
return 0;
}
$deleted = EquipmentInspectionDetail::where('inspection_id', $inspection->id)->delete();
$inspection->update([
'overall_judgment' => null,
'repair_note' => null,
'issue_note' => null,
'inspector_id' => null,
]);
return $deleted;
});
}
public function saveTemplate(int $equipmentId, array $data): EquipmentInspectionTemplate
{
return DB::transaction(function () use ($equipmentId, $data) {
$tenantId = $this->tenantId();
return EquipmentInspectionTemplate::create(array_merge($data, [
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
]));
});
}
public function updateTemplate(int $id, array $data): EquipmentInspectionTemplate
{
return DB::transaction(function () use ($id, $data) {
$template = EquipmentInspectionTemplate::find($id);
if (! $template) {
throw new NotFoundHttpException(__('error.equipment.template_not_found'));
}
$template->update($data);
return $template->fresh();
});
}
public function deleteTemplate(int $id): bool
{
return DB::transaction(function () use ($id) {
$template = EquipmentInspectionTemplate::find($id);
if (! $template) {
throw new NotFoundHttpException(__('error.equipment.template_not_found'));
}
return $template->delete();
});
}
public function copyTemplates(int $equipmentId, string $sourceCycle, array $targetCycles): array
{
return DB::transaction(function () use ($equipmentId, $sourceCycle, $targetCycles) {
$tenantId = $this->tenantId();
$sourceTemplates = EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
->byCycle($sourceCycle)
->active()
->orderBy('sort_order')
->get();
if ($sourceTemplates->isEmpty()) {
throw new BadRequestHttpException(__('error.equipment.no_source_templates'));
}
$copiedCount = 0;
$skippedCount = 0;
foreach ($targetCycles as $targetCycle) {
foreach ($sourceTemplates as $template) {
$exists = EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
->where('inspection_cycle', $targetCycle)
->where('item_no', $template->item_no)
->exists();
if ($exists) {
$skippedCount++;
continue;
}
EquipmentInspectionTemplate::create([
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'inspection_cycle' => $targetCycle,
'item_no' => $template->item_no,
'check_point' => $template->check_point,
'check_item' => $template->check_item,
'check_timing' => $template->check_timing,
'check_frequency' => $template->check_frequency,
'check_method' => $template->check_method,
'sort_order' => $template->sort_order,
'is_active' => true,
]);
$copiedCount++;
}
}
return [
'copied' => $copiedCount,
'skipped' => $skippedCount,
'source_count' => $sourceTemplates->count(),
'target_cycles' => $targetCycles,
];
});
}
public function getActiveCycles(int $equipmentId): array
{
return EquipmentInspectionTemplate::where('equipment_id', $equipmentId)
->active()
->distinct()
->pluck('inspection_cycle')
->toArray();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\Equipment;
use App\Models\Equipment\EquipmentRepair;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EquipmentRepairService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = EquipmentRepair::query()->with('equipment', 'repairer');
if (! empty($filters['equipment_id'])) {
$query->where('equipment_id', $filters['equipment_id']);
}
if (! empty($filters['repair_type'])) {
$query->where('repair_type', $filters['repair_type']);
}
if (! empty($filters['date_from'])) {
$query->where('repair_date', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('repair_date', '<=', $filters['date_to']);
}
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhereHas('equipment', function ($eq) use ($search) {
$eq->where('name', 'like', "%{$search}%")
->orWhere('equipment_code', 'like', "%{$search}%");
});
});
}
return $query->orderBy('repair_date', 'desc')->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): EquipmentRepair
{
$repair = EquipmentRepair::with('equipment', 'repairer')->find($id);
if (! $repair) {
throw new NotFoundHttpException(__('error.equipment.repair_not_found'));
}
return $repair;
}
public function store(array $data): EquipmentRepair
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return EquipmentRepair::create($data);
});
}
public function update(int $id, array $data): EquipmentRepair
{
return DB::transaction(function () use ($id, $data) {
$repair = EquipmentRepair::find($id);
if (! $repair) {
throw new NotFoundHttpException(__('error.equipment.repair_not_found'));
}
$repair->update($data);
return $repair->fresh();
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$repair = EquipmentRepair::find($id);
if (! $repair) {
throw new NotFoundHttpException(__('error.equipment.repair_not_found'));
}
return $repair->delete();
});
}
public function recentRepairs(int $limit = 5): \Illuminate\Database\Eloquent\Collection
{
return EquipmentRepair::with('equipment')
->orderBy('repair_date', 'desc')
->limit($limit)
->get();
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Services\Equipment;
use App\Models\Equipment\Equipment;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EquipmentService extends Service
{
public function index(array $filters = []): LengthAwarePaginator
{
$query = Equipment::query()->with(['manager', 'subManager']);
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('equipment_code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
}
if (! empty($filters['status'])) {
$query->byStatus($filters['status']);
}
if (! empty($filters['production_line'])) {
$query->byLine($filters['production_line']);
}
if (! empty($filters['equipment_type'])) {
$query->byType($filters['equipment_type']);
}
$sortBy = $filters['sort_by'] ?? 'sort_order';
$sortDir = $filters['sort_direction'] ?? 'asc';
$query->orderBy($sortBy, $sortDir);
return $query->paginate($filters['per_page'] ?? 20);
}
public function show(int $id): Equipment
{
$equipment = Equipment::with(['manager', 'subManager', 'inspectionTemplates', 'repairs', 'processes', 'photos'])->find($id);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
return $equipment;
}
public function store(array $data): Equipment
{
return DB::transaction(function () use ($data) {
$data['tenant_id'] = $this->tenantId();
return Equipment::create($data);
});
}
public function update(int $id, array $data): Equipment
{
return DB::transaction(function () use ($id, $data) {
$equipment = Equipment::find($id);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
$equipment->update($data);
return $equipment->fresh();
});
}
public function destroy(int $id): bool
{
return DB::transaction(function () use ($id) {
$equipment = Equipment::find($id);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
return $equipment->delete();
});
}
public function restore(int $id): Equipment
{
return DB::transaction(function () use ($id) {
$equipment = Equipment::onlyTrashed()->find($id);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
$equipment->restore();
return $equipment->fresh();
});
}
public function toggleActive(int $id): Equipment
{
return DB::transaction(function () use ($id) {
$equipment = Equipment::find($id);
if (! $equipment) {
throw new NotFoundHttpException(__('error.equipment.not_found'));
}
$equipment->update(['is_active' => ! $equipment->is_active]);
return $equipment->fresh();
});
}
public function stats(): array
{
$total = Equipment::count();
$active = Equipment::where('status', 'active')->count();
$idle = Equipment::where('status', 'idle')->count();
$disposed = Equipment::where('status', 'disposed')->count();
return compact('total', 'active', 'idle', 'disposed');
}
public function options(): array
{
return [
'equipment_types' => Equipment::getEquipmentTypes(),
'production_lines' => Equipment::getProductionLines(),
'statuses' => Equipment::getStatuses(),
'equipment_list' => Equipment::active()
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'equipment_code', 'name', 'equipment_type', 'production_line']),
];
}
public function typeStats(): array
{
return Equipment::where('status', '!=', 'disposed')
->selectRaw('equipment_type, count(*) as count')
->groupBy('equipment_type')
->pluck('count', 'equipment_type')
->toArray();
}
}

View File

@@ -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(),

View File

@@ -0,0 +1,222 @@
<?php
namespace App\Services;
use App\Models\Barobill\HometaxInvoice;
use App\Models\Barobill\HometaxInvoiceJournal;
use Illuminate\Support\Facades\DB;
/**
* 홈택스 세금계산서 서비스 (React 연동용)
*
* MNG에서 동기화된 hometax_invoices 데이터를
* React 프론트엔드에서 조회/분개 등 처리
*/
class HometaxInvoiceService extends Service
{
/**
* 매출 세금계산서 목록
*/
public function sales(array $params): array
{
return $this->listByType('sales', $params);
}
/**
* 매입 세금계산서 목록
*/
public function purchases(array $params): array
{
return $this->listByType('purchase', $params);
}
/**
* 세금계산서 상세 조회
*/
public function show(int $id): ?HometaxInvoice
{
return HometaxInvoice::where('tenant_id', $this->tenantId())
->with('journals')
->find($id);
}
/**
* 분개 저장 (홈택스 자체 분개 테이블 사용)
*/
public function saveJournals(int $invoiceId, array $items): array
{
$tenantId = $this->tenantId();
$invoice = HometaxInvoice::where('tenant_id', $tenantId)->findOrFail($invoiceId);
return DB::transaction(function () use ($tenantId, $invoice, $items) {
// 기존 분개 삭제
HometaxInvoiceJournal::where('tenant_id', $tenantId)
->where('hometax_invoice_id', $invoice->id)
->delete();
$created = [];
foreach ($items as $index => $item) {
$created[] = HometaxInvoiceJournal::create([
'tenant_id' => $tenantId,
'hometax_invoice_id' => $invoice->id,
'nts_confirm_num' => $invoice->nts_confirm_num,
'dc_type' => $item['dc_type'],
'account_code' => $item['account_code'],
'account_name' => $item['account_name'] ?? null,
'debit_amount' => $item['debit_amount'] ?? 0,
'credit_amount' => $item['credit_amount'] ?? 0,
'description' => $item['description'] ?? null,
'sort_order' => $index + 1,
'invoice_type' => $invoice->invoice_type,
'write_date' => $invoice->write_date,
'supply_amount' => $invoice->supply_amount,
'tax_amount' => $invoice->tax_amount,
'total_amount' => $invoice->total_amount,
'trading_partner_name' => $invoice->invoice_type === 'sales'
? $invoice->invoicee_corp_name
: $invoice->invoicer_corp_name,
]);
}
return ['items' => $created, 'count' => count($created)];
});
}
/**
* 분개 조회
*/
public function getJournals(int $invoiceId): array
{
$tenantId = $this->tenantId();
$journals = HometaxInvoiceJournal::getByInvoiceId($tenantId, $invoiceId);
return ['items' => $journals];
}
/**
* 분개 삭제
*/
public function deleteJournals(int $invoiceId): array
{
$tenantId = $this->tenantId();
$deleted = HometaxInvoiceJournal::where('tenant_id', $tenantId)
->where('hometax_invoice_id', $invoiceId)
->delete();
return ['deleted_count' => $deleted];
}
/**
* 수동 세금계산서 등록
*/
public function storeManual(array $data): HometaxInvoice
{
$tenantId = $this->tenantId();
return HometaxInvoice::create(array_merge($data, [
'tenant_id' => $tenantId,
]));
}
/**
* 수동 세금계산서 수정
*/
public function updateManual(int $id, array $data): HometaxInvoice
{
$invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id);
$invoice->update($data);
return $invoice->fresh();
}
/**
* 수동 세금계산서 삭제 (soft delete)
*/
public function destroyManual(int $id): bool
{
$invoice = HometaxInvoice::where('tenant_id', $this->tenantId())->findOrFail($id);
return $invoice->delete();
}
/**
* 요약 통계
*/
public function summary(array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
$salesQuery = HometaxInvoice::where('tenant_id', $tenantId)
->sales()
->period($startDate, $endDate);
$purchaseQuery = HometaxInvoice::where('tenant_id', $tenantId)
->purchase()
->period($startDate, $endDate);
return [
'sales' => [
'count' => (clone $salesQuery)->count(),
'supply_amount' => (int) (clone $salesQuery)->sum('supply_amount'),
'tax_amount' => (int) (clone $salesQuery)->sum('tax_amount'),
'total_amount' => (int) (clone $salesQuery)->sum('total_amount'),
],
'purchase' => [
'count' => (clone $purchaseQuery)->count(),
'supply_amount' => (int) (clone $purchaseQuery)->sum('supply_amount'),
'tax_amount' => (int) (clone $purchaseQuery)->sum('tax_amount'),
'total_amount' => (int) (clone $purchaseQuery)->sum('total_amount'),
],
];
}
/**
* 타입별 목록 조회 (공통)
*/
private function listByType(string $invoiceType, array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'] ?? now()->startOfMonth()->format('Y-m-d');
$endDate = $params['end_date'] ?? now()->format('Y-m-d');
$search = $params['search'] ?? null;
$perPage = $params['per_page'] ?? 50;
$query = HometaxInvoice::where('tenant_id', $tenantId)
->where('invoice_type', $invoiceType)
->period($startDate, $endDate);
if ($search) {
$query->where(function ($q) use ($search, $invoiceType) {
if ($invoiceType === 'sales') {
$q->where('invoicee_corp_name', 'like', "%{$search}%")
->orWhere('invoicee_corp_num', 'like', "%{$search}%");
} else {
$q->where('invoicer_corp_name', 'like', "%{$search}%")
->orWhere('invoicer_corp_num', 'like', "%{$search}%");
}
$q->orWhere('nts_confirm_num', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%");
});
}
$query->orderByDesc('write_date')->orderByDesc('issue_date');
$invoices = $query->paginate($perPage);
// 분개 존재 여부 로드
$invoiceIds = $invoices->getCollection()->pluck('id')->toArray();
$journaledIds = HometaxInvoiceJournal::getJournaledInvoiceIds($tenantId, $invoiceIds);
$invoices->getCollection()->transform(function ($invoice) use ($journaledIds) {
$invoice->has_journal = in_array($invoice->id, $journaledIds);
return $invoice;
});
return [
'data' => $invoices,
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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',
])
@@ -83,13 +82,14 @@ public function show(int $id): array
}
/**
* 수주 트별 8종 서류 목록 (Document[])
* 수주 트별 8종 서류 목록 (Document[])
*/
public function routeDocuments(int $qualityDocumentOrderId): array
{
$docOrder = QualityDocumentOrder::with([
'order.workOrders.process',
'locations',
'locations.orderItem',
'locations.document',
'qualityDocument',
])->findOrFail($qualityDocumentOrderId);
@@ -119,17 +119,15 @@ public function routeDocuments(int $qualityDocumentOrderId): array
// 2. 수주서
$documents[] = $this->formatDocument('order', '수주서', collect([$order]));
// 3. 작업일지 (subType: process.process_name 기반)
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $workOrders);
// 3. 작업일지 & 4. 중간검사 성적서 (인식 가능한 공정만 — 공정별 1개씩)
$recognizedWorkOrders = $workOrders->filter(function ($wo) {
$subType = $this->mapProcessToSubType($wo->process?->process_name);
// 4. 중간검사 성적서 (PQC)
$pqcInspections = Inspection::where('inspection_type', 'PQC')
->whereIn('work_order_id', $workOrders->pluck('id'))
->where('status', 'completed')
->with('workOrder.process')
->get();
return $subType !== null;
})->groupBy('process_id')->map(fn ($group) => $group->first());
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $pqcInspections, 'workOrder');
$documents[] = $this->formatDocumentWithSubType('log', '작업일지', $recognizedWorkOrders);
$documents[] = $this->formatDocumentWithSubType('report', '중간검사 성적서', $recognizedWorkOrders);
// 5. 납품확인서
$shipments = $order->shipments()->get();
@@ -138,9 +136,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]));
@@ -157,7 +157,7 @@ public function documentDetail(string $type, int $id): array
'import' => $this->getInspectionDetail($id, 'IQC'),
'order' => $this->getOrderDetail($id),
'log' => $this->getWorkOrderLogDetail($id),
'report' => $this->getInspectionDetail($id, 'PQC'),
'report' => $this->getWorkOrderLogDetail($id),
'confirmation', 'shipping' => $this->getShipmentDetail($id),
'product' => $this->getLocationDetail($id),
'quality' => $this->getQualityDocDetail($id),
@@ -220,30 +220,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
@@ -252,6 +236,7 @@ private function transformRouteToFrontend(QualityDocumentOrder $docOrder, Qualit
'id' => (string) $docOrder->id,
'code' => $docOrder->order->order_no,
'date' => $docOrder->order->received_at?->toDateString(),
'client' => $docOrder->order->client_name ?? '',
'site' => $docOrder->order->site_name ?? '',
'location_count' => $docOrder->locations->count(),
'sub_items' => $docOrder->locations->values()->map(fn ($loc, $idx) => [
@@ -313,11 +298,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;
@@ -328,12 +314,18 @@ private function formatDocumentWithSubType(string $type, string $title, $collect
private function formatDocumentItem(string $type, $item): array
{
return match ($type) {
'import', 'report' => [
'import' => [
'id' => (string) $item->id,
'title' => $item->inspection_no ?? '',
'date' => $item->inspection_date?->toDateString() ?? $item->request_date?->toDateString() ?? '',
'code' => $item->inspection_no ?? '',
],
'report' => [
'id' => (string) $item->id,
'title' => $item->process?->process_name ?? '중간검사 성적서',
'date' => $item->created_at?->toDateString() ?? '',
'code' => $item->work_order_no ?? '',
],
'order' => [
'id' => (string) $item->id,
'title' => $item->order_no,
@@ -342,9 +334,9 @@ private function formatDocumentItem(string $type, $item): array
],
'log' => [
'id' => (string) $item->id,
'title' => $item->project_name ?? '작업일지',
'title' => $item->process?->process_name ?? '작업일지',
'date' => $item->created_at?->toDateString() ?? '',
'code' => $item->id,
'code' => $item->work_order_no ?? '',
],
'confirmation', 'shipping' => [
'id' => (string) $item->id,
@@ -354,9 +346,9 @@ 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' => '',
'code' => $item->document?->document_no ?? '',
],
'quality' => [
'id' => (string) $item->id,
@@ -479,9 +471,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 +493,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

View File

@@ -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, '변수계산', [

View File

@@ -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),
];

View File

@@ -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);

View File

@@ -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'])) {

View File

@@ -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();
}
}
}
/**
* 자재 투입 이력 조회
*/

View File

@@ -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
View File

@@ -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"
}

View File

@@ -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,
],
],
/*

View File

@@ -58,4 +58,17 @@
'exchange_secret' => env('INTERNAL_EXCHANGE_SECRET'),
],
/*
|--------------------------------------------------------------------------
| BaroBill (바로빌 전자세금계산서/회계 연동)
|--------------------------------------------------------------------------
| MNG와 동일한 설정 구조를 사용한다.
*/
'barobill' => [
'cert_key_test' => env('BAROBILL_CERT_KEY_TEST', ''),
'cert_key_prod' => env('BAROBILL_CERT_KEY_PROD', ''),
'corp_num' => env('BAROBILL_CORP_NUM', ''),
'test_mode' => env('BAROBILL_TEST_MODE', true),
],
];

View File

@@ -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');
});
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 바로빌 관련 테이블에 options JSON 컬럼 추가
*
* SAM options 컬럼 정책에 따라 모든 비즈니스 테이블에
* 확장 가능한 options JSON 컬럼을 추가한다.
*
* @see docs/standards/options-column-policy.md
*/
return new class extends Migration
{
/**
* options 컬럼을 추가할 테이블 목록
*/
private array $tables = [
'barobill_settings',
'barobill_configs',
'barobill_members',
'barobill_subscriptions',
'barobill_billing_records',
'barobill_monthly_summaries',
'barobill_pricing_policies',
'barobill_bank_transactions',
'barobill_bank_transaction_overrides',
'barobill_bank_transaction_splits',
'barobill_bank_sync_status',
'barobill_card_transactions',
'barobill_card_transaction_splits',
'barobill_card_transaction_amount_logs',
'barobill_card_transaction_hides',
];
public function up(): void
{
foreach ($this->tables as $table) {
if (Schema::hasTable($table) && ! Schema::hasColumn($table, 'options')) {
Schema::table($table, function (Blueprint $table) {
$table->json('options')->nullable()->after('id');
});
}
}
}
public function down(): void
{
foreach ($this->tables as $table) {
if (Schema::hasTable($table) && Schema::hasColumn($table, 'options')) {
Schema::table($table, function (Blueprint $table) {
$table->dropColumn('options');
});
}
}
}
};

View File

@@ -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,
]);
}
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('files', function (Blueprint $table) {
$table->string('mime_type', 150)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('files', function (Blueprint $table) {
$table->string('mime_type', 50)->change();
});
}
};

View File

@@ -0,0 +1,38 @@
<?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
{
if (Schema::hasTable('equipments') && ! Schema::hasColumn('equipments', 'options')) {
Schema::table('equipments', function (Blueprint $table) {
$table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON');
});
}
if (Schema::hasTable('equipment_repairs') && ! Schema::hasColumn('equipment_repairs', 'options')) {
Schema::table('equipment_repairs', function (Blueprint $table) {
$table->json('options')->nullable()->after('memo')->comment('확장 속성 JSON');
});
}
}
public function down(): void
{
if (Schema::hasTable('equipments') && Schema::hasColumn('equipments', 'options')) {
Schema::table('equipments', function (Blueprint $table) {
$table->dropColumn('options');
});
}
if (Schema::hasTable('equipment_repairs') && Schema::hasColumn('equipment_repairs', 'options')) {
Schema::table('equipment_repairs', function (Blueprint $table) {
$table->dropColumn('options');
});
}
}
};

View File

@@ -0,0 +1,38 @@
<?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::create('tenant_mail_configs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('provider', 20)->default('platform')->comment('발송 방식: platform, smtp, ses, mailgun');
$table->string('from_address', 255)->comment('발신 이메일');
$table->string('from_name', 255)->comment('발신자명');
$table->string('reply_to', 255)->nullable()->comment('회신 주소');
$table->boolean('is_verified')->default(false)->comment('도메인 검증 여부');
$table->unsignedInteger('daily_limit')->default(500)->comment('일일 발송 한도');
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->json('options')->nullable()->comment('SMTP 설정, 브랜딩, 연결 테스트 결과');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
$table->timestamps();
$table->softDeletes();
$table->unique('tenant_id', 'uq_tenant_mail_configs');
$table->index(['tenant_id', 'is_active']);
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('tenant_mail_configs');
}
};

View File

@@ -0,0 +1,34 @@
<?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::create('mail_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('mailable_type', 100)->comment('Mailable 클래스명');
$table->string('to_address', 255)->comment('수신자');
$table->string('from_address', 255)->comment('발신자');
$table->string('subject', 500)->comment('제목');
$table->string('status', 20)->default('queued')->comment('상태: queued, sent, failed, bounced');
$table->timestamp('sent_at')->nullable()->comment('발송 시각');
$table->json('options')->nullable()->comment('에러 메시지, 재시도 횟수, 관련 모델');
$table->timestamps();
$table->index(['tenant_id', 'status'], 'idx_mail_logs_tenant_status');
$table->index(['tenant_id', 'created_at'], 'idx_mail_logs_tenant_date');
$table->index(['tenant_id', 'mailable_type'], 'idx_mail_logs_mailable');
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::dropIfExists('mail_logs');
}
};

View File

@@ -0,0 +1,203 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* equipment 테이블이 기본 DB(sam)에 존재하는지 확인하고, 없으면 생성한다.
*
* 배경: 기존 마이그레이션이 codebridge DB에 테이블을 생성했으나,
* 운영서버(API+React)에서는 기본 DB(sam/sam_prod)에 테이블이 필요하다.
* 이미 테이블이 존재하는 환경에서는 모든 생성을 건너뛴다.
*/
return new class extends Migration
{
public function up(): void
{
// 1. equipments (메인 설비 마스터)
if (! Schema::hasTable('equipments')) {
Schema::create('equipments', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('equipment_code', 20)->comment('설비코드 (KD-M-001 형식)');
$table->string('name', 100)->comment('설비명');
$table->string('equipment_type', 50)->nullable()->comment('설비유형');
$table->string('specification', 255)->nullable()->comment('규격');
$table->string('manufacturer', 100)->nullable()->comment('제조사');
$table->string('model_name', 100)->nullable()->comment('모델명');
$table->string('serial_no', 100)->nullable()->comment('제조번호');
$table->string('location', 100)->nullable()->comment('위치');
$table->string('production_line', 50)->nullable()->comment('생산라인');
$table->date('purchase_date')->nullable()->comment('구입일');
$table->date('install_date')->nullable()->comment('설치일');
$table->decimal('purchase_price', 15, 2)->nullable()->comment('구입가격');
$table->integer('useful_life')->nullable()->comment('내용연수');
$table->string('status', 20)->default('active')->comment('상태: active/idle/disposed');
$table->date('disposed_date')->nullable()->comment('폐기일');
$table->foreignId('manager_id')->nullable()->comment('담당자 ID');
$table->foreignId('sub_manager_id')->nullable()->comment('부 담당자 ID');
$table->string('photo_path', 500)->nullable()->comment('설비사진 경로');
$table->text('memo')->nullable()->comment('비고');
$table->json('options')->nullable()->comment('확장 속성 JSON');
$table->tinyInteger('is_active')->default(1)->comment('사용여부');
$table->integer('sort_order')->default(0)->comment('정렬순서');
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
$table->foreignId('deleted_by')->nullable()->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'equipment_code'], 'uq_equipment_code');
$table->index(['tenant_id', 'status'], 'idx_equipment_status');
$table->index(['tenant_id', 'production_line'], 'idx_equipment_line');
$table->index(['tenant_id', 'equipment_type'], 'idx_equipment_type');
});
}
// 2. equipment_inspection_templates (점검항목 템플릿)
if (! Schema::hasTable('equipment_inspection_templates')) {
Schema::create('equipment_inspection_templates', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
$table->string('inspection_cycle', 20)->default('daily')
->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual');
$table->integer('item_no')->comment('항목번호');
$table->string('check_point', 50)->comment('점검개소');
$table->string('check_item', 100)->comment('점검항목');
$table->string('check_timing', 20)->nullable()->comment('시기: operating/stopped');
$table->string('check_frequency', 50)->nullable()->comment('주기');
$table->text('check_method')->nullable()->comment('점검방법 및 기준');
$table->integer('sort_order')->default(0)->comment('정렬순서');
$table->tinyInteger('is_active')->default(1)->comment('사용여부');
$table->timestamps();
$table->unique(['equipment_id', 'inspection_cycle', 'item_no'], 'uq_equipment_cycle_item_no');
$table->index('tenant_id', 'idx_insp_tmpl_tenant');
$table->index('inspection_cycle', 'idx_insp_tmpl_cycle');
$table->foreign('equipment_id')
->references('id')
->on('equipments')
->onDelete('cascade');
});
}
// 3. equipment_inspections (점검 헤더)
if (! Schema::hasTable('equipment_inspections')) {
Schema::create('equipment_inspections', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
$table->string('inspection_cycle', 20)->default('daily')
->comment('점검주기: daily/weekly/monthly/bimonthly/quarterly/semiannual');
$table->string('year_month', 7)->comment('점검년월 (2026-02)');
$table->string('overall_judgment', 10)->nullable()->comment('종합판정: OK/NG');
$table->foreignId('inspector_id')->nullable()->comment('점검자 ID');
$table->text('repair_note')->nullable()->comment('수리내역');
$table->text('issue_note')->nullable()->comment('이상내용');
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
$table->unique(['tenant_id', 'equipment_id', 'inspection_cycle', 'year_month'], 'uq_inspection_cycle_period');
$table->index(['tenant_id', 'inspection_cycle', 'year_month'], 'idx_inspection_cycle_period');
$table->foreign('equipment_id')
->references('id')
->on('equipments')
->onDelete('cascade');
});
}
// 4. equipment_inspection_details (점검 상세)
if (! Schema::hasTable('equipment_inspection_details')) {
Schema::create('equipment_inspection_details', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('inspection_id')->comment('점검 헤더 ID');
$table->unsignedBigInteger('template_item_id')->comment('점검항목 템플릿 ID');
$table->date('check_date')->comment('점검일');
$table->string('result', 10)->nullable()->comment('결과: good/bad/repaired');
$table->string('note', 500)->nullable()->comment('비고');
$table->timestamps();
$table->unique(['inspection_id', 'template_item_id', 'check_date'], 'uq_inspection_detail');
$table->foreign('inspection_id')
->references('id')
->on('equipment_inspections')
->onDelete('cascade');
$table->foreign('template_item_id')
->references('id')
->on('equipment_inspection_templates')
->onDelete('cascade');
});
}
// 5. equipment_repairs (수리이력)
if (! Schema::hasTable('equipment_repairs')) {
Schema::create('equipment_repairs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
$table->date('repair_date')->comment('수리일');
$table->string('repair_type', 20)->comment('보전구분: internal/external');
$table->decimal('repair_hours', 5, 1)->nullable()->comment('수리시간');
$table->text('description')->nullable()->comment('수리내용');
$table->decimal('cost', 15, 2)->nullable()->comment('수리비용');
$table->string('vendor', 100)->nullable()->comment('외주업체');
$table->foreignId('repaired_by')->nullable()->comment('수리자 ID');
$table->text('memo')->nullable()->comment('비고');
$table->json('options')->nullable()->comment('확장 속성 JSON');
$table->foreignId('created_by')->nullable()->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
$table->softDeletes();
$table->index(['tenant_id', 'repair_date'], 'idx_repair_date');
$table->index(['tenant_id', 'equipment_id'], 'idx_repair_equipment');
$table->foreign('equipment_id')
->references('id')
->on('equipments')
->onDelete('cascade');
});
}
// 6. equipment_process (설비-공정 매핑)
if (! Schema::hasTable('equipment_process')) {
Schema::create('equipment_process', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('equipment_id')->comment('설비 ID');
$table->unsignedBigInteger('process_id')->comment('공정 ID');
$table->tinyInteger('is_primary')->default(0)->comment('주 설비 여부');
$table->timestamps();
$table->unique(['equipment_id', 'process_id'], 'uq_equipment_process');
$table->foreign('equipment_id')
->references('id')
->on('equipments')
->onDelete('cascade');
$table->foreign('process_id')
->references('id')
->on('processes')
->onDelete('cascade');
});
}
}
public function down(): void
{
// 역순으로 삭제 (FK 의존성)
Schema::dropIfExists('equipment_process');
Schema::dropIfExists('equipment_inspection_details');
Schema::dropIfExists('equipment_repairs');
Schema::dropIfExists('equipment_inspections');
Schema::dropIfExists('equipment_inspection_templates');
Schema::dropIfExists('equipments');
}
};

View File

@@ -99,6 +99,9 @@
'cannot_delete' => '현재 상태에서는 삭제할 수 없습니다.',
'invalid_status' => '유효하지 않은 상태입니다.',
'cannot_ship' => '출하 가능 상태가 아닙니다.',
'order_not_produced' => '생산완료 상태의 수주만 출하를 생성할 수 있습니다.',
'no_work_orders' => '해당 수주에 유효한 작업지시가 없습니다.',
'already_exists' => '이미 해당 수주에 출하가 존재합니다.',
],
// 파일 관리 관련
@@ -282,10 +285,14 @@
'not_editable' => '작성중 상태의 급여만 수정할 수 있습니다.',
'not_deletable' => '작성중 상태의 급여만 삭제할 수 있습니다.',
'not_confirmable' => '작성중 상태의 급여만 확정할 수 있습니다.',
'not_unconfirmable' => '확정된 급여만 확정 취소할 수 있습니다.',
'not_payable' => '확정된 급여만 지급 처리할 수 있습니다.',
'not_unpayable' => '지급완료된 급여만 지급 취소할 수 있습니다.',
'no_previous_month' => '전월 급여 데이터가 없습니다.',
'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.',
'user_not_found' => '직원 정보를 찾을 수 없습니다.',
'no_base_salary' => '기본급이 설정되지 않았습니다.',
'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.',
],
// 세금계산서 관련
@@ -469,6 +476,17 @@
'invalid_status' => '유효하지 않은 입찰 상태입니다.',
],
// 설비 관리
'equipment' => [
'not_found' => '설비 정보를 찾을 수 없습니다.',
'template_not_found' => '점검항목을 찾을 수 없습니다.',
'inspection_not_found' => '점검 데이터를 찾을 수 없습니다.',
'repair_not_found' => '수리이력을 찾을 수 없습니다.',
'photo_not_found' => '사진을 찾을 수 없습니다.',
'invalid_cycle' => '유효하지 않은 점검주기입니다.',
'no_source_templates' => '복사할 점검항목이 없습니다.',
],
// 전자계약 (E-Sign)
'esign' => [
'invalid_token' => '유효하지 않은 서명 링크입니다.',

View File

@@ -320,10 +320,16 @@
'updated' => '급여가 수정되었습니다.',
'deleted' => '급여가 삭제되었습니다.',
'confirmed' => '급여가 확정되었습니다.',
'unconfirmed' => '급여 확정이 취소되었습니다.',
'paid' => '급여가 지급 처리되었습니다.',
'unpaid' => '급여 지급이 취소되었습니다.',
'bulk_confirmed' => '급여가 일괄 확정되었습니다.',
'bulk_generated' => '급여가 일괄 생성되었습니다.',
'copied' => '전월 급여가 복사되었습니다.',
'calculated' => '급여가 일괄 계산되었습니다.',
'payslip_fetched' => '급여명세서를 조회했습니다.',
'exported' => '급여 현황이 내보내기되었습니다.',
'journal_created' => '급여 전표가 생성되었습니다.',
],
// 급여 설정 관리
@@ -571,6 +577,20 @@
'downloaded' => '문서가 다운로드되었습니다.',
],
// 설비 관리
'equipment' => [
'created' => '설비가 등록되었습니다.',
'updated' => '설비 정보가 수정되었습니다.',
'deleted' => '설비가 삭제되었습니다.',
'restored' => '설비가 복원되었습니다.',
'inspection_saved' => '점검 정보가 저장되었습니다.',
'inspection_reset' => '점검 데이터가 초기화되었습니다.',
'template_created' => '점검항목이 추가되었습니다.',
'template_copied' => '점검항목이 복사되었습니다.',
'repair_created' => '수리이력이 등록되었습니다.',
'photo_uploaded' => '사진이 업로드되었습니다.',
],
// 일반전표입력
'journal_entry' => [
'fetched' => '전표 조회 성공',

View File

@@ -42,6 +42,7 @@
require __DIR__.'/api/v1/audit.php';
require __DIR__.'/api/v1/esign.php';
require __DIR__.'/api/v1/quality.php';
require __DIR__.'/api/v1/equipment.php';
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');

View File

@@ -0,0 +1,45 @@
<?php
use App\Http\Controllers\V1\Equipment\EquipmentController;
use App\Http\Controllers\V1\Equipment\EquipmentInspectionController;
use App\Http\Controllers\V1\Equipment\EquipmentPhotoController;
use App\Http\Controllers\V1\Equipment\EquipmentRepairController;
use Illuminate\Support\Facades\Route;
Route::prefix('equipment')->group(function () {
// 설비 CRUD
Route::get('', [EquipmentController::class, 'index'])->name('v1.equipment.index');
Route::get('/options', [EquipmentController::class, 'options'])->name('v1.equipment.options');
Route::get('/stats', [EquipmentController::class, 'stats'])->name('v1.equipment.stats');
Route::post('', [EquipmentController::class, 'store'])->name('v1.equipment.store');
Route::get('/{id}', [EquipmentController::class, 'show'])->whereNumber('id')->name('v1.equipment.show');
Route::put('/{id}', [EquipmentController::class, 'update'])->whereNumber('id')->name('v1.equipment.update');
Route::delete('/{id}', [EquipmentController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.destroy');
Route::post('/{id}/restore', [EquipmentController::class, 'restore'])->whereNumber('id')->name('v1.equipment.restore');
Route::patch('/{id}/toggle', [EquipmentController::class, 'toggleActive'])->whereNumber('id')->name('v1.equipment.toggle');
// 점검 템플릿
Route::get('/{id}/templates', [EquipmentInspectionController::class, 'templates'])->whereNumber('id')->name('v1.equipment.templates');
Route::post('/{id}/templates', [EquipmentInspectionController::class, 'storeTemplate'])->whereNumber('id')->name('v1.equipment.templates.store');
Route::put('/templates/{templateId}', [EquipmentInspectionController::class, 'updateTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.update');
Route::delete('/templates/{templateId}', [EquipmentInspectionController::class, 'deleteTemplate'])->whereNumber('templateId')->name('v1.equipment.templates.destroy');
Route::post('/{id}/templates/copy', [EquipmentInspectionController::class, 'copyTemplates'])->whereNumber('id')->name('v1.equipment.templates.copy');
// 점검
Route::get('/inspections', [EquipmentInspectionController::class, 'index'])->name('v1.equipment.inspections.index');
Route::patch('/inspections/toggle', [EquipmentInspectionController::class, 'toggleDetail'])->name('v1.equipment.inspections.toggle');
Route::patch('/inspections/set-result', [EquipmentInspectionController::class, 'setResult'])->name('v1.equipment.inspections.set-result');
Route::patch('/inspections/notes', [EquipmentInspectionController::class, 'updateNotes'])->name('v1.equipment.inspections.notes');
Route::delete('/inspections/reset', [EquipmentInspectionController::class, 'resetInspection'])->name('v1.equipment.inspections.reset');
// 수리이력
Route::get('/repairs', [EquipmentRepairController::class, 'index'])->name('v1.equipment.repairs.index');
Route::post('/repairs', [EquipmentRepairController::class, 'store'])->name('v1.equipment.repairs.store');
Route::put('/repairs/{id}', [EquipmentRepairController::class, 'update'])->whereNumber('id')->name('v1.equipment.repairs.update');
Route::delete('/repairs/{id}', [EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.repairs.destroy');
// 사진
Route::get('/{id}/photos', [EquipmentPhotoController::class, 'index'])->whereNumber('id')->name('v1.equipment.photos.index');
Route::post('/{id}/photos', [EquipmentPhotoController::class, 'store'])->whereNumber('id')->name('v1.equipment.photos.store');
Route::delete('/{id}/photos/{fileId}', [EquipmentPhotoController::class, 'destroy'])->whereNumber('id')->name('v1.equipment.photos.destroy');
});

View File

@@ -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'); // 파일 영구 삭제

View File

@@ -16,6 +16,8 @@
use App\Http\Controllers\Api\V1\BadDebtController;
use App\Http\Controllers\Api\V1\BankAccountController;
use App\Http\Controllers\Api\V1\BankTransactionController;
use App\Http\Controllers\Api\V1\BarobillBankTransactionController;
use App\Http\Controllers\Api\V1\BarobillCardTransactionController;
use App\Http\Controllers\Api\V1\BarobillController;
use App\Http\Controllers\Api\V1\BarobillSettingController;
use App\Http\Controllers\Api\V1\BillController;
@@ -28,6 +30,7 @@
use App\Http\Controllers\Api\V1\EntertainmentController;
use App\Http\Controllers\Api\V1\ExpectedExpenseController;
use App\Http\Controllers\Api\V1\GeneralJournalEntryController;
use App\Http\Controllers\Api\V1\HometaxInvoiceController;
use App\Http\Controllers\Api\V1\LoanController;
use App\Http\Controllers\Api\V1\PaymentController;
use App\Http\Controllers\Api\V1\PayrollController;
@@ -96,12 +99,19 @@
Route::post('', [PayrollController::class, 'store'])->name('v1.payrolls.store');
Route::get('/summary', [PayrollController::class, 'summary'])->name('v1.payrolls.summary');
Route::post('/calculate', [PayrollController::class, 'calculate'])->name('v1.payrolls.calculate');
Route::post('/calculate-preview', [PayrollController::class, 'calculatePreview'])->name('v1.payrolls.calculate-preview');
Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm');
Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate');
Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous');
Route::get('/export', [PayrollController::class, 'export'])->name('v1.payrolls.export');
Route::post('/journal-entries', [PayrollController::class, 'journalEntries'])->name('v1.payrolls.journal-entries');
Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show');
Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update');
Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy');
Route::post('/{id}/confirm', [PayrollController::class, 'confirm'])->whereNumber('id')->name('v1.payrolls.confirm');
Route::post('/{id}/unconfirm', [PayrollController::class, 'unconfirm'])->whereNumber('id')->name('v1.payrolls.unconfirm');
Route::post('/{id}/pay', [PayrollController::class, 'pay'])->whereNumber('id')->name('v1.payrolls.pay');
Route::post('/{id}/unpay', [PayrollController::class, 'unpay'])->whereNumber('id')->name('v1.payrolls.unpay');
Route::get('/{id}/payslip', [PayrollController::class, 'payslip'])->whereNumber('id')->name('v1.payrolls.payslip');
});
@@ -277,6 +287,60 @@
Route::get('/certificate-url', [BarobillController::class, 'certificateUrl'])->name('v1.barobill.certificate-url');
});
// Barobill Card Transaction API (바로빌 카드 거래 - React 연동)
Route::prefix('barobill-card-transactions')->group(function () {
Route::get('', [BarobillCardTransactionController::class, 'index'])->name('v1.barobill-card-transactions.index');
Route::get('/card-numbers', [BarobillCardTransactionController::class, 'cardNumbers'])->name('v1.barobill-card-transactions.card-numbers');
Route::get('/hidden', [BarobillCardTransactionController::class, 'hiddenList'])->name('v1.barobill-card-transactions.hidden');
Route::get('/splits', [BarobillCardTransactionController::class, 'getSplits'])->name('v1.barobill-card-transactions.splits.show');
Route::post('/splits', [BarobillCardTransactionController::class, 'saveSplits'])->name('v1.barobill-card-transactions.splits.store');
Route::delete('/splits', [BarobillCardTransactionController::class, 'deleteSplits'])->name('v1.barobill-card-transactions.splits.destroy');
Route::post('/manual', [BarobillCardTransactionController::class, 'storeManual'])->name('v1.barobill-card-transactions.manual.store');
Route::put('/manual/{id}', [BarobillCardTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.update');
Route::delete('/manual/{id}', [BarobillCardTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-card-transactions.manual.destroy');
Route::get('/{id}', [BarobillCardTransactionController::class, 'show'])->whereNumber('id')->name('v1.barobill-card-transactions.show');
Route::post('/{id}/hide', [BarobillCardTransactionController::class, 'hide'])->whereNumber('id')->name('v1.barobill-card-transactions.hide');
Route::post('/{id}/restore', [BarobillCardTransactionController::class, 'restore'])->whereNumber('id')->name('v1.barobill-card-transactions.restore');
Route::put('/{id}/amount', [BarobillCardTransactionController::class, 'updateAmount'])->whereNumber('id')->name('v1.barobill-card-transactions.update-amount');
Route::get('/{id}/journal-entries', [BarobillCardTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.show');
Route::post('/{id}/journal-entries', [BarobillCardTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.store');
Route::delete('/{id}/journal-entries', [BarobillCardTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-card-transactions.journal-entries.destroy');
});
// Barobill Bank Transaction API (바로빌 은행 거래 - React 연동)
Route::prefix('barobill-bank-transactions')->group(function () {
Route::get('', [BarobillBankTransactionController::class, 'index'])->name('v1.barobill-bank-transactions.index');
Route::get('/accounts', [BarobillBankTransactionController::class, 'accounts'])->name('v1.barobill-bank-transactions.accounts');
Route::get('/balance-summary', [BarobillBankTransactionController::class, 'balanceSummary'])->name('v1.barobill-bank-transactions.balance-summary');
Route::get('/splits', [BarobillBankTransactionController::class, 'getSplits'])->name('v1.barobill-bank-transactions.splits.show');
Route::post('/splits', [BarobillBankTransactionController::class, 'saveSplits'])->name('v1.barobill-bank-transactions.splits.store');
Route::delete('/splits', [BarobillBankTransactionController::class, 'deleteSplits'])->name('v1.barobill-bank-transactions.splits.destroy');
Route::post('/override', [BarobillBankTransactionController::class, 'saveOverride'])->name('v1.barobill-bank-transactions.override');
Route::post('/manual', [BarobillBankTransactionController::class, 'storeManual'])->name('v1.barobill-bank-transactions.manual.store');
Route::put('/manual/{id}', [BarobillBankTransactionController::class, 'updateManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.update');
Route::delete('/manual/{id}', [BarobillBankTransactionController::class, 'destroyManual'])->whereNumber('id')->name('v1.barobill-bank-transactions.manual.destroy');
Route::get('/{id}/journal-entries', [BarobillBankTransactionController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.show');
Route::post('/{id}/journal-entries', [BarobillBankTransactionController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.store');
Route::delete('/{id}/journal-entries', [BarobillBankTransactionController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.barobill-bank-transactions.journal-entries.destroy');
});
// Hometax Invoice API (홈택스 세금계산서 - React 연동)
Route::prefix('hometax-invoices')->group(function () {
Route::get('/sales', [HometaxInvoiceController::class, 'sales'])->name('v1.hometax-invoices.sales');
Route::get('/purchases', [HometaxInvoiceController::class, 'purchases'])->name('v1.hometax-invoices.purchases');
Route::get('/summary', [HometaxInvoiceController::class, 'summary'])->name('v1.hometax-invoices.summary');
Route::post('', [HometaxInvoiceController::class, 'store'])->name('v1.hometax-invoices.store');
Route::get('/{id}', [HometaxInvoiceController::class, 'show'])->whereNumber('id')->name('v1.hometax-invoices.show');
Route::put('/{id}', [HometaxInvoiceController::class, 'update'])->whereNumber('id')->name('v1.hometax-invoices.update');
Route::delete('/{id}', [HometaxInvoiceController::class, 'destroy'])->whereNumber('id')->name('v1.hometax-invoices.destroy');
Route::get('/{id}/journals', [HometaxInvoiceController::class, 'getJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.show');
Route::post('/{id}/journals', [HometaxInvoiceController::class, 'saveJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.store');
Route::delete('/{id}/journals', [HometaxInvoiceController::class, 'deleteJournals'])->whereNumber('id')->name('v1.hometax-invoices.journals.destroy');
Route::get('/{id}/journal-entries', [HometaxInvoiceController::class, 'getJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.show');
Route::post('/{id}/journal-entries', [HometaxInvoiceController::class, 'storeJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.store');
Route::delete('/{id}/journal-entries', [HometaxInvoiceController::class, 'deleteJournalEntries'])->whereNumber('id')->name('v1.hometax-invoices.journal-entries.destroy');
});
// Tax Invoice API (세금계산서)
Route::prefix('tax-invoices')->group(function () {
Route::get('', [TaxInvoiceController::class, 'index'])->name('v1.tax-invoices.index');

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