Compare commits

...

84 Commits

Author SHA1 Message Date
김보곤
926a7c7da6 refactor: [performance] N+1 쿼리 3건 배치 조회로 최적화
- WorkOrderService.getMaterials(): 기존 BOM 루프 내 find() x2 제거
  → 루프 전 bomItemsMap/bomChildItemsMap 일괄 사전 로드
- OrderService.createWorkOrderFromOrder(): 루프 내 DB 쿼리 x2 제거
  → item_code→id, process_items 사전 배치 조회
- OrderService.checkBendingStockForOrder(): 루프 내 StockService 호출 제거
  → Stock 배치 조회 후 맵 참조
2026-03-14 14:42:22 +09:00
김보곤
877d15420a test: [work-order] 작업지시 API 테스트 16개 추가
- CRUD 테스트 5개 (생성/상세/수정/삭제/404)
- 상태전이 테스트 4개 (미배정→대기→준비→진행→완료)
- 담당자 배정 테스트 1개
- 공정단계/자재 조회 테스트 3개
- 목록/통계/인증 테스트 3개
2026-03-14 14:42:22 +09:00
김보곤
63b174811c test: [approval] 결재 API 워크플로우 테스트 15개 추가
- CRUD 테스트 5개 (생성/목록/상세/수정/삭제)
- 워크플로우 테스트 4개 (상신/승인/반려/회수)
- 결재함/참조함/완료함 목록 조회 4개
- 뱃지 건수, 인증 테스트 2개
- 결재자 별도 사용자 로그인 검증 (loginAs 헬퍼)
2026-03-14 14:42:22 +09:00
김보곤
95bae11042 test: [stock] 재고 API 및 FIFO 로직 테스트 13개 추가
- Stock API 엔드포인트 테스트 5개 (목록/통계/유형별통계/상세/인증)
- FIFO 핵심 로직 테스트 4개 (입고/차감/LOT걸침/재고부족)
- 예약/해제 테스트 2개 (수주확정→예약, 수주취소→해제)
- 거래이력 기록 테스트 1개
- 재고 상태 자동 계산 테스트 1개 (normal/low/out)
- Factory 2개 추가: StockFactory, StockLotFactory
2026-03-14 14:42:22 +09:00
김보곤
adc07b7343 feat: [sales] sales_product_categories에 최저 개발비/구독료 컬럼 추가
- min_development_fee: 최저 개발비 (이 금액 이하 설정 불가)
- min_subscription_fee: 최저 구독료 (이 금액 이하 설정 불가)
2026-03-14 14:42:22 +09:00
김보곤
c942788119 test: [orders] 테스트 인프라 정비 및 수주 API 테스트 추가
- TestCase 공통화: setUpAuthenticatedUser(), api(), assertApiSuccess(), assertApiPaginated()
- 기존 10개 테스트 파일의 중복 setUp 코드 → TestCase 상속으로 전환
- Factory 3개 추가: TenantFactory, ClientFactory, OrderFactory
- OrderApiTest 12개 테스트 신규 작성 (목록/생성/조회/수정/삭제/상태변경/인증)
- 발견: 빈 데이터로 수주 생성 가능 (FormRequest 검증 강화 필요)
2026-03-14 14:42:22 +09:00
김보곤
2e284f6393 feat: [demo] Phase 4 고도화 — 분석 API, 비활성 알림, 통계 시딩
- DemoAnalyticsService: 전환율 퍼널, 파트너 성과, 활동 현황, 대시보드 요약
- DemoAnalyticsController: 분석 API 4개 엔드포인트
- CheckDemoInactiveCommand: 7일 비활성 데모 테넌트 탐지 및 로그 알림
- ManufacturingPresetSeeder: sam_stat DB에 90일 매출/생산 통계 시딩
- 라우트: demo-analytics prefix 4개 GET 엔드포인트 등록
- 스케줄러: demo:check-inactive 매일 09:30 실행
2026-03-14 14:42:21 +09:00
김보곤
e12fc461a7 feat: [demo] 데모 테넌트 관리 API 및 만료 알림 (Phase 3)
- DemoTenantController: 목록/상세/생성/리셋/연장/전환/통계 API
- DemoTenantStoreRequest: 고객 체험 테넌트 생성 검증
- DemoTenantService: API용 메서드 추가 (index/show/reset/extend/convert/stats)
- CheckDemoExpiredCommand: 만료 임박(7일) 알림 + 만료 테넌트 비활성 처리
- 라우트 등록 (api/v1/demo-tenants, 7개 엔드포인트)
- 스케줄러 등록 (04:20 demo:check-expired)
- i18n 메시지 추가 (message.demo_tenant.*, error.demo_tenant.*)
2026-03-14 14:42:21 +09:00
김보곤
1eb8d2cb01 feat: [demo] 데모 테넌트 운영 자동화 (Phase 2)
- DemoLimitMiddleware: 쇼케이스 읽기전용, 만료 체크, 외부연동 차단
- DemoTenantService: 파트너/체험 테넌트 생성, 기간 연장, 정식 전환
- ResetDemoShowcaseCommand: 매일 자정 데이터 리셋 + 샘플 재시드
- ManufacturingPresetSeeder: 부서/거래처/품목/견적/수주 샘플 데이터
- 스케줄러 등록 (00:00 demo:reset-showcase --seed)
- 미들웨어 별칭 등록 (demo.limit)
2026-03-14 14:41:55 +09:00
김보곤
39844a3ba0 fix: [tenant] 데모 관련 필드를 fillable에서 제거
- tenant_type, demo_expires_at, demo_source_partner_id를 fillable에서 제외
- 기존 mass assignment 동작에 영향 없도록 보호
- convertToProduction()에서 forceFill() 사용으로 변경
2026-03-14 14:41:55 +09:00
김보곤
45c30aa2aa feat: [tenant] 데모 테넌트 지원 추가 (Phase 1)
- tenants.tenant_type ENUM 확장: DEMO_SHOWCASE, DEMO_PARTNER, DEMO_TRIAL
- demo_expires_at, demo_source_partner_id 컬럼 추가
- Tenant 모델에 데모 관련 메서드 추가 (isDemoTenant, isDemoShowcase 등)
- getOption/setOption 헬퍼 메서드 추가
- 데모→정식 전환 convertToProduction() 메서드
2026-03-14 14:41:55 +09:00
김보곤
85d5b98966 feat: [vehicle] 법인차량 사진 API 추가
- CorporateVehicle 모델 (photos 관계 포함)
- VehiclePhotoService (R2 저장, 최대 10장 제한)
- VehiclePhotoController (index/store/destroy)
- StoreVehiclePhotoRequest (동적 max 검증)
- finance.php 라우트 등록
2026-03-14 14:41:55 +09:00
aeffd5be61 feat: [work-order] 작업 완료 API 응답에 실제 LOT 번호 포함
- saveItemResults() 반환 타입 void → string (생성된 lot_no 반환)
- updateStatus() 완료 시 lot_no를 setAttribute로 응답에 포함
2026-03-13 23:43:02 +09:00
d7c096b615 feat: [shipment] MES 데이터 정합성 개선 — can_ship 검증, ShipmentItem FK, 재고차감 비활성화
- ShipmentService::updateStatus()에 can_ship 검증 추가 (ready/shipping/completed 전환 시)
- shipment_items에 order_item_id, work_order_item_id 컬럼+인덱스 추가 (마이그레이션)
- ShipmentItem 모델에 orderItem(), workOrderItem() 관계 추가
- createShipmentFromOrder()에서 order_item_id, work_order_item_id 자동 매핑
- decreaseStockForShipment() 호출 비활성화 (수주생산=재고 미경유, 선생산=자재 투입 시 차감)
2026-03-13 22:45:43 +09:00
54686cfc8a fix: [work-order] 생산지시 생성 시 부서/우선순위 자동 매핑
- team_id 미지정 시 공정 담당부서에서 자동 매핑
- priority 문자열→숫자 변환 (urgent=1, high=4, normal=7)
- 부서/담당자 배정 시 작업대기(waiting) 상태로 설정
2026-03-13 18:55:11 +09:00
유병철
a36b7a2514 feat: [vehicle] 법인차량 관리 API 추가
- 법인차량 CRUD (CorporateVehicle)
- 차량 운행일지 CRUD (VehicleLog)
- 차량 정비이력 CRUD (VehicleMaintenance)
- 모델, 서비스, 컨트롤러, 라우트 구성

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:38:18 +09:00
e241c6a681 fix: [work-order] 보조공정/미배정 작업지시 목록 제외
- process_id NOT NULL 기본 필터 추가
- is_auxiliary 보조공정 제외 조건 추가
- stats()에도 동일 필터 적용
- 기타(none) 탭 관련 로직 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:14:15 +09:00
73c8f78788 feat: [qms] 수주로트 감사 상세 정보 확장
- 수주 상세: 개소별 제품, 모터, 절곡물, 부자재 정보 추가
- 출하 상세: 배차정보, 제품 그룹별 품목 분류 추가
- 확인 로직: documentOrders 기준 수주로트 카운트로 변경
- locations relation 경로 수정 (documentOrders.locations)
- 품질관리서 파일 정보 routeDocuments에 포함
- Shipment Client 모델 네임스페이스 수정
- DocumentService data relation null 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:14:09 +09:00
597aecb5e8 feat: [quality] 품질관리서 파일 업로드/삭제 API
- POST /{id}/upload-file: 1건당 1파일, 기존 파일 교체
- DELETE /{id}/file: 파일 soft delete
- QualityDocument.file() relation 추가
- R2 저장 경로: {tenant_id}/quality-documents/{year}/{month}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:14:00 +09:00
ef591074c7 feat: [storage] R2 테넌트 파일 프록시 라우트 추가
- /storage/tenants/{path} 라우트 추가
- R2에서 스트리밍 방식으로 파일 제공
- 인증 불필요, Cache-Control 1일

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:13:50 +09:00
8b7d932f00 feat: [storage] R2 정적 이미지 프록시 라우트 추가
- /images/{path} 라우트로 R2에서 이미지 스트리밍
- bending 도면 이미지 등 정적 파일을 R2에서 서빙
- 캐시 1일 적용 (Cache-Control: max-age=86400)
2026-03-13 02:06:44 +09:00
42d818596d fix: [employee] 사용자-사원 삭제 동기화 수정
- destroy/bulkDelete 퇴직처리 시 user_tenants.is_active = false 추가
- update에서 employee_status 변경 시 is_active 자동 동기화 (퇴직/복직)
- SwitchTenantRequest에 user_tenants.is_active 검증 추가 (비활성 테넌트 전환 차단)
- tenant_access_denied i18n 메시지 추가 (ko/en)
2026-03-13 00:30:33 +09:00
유병철
0d9a840358 feat: [equipment-inspection] 설비별 점검 템플릿 조회 API 개선
- templates 엔드포인트에 cycle 필터 파라미터 추가
- getTemplatesByEquipment 서비스 메서드 신규 추가
- Controller에서 Request 주입하여 cycle 쿼리 파라미터 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:21:38 +09:00
유병철
2c4f5ee91d fix: [equipment] User 모델 네임스페이스 경로 수정
- App\Models\User → App\Models\Members\User 경로 일괄 수정
- Equipment(manager, subManager), EquipmentInspection(inspector), EquipmentRepair(repairer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:03:31 +09:00
유병철
08582261db feat: [equipment] 설비 통계 API에 유형별 분포 및 점검 현황 추가
- 설비 유형별(equipment_type) 현황 집계 추가
- 이번달 점검 대상/완료/이슈 건수 통계 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:47:39 +09:00
a50d69b243 sync: main 배포 동기화 2026-03-12 2026-03-12 15:22:18 +09:00
37bb691838 sync: main 배포 동기화 2026-03-12 2026-03-12 15:22:15 +09:00
김보곤
88d9192618 refactor: [pmis] 마이그레이션을 MNG 프로젝트로 이관
- PMIS 테이블은 MNG 전용이므로 API에서 제거
- pmis_workers, pmis_job_types, pmis_construction_workers, pmis_equipments, pmis_materials
2026-03-12 14:43:51 +09:00
김보곤
cefad468b9 feat: [pmis] 자재관리 테이블 마이그레이션 추가 2026-03-12 14:37:41 +09:00
김보곤
9ad76ceb82 feat: [pmis] 장비관리 테이블 생성 (pmis_equipments) 2026-03-12 14:13:34 +09:00
김보곤
76e098337f feat: [pmis] 시공관리 인원관리 테이블 생성 (job_types, construction_workers) 2026-03-12 14:03:35 +09:00
57d8b97dde chore: [API] 문서/설정 업데이트
- LOGICAL_RELATIONSHIPS.md 관계 정보 추가
- Swagger 서버 설명 변경
- files 테이블 mime_type 컬럼 확장 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:00:59 +09:00
f5b60aab38 fix: [QMS] 로트심사 서류 목록 개선
- 작업일지/중간검사: 인식 가능한 공정만 표시, 공정별 그룹핑
- 중간검사 detail: PQC Inspection 대신 WorkOrder 기반으로 변경
- 문서 아이템 표시 개선 (공정명, 작업지시번호, 문서번호 추가)
- 루트 정보에 거래처(client) 필드 추가
- location에 document 관계 eager loading 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:00:49 +09:00
f3849808d5 feat: [QMS] 점검표 토글 API 추가 + 레거시 AuditChecklist 라우트 제거
- ChecklistTemplateController.toggleItem() 추가 (PATCH /{id}/items/{subItemId}/toggle)
- ChecklistTemplate 모델 User 클래스 경로 수정 (Members\User)
- AuditChecklistController 라우트 제거 (checklist_templates로 통합)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:00:43 +09:00
김보곤
2d32faa9b5 refactor: [equipment] 사진 업로드를 R2(FileStorageSystem) 기반으로 전환
- GCS 스텁 코드를 Cloudflare R2 기반 실제 파일 업로드로 교체
- File 모델 import를 Boards\File에서 Commons\File로 수정
- StoreEquipmentPhotoRequest FormRequest 추가 (파일 검증)
- 다중 파일 업로드 지원 (최대 10장 제한)
- softDeleteFile 패턴 적용 (삭제 시 soft delete)
- ItemsFileController 패턴 준용 (R2 저장, 랜덤 파일명)
2026-03-12 13:45:15 +09:00
김보곤
723b5a8e1a feat: [pmis] pmis_workers 테이블 마이그레이션 추가
- 건설PMIS 현장 작업자 전용 프로필 테이블
- tenant_id + user_id 유니크 제약 포함
2026-03-12 12:22:58 +09:00
김보곤
8c301b54e3 fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기
- bulkGenerate에서 users 테이블에 존재하지 않는 user_id로 인한 FK 위반 해결
- whereHas('user')로 유효한 사용자만 조회
2026-03-12 11:30:50 +09:00
김보곤
19c524d692 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-03-12 11:06:53 +09:00
김보곤
964ee40e8d fix: [equipment] 기본 DB에 equipment 테이블 생성 마이그레이션 추가
- 기존 마이그레이션이 codebridge DB에만 테이블을 생성하는 문제 수정
- 운영서버(API+React)에서는 기본 DB(sam/sam_prod)에 테이블 필요
- hasTable() 체크로 이미 존재하는 환경에서는 건너뜀
- 모든 컬럼 최신 스키마 반영 (inspection_cycle, sub_manager_id, options)
- options 마이그레이션도 hasTable/hasColumn 안전 체크 추가
2026-03-12 11:00:15 +09:00
김보곤
f401e17447 feat: [payroll] 엑셀 내보내기 및 전표 생성 API 추가
- GET /payrolls/export: 급여 현황 엑셀 다운로드 (필터 지원)
- POST /payrolls/journal-entries: 연월 기준 급여 전표 일괄 생성
- JournalEntry SOURCE_PAYROLL 상수 추가
- StorePayrollJournalRequest 유효성 검증 추가
2026-03-12 10:55:17 +09:00
김보곤
069d0206a0 feat: [equipment] 설비관리 API 백엔드 구현 (Phase 1)
- 모델 6개: Equipment, EquipmentInspection, EquipmentInspectionDetail, EquipmentInspectionTemplate, EquipmentRepair, EquipmentProcess
- InspectionCycle Enum: 6주기(일/주/월/격월/분기/반기) 날짜 해석
- 서비스 4개: EquipmentService, EquipmentInspectionService, EquipmentRepairService, EquipmentPhotoService
- 컨트롤러 4개: CRUD + 점검 토글/결과 설정/메모/초기화 + 템플릿 관리 + 수리이력 + 사진
- FormRequest 6개: 설비등록/수정, 수리이력, 점검템플릿, 토글, 메모
- 라우트 26개: equipment prefix 하위 RESTful 엔드포인트
- i18n 메시지: message.equipment.*, error.equipment.*
- 마이그레이션: equipments/equipment_repairs options JSON 컬럼 추가
2026-03-12 10:52:30 +09:00
8c16993746 fix: token-login API KEY 미들웨어 화이트리스트 추가 2026-03-12 10:19:10 +09:00
3a889b33ef fix: [QMS] 인정품목 표시 수정 + 제품검사 성적서 필터 개선
- getFgProductName(): BOM 순회 대신 Order.item_id 직접 참조로 변경
- 제품검사 성적서 필터: document_id만 → document_id || inspection_status=completed
- getLocationDetail(): FQC 문서 데이터 포함 (template + data)
- formatFqcTemplate(): DB item → item_name 매핑 추가
- formatDocumentItem('product'): 개소별 층/기호 코드 표시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
073ad11ecd fix: [QMS] 점검표 토글 해제 안되는 버그 수정
- PHP foreach 참조(&)와 ?? 연산자 조합 시 임시 복사본이 생성되어 원본 배열 수정 불가
- `$category['subItems'] ?? []` → `empty() + continue` + `$category['subItems']` 로 변경
- 토글 API가 항상 is_completed: true 반환하던 문제 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
479059747b feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선
- 출하를 작업지시(WO) 단위 → 수주(Order) 단위로 변경
  - createShipmentFromOrder: 모든 메인 WO 품목을 통합하여 출하 1건 생성
  - 출하에 수주 정보 복사 안함 (order_info accessor로 조인 참조)
- syncOrderStatus에서 PRODUCED 전환 시 자동 출하 생성
  - ensureShipmentExists: 이미 PRODUCED인데 출하 없으면 재생성
- POST /shipments/from-order/{orderId} 수동 출하 생성 API 추가
  - createShipmentForOrder: 상태 검증 + 작업지시 조회 + 출하 생성
- Shipment order_info accessor 확장 (receiver, delivery_address_detail, delivery_method)
- ShipmentService index에 creator 관계 추가 (목록 작성자 표시)
- autoCompleteWorkOrderIfAllStepsDone: 전체 step 완료 시 WO 자동완료
- autoCompleteOrphanedSteps: 고아 step 자동보정
- syncOrderStatus: 공정 미지정 WO 바이패스
- ApiResponse::success 201 인자 오류 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
12373edf8c feat: [QMS] 점검표 템플릿 관리 백엔드 구현
- checklist_templates 테이블 마이그레이션 + 기본 시딩
- ChecklistTemplate 모델 (BelongsToTenant, Auditable, SoftDeletes)
- ChecklistTemplateService: 조회/저장/파일 업로드/삭제
- SaveChecklistTemplateRequest: 중첩 JSON 검증
- ChecklistTemplateController: 5개 엔드포인트
- 라우트 등록 (quality/checklist-templates, quality/qms-documents)
2026-03-12 10:19:10 +09:00
3bae303447 fix: [작업지시] syncOrderStatus 집계 방식으로 변경
- 기존: 단일 작업지시 상태만 보고 수주 상태 매핑 (첫 WO 완료 시 즉시 PRODUCED)
- 변경: 수주의 모든 비보조 작업지시 상태를 집계하여 결정
  - 전부 shipped → SHIPPED
  - 전부 completed/shipped → PRODUCED
  - 하나라도 진행중/완료/출하 → IN_PRODUCTION
- 감사 로그에 집계 내역(work_order_counts) 포함
2026-03-12 10:19:10 +09:00
b55cbc2ec4 feat: [견적] 제어기 타입 체계 변경 (basic/smart/premium → exposed/embedded/embedded_no_box)
- QuoteBomBulkCalculateRequest: controller validation 값 변경, 기본값 exposed
- QuoteBomCalculateRequest: 동일 변경
- FormulaEvaluatorService: CT → controller_type 매핑 추가 (exposed→노출형, embedded→매립형)
- FormulaEvaluatorService: CT 값에 따라 backbox_qty 자동 설정 (embedded만 뒷박스 포함)
- QuoteService: CT 기본값 exposed로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
f0b1b5e33a feat: [배포] Jenkinsfile 롤백 기능 추가
- parameters 블록 추가 (ACTION, ROLLBACK_TARGET, ROLLBACK_RELEASE)
- Jenkins 웹에서 Build with Parameters로 롤백 실행 가능
- 릴리스 목록 조회 + symlink 전환 + 캐시 재생성
- production/stage 환경 선택 가능
- 서버 IP를 PROD_SERVER 환경변수로 추출
- 롤백 시 Slack 알림 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:19:10 +09:00
김보곤
918ae0ebc1 feat: [email] 테넌트 메일 설정 마이그레이션 및 모델 추가
- tenant_mail_configs 테이블 생성 (SMTP 설정, 브랜딩, 연결 테스트 결과)
- mail_logs 테이블 생성 (발송 이력 추적)
- TenantMailConfig, MailLog 모델 추가 (options JSON 정책 준수)
2026-03-12 07:42:06 +09:00
유병철
2c9e5ae2da feat: [receiving] 입고 성적서 파일 연결 기능 추가
- receivings 테이블에 certificate_file_id 컬럼 추가 (마이그레이션)
- Receiving 모델에 certificateFile 관계 및 fillable/casts 추가
- Store/Update Request에 certificate_file_id 검증 규칙 추가
- ReceivingService index/show에 certificateFile eager loading 추가
- store/update 시 certificate_file_id 저장 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:32:45 +09:00
김보곤
eeda6d980e feat: [barobill] React 연동용 바로빌 카드/은행/홈택스 REST API 구현
- 바로빌 카드 거래 API (16 엔드포인트): 조회, 분할, 수동입력, 숨김/복원, 금액수정, 분개
- 바로빌 은행 거래 API (13 엔드포인트): 조회, 분할, 오버라이드, 수동입력, 잔액요약, 분개
- 홈택스 세금계산서 API (13 엔드포인트): 매출/매입 조회, 수동입력, 자체분개, 통합분개
- JournalEntry 소스 타입 상수 추가 (barobill_card, barobill_bank, hometax_invoice)
2026-03-11 19:41:07 +09:00
김보곤
82621a6045 feat: [payroll] MNG 급여관리 계산 엔진 및 일괄 처리 API 구현
- IncomeTaxBracket 모델 추가 (2024 간이세액표 DB 조회)
- PayrollService 전면 개편: 4대보험 + 소득세 자동 계산 엔진
- 10,000천원 초과 고소득 구간 공식 계산 지원
- 과세표준 = 총지급액 - 식대(비과세), 10원 단위 절삭
- 일괄 생성(bulkGenerate), 전월 복사(copyFromPrevious) 기능
- 확정취소(unconfirm), 지급취소(unpay) 상태 관리
- 계산 미리보기(calculatePreview) 엔드포인트 추가
- 공제항목 수동 오버라이드(deduction_overrides) 지원
- Payroll 모델에 long_term_care, options 필드 추가
2026-03-11 19:18:27 +09:00
김보곤
18a6f3e7aa refactor: [barobill] 바로빌 연동 코드 전면 개선
- config/services.php에 barobill 설정 등록 (운영/테스트 모드 분기 정상화)
- BarobillSetting 모델에 BelongsToTenant 적용 및 use_* 필드 casts 추가
- BarobillService API URL을 baroservice.com(SOAP)으로 수정
- BarobillService callApi 메서드 경로 하드코딩 제거 (서비스별 분기)
- BarobillService 예외 이중 래핑 문제 수정
- BarobillController URL 메서드 중복 코드 제거
- 누락 모델 16개 생성 (MNG 패턴 준수, BelongsToTenant 적용)
- 바로빌 전 테이블 options JSON 컬럼 추가 마이그레이션
2026-03-11 18:16:59 +09:00
김보곤
5828261dce Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-03-11 17:49:20 +09:00
김보곤
0ab3d5ab88 R2 파일 업로드 2026-03-11 17:49:16 +09:00
김보곤
0be88f95ca refactor: [approval] SAM API 규칙 준수 코드 리뷰 반영
- ApprovalStep에 BelongsToTenant, SoftDeletes 추가 (마이그레이션 포함)
- ApprovalForm, ApprovalDelegation에 ModelTrait 추가 (중복 scopeActive 제거)
- ApprovalDelegation에 Auditable 추가
- 모든 결재 액션에 FormRequest 적용 (approve, cancel, hold, preDecide)
- 위임 CRUD에 DelegationStoreRequest, DelegationUpdateRequest 적용
- ApprovalStep 생성 시 tenant_id 포함
2026-03-11 17:13:08 +09:00
김보곤
3fd412f89d feat: [approval] 결재관리 시스템 MNG 스타일로 전면 개선
- 보류/보류해제 기능 추가 (hold, releaseHold)
- 전결 기능 추가 (preDecide - 이후 결재 건너뛰고 최종 승인)
- 복사 재기안 기능 추가 (copyForRedraft)
- 반려 후 재상신 로직 (rejection_history 저장, resubmit_count 증가)
- 결재자 스냅샷 저장 (approver_name, department, position)
- 완료함 목록/현황 API 추가 (completed, completedSummary)
- 뱃지 카운트 API 추가 (badgeCounts)
- 완료함 일괄 읽음 처리 (markCompletedAsRead)
- 위임 관리 CRUD API 추가 (delegations)
- Leave 연동 (승인/반려/회수/삭제 시 휴가 상태 동기화)
- ApprovalDelegation 모델 신규 생성
- STATUS_ON_HOLD 상수 추가 (Approval, ApprovalStep)
- isEditable/isSubmittable 반려 상태 허용으로 확장
- isCancellable 보류 상태 포함
- 회수 시 첫 번째 결재자 처리 여부 검증 추가
- i18n 에러/메시지 키 추가
2026-03-11 16:57:54 +09:00
김보곤
6f48b86206 fix: [account-codes] 계정과목 중복 데이터 정리 마이그레이션
- 비표준 코드(5자리 KIS 중복, 1-2자리 카테고리 헤더) 비활성화
- 홈택스 분개 코드 수정: 135→117, 251→201, 255→208
2026-03-11 10:15:59 +09:00
김보곤
f0464d4f8c fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 52개 제거
- sam → codebridge 테이블 이동 후 users, tenants 등 참조하는 FK 잔존
- esign_field_templates INSERT 시 FK violation 발생 수정
2026-03-11 10:06:36 +09:00
bbaeefb6b5 sync: main 배포 동기화 2026-03-11 2026-03-11 02:04:47 +09:00
079f4b0ffb feat: [문서] document_data, document_approvals, document_attachments에 tenant_id 추가
- 3개 테이블에 tenant_id 컬럼 + 인덱스 추가
- 기존 데이터는 부모 테이블(documents)에서 tenant_id 자동 채움
- 멀티테넌시 일관성 확보 및 데이터 동기화 지원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:01:12 +09:00
e061faadc2 feat: [QMS] Auditable trait 경로 수정 + 품질 더미 데이터 Seeder
- AuditChecklist.php: App\Models\Traits → App\Traits 경로 수정
- QualityDummyDataSeeder: 3개 품질 페이지용 더미 데이터 생성
  - 품질관리서 10건, 실적신고 6건, 점검표 2건(Q1/Q2), 항목 54건, 기준문서 114건

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:55:00 +09:00
30c2484440 feat: [QMS] 1일차 기준/매뉴얼 심사 백엔드 구현 (Phase 2)
- 마이그레이션: audit_checklists, audit_checklist_categories, audit_checklist_items, audit_standard_documents (4테이블)
- 모델 4개: AuditChecklist, AuditChecklistCategory, AuditChecklistItem, AuditStandardDocument
- AuditChecklistService: CRUD, 완료처리, 항목 토글(lockForUpdate), 기준 문서 연결/해제, 카테고리+항목 일괄 동기화
- AuditChecklistController: 9개 엔드포인트
- FormRequest 2개: Store(카테고리+항목 중첩 검증), Update
- 라우트 9개 등록 (/api/v1/qms/checklists, checklist-items)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:11:56 +09:00
334e39d2de feat: [QMS] 2일차 로트 추적 심사 API 구현 (Phase 1)
- QmsLotAuditService: 품질관리서 목록/상세, 8종 서류 조합, 서류 상세(2단계 로딩), 확인 토글
- QmsLotAuditController: 5개 엔드포인트 (index, show, routeDocuments, documentDetail, confirm)
- FormRequest 3개: Index, Confirm, DocumentDetail 파라미터 검증
- QualityDocumentLocation: options JSON 컬럼 추가 (마이그레이션 + 모델 casts)
- IQC 추적: WorkOrderMaterialInput → StockLot → lot_no → Inspection(IQC) 경로
- 비관적 업데이트: DB::transaction + lockForUpdate() 원자성 보장

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:11:56 +09:00
e372b9543b fix: [stats] QuoteStatService에서 codebridge 테이블 조회 제거
- sales_prospect_consultations, sales_prospects 쿼리 제거
- codebridge DB에 이관된 테이블이며 tenant_id 없어 테넌트별 집계 불가
- prospect_*, consultation_count 필드는 DB default(0) 처리

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:11:56 +09:00
유병철
4e192d1c00 feat: [로그인] 사용자 정보에 department_id 추가 반환
- getUserInfoForLogin에서 department_id도 함께 반환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:07:16 +09:00
유병철
6d1925fcd1 feat: [캘린더] 매입결제·수주납기·출고 예정일 일정 연동 추가
- expected_expense: 매입 결제 예정일 (미결제 건만)
- delivery: 수주 납기일 (활성 상태 수주만)
- shipment: 출고 예정일 (scheduled/ready 상태만)
- type 필터에 3개 타입 추가, null(전체)일 때 모두 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:44:43 +09:00
22f72f1bbc fix: [stats] QuoteStatService codebridge DB 커넥션 연결
- codebridge DB로 이관된 테이블(sales_prospect_consultations, sales_prospects) 커넥션을 mysql → codebridge로 변경
- config/database.php에 codebridge 커넥션 추가
- quote_daily 집계 실패 해결

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:04:22 +09:00
유병철
6f0ad1cf2d feat: [캘린더] 어음 만기일 일정 연동 추가
- Bill 모델 기반 만기일 일정 조회 (getBillSchedules)
- type 필터에 'bill' 추가, null(전체)일 때도 포함
- 완료/부도 상태 제외, 만기일 기준 정렬
- 표시 형식: [만기] 거래처명 금액원

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 10:59:19 +09:00
김보곤
d8f2361c88 feat: [payroll] payrolls 테이블에 options JSON 컬럼 추가
- 이메일 발송 이력 등 확장 속성 저장용
2026-03-10 01:21:11 +09:00
74e3c21ee0 feat: [database] codebridge 이관 완료 테이블 58개 삭제 마이그레이션
- sam DB에서 codebridge DB로 이관된 58개 테이블 DROP
- FK 체크 비활성화 후 일괄 삭제
- 복원: ~/backups/sam_codebridge_tables_20260309.sql
2026-03-09 23:13:31 +09:00
45a207d4a8 feat: [결재] 테넌트 부트스트랩에 기본 결재 양식 자동 시딩 추가
- ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report)
- RecipeRegistry STANDARD 레시피에 등록
- 테넌트 생성 시 TenantObserver → TenantBootstrapper로 자동 실행
- 기존 테넌트는 php artisan tenants:bootstrap --all로 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:10 +09:00
3fc5f511bc feat: [quality] 검사 상태 자동 재계산 + 수주처 선택 연동
- 개소별 inspection_status를 검사 데이터 내용 기반으로 자동 판정
  (15개 판정필드 + 사진 유무 → pending/in_progress/completed)
- 문서 status를 개소 상태 집계로 자동 재계산
- inspectLocation, updateLocations 모두 적용
- QualityDocumentLocation에 STATUS_IN_PROGRESS 상수 추가
- transformToFrontend에 client_id 매핑 추가
2026-03-09 20:45:35 +09:00
유병철
ee9f4d0b8f fix: [현황판] 결재 카드 조회에 approvalOnly 스코프 추가
- ApprovalStep 쿼리에 approvalOnly() 스코프 적용
- 결재 유형만 필터링되도록 보정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:00:40 +09:00
유병철
ca259ccb18 fix: [악성채권] JOIN 쿼리 나머지 컬럼 테이블 prefix 보완
- is_active, status 컬럼에도 bad_debts. prefix 추가
- BadDebtService, StatusBoardService 동일 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:43:12 +09:00
유병철
3929c5fd1e fix: [악성채권] tenant_id 컬럼 ambiguous 에러 수정
- JOIN 쿼리에서 bad_debts.tenant_id로 테이블 명시
- BadDebtService, StatusBoardService 동일 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:38:17 +09:00
유병철
56c60ec3df feat: [현황판/악성채권] 카드별 sub_label(대표 거래처명 + 건수) 추가
- BadDebtService: summary에 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가
- StatusBoardService: 악성채권·신규거래처·결재 카드에 sub_label 추가
  - 악성채권: 최다 금액 거래처명
  - 신규거래처: 최근 등록 업체명
  - 결재: 최근 결재 제목

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:32:58 +09:00
유병철
60c4256bd0 feat: [복리후생] 상세 조회 커스텀 날짜 범위 필터 추가
- start_date, end_date 쿼리 파라미터 추가
- 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회
- 미지정 시 기존 분기 기준 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:20:30 +09:00
유병철
1861f4daf2 fix: [세금계산서] NOT NULL 컬럼 null 방어 처리
- supplier/buyer corp_num, corp_name null→빈문자열 보정
- Laravel ConvertEmptyStringsToNull 미들웨어로 인한 DB 에러 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:40:08 +09:00
유병철
c62e59ad17 fix: [세금계산서] 매입/매출 방향별 공급자·공급받는자 필수값 조건 분리
- 매입(purchases): supplier 정보 필수, buyer 선택
- 매출(sales): buyer 정보 필수, supplier 선택
- required → required_if:direction 조건부 검증으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:35:39 +09:00
유병철
e6f13e3870 refactor: [세금계산서/바로빌] ApiResponse::handle() 클로저 패턴으로 통일
- BarobillSettingController: show/save/testConnection 클로저 방식 전환
- TaxInvoiceController: 전체 액션(index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary) 클로저 방식 전환
- 중간 변수 할당 제거, 일관된 응답 패턴 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:21:20 +09:00
유병철
1d5d161e05 feat: [finance] 더존 Smart A 표준 계정과목 추가 시딩 마이그레이션
- 기획서 14장 기준 누락분 보완
- tenant_id + code 중복 시 skip (기존 데이터 보호)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:56:49 +09:00
유병철
0044779eb4 feat: [finance] 계정과목 확장 및 전표 연동 시스템 구현
- AccountCode 모델/서비스 확장 (업데이트, 기본 계정과목 시딩)
- JournalSyncService 추가 (전표 자동 연동)
- SyncsExpenseAccounts 트레이트 추가
- CardTransactionController, TaxInvoiceController 기능 확장
- expense_accounts 테이블에 전표 연결 컬럼 마이그레이션
- account_codes 테이블 확장 마이그레이션
- 전체 테넌트 기본 계정과목 시딩 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 10:32:20 +09:00
233 changed files with 20545 additions and 1527 deletions

View File

@@ -114,3 +114,12 @@ symbol_info_budget:
# Note: the backend is fixed at startup. If a project with a different backend # Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned. # is activated post-init, an error will be returned.
language_backend: language_backend:
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

130
Jenkinsfile vendored
View File

@@ -1,6 +1,12 @@
pipeline { pipeline {
agent any 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 { options {
disableConcurrentBuilds() disableConcurrentBuilds()
} }
@@ -8,10 +14,73 @@ pipeline {
environment { environment {
DEPLOY_USER = 'hskwon' DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
PROD_SERVER = '211.117.60.189'
} }
stages { 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') { stage('Checkout') {
when { expression { params.ACTION == 'deploy' } }
steps { steps {
checkout scm checkout scm
script { script {
@@ -24,17 +93,22 @@ pipeline {
// ── main → 운영서버 Stage 배포 ── // ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') { stage('Deploy Stage') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ 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 \ rsync -az --delete \
--exclude='.git' --exclude='.env' \ --exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/ . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 ' ssh ${DEPLOY_USER}@${PROD_SERVER} '
cd /home/webservice/api-stage/releases/${RELEASE_ID} && cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache && sudo chown -R www-data:webservice storage bootstrap/cache &&
@@ -71,17 +145,22 @@ pipeline {
// ── main → 운영서버 Production 배포 ── // ── main → 운영서버 Production 배포 ──
stage('Deploy Production') { stage('Deploy Production') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ 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 \ rsync -az --delete \
--exclude='.git' --exclude='.env' \ --exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \ --exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \ --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/ . ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 ' ssh ${DEPLOY_USER}@${PROD_SERVER} '
cd /home/webservice/api/releases/${RELEASE_ID} && cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs && mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache && sudo chown -R www-data:webservice storage bootstrap/cache &&
@@ -109,23 +188,32 @@ pipeline {
post { post {
success { success {
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token', script {
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" 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 { 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 { script {
if (env.BRANCH_NAME == 'main') { if (params.ACTION == 'deploy') {
sshagent(credentials: ['deploy-ssh-key']) { slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
sh """ message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
ssh ${DEPLOY_USER}@211.117.60.189 ' if (env.BRANCH_NAME == 'main') {
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) && sshagent(credentials: ['deploy-ssh-key']) {
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current && sh """
sudo systemctl reload php8.4-fpm ssh ${DEPLOY_USER}@${PROD_SERVER} '
' || true 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-06 21:25:05 > **자동 생성**: 2026-03-12 13:58:25
> **소스**: Eloquent 모델 관계 분석 > **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황 ## 📊 모델별 관계 현황
@@ -26,6 +26,68 @@ ### bad_debt_memos
- **badDebt()**: belongsTo → `bad_debts` - **badDebt()**: belongsTo → `bad_debts`
- **creator()**: belongsTo → `users` - **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 ### biddings
**모델**: `App\Models\Bidding\Bidding` **모델**: `App\Models\Bidding\Bidding`
@@ -309,6 +371,43 @@ ### esign_signers
- **contract()**: belongsTo → `esign_contracts` - **contract()**: belongsTo → `esign_contracts`
- **signFields()**: hasMany → `esign_sign_fields` - **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 ### estimates
**모델**: `App\Models\Estimate\Estimate` **모델**: `App\Models\Estimate\Estimate`
@@ -734,6 +833,36 @@ ### push_notification_settings
**모델**: `App\Models\PushNotificationSetting` **모델**: `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 ### inspections
**모델**: `App\Models\Qualitys\Inspection` **모델**: `App\Models\Qualitys\Inspection`
@@ -838,6 +967,11 @@ ### quote_revisions
- **quote()**: belongsTo → `quotes` - **quote()**: belongsTo → `quotes`
- **reviser()**: belongsTo → `users` - **reviser()**: belongsTo → `users`
### account_codes
**모델**: `App\Models\Tenants\AccountCode`
- **children()**: hasMany → `account_codes`
### ai_reports ### ai_reports
**모델**: `App\Models\Tenants\AiReport` **모델**: `App\Models\Tenants\AiReport`
@@ -857,14 +991,24 @@ ### approvals
**모델**: `App\Models\Tenants\Approval` **모델**: `App\Models\Tenants\Approval`
- **form()**: belongsTo → `approval_forms` - **form()**: belongsTo → `approval_forms`
- **line()**: belongsTo → `approval_lines`
- **drafter()**: belongsTo → `users` - **drafter()**: belongsTo → `users`
- **department()**: belongsTo → `departments`
- **parentDocument()**: belongsTo → `approvals`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users` - **updater()**: belongsTo → `users`
- **childDocuments()**: hasMany → `approvals`
- **steps()**: hasMany → `approval_steps` - **steps()**: hasMany → `approval_steps`
- **approverSteps()**: hasMany → `approval_steps` - **approverSteps()**: hasMany → `approval_steps`
- **referenceSteps()**: hasMany → `approval_steps` - **referenceSteps()**: hasMany → `approval_steps`
- **linkable()**: morphTo → `(Polymorphic)` - **linkable()**: morphTo → `(Polymorphic)`
### approval_delegations
**모델**: `App\Models\Tenants\ApprovalDelegation`
- **delegator()**: belongsTo → `users`
- **delegate()**: belongsTo → `users`
### approval_forms ### approval_forms
**모델**: `App\Models\Tenants\ApprovalForm` **모델**: `App\Models\Tenants\ApprovalForm`
@@ -883,6 +1027,7 @@ ### approval_steps
- **approval()**: belongsTo → `approvals` - **approval()**: belongsTo → `approvals`
- **approver()**: belongsTo → `users` - **approver()**: belongsTo → `users`
- **actedBy()**: belongsTo → `users`
### attendances ### attendances
**모델**: `App\Models\Tenants\Attendance` **모델**: `App\Models\Tenants\Attendance`
@@ -1004,6 +1149,11 @@ ### loans
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users` - **updater()**: belongsTo → `users`
### mail_logs
**모델**: `App\Models\Tenants\MailLog`
- **tenant()**: belongsTo → `tenants`
### payments ### payments
**모델**: `App\Models\Tenants\Payment` **모델**: `App\Models\Tenants\Payment`
@@ -1046,6 +1196,7 @@ ### receivings
**모델**: `App\Models\Tenants\Receiving` **모델**: `App\Models\Tenants\Receiving`
- **item()**: belongsTo → `items` - **item()**: belongsTo → `items`
- **certificateFile()**: belongsTo → `files`
- **creator()**: belongsTo → `users` - **creator()**: belongsTo → `users`
### salarys ### salarys
@@ -1167,6 +1318,11 @@ ### tenant_field_settings
- **fieldDef()**: belongsTo → `setting_field_defs` - **fieldDef()**: belongsTo → `setting_field_defs`
- **optionGroup()**: belongsTo → `tenant_option_groups` - **optionGroup()**: belongsTo → `tenant_option_groups`
### tenant_mail_configs
**모델**: `App\Models\Tenants\TenantMailConfig`
- **tenant()**: belongsTo → `tenants`
### tenant_option_groups ### tenant_option_groups
**모델**: `App\Models\Tenants\TenantOptionGroup` **모델**: `App\Models\Tenants\TenantOptionGroup`

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 데모 테넌트 만료 체크 및 알림 커맨드
*
* - 만료 임박 (7일 이내): 파트너에게 알림 로그
* - 만료된 테넌트: 비활성 상태로 전환
*
* 기존 코드 영향 없음: DEMO_TRIAL 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class CheckDemoExpiredCommand extends Command
{
protected $signature = 'demo:check-expired
{--dry-run : 실제 변경 없이 대상만 표시}';
protected $description = '데모 체험 테넌트 만료 체크 및 비활성 처리';
public function handle(): int
{
// 1. 만료 임박 테넌트 (7일 이내)
$expiringSoon = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->where('tenant_st_code', '!=', 'expired')
->whereNotNull('demo_expires_at')
->where('demo_expires_at', '>', now())
->where('demo_expires_at', '<=', now()->addDays(7))
->get();
if ($expiringSoon->isNotEmpty()) {
$this->info("만료 임박 테넌트: {$expiringSoon->count()}");
foreach ($expiringSoon as $tenant) {
$daysLeft = (int) now()->diffInDays($tenant->demo_expires_at, false);
$this->line(" - [{$tenant->id}] {$tenant->company_name} (D-{$daysLeft})");
Log::info('데모 체험 만료 임박', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'expires_at' => $tenant->demo_expires_at->toDateString(),
'days_left' => $daysLeft,
'partner_id' => $tenant->demo_source_partner_id,
]);
}
}
// 2. 이미 만료된 테넌트 → 상태 변경
$expired = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_TRIAL)
->where('tenant_st_code', '!=', 'expired')
->whereNotNull('demo_expires_at')
->where('demo_expires_at', '<', now())
->get();
if ($expired->isEmpty()) {
$this->info('만료 처리 대상 없음');
return self::SUCCESS;
}
$this->info("만료 처리 대상: {$expired->count()}");
foreach ($expired as $tenant) {
$this->line(" - [{$tenant->id}] {$tenant->company_name} (만료: {$tenant->demo_expires_at->toDateString()})");
if (! $this->option('dry-run')) {
$tenant->forceFill(['tenant_st_code' => 'expired']);
$tenant->save();
Log::info('데모 체험 만료 처리', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'partner_id' => $tenant->demo_source_partner_id,
]);
}
}
if ($this->option('dry-run')) {
$this->warn('(dry-run 모드 — 실제 변경 없음)');
} else {
$this->info(" {$expired->count()}건 만료 처리 완료");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 데모 테넌트 비활성 알림 커맨드
*
* - 7일 이상 활동 없는 데모 테넌트 탐지
* - 파트너에게 후속 조치 알림 로그
*
* 기존 코드 영향 없음: DEMO 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class CheckDemoInactiveCommand extends Command
{
protected $signature = 'demo:check-inactive
{--days=7 : 비활성 기준 일수}';
protected $description = '데모 테넌트 비활성 알림 (활동 없는 테넌트 탐지)';
public function handle(): int
{
$thresholdDays = (int) $this->option('days');
$demos = Tenant::withoutGlobalScopes()
->whereIn('tenant_type', Tenant::DEMO_TYPES)
->where('tenant_st_code', '!=', 'expired')
->get();
if ($demos->isEmpty()) {
$this->info('활성 데모 테넌트 없음');
return self::SUCCESS;
}
$inactiveCount = 0;
foreach ($demos as $tenant) {
$lastActivity = $this->getLastActivity($tenant->id);
if (! $lastActivity) {
continue;
}
$daysSince = (int) now()->diffInDays($lastActivity);
if ($daysSince < $thresholdDays) {
continue;
}
$inactiveCount++;
$this->line(" - [{$tenant->id}] {$tenant->company_name} ({$daysSince}일 비활성)");
Log::warning('데모 테넌트 비활성 알림', [
'tenant_id' => $tenant->id,
'company_name' => $tenant->company_name,
'tenant_type' => $tenant->tenant_type,
'days_inactive' => $daysSince,
'last_activity' => $lastActivity->toDateString(),
'partner_id' => $tenant->demo_source_partner_id,
]);
}
if ($inactiveCount === 0) {
$this->info("비활성 테넌트 없음 (기준: {$thresholdDays}일)");
} else {
$this->info("비활성 테넌트: {$inactiveCount}건 (기준: {$thresholdDays}일)");
}
return self::SUCCESS;
}
private function getLastActivity(int $tenantId): ?\Carbon\Carbon
{
$tables = ['orders', 'quotes', 'items', 'clients'];
$latest = null;
foreach ($tables as $table) {
if (! \Schema::hasTable($table) || ! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$date = DB::table($table)
->where('tenant_id', $tenantId)
->max('updated_at');
if ($date) {
$parsed = \Carbon\Carbon::parse($date);
if (! $latest || $parsed->gt($latest)) {
$latest = $parsed;
}
}
}
return $latest;
}
}

View File

@@ -40,8 +40,8 @@ public function handle(): int
foreach ($files as $file) { foreach ($files as $file) {
try { try {
// Delete physical file // Delete physical file
if (Storage::disk('tenant')->exists($file->file_path)) { if (Storage::disk('r2')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path); Storage::disk('r2')->delete($file->file_path);
} }
// Force delete from DB // Force delete from DB

View File

@@ -60,8 +60,8 @@ private function permanentDelete(File $file): void
{ {
DB::transaction(function () use ($file) { DB::transaction(function () use ($file) {
// Delete physical file // Delete physical file
if (Storage::disk('tenant')->exists($file->file_path)) { if (Storage::disk('r2')->exists($file->file_path)) {
Storage::disk('tenant')->delete($file->file_path); Storage::disk('r2')->delete($file->file_path);
} }
// Update tenant storage usage // Update tenant storage usage

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenants\Tenant;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* 데모 쇼케이스 테넌트 데이터 리셋 커맨드
*
* 매일 자정에 쇼케이스 테넌트의 비즈니스 데이터를 삭제하고
* 샘플 데이터를 다시 시드한다.
*
* 기존 코드 영향 없음: DEMO_SHOWCASE 테넌트만 대상
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class ResetDemoShowcaseCommand extends Command
{
protected $signature = 'demo:reset-showcase
{--seed : 리셋 후 샘플 데이터 시드}
{--dry-run : 실제 삭제 없이 대상만 표시}';
protected $description = '데모 쇼케이스 테넌트의 비즈니스 데이터를 리셋합니다';
/**
* 리셋 대상 테이블 목록 (tenant_id 기반)
* 순서 중요: FK 의존성 역순으로 삭제
*/
private const RESET_TABLES = [
// 영업/주문
'order_item_components',
'order_items',
'order_histories',
'orders',
'quotes',
// 생산
'production_results',
'production_plans',
// 자재/재고
'material_inspection_items',
'material_inspections',
'material_receipts',
'lot_sales',
'lots',
// 마스터
'price_histories',
'product_components',
'items',
'clients',
// 파일 (데모 데이터 관련)
// files는 morphable이므로 별도 처리 필요
// 조직
'departments',
// 감사 로그 (데모 데이터)
'audit_logs',
];
public function handle(): int
{
$showcases = Tenant::withoutGlobalScopes()
->where('tenant_type', Tenant::TYPE_DEMO_SHOWCASE)
->get();
if ($showcases->isEmpty()) {
$this->info('데모 쇼케이스 테넌트가 없습니다.');
return self::SUCCESS;
}
foreach ($showcases as $tenant) {
$this->info("리셋 대상: [{$tenant->id}] {$tenant->company_name}");
if ($this->option('dry-run')) {
$this->showStats($tenant);
continue;
}
$this->resetTenantData($tenant);
if ($this->option('seed')) {
$this->seedSampleData($tenant);
}
}
return self::SUCCESS;
}
private function showStats(Tenant $tenant): void
{
foreach (self::RESET_TABLES as $table) {
if (! \Schema::hasTable($table)) {
continue;
}
if (! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$count = DB::table($table)->where('tenant_id', $tenant->id)->count();
if ($count > 0) {
$this->line(" - {$table}: {$count}");
}
}
}
private function resetTenantData(Tenant $tenant): void
{
$totalDeleted = 0;
DB::beginTransaction();
try {
foreach (self::RESET_TABLES as $table) {
if (! \Schema::hasTable($table)) {
continue;
}
if (! \Schema::hasColumn($table, 'tenant_id')) {
continue;
}
$deleted = DB::table($table)->where('tenant_id', $tenant->id)->delete();
if ($deleted > 0) {
$this->line(" 삭제: {$table}{$deleted}");
$totalDeleted += $deleted;
}
}
DB::commit();
$this->info("{$totalDeleted}건 삭제 완료");
Log::info('데모 쇼케이스 리셋 완료', [
'tenant_id' => $tenant->id,
'deleted_count' => $totalDeleted,
]);
} catch (\Exception $e) {
DB::rollBack();
$this->error(" 리셋 실패: {$e->getMessage()}");
Log::error('데모 쇼케이스 리셋 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
return;
}
}
private function seedSampleData(Tenant $tenant): void
{
$preset = $tenant->getDemoPreset() ?? 'manufacturing';
$this->info(" 샘플 데이터 시드: {$preset}");
try {
$seeder = new \Database\Seeders\Demo\ManufacturingPresetSeeder;
$seeder->run($tenant->id);
$this->info(' 샘플 데이터 시드 완료');
} catch (\Exception $e) {
$this->error(" 시드 실패: {$e->getMessage()}");
Log::error('데모 샘플 데이터 시드 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
}
}
}

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

@@ -5,6 +5,7 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest; use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
use App\Http\Requests\V1\AccountSubject\UpdateAccountSubjectRequest;
use App\Services\AccountCodeService; use App\Services\AccountCodeService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -19,7 +20,10 @@ public function __construct(
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$params = $request->only(['search', 'category']); $params = $request->only([
'search', 'category', 'sub_category',
'department_type', 'depth', 'is_active', 'selectable',
]);
$subjects = $this->service->index($params); $subjects = $this->service->index($params);
@@ -36,6 +40,16 @@ public function store(StoreAccountSubjectRequest $request)
return ApiResponse::success($subject, __('message.created'), [], 201); return ApiResponse::success($subject, __('message.created'), [], 201);
} }
/**
* 계정과목 수정
*/
public function update(int $id, UpdateAccountSubjectRequest $request)
{
$subject = $this->service->update($id, $request->validated());
return ApiResponse::success($subject, __('message.updated'));
}
/** /**
* 계정과목 활성/비활성 토글 * 계정과목 활성/비활성 토글
*/ */
@@ -57,4 +71,17 @@ public function destroy(int $id)
return ApiResponse::success(null, __('message.deleted')); return ApiResponse::success(null, __('message.deleted'));
} }
/**
* 기본 계정과목표 일괄 생성 (더존 표준)
*/
public function seedDefaults()
{
$count = $this->service->seedDefaults();
return ApiResponse::success(
['inserted_count' => $count],
__('message.created')
);
}
} }

View File

@@ -4,8 +4,14 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Approval\ApproveRequest;
use App\Http\Requests\Approval\CancelRequest;
use App\Http\Requests\Approval\DelegationStoreRequest;
use App\Http\Requests\Approval\DelegationUpdateRequest;
use App\Http\Requests\Approval\HoldRequest;
use App\Http\Requests\Approval\InboxIndexRequest; use App\Http\Requests\Approval\InboxIndexRequest;
use App\Http\Requests\Approval\IndexRequest; use App\Http\Requests\Approval\IndexRequest;
use App\Http\Requests\Approval\PreDecideRequest;
use App\Http\Requests\Approval\ReferenceIndexRequest; use App\Http\Requests\Approval\ReferenceIndexRequest;
use App\Http\Requests\Approval\RejectRequest; use App\Http\Requests\Approval\RejectRequest;
use App\Http\Requests\Approval\StoreRequest; use App\Http\Requests\Approval\StoreRequest;
@@ -133,10 +139,10 @@ public function submit(int $id, SubmitRequest $request): JsonResponse
* 결재 승인 * 결재 승인
* POST /v1/approvals/{id}/approve * POST /v1/approvals/{id}/approve
*/ */
public function approve(int $id, Request $request): JsonResponse public function approve(int $id, ApproveRequest $request): JsonResponse
{ {
return ApiResponse::handle(function () use ($id, $request) { return ApiResponse::handle(function () use ($id, $request) {
return $this->service->approve($id, $request->input('comment')); return $this->service->approve($id, $request->validated()['comment'] ?? null);
}, __('message.approval.approved')); }, __('message.approval.approved'));
} }
@@ -155,11 +161,99 @@ public function reject(int $id, RejectRequest $request): JsonResponse
* 결재 회수 (기안자만) * 결재 회수 (기안자만)
* POST /v1/approvals/{id}/cancel * POST /v1/approvals/{id}/cancel
*/ */
public function cancel(int $id): JsonResponse public function cancel(int $id, CancelRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->cancel($id, $request->validated()['recall_reason'] ?? null);
}, __('message.approval.cancelled'));
}
/**
* 보류 (현재 결재자만)
* POST /v1/approvals/{id}/hold
*/
public function hold(int $id, HoldRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->hold($id, $request->validated()['comment']);
}, __('message.approval.held'));
}
/**
* 보류 해제 (보류한 결재자만)
* POST /v1/approvals/{id}/release-hold
*/
public function releaseHold(int $id): JsonResponse
{ {
return ApiResponse::handle(function () use ($id) { return ApiResponse::handle(function () use ($id) {
return $this->service->cancel($id); return $this->service->releaseHold($id);
}, __('message.approval.cancelled')); }, __('message.approval.hold_released'));
}
/**
* 전결 (현재 결재자가 이후 모든 결재를 건너뛰고 최종 승인)
* POST /v1/approvals/{id}/pre-decide
*/
public function preDecide(int $id, PreDecideRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->preDecide($id, $request->validated()['comment'] ?? null);
}, __('message.approval.pre_decided'));
}
/**
* 복사 재기안
* POST /v1/approvals/{id}/copy
*/
public function copyForRedraft(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->copyForRedraft($id);
}, __('message.approval.copied'));
}
/**
* 완료함 목록
* GET /v1/approvals/completed
*/
public function completed(IndexRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->completed($request->validated());
}, __('message.fetched'));
}
/**
* 완료함 현황 카드
* GET /v1/approvals/completed/summary
*/
public function completedSummary(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->completedSummary();
}, __('message.fetched'));
}
/**
* 미처리 건수 (뱃지용)
* GET /v1/approvals/badge-counts
*/
public function badgeCounts(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->badgeCounts();
}, __('message.fetched'));
}
/**
* 완료함 미읽음 일괄 읽음 처리
* POST /v1/approvals/completed/mark-read
*/
public function markCompletedAsRead(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->markCompletedAsRead();
}, __('message.approval.marked_read'));
} }
/** /**
@@ -183,4 +277,52 @@ public function markUnread(int $id): JsonResponse
return $this->service->markUnread($id); return $this->service->markUnread($id);
}, __('message.approval.marked_unread')); }, __('message.approval.marked_unread'));
} }
// =========================================================================
// 위임 관리
// =========================================================================
/**
* 위임 목록
* GET /v1/approvals/delegations
*/
public function delegationIndex(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->delegationIndex($request->all());
}, __('message.fetched'));
}
/**
* 위임 생성
* POST /v1/approvals/delegations
*/
public function delegationStore(DelegationStoreRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->delegationStore($request->validated());
}, __('message.created'));
}
/**
* 위임 수정
* PATCH /v1/approvals/delegations/{id}
*/
public function delegationUpdate(int $id, DelegationUpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->delegationUpdate($id, $request->validated());
}, __('message.updated'));
}
/**
* 위임 삭제
* DELETE /v1/approvals/delegations/{id}
*/
public function delegationDestroy(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->delegationDestroy($id);
}, __('message.deleted'));
}
} }

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Qms\AuditChecklistStoreRequest;
use App\Http\Requests\Qms\AuditChecklistUpdateRequest;
use App\Services\AuditChecklistService;
use Illuminate\Http\Request;
class AuditChecklistController extends Controller
{
public function __construct(private AuditChecklistService $service) {}
/**
* 점검표 목록
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
/**
* 점검표 생성 (카테고리+항목 일괄)
*/
public function store(AuditChecklistStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
/**
* 점검표 상세
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 점검표 수정
*/
public function update(AuditChecklistUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* 점검표 완료 처리
*/
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
/**
* 항목 완료/미완료 토글
*/
public function toggleItem(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->toggleItem($id);
}, __('message.updated'));
}
/**
* 항목별 기준 문서 조회
*/
public function itemDocuments(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->itemDocuments($id);
}, __('message.fetched'));
}
/**
* 기준 문서 연결
*/
public function attachDocument(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachDocument($id, $request->validate([
'title' => 'required|string|max:200',
'version' => 'nullable|string|max:20',
'date' => 'nullable|date',
'document_id' => 'nullable|integer|exists:documents,id',
]));
}, __('message.created'));
}
/**
* 기준 문서 연결 해제
*/
public function detachDocument(int $id, int $docId)
{
return ApiResponse::handle(function () use ($id, $docId) {
$this->service->detachDocument($id, $docId);
return null;
}, __('message.deleted'));
}
}

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

View File

@@ -18,12 +18,9 @@ public function __construct(
*/ */
public function show() public function show()
{ {
$setting = $this->barobillService->getSetting(); return ApiResponse::handle(function () {
return $this->barobillService->getSetting();
return ApiResponse::handle( }, __('message.fetched'));
data: $setting,
message: __('message.fetched')
);
} }
/** /**
@@ -31,12 +28,9 @@ public function show()
*/ */
public function save(SaveBarobillSettingRequest $request) public function save(SaveBarobillSettingRequest $request)
{ {
$setting = $this->barobillService->saveSetting($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->barobillService->saveSetting($request->validated());
return ApiResponse::handle( }, __('message.saved'));
data: $setting,
message: __('message.saved')
);
} }
/** /**
@@ -44,11 +38,8 @@ public function save(SaveBarobillSettingRequest $request)
*/ */
public function testConnection() public function testConnection()
{ {
$result = $this->barobillService->testConnection(); return ApiResponse::handle(function () {
return $this->barobillService->testConnection();
return ApiResponse::handle( }, __('message.barobill.connection_success'));
data: $result,
message: __('message.barobill.connection_success')
);
} }
} }

View File

@@ -4,7 +4,9 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\CardTransactionService; use App\Services\CardTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -14,7 +16,8 @@
class CardTransactionController extends Controller class CardTransactionController extends Controller
{ {
public function __construct( public function __construct(
protected CardTransactionService $service protected CardTransactionService $service,
protected JournalSyncService $journalSyncService,
) {} ) {}
/** /**
@@ -148,4 +151,105 @@ public function destroy(int $id): JsonResponse
return $this->service->destroy($id); return $this->service->destroy($id);
}, __('message.deleted')); }, __('message.deleted'));
} }
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 카드 거래 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "card_{$id}";
$data = $this->journalSyncService->getForSource(
JournalEntry::SOURCE_CARD_TRANSACTION,
$sourceKey
);
if (! $data) {
return ['items' => []];
}
// 프론트엔드가 기대하는 items 형식으로 변환
$items = array_map(fn ($row) => [
'id' => $row['id'],
'supply_amount' => $row['debit_amount'],
'tax_amount' => 0,
'account_code' => $row['account_code'],
'deduction_type' => 'deductible',
'vendor_name' => $row['vendor_name'],
'description' => $row['memo'],
'memo' => '',
], $data['rows']);
return ['items' => $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.*.supply_amount' => 'required|integer|min:0',
'items.*.tax_amount' => 'required|integer|min:0',
'items.*.account_code' => 'required|string|max:20',
'items.*.deduction_type' => 'nullable|string|max:20',
'items.*.vendor_name' => 'nullable|string|max:200',
'items.*.description' => 'nullable|string|max:500',
'items.*.memo' => 'nullable|string|max:500',
]);
// 카드 거래 정보 조회 (날짜용)
$transaction = $this->service->show($id);
if (! $transaction) {
throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
}
$entryDate = $transaction->used_at
? $transaction->used_at->format('Y-m-d')
: ($transaction->withdrawal_date?->format('Y-m-d') ?? now()->format('Y-m-d'));
// items → journal rows 변환 (각 item을 차변 행으로)
$rows = [];
foreach ($validated['items'] as $item) {
$amount = ($item['supply_amount'] ?? 0) + ($item['tax_amount'] ?? 0);
$rows[] = [
'side' => 'debit',
'account_code' => $item['account_code'],
'debit_amount' => $amount,
'credit_amount' => 0,
'vendor_name' => $item['vendor_name'] ?? '',
'memo' => $item['description'] ?? $item['memo'] ?? '',
];
}
// 대변 합계 행 (카드미지급금)
$totalAmount = array_sum(array_column($rows, 'debit_amount'));
$rows[] = [
'side' => 'credit',
'account_code' => '25300', // 미지급금 (표준 코드)
'account_name' => '미지급금',
'debit_amount' => 0,
'credit_amount' => $totalAmount,
'vendor_name' => $transaction->merchant_name ?? '',
'memo' => '카드결제',
];
$sourceKey = "card_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_CARD_TRANSACTION,
$sourceKey,
$entryDate,
"카드거래 분개 (#{$id})",
$rows,
);
}, __('message.created'));
}
} }

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

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Demo\DemoAnalyticsService;
use Illuminate\Http\Request;
/**
* 데모 테넌트 분석 API 컨트롤러
*
* 전환율, 파트너 성과, 활동 현황 등 데모 분석 엔드포인트
*
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoAnalyticsController extends Controller
{
public function __construct(private DemoAnalyticsService $service) {}
/**
* 대시보드 요약
*/
public function summary()
{
return ApiResponse::handle(function () {
return $this->service->summary();
}, __('message.fetched'));
}
/**
* 전환율 퍼널 분석
*/
public function conversionFunnel(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->conversionFunnel($request->all());
}, __('message.fetched'));
}
/**
* 파트너별 성과 분석
*/
public function partnerPerformance(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->partnerPerformance($request->all());
}, __('message.fetched'));
}
/**
* 데모 테넌트 활동 현황
*/
public function activityReport(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->activityReport($request->all());
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Demo\DemoTenantStoreRequest;
use App\Services\Demo\DemoTenantService;
use Illuminate\Http\Request;
/**
* 데모 테넌트 관리 API 컨트롤러
*
* 파트너가 고객 체험 테넌트를 생성/관리하는 엔드포인트
*
* 기존 코드 영향 없음: 데모 전용 라우트에서만 사용
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoTenantController extends Controller
{
public function __construct(private DemoTenantService $service) {}
/**
* 내가 생성한 데모 테넌트 목록
*/
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.demo_tenant.fetched'));
}
/**
* 데모 테넌트 상세 조회
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.demo_tenant.fetched'));
}
/**
* 고객 체험 테넌트 생성 (Tier 3)
*/
public function store(DemoTenantStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->createTrialFromApi($request->validated());
}, __('message.demo_tenant.created'));
}
/**
* 데모 데이터 리셋
*/
public function reset(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->resetFromApi($id);
}, __('message.demo_tenant.reset'));
}
/**
* 체험 기간 연장
*/
public function extend(int $id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
$days = (int) $request->input('days', 30);
return $this->service->extendFromApi($id, $days);
}, __('message.demo_tenant.extended'));
}
/**
* 데모 → 정식 전환
*/
public function convert(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->convertFromApi($id);
}, __('message.demo_tenant.converted'));
}
/**
* 데모 현황 통계
*/
public function stats()
{
return ApiResponse::handle(function () {
return $this->service->stats();
}, __('message.fetched'));
}
}

View File

@@ -83,14 +83,25 @@ public function trash()
} }
/** /**
* Download file * Download file (attachment)
*/ */
public function download(int $id) public function download(int $id)
{ {
$service = new FileStorageService; $service = new FileStorageService;
$file = $service->getFile($id); $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; $filePath = $directory.'/'.$storedName;
// 파일 저장 (tenant 디스크) // 파일 저장 (tenant 디스크)
Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName); Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
// file_type 자동 분류 (MIME 타입 기반) // file_type 자동 분류 (MIME 타입 기반)
$mimeType = $uploadedFile->getMimeType(); $mimeType = $uploadedFile->getMimeType();

View File

@@ -4,18 +4,24 @@
use App\Helpers\ApiResponse; use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller; 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\CalculatePayrollRequest;
use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest;
use App\Http\Requests\V1\Payroll\PayPayrollRequest; 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\StorePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest;
use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest;
use App\Services\ExportService;
use App\Services\PayrollService; use App\Services\PayrollService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class PayrollController extends Controller class PayrollController extends Controller
{ {
public function __construct( 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', 'month',
'user_id', 'user_id',
'status', 'status',
'department_id',
'search', 'search',
'sort_by', 'sort_by',
'sort_dir', 'sort_dir',
@@ -103,6 +110,16 @@ public function confirm(int $id)
return ApiResponse::success($payroll, __('message.payroll.confirmed')); 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')); 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')); 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

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Qms\QmsLotAuditConfirmRequest;
use App\Http\Requests\Qms\QmsLotAuditDocumentDetailRequest;
use App\Http\Requests\Qms\QmsLotAuditIndexRequest;
use App\Services\QmsLotAuditService;
class QmsLotAuditController extends Controller
{
public function __construct(private QmsLotAuditService $service) {}
/**
* 품질관리서 목록 (로트 추적 심사용)
*/
public function index(QmsLotAuditIndexRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->validated());
}, __('message.fetched'));
}
/**
* 품질관리서 상세 — 수주/개소 목록
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* 수주 루트별 8종 서류 목록
*/
public function routeDocuments(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->routeDocuments($id);
}, __('message.fetched'));
}
/**
* 서류 상세 조회 (2단계 로딩)
*/
public function documentDetail(QmsLotAuditDocumentDetailRequest $request, string $type, int $id)
{
return ApiResponse::handle(function () use ($type, $id) {
return $this->service->documentDetail($type, $id);
}, __('message.fetched'));
}
/**
* 개소별 로트 심사 확인 토글
*/
public function confirm(QmsLotAuditConfirmRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->confirm($id, $request->validated());
}, __('message.updated'));
}
}

View File

@@ -124,4 +124,24 @@ public function resultDocument(int $id)
return $this->service->resultDocument($id); return $this->service->resultDocument($id);
}, __('message.fetched')); }, __('message.fetched'));
} }
public function uploadFile(Request $request, int $id)
{
$request->validate([
'file' => ['required', 'file', 'max:51200'], // 50MB
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->uploadFile($id, $request->file('file'));
}, __('message.created'));
}
public function deleteFile(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->deleteFile($id);
return 'success';
}, __('message.deleted'));
}
} }

View File

@@ -8,13 +8,15 @@
use App\Http\Requests\Shipment\ShipmentUpdateRequest; use App\Http\Requests\Shipment\ShipmentUpdateRequest;
use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest; use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest;
use App\Services\ShipmentService; use App\Services\ShipmentService;
use App\Services\WorkOrderService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ShipmentController extends Controller class ShipmentController extends Controller
{ {
public function __construct( 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()); $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 옵션 조회 * LOT 옵션 조회
*/ */

View File

@@ -10,12 +10,17 @@
use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest; use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest;
use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest; use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest;
use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest; use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest;
use App\Models\Tenants\JournalEntry;
use App\Services\JournalSyncService;
use App\Services\TaxInvoiceService; use App\Services\TaxInvoiceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TaxInvoiceController extends Controller class TaxInvoiceController extends Controller
{ {
public function __construct( public function __construct(
private TaxInvoiceService $taxInvoiceService private TaxInvoiceService $taxInvoiceService,
private JournalSyncService $journalSyncService,
) {} ) {}
/** /**
@@ -23,12 +28,9 @@ public function __construct(
*/ */
public function index(TaxInvoiceListRequest $request) public function index(TaxInvoiceListRequest $request)
{ {
$taxInvoices = $this->taxInvoiceService->list($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->list($request->validated());
return ApiResponse::handle( }, __('message.fetched'));
data: $taxInvoices,
message: __('message.fetched')
);
} }
/** /**
@@ -36,12 +38,9 @@ public function index(TaxInvoiceListRequest $request)
*/ */
public function show(int $id) public function show(int $id)
{ {
$taxInvoice = $this->taxInvoiceService->show($id); return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->show($id);
return ApiResponse::handle( }, __('message.fetched'));
data: $taxInvoice,
message: __('message.fetched')
);
} }
/** /**
@@ -49,13 +48,9 @@ public function show(int $id)
*/ */
public function store(CreateTaxInvoiceRequest $request) public function store(CreateTaxInvoiceRequest $request)
{ {
$taxInvoice = $this->taxInvoiceService->create($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->create($request->validated());
return ApiResponse::handle( }, __('message.created'));
data: $taxInvoice,
message: __('message.created'),
status: 201
);
} }
/** /**
@@ -63,12 +58,9 @@ public function store(CreateTaxInvoiceRequest $request)
*/ */
public function update(UpdateTaxInvoiceRequest $request, int $id) public function update(UpdateTaxInvoiceRequest $request, int $id)
{ {
$taxInvoice = $this->taxInvoiceService->update($id, $request->validated()); return ApiResponse::handle(function () use ($request, $id) {
return $this->taxInvoiceService->update($id, $request->validated());
return ApiResponse::handle( }, __('message.updated'));
data: $taxInvoice,
message: __('message.updated')
);
} }
/** /**
@@ -76,12 +68,11 @@ public function update(UpdateTaxInvoiceRequest $request, int $id)
*/ */
public function destroy(int $id) public function destroy(int $id)
{ {
$this->taxInvoiceService->delete($id); return ApiResponse::handle(function () use ($id) {
$this->taxInvoiceService->delete($id);
return ApiResponse::handle( return null;
data: null, }, __('message.deleted'));
message: __('message.deleted')
);
} }
/** /**
@@ -89,12 +80,9 @@ public function destroy(int $id)
*/ */
public function issue(int $id) public function issue(int $id)
{ {
$taxInvoice = $this->taxInvoiceService->issue($id); return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->issue($id);
return ApiResponse::handle( }, __('message.tax_invoice.issued'));
data: $taxInvoice,
message: __('message.tax_invoice.issued')
);
} }
/** /**
@@ -102,12 +90,9 @@ public function issue(int $id)
*/ */
public function bulkIssue(BulkIssueRequest $request) public function bulkIssue(BulkIssueRequest $request)
{ {
$result = $this->taxInvoiceService->bulkIssue($request->getIds()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->bulkIssue($request->getIds());
return ApiResponse::handle( }, __('message.tax_invoice.bulk_issued'));
data: $result,
message: __('message.tax_invoice.bulk_issued')
);
} }
/** /**
@@ -115,12 +100,9 @@ public function bulkIssue(BulkIssueRequest $request)
*/ */
public function cancel(CancelTaxInvoiceRequest $request, int $id) public function cancel(CancelTaxInvoiceRequest $request, int $id)
{ {
$taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']); return ApiResponse::handle(function () use ($request, $id) {
return $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
return ApiResponse::handle( }, __('message.tax_invoice.cancelled'));
data: $taxInvoice,
message: __('message.tax_invoice.cancelled')
);
} }
/** /**
@@ -128,12 +110,9 @@ public function cancel(CancelTaxInvoiceRequest $request, int $id)
*/ */
public function checkStatus(int $id) public function checkStatus(int $id)
{ {
$taxInvoice = $this->taxInvoiceService->checkStatus($id); return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->checkStatus($id);
return ApiResponse::handle( }, __('message.fetched'));
data: $taxInvoice,
message: __('message.fetched')
);
} }
/** /**
@@ -141,11 +120,79 @@ public function checkStatus(int $id)
*/ */
public function summary(TaxInvoiceSummaryRequest $request) public function summary(TaxInvoiceSummaryRequest $request)
{ {
$summary = $this->taxInvoiceService->summary($request->validated()); return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->summary($request->validated());
}, __('message.fetched'));
}
return ApiResponse::handle( // =========================================================================
data: $summary, // 분개 (Journal Entries)
message: __('message.fetched') // =========================================================================
);
/**
* 세금계산서 분개 조회
*/
public function getJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "tax_invoice_{$id}";
$data = $this->journalSyncService->getForSource(
JournalEntry::SOURCE_TAX_INVOICE,
$sourceKey
);
return $data ?? ['rows' => []];
}, __('message.fetched'));
}
/**
* 세금계산서 분개 저장/수정
*/
public function storeJournalEntries(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(function () use ($request, $id) {
$validated = $request->validate([
'rows' => 'required|array|min:1',
'rows.*.side' => 'required|in:debit,credit',
'rows.*.account_subject' => 'required|string|max:20',
'rows.*.debit_amount' => 'required|integer|min:0',
'rows.*.credit_amount' => 'required|integer|min:0',
]);
// 세금계산서 정보 조회 (entry_date용)
$taxInvoice = $this->taxInvoiceService->show($id);
$rows = array_map(fn ($row) => [
'side' => $row['side'],
'account_code' => $row['account_subject'],
'debit_amount' => $row['debit_amount'],
'credit_amount' => $row['credit_amount'],
], $validated['rows']);
$sourceKey = "tax_invoice_{$id}";
return $this->journalSyncService->saveForSource(
JournalEntry::SOURCE_TAX_INVOICE,
$sourceKey,
$taxInvoice->issue_date?->format('Y-m-d') ?? now()->format('Y-m-d'),
"세금계산서 분개 (#{$id})",
$rows,
);
}, __('message.created'));
}
/**
* 세금계산서 분개 삭제
*/
public function deleteJournalEntries(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$sourceKey = "tax_invoice_{$id}";
return $this->journalSyncService->deleteForSource(
JournalEntry::SOURCE_TAX_INVOICE,
$sourceKey
);
}, __('message.deleted'));
} }
} }

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\StoreVehiclePhotoRequest;
use App\Services\Finance\VehiclePhotoService;
use Illuminate\Http\JsonResponse;
class VehiclePhotoController extends Controller
{
public function __construct(private readonly VehiclePhotoService $service) {}
public function index(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($id),
__('message.fetched')
);
}
public function store(StoreVehiclePhotoRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($id, $request->file('files')),
__('message.created')
);
}
public function destroy(int $id, int $fileId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id, $fileId),
__('message.deleted')
);
}
}

View File

@@ -61,14 +61,18 @@ public function detail(Request $request): JsonResponse
: 0.05; : 0.05;
$year = $request->query('year') ? (int) $request->query('year') : null; $year = $request->query('year') ? (int) $request->query('year') : null;
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null; $quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter) { return ApiResponse::handle(function () use ($calculationType, $fixedAmountPerMonth, $ratio, $year, $quarter, $startDate, $endDate) {
return $this->welfareService->getDetail( return $this->welfareService->getDetail(
$calculationType, $calculationType,
$fixedAmountPerMonth, $fixedAmountPerMonth,
$ratio, $ratio,
$year, $year,
$quarter $quarter,
$startDate,
$endDate
); );
}, __('message.fetched')); }, __('message.fetched'));
} }

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(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->getTemplatesByEquipment($id, $request->input('cycle')),
__('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,38 @@
<?php
namespace App\Http\Controllers\V1\Equipment;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Equipment\StoreEquipmentPhotoRequest;
use App\Services\Equipment\EquipmentPhotoService;
use Illuminate\Http\JsonResponse;
class EquipmentPhotoController extends Controller
{
public function __construct(private readonly EquipmentPhotoService $service) {}
public function index(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($id),
__('message.fetched')
);
}
public function store(StoreEquipmentPhotoRequest $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($id, $request->file('files')),
__('message.equipment.photo_uploaded')
);
}
public function destroy(int $id, int $fileId): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id, $fileId),
__('message.deleted')
);
}
}

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

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\CorporateVehicleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CorporateVehicleController extends Controller
{
public function __construct(private readonly CorporateVehicleService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'ownership_type', 'status', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
public function dropdown(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->dropdown(),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\VehicleLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleLogController extends Controller
{
public function __construct(private readonly VehicleLogService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'vehicle_id', 'year', 'month', 'trip_type', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
public function summary(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->summary($request->only([
'vehicle_id', 'year', 'month',
])),
__('message.fetched')
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\V1\Vehicle;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Vehicle\VehicleMaintenanceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleMaintenanceController extends Controller
{
public function __construct(private readonly VehicleMaintenanceService $service) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->index($request->only([
'search', 'vehicle_id', 'category', 'start_date', 'end_date', 'per_page',
])),
__('message.fetched')
);
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
public function store(Request $request): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->store($request->all()),
__('message.created')
);
}
public function update(Request $request, int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->update($id, $request->all()),
__('message.updated')
);
}
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->destroy($id),
__('message.deleted')
);
}
}

View File

@@ -117,6 +117,7 @@ public function handle(Request $request, Closure $next)
// 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능 // 화이트리스트(인증 예외 라우트) - Bearer 토큰 없이 접근 가능
$allowWithoutAuth = [ $allowWithoutAuth = [
'api/v1/login', 'api/v1/login',
'api/v1/token-login', // MNG → SAM 자동 로그인 (API Key만 필요)
'api/v1/signup', 'api/v1/signup',
'api/v1/register', 'api/v1/register',
'api/v1/refresh', 'api/v1/refresh',

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Http\Middleware;
use App\Models\Tenants\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 데모 테넌트 제한 미들웨어
*
* - DEMO_SHOWCASE: 모든 쓰기 작업 차단 (읽기 전용)
* - DEMO_PARTNER / DEMO_TRIAL: 만료 체크 + 차단 기능 체크
*
* 기존 코드 영향 없음: 프로덕션 테넌트(STD/TPL/HQ)는 즉시 통과
*
* @see docs/features/sales/demo-tenant-policy.md
*/
class DemoLimitMiddleware
{
/**
* 데모에서 차단하는 라우트 프리픽스 (외부 시스템 연동)
*/
private const BLOCKED_ROUTE_PREFIXES = [
'api/v1/barobill',
'api/v1/ecount',
];
public function handle(Request $request, Closure $next): Response
{
$tenantId = app('tenant_id');
if (! $tenantId) {
return $next($request);
}
$tenant = Tenant::withoutGlobalScopes()->find($tenantId);
if (! $tenant || ! $tenant->isDemoTenant()) {
// 프로덕션 테넌트 → 즉시 통과 (기존 동작 유지)
return $next($request);
}
// 1. 만료 체크 (파트너 데모, 고객 체험)
if ($tenant->isDemoExpired()) {
return response()->json([
'success' => false,
'message' => '체험 기간이 만료되었습니다. 정식 계약을 진행해 주세요.',
'error_code' => 'DEMO_EXPIRED',
], 403);
}
// 2. 쇼케이스 → 읽기 전용 (GET, HEAD, OPTIONS만 허용)
if ($tenant->isDemoShowcase() && ! $request->isMethodSafe()) {
return response()->json([
'success' => false,
'message' => '데모 환경에서는 조회만 가능합니다.',
'error_code' => 'DEMO_READ_ONLY',
], 403);
}
// 3. 읽기전용 옵션이 설정된 데모 테넌트
if ($tenant->isDemoReadOnly() && ! $request->isMethodSafe()) {
return response()->json([
'success' => false,
'message' => '데모 환경에서는 조회만 가능합니다.',
'error_code' => 'DEMO_READ_ONLY',
], 403);
}
// 4. 차단 기능 체크 (바로빌, 이카운트 등 외부 연동)
if ($this->isBlockedRoute($request)) {
return response()->json([
'success' => false,
'message' => '데모 환경에서 사용할 수 없는 기능입니다. 정식 계약 후 이용 가능합니다.',
'error_code' => 'DEMO_FEATURE_BLOCKED',
], 403);
}
return $next($request);
}
private function isBlockedRoute(Request $request): bool
{
$path = $request->path();
foreach (self::BLOCKED_ROUTE_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class ApproveRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'comment' => 'nullable|string|max:1000',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class CancelRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'recall_reason' => 'nullable|string|max:1000',
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class DelegationStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'delegate_id' => 'required|integer|exists:users,id',
'start_date' => 'required|date|after_or_equal:today',
'end_date' => 'required|date|after_or_equal:start_date',
'form_ids' => 'nullable|array',
'form_ids.*' => 'integer|exists:approval_forms,id',
'notify_delegator' => 'nullable|boolean',
'reason' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class DelegationUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'delegate_id' => 'nullable|integer|exists:users,id',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'form_ids' => 'nullable|array',
'form_ids.*' => 'integer|exists:approval_forms,id',
'notify_delegator' => 'nullable|boolean',
'is_active' => 'nullable|boolean',
'reason' => 'nullable|string|max:500',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class HoldRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'comment' => 'required|string|max:1000',
];
}
public function messages(): array
{
return [
'comment.required' => __('error.approval.comment_required'),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Approval;
use Illuminate\Foundation\Http\FormRequest;
class PreDecideRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'comment' => 'nullable|string|max:1000',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Demo;
use Illuminate\Foundation\Http\FormRequest;
class DemoTenantStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'company_name' => 'required|string|max:100',
'email' => 'required|email|max:255',
'duration_days' => 'sometimes|integer|min:7|max:60',
'preset' => 'sometimes|string|in:manufacturing',
];
}
public function messages(): array
{
return [
'company_name.required' => '회사명은 필수입니다.',
'email.required' => '이메일은 필수입니다.',
'email.email' => '올바른 이메일 형식이 아닙니다.',
'duration_days.min' => '체험 기간은 최소 7일입니다.',
'duration_days.max' => '체험 기간은 최대 60일입니다.',
'preset.in' => '유효하지 않은 프리셋입니다.',
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Equipment;
use App\Models\Commons\File;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentPhotoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$equipmentId = $this->route('id');
$currentCount = File::where('document_id', $equipmentId)
->where('document_type', 'equipment')
->whereNull('deleted_at')
->count();
$maxFiles = 10 - $currentCount;
return [
'files' => ['required', 'array', 'min:1', "max:{$maxFiles}"],
'files.*' => [
'required',
'file',
'mimes:jpg,jpeg,png,gif,bmp,webp',
'max:10240', // 10MB
],
];
}
public function attributes(): array
{
return [
'files' => '사진 파일',
'files.*' => '사진 파일',
];
}
public function messages(): array
{
return [
'files.required' => __('error.file.required'),
'files.max' => __('error.equipment.photo_limit_exceeded'),
'files.*.mimes' => __('error.file.invalid_type'),
'files.*.max' => __('error.file.size_exceeded'),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Finance;
use App\Models\Commons\File;
use Illuminate\Foundation\Http\FormRequest;
class StoreVehiclePhotoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$vehicleId = $this->route('id');
$currentCount = File::where('document_id', $vehicleId)
->where('document_type', 'corporate_vehicle')
->whereNull('deleted_at')
->count();
$maxFiles = 10 - $currentCount;
return [
'files' => ['required', 'array', 'min:1', "max:{$maxFiles}"],
'files.*' => [
'required',
'file',
'mimes:jpg,jpeg,png,gif,bmp,webp',
'max:10240', // 10MB
],
];
}
public function attributes(): array
{
return [
'files' => '사진 파일',
'files.*' => '사진 파일',
];
}
public function messages(): array
{
return [
'files.required' => __('error.file.required'),
'files.max' => __('error.vehicle.photo_limit_exceeded'),
'files.*.mimes' => __('error.file.invalid_type'),
'files.*.max' => __('error.file.size_exceeded'),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class AuditChecklistStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => 'required|integer|min:2020|max:2100',
'quarter' => 'required|integer|in:1,2,3,4',
'type' => 'nullable|string|max:30',
'categories' => 'required|array|min:1',
'categories.*.title' => 'required|string|max:200',
'categories.*.sort_order' => 'nullable|integer|min:0',
'categories.*.items' => 'required|array|min:1',
'categories.*.items.*.name' => 'required|string|max:200',
'categories.*.items.*.description' => 'nullable|string',
'categories.*.items.*.sort_order' => 'nullable|integer|min:0',
];
}
public function messages(): array
{
return [
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
'categories.*.items.required' => __('validation.required', ['attribute' => '점검 항목']),
'categories.*.items.*.name.required' => __('validation.required', ['attribute' => '항목명']),
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class AuditChecklistUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'categories' => 'sometimes|array|min:1',
'categories.*.id' => 'nullable|integer|exists:audit_checklist_categories,id',
'categories.*.title' => 'required|string|max:200',
'categories.*.sort_order' => 'nullable|integer|min:0',
'categories.*.items' => 'required|array|min:1',
'categories.*.items.*.id' => 'nullable|integer|exists:audit_checklist_items,id',
'categories.*.items.*.name' => 'required|string|max:200',
'categories.*.items.*.description' => 'nullable|string',
'categories.*.items.*.sort_order' => 'nullable|integer|min:0',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'confirmed' => 'required|boolean',
];
}
public function messages(): array
{
return [
'confirmed.required' => __('validation.required', ['attribute' => '확인 상태']),
'confirmed.boolean' => __('validation.boolean', ['attribute' => '확인 상태']),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditDocumentDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'type' => $this->route('type'),
]);
}
public function rules(): array
{
return [
'type' => 'required|string|in:import,order,log,report,confirmation,shipping,product,quality',
];
}
public function messages(): array
{
return [
'type.in' => __('validation.in', ['attribute' => '서류 타입']),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Qms;
use Illuminate\Foundation\Http\FormRequest;
class QmsLotAuditIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => 'nullable|integer|min:2020|max:2100',
'quarter' => 'nullable|integer|in:1,2,3,4',
'q' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

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.*.productCategory' => 'nullable|string|in:SCREEN,STEEL',
'items.*.guideRailType' => 'nullable|string|in:wall,ceiling,floor,mixed', 'items.*.guideRailType' => 'nullable|string|in:wall,ceiling,floor,mixed',
'items.*.motorPower' => 'nullable|string|in:single,three', '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.*.wingSize' => 'nullable|numeric|min:0|max:500',
'items.*.inspectionFee' => 'nullable|numeric|min:0', 'items.*.inspectionFee' => 'nullable|numeric|min:0',
@@ -45,7 +45,7 @@ public function rules(): array
'items.*.PC' => 'nullable|string|in:SCREEN,STEEL', 'items.*.PC' => 'nullable|string|in:SCREEN,STEEL',
'items.*.GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'items.*.GT' => 'nullable|string|in:wall,ceiling,floor,mixed',
'items.*.MP' => 'nullable|string|in:single,three', '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.*.WS' => 'nullable|numeric|min:0|max:500',
'items.*.INSP' => 'nullable|numeric|min:0', 'items.*.INSP' => 'nullable|numeric|min:0',
@@ -128,7 +128,7 @@ private function normalizeInputVariables(array $item): array
'PC' => $item['productCategory'] ?? $item['PC'] ?? 'SCREEN', 'PC' => $item['productCategory'] ?? $item['PC'] ?? 'SCREEN',
'GT' => $item['guideRailType'] ?? $item['GT'] ?? 'wall', 'GT' => $item['guideRailType'] ?? $item['GT'] ?? 'wall',
'MP' => $item['motorPower'] ?? $item['MP'] ?? 'single', '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), 'WS' => (float) ($item['wingSize'] ?? $item['WS'] ?? 50),
'INSP' => (float) ($item['inspectionFee'] ?? $item['INSP'] ?? 50000), 'INSP' => (float) ($item['inspectionFee'] ?? $item['INSP'] ?? 50000),
]; ];

View File

@@ -30,7 +30,7 @@ public function rules(): array
'PC' => 'nullable|string|in:SCREEN,STEEL', 'PC' => 'nullable|string|in:SCREEN,STEEL',
'GT' => 'nullable|string|in:wall,ceiling,floor,mixed', 'GT' => 'nullable|string|in:wall,ceiling,floor,mixed',
'MP' => 'nullable|string|in:single,three', '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', 'WS' => 'nullable|numeric|min:0|max:500',
'INSP' => 'nullable|numeric|min:0', 'INSP' => 'nullable|numeric|min:0',
@@ -82,7 +82,7 @@ public function getInputVariables(): array
'PC' => $validated['PC'] ?? 'SCREEN', 'PC' => $validated['PC'] ?? 'SCREEN',
'GT' => $validated['GT'] ?? 'wall', 'GT' => $validated['GT'] ?? 'wall',
'MP' => $validated['MP'] ?? 'single', 'MP' => $validated['MP'] ?? 'single',
'CT' => $validated['CT'] ?? 'basic', 'CT' => $validated['CT'] ?? 'exposed',
'WS' => (float) ($validated['WS'] ?? 50), 'WS' => (float) ($validated['WS'] ?? 50),
'INSP' => (float) ($validated['INSP'] ?? 50000), 'INSP' => (float) ($validated['INSP'] ?? 50000),
]; ];

View File

@@ -20,18 +20,18 @@ public function rules(): array
'issue_type' => ['required', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)], 'issue_type' => ['required', 'string', Rule::in(TaxInvoice::ISSUE_TYPES)],
'direction' => ['required', 'string', Rule::in(TaxInvoice::DIRECTIONS)], 'direction' => ['required', 'string', Rule::in(TaxInvoice::DIRECTIONS)],
// 공급자 정보 // 공급자 정보 (매입 시 필수, 매출 시 선택)
'supplier_corp_num' => ['required', 'string', 'max:20'], 'supplier_corp_num' => ['required_if:direction,purchases', 'nullable', 'string', 'max:20'],
'supplier_corp_name' => ['required', 'string', 'max:100'], 'supplier_corp_name' => ['required_if:direction,purchases', 'nullable', 'string', 'max:100'],
'supplier_ceo_name' => ['nullable', 'string', 'max:50'], 'supplier_ceo_name' => ['nullable', 'string', 'max:50'],
'supplier_addr' => ['nullable', 'string', 'max:200'], 'supplier_addr' => ['nullable', 'string', 'max:200'],
'supplier_biz_type' => ['nullable', 'string', 'max:100'], 'supplier_biz_type' => ['nullable', 'string', 'max:100'],
'supplier_biz_class' => ['nullable', 'string', 'max:100'], 'supplier_biz_class' => ['nullable', 'string', 'max:100'],
'supplier_contact_id' => ['nullable', 'string', 'email', 'max:100'], 'supplier_contact_id' => ['nullable', 'string', 'email', 'max:100'],
// 공급받는자 정보 // 공급받는자 정보 (매출 시 필수, 매입 시 선택)
'buyer_corp_num' => ['required', 'string', 'max:20'], 'buyer_corp_num' => ['required_if:direction,sales', 'nullable', 'string', 'max:20'],
'buyer_corp_name' => ['required', 'string', 'max:100'], 'buyer_corp_name' => ['required_if:direction,sales', 'nullable', 'string', 'max:100'],
'buyer_ceo_name' => ['nullable', 'string', 'max:50'], 'buyer_ceo_name' => ['nullable', 'string', 'max:50'],
'buyer_addr' => ['nullable', 'string', 'max:200'], 'buyer_addr' => ['nullable', 'string', 'max:200'],
'buyer_biz_type' => ['nullable', 'string', 'max:100'], 'buyer_biz_type' => ['nullable', 'string', 'max:100'],

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\User; namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SwitchTenantRequest extends FormRequest class SwitchTenantRequest extends FormRequest
{ {
@@ -13,8 +14,23 @@ public function authorize(): bool
public function rules(): array public function rules(): array
{ {
$userId = app('api_user');
return [ return [
'tenant_id' => 'required|integer|exists:tenants,id', 'tenant_id' => [
'required',
'integer',
Rule::exists('user_tenants', 'tenant_id')
->where('user_id', $userId)
->where('is_active', 1),
],
];
}
public function messages(): array
{
return [
'tenant_id.exists' => __('error.tenant_access_denied'),
]; ];
} }
} }

View File

@@ -17,6 +17,12 @@ public function rules(): array
'code' => ['required', 'string', 'max:10'], 'code' => ['required', 'string', 'max:10'],
'name' => ['required', 'string', 'max:100'], 'name' => ['required', 'string', 'max:100'],
'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'], 'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'],
'sub_category' => ['nullable', 'string', 'max:50'],
'parent_code' => ['nullable', 'string', 'max:10'],
'depth' => ['nullable', 'integer', 'in:1,2,3'],
'department_type' => ['nullable', 'string', 'in:common,manufacturing,admin'],
'description' => ['nullable', 'string', 'max:500'],
'sort_order' => ['nullable', 'integer'],
]; ];
} }
@@ -26,6 +32,8 @@ public function messages(): array
'code.required' => '계정과목 코드를 입력하세요.', 'code.required' => '계정과목 코드를 입력하세요.',
'name.required' => '계정과목명을 입력하세요.', 'name.required' => '계정과목명을 입력하세요.',
'category.in' => '유효한 분류를 선택하세요.', 'category.in' => '유효한 분류를 선택하세요.',
'depth.in' => '계층은 1(대), 2(중), 3(소) 중 하나여야 합니다.',
'department_type.in' => '부문은 common, manufacturing, admin 중 하나여야 합니다.',
]; ];
} }
} }

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\V1\AccountSubject;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAccountSubjectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:100'],
'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'],
'sub_category' => ['nullable', 'string', 'max:50'],
'parent_code' => ['nullable', 'string', 'max:10'],
'depth' => ['nullable', 'integer', 'in:1,2,3'],
'department_type' => ['nullable', 'string', 'in:common,manufacturing,admin'],
'description' => ['nullable', 'string', 'max:500'],
'sort_order' => ['nullable', 'integer'],
];
}
public function messages(): array
{
return [
'category.in' => '유효한 분류를 선택하세요.',
'depth.in' => '계층은 1(대), 2(중), 3(소) 중 하나여야 합니다.',
'department_type.in' => '부문은 common, manufacturing, admin 중 하나여야 합니다.',
];
}
}

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' => ['nullable', 'array'],
'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'],
'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], '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'], 'note' => ['nullable', 'string', 'max:1000'],
]; ];
} }

View File

@@ -31,6 +31,14 @@ public function rules(): array
'deductions' => ['nullable', 'array'], 'deductions' => ['nullable', 'array'],
'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'], 'deductions.*.name' => ['required_with:deductions', 'string', 'max:50'],
'deductions.*.amount' => ['required_with:deductions', 'numeric', 'min:0'], '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'], 'note' => ['nullable', 'string', 'max:1000'],
]; ];
} }

View File

@@ -31,6 +31,7 @@ public function rules(): array
'remark' => ['nullable', 'string', 'max:1000'], 'remark' => ['nullable', 'string', 'max:1000'],
'manufacturer' => ['nullable', 'string', 'max:100'], 'manufacturer' => ['nullable', 'string', 'max:100'],
'material_no' => ['nullable', 'string', 'max:50'], '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'], 'inspection_result' => ['nullable', 'string', 'max:20'],
'manufacturer' => ['nullable', 'string', 'max:100'], 'manufacturer' => ['nullable', 'string', 'max:100'],
'material_no' => ['nullable', 'string', 'max:50'], '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 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 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()) { if (! $this->exists()) {
abort(404, 'File not found in storage'); abort(404, 'File not found in storage');
} }
return response()->download( $fileName = $this->display_name ?? $this->original_name;
$this->getStoragePath(), $mimeType = $this->mime_type ?? 'application/octet-stream';
$this->display_name ?? $this->original_name $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 $this->stored_name ?? $this->file_name
); );
// Move physical file // Move physical file in R2
if (Storage::disk('tenant')->exists($this->file_path)) { if (Storage::disk('r2')->exists($this->file_path)) {
Storage::disk('tenant')->move($this->file_path, $newPath); Storage::disk('r2')->move($this->file_path, $newPath);
} }
// Update DB // Update DB
@@ -182,9 +198,9 @@ public function softDeleteFile(int $userId): void
*/ */
public function permanentDelete(): void public function permanentDelete(): void
{ {
// Delete physical file // Delete physical file from R2
if ($this->exists()) { if ($this->exists()) {
Storage::disk('tenant')->delete($this->file_path); Storage::disk('r2')->delete($this->file_path);
} }
// Decrement tenant storage // Decrement tenant storage

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Models\Equipment;
use App\Models\Commons\File;
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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Equipment extends Model
{
use Auditable, BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'equipments';
protected $fillable = [
'tenant_id',
'equipment_code',
'name',
'equipment_type',
'specification',
'manufacturer',
'model_name',
'serial_no',
'location',
'production_line',
'purchase_date',
'install_date',
'purchase_price',
'useful_life',
'status',
'disposed_date',
'manager_id',
'sub_manager_id',
'photo_path',
'memo',
'options',
'is_active',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'purchase_date' => 'date',
'install_date' => 'date',
'disposed_date' => 'date',
'purchase_price' => 'decimal:2',
'is_active' => 'boolean',
'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 manager(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'manager_id');
}
public function subManager(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'sub_manager_id');
}
public function canInspect(?int $userId = null): bool
{
if (! $userId) {
return false;
}
return $this->manager_id === $userId || $this->sub_manager_id === $userId;
}
public function inspectionTemplates(): HasMany
{
return $this->hasMany(EquipmentInspectionTemplate::class, 'equipment_id')->orderBy('sort_order');
}
public function inspections(): HasMany
{
return $this->hasMany(EquipmentInspection::class, 'equipment_id');
}
public function repairs(): HasMany
{
return $this->hasMany(EquipmentRepair::class, 'equipment_id');
}
public function photos(): HasMany
{
return $this->hasMany(File::class, 'document_id')
->where('document_type', 'equipment')
->orderBy('id');
}
public function processes(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Process::class, 'equipment_process')
->withPivot('is_primary')
->withTimestamps();
}
public function scopeByLine($query, string $line)
{
return $query->where('production_line', $line);
}
public function scopeByType($query, string $type)
{
return $query->where('equipment_type', $type);
}
public function scopeByStatus($query, string $status)
{
return $query->where('status', $status);
}
public static function getEquipmentTypes(): array
{
return ['포밍기', '미싱기', '샤링기', 'V컷팅기', '절곡기', '프레스', '드릴', '기타'];
}
public static function getProductionLines(): array
{
return ['스라트', '스크린', '절곡', '기타'];
}
public static function getStatuses(): array
{
return [
'active' => '가동',
'idle' => '유휴',
'disposed' => '폐기',
];
}
}

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\Members\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\Members\User::class, 'repaired_by');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Finance;
use App\Models\Commons\File;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class CorporateVehicle extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'corporate_vehicles';
protected $fillable = [
'tenant_id',
'plate_number',
'model',
'vehicle_type',
'ownership_type',
'mileage',
'options',
'is_active',
];
protected $casts = [
'mileage' => 'integer',
'options' => 'array',
'is_active' => 'boolean',
];
public function photos(): HasMany
{
return $this->hasMany(File::class, 'document_id')
->where('document_type', 'corporate_vehicle')
->orderBy('id');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models\Qualitys;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class AuditChecklist extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'audit_checklists';
const STATUS_DRAFT = 'draft';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
const TYPE_STANDARD_MANUAL = 'standard_manual';
protected $fillable = [
'tenant_id',
'year',
'quarter',
'type',
'status',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'year' => 'integer',
'quarter' => 'integer',
'options' => 'array',
];
public function categories(): HasMany
{
return $this->hasMany(AuditChecklistCategory::class, 'checklist_id')->orderBy('sort_order');
}
public function isDraft(): bool
{
return $this->status === self::STATUS_DRAFT;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models\Qualitys;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AuditChecklistCategory extends Model
{
protected $table = 'audit_checklist_categories';
protected $fillable = [
'tenant_id',
'checklist_id',
'title',
'sort_order',
'options',
];
protected $casts = [
'sort_order' => 'integer',
'options' => 'array',
];
public function checklist(): BelongsTo
{
return $this->belongsTo(AuditChecklist::class, 'checklist_id');
}
public function items(): HasMany
{
return $this->hasMany(AuditChecklistItem::class, 'category_id')->orderBy('sort_order');
}
}

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