Compare commits

...

195 Commits

Author SHA1 Message Date
김보곤
0ca3242e83 merge: develop → main 전체 반영
- 급여 계산 엔진, 바로빌 카드/은행/홈택스 API
- 장비관리, 체크리스트 템플릿
- 메일 설정, 파일 스토리지 개선
2026-03-12 16:11:29 +09:00
5448f0e57d deploy: 2026-03-12 배포
- feat: [barobill] 바로빌 카드/은행/홈택스 REST API 구현
- feat: [equipment] 설비관리 API 백엔드 구현
- feat: [payroll] 급여관리 계산 엔진 및 일괄 처리 API
- feat: [QMS] 점검표 템플릿 관리 + 로트심사 개선
- feat: [생산/출하] 수주 단위 출하 자동생성 + 상태 흐름 개선
- feat: [receiving] 입고 성적서 파일 연결
- feat: [견적] 제어기 타입 체계 변경
- feat: [email] 테넌트 메일 설정 마이그레이션 및 모델
- feat: [pmis] 시공관리 테이블 마이그레이션
- feat: [R2] 파일 업로드 커맨드 + filesystems 설정
- feat: [배포] Jenkinsfile 롤백 기능 추가
- fix: [approval] SAM API 규칙 준수 코드 개선
- fix: [account-codes] 계정과목 중복 데이터 정리
- fix: [payroll] 일괄 생성 시 삭제된 사용자 건너뛰기
- fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 제거
- refactor: [barobill] 바로빌 연동 코드 전면 개선
2026-03-12 15:20:20 +09:00
김보곤
4e19e3ed67 refactor: [pmis] 마이그레이션을 MNG 프로젝트로 이관
- PMIS 테이블은 MNG 전용이므로 API에서 제거
- pmis_workers, pmis_job_types, pmis_construction_workers, pmis_equipments, pmis_materials
2026-03-12 14:44:30 +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
김보곤
7b93fd7f68 feat: [pmis] 자재관리 테이블 마이그레이션 추가 2026-03-12 14:39:21 +09:00
김보곤
cefad468b9 feat: [pmis] 자재관리 테이블 마이그레이션 추가 2026-03-12 14:37:41 +09:00
김보곤
e371b7c9ab feat: [pmis] 장비관리 테이블 생성 (pmis_equipments) 2026-03-12 14:14:51 +09:00
김보곤
9ad76ceb82 feat: [pmis] 장비관리 테이블 생성 (pmis_equipments) 2026-03-12 14:13:34 +09:00
김보곤
f231c60d3d feat: [pmis] 시공관리 인원관리 테이블 생성 (job_types, construction_workers) 2026-03-12 14:04:49 +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
김보곤
6c208cfb2c 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: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
김보곤
c4b4be495a feat: [pmis] pmis_workers 테이블 마이그레이션 추가
- 건설PMIS 현장 작업자 전용 프로필 테이블
- tenant_id + user_id 유니크 제약 포함
2026-03-12 12:24:16 +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
김보곤
7eab19508a 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:19:48 +09:00
김보곤
100a78b6e5 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 17:19:48 +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
김보곤
f662a389f7 fix: [account-codes] 계정과목 중복 데이터 정리 마이그레이션
- 비표준 코드(5자리 KIS 중복, 1-2자리 카테고리 헤더) 비활성화
- 홈택스 분개 코드 수정: 135→117, 251→201, 255→208
2026-03-11 10:16:21 +09:00
김보곤
6f48b86206 fix: [account-codes] 계정과목 중복 데이터 정리 마이그레이션
- 비표준 코드(5자리 KIS 중복, 1-2자리 카테고리 헤더) 비활성화
- 홈택스 분개 코드 수정: 135→117, 251→201, 255→208
2026-03-11 10:15:59 +09:00
김보곤
8222ba667f fix: [db] codebridge DB 분리 후 깨진 FK 제약조건 52개 제거
- sam → codebridge 테이블 이동 후 users, tenants 등 참조하는 FK 잔존
- esign_field_templates INSERT 시 FK violation 발생 수정
2026-03-11 10:12:38 +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
047524c19f deploy: 2026-03-11 배포
- feat: QMS 감사 체크리스트 (AuditChecklist CRUD, 카테고리/항목/표준문서 모델, 마이그레이션)
- feat: QMS LOT 감사 (QmsLotAudit 컨트롤러/서비스, 확인/문서상세 FormRequest)
- fix: CalendarService, MemberService 수정
- chore: QualityDocumentLocation options 컬럼 추가, tenant_id 마이그레이션, 품질 더미데이터 시더
2026-03-11 02:04:29 +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
bd500a87bd feat: 품질관리·대시보드·결재 양식 개선
- 품질관리 수주선택 필터링 + 검사 상태 자동 재계산
- 제품검사 요청서 Document(EAV) 자동생성 및 동기화
- 현황판 결재 카드 approvalOnly 스코프 + sub_label 추가
- 캘린더 어음 만기일 일정 연동
- QuoteStatService codebridge DB 커넥션 연결
- 테넌트 부트스트랩 기본 결재 양식 자동 시딩
2026-03-10 11:29:56 +09:00
c46b950fde feat: 회계 시스템 확장 — 계정과목·전표·세금계산서·카드·바로빌
- 계정과목 확장 및 더존 Smart A 표준 시딩 (전 테넌트)
- 전표 연동 시스템 구현 (JournalSyncService, SyncsExpenseAccounts)
- 세금계산서 매입/매출 필수값 조건 분리 + null 방어
- 카드거래 대시보드 리다이렉트 + 악성채권 집계 수정
- 바로빌 연동 API 엔드포인트 추가
- 복리후생 날짜 필터 + 바로빌 조인 컬럼 수정
- codebridge DB 커넥션 설정 추가
2026-03-10 11:29:39 +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
김보곤
7ff9206f93 feat: [payroll] payrolls 테이블에 options JSON 컬럼 추가
- 이메일 발송 이력 등 확장 속성 저장용
2026-03-10 01:22:03 +09:00
김보곤
d8f2361c88 feat: [payroll] payrolls 테이블에 options JSON 컬럼 추가
- 이메일 발송 이력 등 확장 속성 저장용
2026-03-10 01:21:11 +09:00
28ae481a7d feat: [database] codebridge 이관 완료 테이블 58개 삭제 마이그레이션
- sam DB에서 codebridge DB로 이관된 58개 테이블 DROP
- FK 체크 비활성화 후 일괄 삭제
- 복원: ~/backups/sam_codebridge_tables_20260309.sql
2026-03-09 23:14:32 +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
b6d2b9942e chore: [인프라] Slack 채널 분리 + logging 권한 + 문서 갱신
- Slack 알림 채널: product_infra → deploy_api
- logging.php daily/api 채널 permission 0664 추가
- CLAUDE.md, INDEX.md, 변경이력 문서 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:49 +09:00
4208ca3010 feat: [HR/기타] 캘린더/배차/설비/재고 + DB 마이그레이션
- 캘린더 CRUD API, 배차차량 관리 API (CRUD + options)
- 배차정보 다중 행 시스템 (shipment_vehicle_dispatches)
- 설비 다중점검주기 + 부 담당자 스키마 추가
- TodayIssue 날짜 기반 조회, Stock/Client 날짜 필터
- i18n 메시지 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:30 +09:00
95371fd841 feat: [CEO 대시보드] 섹션별 API + 일일보고서 엑셀
- DashboardCeo 리스크 감지형 서비스 리팩토링
- 일일보고서 어음/외상매출채권 현황 섹션 추가
- 엑셀 내보내기 화면 데이터 기반 리팩토링
- 공정명 컬럼 및 근태 부서 조인 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:59:05 +09:00
1df34b2fa9 feat: [재무] 어음 V8 + 상품권 접대비 연동 + 일반전표/계정과목 API
- Bill 확장 필드 (V8), Loan 상품권 카테고리/접대비 자동 연동
- GeneralJournalEntry CRUD, AccountSubject API
- 접대비/복리후생비 날짜 필터, 매출채권 soft delete 제외
- 바로빌 연동 API 엔드포인트 추가
- 부가세 상세 조회 API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:55 +09:00
3d12687a2d feat: [결재] 양식 마이그레이션 12종 + 반려이력/재상신
- 재직/경력/위촉증명서, 사직서, 사용인감계, 위임장
- 이사회의사록, 견적서, 공문서, 연차사용촉진 1차/2차
- 지출결의서 body_template 고도화
- rejection_history, resubmit_count, drafter_read_at 컬럼
- Document-Approval 브릿지 연동 (linkable)
- 수신함 날짜 범위 필터 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:32 +09:00
5e4cbc7742 feat: [문서] rendered_html 스냅샷 저장 + snapshot 엔드포인트
- Document upsert에 rendered_html 필드 추가
- Lazy Snapshot API (snapshot_document_id resolve)
- UpsertRequest rendered_html 검증 추가
- DocumentTemplateSection description 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:58:06 +09:00
4dd38ab14d feat: [생산지시] 전용 API + 자재투입/공정 개선
- ProductionOrder 전용 엔드포인트 (목록/통계/상세)
- 재고생산 보조공정 일반 워크플로우에서 분리
- 자재투입 replace 모드 + bom_group_key 개별 저장
- 공정단계 options 컬럼 추가 (검사 설정/범위)
- 셔터박스 prefix isStandard 파라미터 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:57:59 +09:00
f9cd219f67 feat: [품질관리] 품질관리서/실적신고/검사 API
- QualityDocument CRUD + 수주 연결 + 개소별 데이터 저장
- PerformanceReport 실적신고 확인/메모 API
- Inspection 검사 설정 + product_code 전파 수정
- 수주선택 API에 client_name 필드 추가
- 절곡 검사 프로파일 분리 (S1/S2/S3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:57:48 +09:00
3ac64d5b76 feat: [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
- availableOrders: client_id/item_id 필터 파라미터 지원
- availableOrders: 응답에 client_id, client_name, item_id, item_name, locations(개소 상세) 추가
- show: 개소별 데이터에 거래처/모델 정보 포함
- DocumentService: fqcStatus rootNodes 기반으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2231c9a48f feat: 제품검사 요청서 Document(EAV) 자동생성 및 동기화
- document_template_sections에 description 컬럼 추가 (마이그레이션)
- DocumentTemplateSection 모델에 description fillable 추가
- QualityDocumentService에 syncRequestDocument() 메서드 추가
  - quality_document 생성/수정/수주연결 시 요청서 Document 자동생성
  - 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
  - rendered_html 초기화 (데이터 변경 시 재캡처 트리거)
- transformToFrontend에 request_document_id 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
ff8553055c chore: [API] logging, docs, seeder 등 부수 정리
- LOGICAL_RELATIONSHIPS.md 보완
- Legacy5130Calculator 수정
- logging.php 설정 추가
- KyungdongItemSeeder 수정
- docs/INDEX.md, changes 문서 경로 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
f2eede6e3a feat: [품질관리] order_ids 영속성 + location 데이터 저장
- StoreRequest/UpdateRequest에 order_ids 검증 추가
- UpdateRequest에 locations 검증 추가 (시공규격, 변경사유, 검사데이터)
- QualityDocumentLocation에 inspection_data(JSON) fillable/cast 추가
- QualityDocumentService store()에 syncOrders 연동
- QualityDocumentService update()에 syncOrders + updateLocations 연동
- inspection_data 컬럼 추가 migration 신규

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
c5d5b5d076 feat: [문서스냅샷] Lazy Snapshot API - snapshot 엔드포인트 + resolve에 snapshot_document_id 추가
- PATCH /documents/{id}/snapshot: canEdit 체크 없이 rendered_html만 업데이트
- DocumentService::patchSnapshot() 메서드 추가
- WorkOrderService::resolveInspectionDocument()에 snapshot_document_id 반환 (상태 무관, rendered_html NULL인 문서)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
5ebf940873 fix: [문서스냅샷] UpsertRequest rendered_html 검증 추가 및 upsert() 전달 누락 수정
- UpsertRequest에 rendered_html nullable string 검증 추가
- DocumentService upsert()에서 create/update 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
293330c418 feat: 문서 rendered_html 스냅샷 저장 지원
- Document 모델 $fillable에 rendered_html 추가
- DocumentService create/update에서 rendered_html 저장
- StoreRequest/UpdateRequest에 rendered_html 검증 추가
- WorkOrderService 검사문서/작업일지 생성 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a845f52fc0 fix: [생산지시] withCount에서도 보조 공정(재고생산) WO 제외
- 목록 조회 시 work_orders_count에서 is_auxiliary WO 제외
- whereNotNull(process_id) + options->is_auxiliary 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0f26ea546a feat: [품질관리] 수주선택 API에 발주처(client_name) 필드 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
3600c7b12b fix: [품질관리] 수주선택 모달 납품일 포맷 및 개소 수 수정
- delivery_date: ISO 타임스탬프 → Y-m-d 포맷으로 변환
- location_count: order_items 수 → order_nodes 루트 노드(개소) 수로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a6e29bc1f3 feat: [품질관리] 백엔드 API 구현 - 품질관리서 + 실적신고
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트
- 실적신고(performance_reports) 관리 API 6개 엔드포인트
- DB 마이그레이션 4개 테이블 (quality_documents, quality_document_orders, quality_document_locations, performance_reports)
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개
- stats() ambiguous column 버그 수정 (JOIN 시 테이블 접두사 추가)
- missing() status_code 컬럼명/값 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0aa0a8592d feat: [생산지시] 재고생산 보조 공정 일반 워크플로우에서 분리
- Process P-004 options에 is_auxiliary 플래그 도입
- WO 생성 시 Process의 is_auxiliary를 WO options에 자동 복사
- ProductionOrderService: 보조 공정 WO를 공정 진행 현황에서 제외
- WorkOrderService: 보조 공정 WO의 상태 변경이 수주 상태에 영향 주지 않도록 처리
  - syncOrderStatus(): 보조 공정이면 스킵
  - autoStartWorkOrderOnMaterialInput(): WO는 진행중 전환하되 수주 상태는 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
38c2402771 fix: [생산지시] 공정 진행 현황 WO 필터링 + BOM 파싱 수정
- 공정 진행 현황: process_id=null인 구매품/서비스 WO 제외 (withCount, 목록/상세 모두)
- extractBomProcessGroups: bom_result.items[] 구조에 맞게 파싱 수정
  - process_name → process_group 키 사용
  - 품목 필드 매핑 수정 (item_id, specification, unit, quantity, unit_price, total_price, node_name)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
59d13eeb9f fix: [생산지시] 날짜포맷·개소수·자재투입 시 자동 상태전환
- ProductionOrderService: production_ordered_at를 Y-m-d 포맷으로 변환
- ProductionOrderService: withCount('nodes')로 개소수(node_count) 응답 추가
- WorkOrderService: autoStartWorkOrderOnMaterialInput() 신규 메서드
  - 자재투입 시 WO가 unassigned/pending/waiting이면 in_progress로 자동 전환
  - syncOrderStatus()로 Order도 IN_PRODUCTION 동기화
- Swagger: node_count 필드 문서화, 날짜 포맷 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2df8ecf765 feat: [생산지시] 전용 API 엔드포인트 신규 생성
- ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현
  - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
  - workOrderProgress 가공 필드 (total/completed/inProgress)
  - production_ordered_at (첫 WorkOrder created_at 기반)
  - BOM 공정 분류 추출 (order_nodes.options.bom_result)
- ProductionOrderController: FormRequest + ApiResponse 패턴
- ProductionOrderIndexRequest: search, production_status, sort, pagination 검증
- ProductionOrderApi.php: Swagger 문서 (목록/통계/상세)
- production.php: GET /production-orders, /stats, /{orderId} 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
김보곤
ad93743bdc feat: [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션 추가
- leave_promotion_1st: 연차사용촉진 통지서 (1차) - hr 카테고리
- leave_promotion_2nd: 연차사용촉진 통지서 (2차) - hr 카테고리
2026-03-07 00:28:59 +09:00
김보곤
449fce1d2b feat: [approval] 공문서 양식 마이그레이션 추가 2026-03-06 23:38:56 +09:00
김보곤
9d4143a4dc feat: [approval] 견적서 양식 마이그레이션 추가 2026-03-06 23:21:50 +09:00
김보곤
c5a0115e01 feat: [approval] 이사회의사록 양식 데이터 마이그레이션 추가 2026-03-06 23:00:55 +09:00
김보곤
eb28b577e0 feat: [approval] 위임장 양식 데이터 마이그레이션 추가
- 전체 테넌트에 delegation 양식 레코드 자동 삽입
2026-03-06 22:41:31 +09:00
김보곤
22160e5904 feat: [menu] 경조사비관리 메뉴 추가 마이그레이션
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가
- 중복 방지 (이미 존재하면 skip)
2026-03-06 21:44:52 +09:00
김보곤
0ea5fa5eb9 feat: [database] 경조사비 관리 테이블 생성
- condolence_expenses 테이블: 거래처 경조사비 관리대장
- 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조)
- 부조금(여부/지출방법/금액), 선물(여부/종류/금액), 총금액
2026-03-06 21:39:02 +09:00
김보곤
58fedb0d43 feat: [approvals] 사용인감계 양식 데이터 마이그레이션 추가
- 모든 테넌트에 seal_usage 양식 자동 등록
2026-03-06 20:51:38 +09:00
김보곤
56e7164243 feat: [departments] options JSON 컬럼 추가
- 조직도 숨기기 등 확장 속성 저장용
2026-03-06 20:24:56 +09:00
김보곤
a67c5d9fca feat: [menu] menu_favorites 테이블 마이그레이션 추가
- tenant_id, user_id, menu_id, sort_order 컬럼
- unique 제약: (tenant_id, user_id, menu_id)
- FK cascade delete: users, menus
2026-03-06 14:35:14 +09:00
유병철
816c25a631 fix: [finance] 일반전표 목록 source 필드 및 페이지네이션 구조 수정
- deposits/withdrawals 조회 시 source를 항상 'linked'로 고정
- 페이지네이션 meta 래핑 제거하여 플랫 구조로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:26:58 +09:00
유병철
12d172e4c3 feat: [finance] 계정과목 및 일반전표 API 추가
- AccountCode 모델/서비스/컨트롤러 구현
- JournalEntry, JournalEntryLine 모델 구현
- GeneralJournalEntry 서비스/컨트롤러 구현
- FormRequest 검증 클래스 추가
- finance 라우트 등록
- i18n 메시지 키 추가 (message.php, error.php)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:10:45 +09:00
유병철
be9c1baa34 fix: [receivables] 상위 거래처 집계에서 soft delete 레코드 제외
- orders, deposits, bills 서브쿼리에 whereNull('deleted_at') 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:16 +09:00
유병철
a7973bb555 feat: [loan] 상품권 summary에 접대비 해당 집계 추가
- expense_accounts 테이블에서 접대비(상품권) 건수/금액 조회
- entertainment_count, entertainment_amount 응답 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:37:57 +09:00
김보곤
96def0d71e feat: [approval] 사직서 양식 마이그레이션 추가 2026-03-06 00:13:18 +09:00
김보곤
846ced3ead feat: [approval] 위촉증명서 양식 데이터 마이그레이션 2026-03-05 23:57:45 +09:00
김보곤
0f25a5d4e1 feat: [approval] 경력증명서 양식 데이터 마이그레이션 2026-03-05 23:42:16 +09:00
유병철
c57e768b87 fix: [loan] 상품권 접대비 연동 시 receipt_no에 시리얼번호 매핑
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:12:46 +09:00
유병철
7fe856f3b7 fix: [loan] 상품권 카테고리는 상태 무관하게 수정/삭제 가능하도록 변경
- isEditable(): 상품권이면 상태와 무관하게 수정 허용
- isDeletable(): 상품권이면 상태와 무관하게 삭제 허용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:42:00 +09:00
유병철
652ac3d1ec fix: [loan] dashboard summary/목록에서도 used/disposed 상품권 제외
- dashboard summary 쿼리에 excludeUsedGiftCert 조건 적용
- 가지급금 목록 쿼리에도 동일 조건 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:17 +09:00
유병철
03f86f375e fix: [loan] 상품권 store 시 접대비 연동 + 카테고리 집계에서 사용/폐기 제외
- store()에서도 상품권 접대비 자동 연동 호출
- getCategoryBreakdown: used/disposed 상품권은 가지급금 집계에서 제외

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:32:36 +09:00
유병철
31d2f08dd8 feat: [loan] 상품권 접대비 자동 연동 기능 추가
- ExpenseAccount: loan_id 필드 + SUB_TYPE_GIFT_CERTIFICATE 상수 추가
- LoanService: 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제
- 마이그레이션: expense_accounts에 loan_id 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:22:44 +09:00
유병철
8c9f2fcfb5 feat: [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리 지원
- Bill 모델: V8 확장 필드 54개 추가 (증권종류, 할인, 배서, 추심, 개서, 부도 등)
- Bill 상태: 수취/발행 어음·수표별 세분화된 상태 체계
- BillService: assignV8Fields/syncInstallments 헬퍼 추출, instrument_type/medium 필터
- BillInstallment: type/counterparty 필드 추가
- Loan 모델: holding/used/disposed 상태 + metadata(JSON) 필드 추가
- LoanService: 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
- FormRequest: V8 확장 필드 검증 규칙 추가
- 마이그레이션: bills V8 필드 + loans metadata 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:45:54 +09:00
김보곤
f41605ca73 feat: [approval] 재직증명서 양식 마이그레이션 추가
- approval_forms 테이블에 employment_cert 폼 삽입
2026-03-05 18:54:25 +09:00
김보곤
66d1004bc2 feat: [rd] CM송 저장 테이블 마이그레이션 추가
- cm_songs 테이블: tenant_id, user_id, company_name, industry, lyrics, audio_path, options
2026-03-05 14:36:47 +09:00
김보곤
ce1f91074e feat: [approval] approvals 테이블에 rejection_history JSON 컬럼 추가 2026-03-05 13:50:46 +09:00
김보곤
558a393c85 feat: [approval] approvals 테이블에 resubmit_count 컬럼 추가 2026-03-05 13:06:31 +09:00
김보곤
ac72487eff feat: [approval] approvals 테이블에 drafter_read_at 컬럼 추가
- 기안자가 완료 결과를 확인했는지 추적하는 타임스탬프
- 완료함 미읽음 뱃지 기능 지원
2026-03-05 11:37:31 +09:00
3d4dd9f252 chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_api
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:32:16 +09:00
2507dcf142 chore: [docs] 모델 관계 문서 갱신 + summary 플레이스홀더 API 추가
- LOGICAL_RELATIONSHIPS.md: 최신 모델 관계 반영 (inspections, shipment dispatches 등)
- stats.php: production/construction/unshipped/attendance summary 플레이스홀더

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:24 +09:00
9b8cdfa2a5 refactor: [core] 모델 스코프 적용 — Tenant::active() 사용 및 코드 규칙 추가
- RecordStorageUsage: where 하드코딩 → Tenant::active() 스코프
- CLAUDE.md: 쿼리 수정 시 모델 스코프 우선 규칙 명시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:10 +09:00
7432fb16aa feat: [production] 자재투입 replace 모드 지원
- registerMaterialInputForItem에 replace 파라미터 추가
- 기존 투입 교체 방식 선택 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:07 +09:00
d4f21f06d6 refactor: [production] 셔터박스 prefix — isStandard 파라미터 제거
- CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize 무관하게 적용
- resolveShutterBoxPrefix()에서 불필요한 isStandard 분기 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:04 +09:00
1f7f45ee60 feat: [process] 공정단계 options 컬럼 추가 — 검사 설정/범위 지원
- ProcessStep 모델에 options JSON 컬럼 추가 (fillable, cast)
- Store/UpdateProcessStepRequest에 inspection_setting, inspection_scope 검증 규칙
- process_steps 테이블 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:01 +09:00
cd847e01a0 feat: [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
- Approval 모델에 linkable morphTo 관계 추가
- DocumentService: 상신 시 Approval 자동 생성 + approval_steps 변환
- ApprovalService: 승인/반려/회수 시 Document 상태 동기화
- approvals 테이블에 linkable_type, linkable_id 컬럼 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:00:53 +09:00
ef7d9fae24 fix: [production] 절곡 검사 products 배열 FormRequest 검증 규칙 추가
- StoreItemInspectionRequest에 inspection_data.products 검증 규칙 누락으로 validated()에서 제거되던 버그 수정
- products.*.id, bendingStatus, lengthMeasured, widthMeasured, gapPoints 규칙 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
1a8bb46137 feat: [outbound] 배차차량 관리 API — CRUD + options JSON 정책
- VehicleDispatchService: index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
- VehicleDispatchController + VehicleDispatchUpdateRequest
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
- ShipmentService.syncDispatches에 options 필드 지원 추가
- inventory.php에 vehicle-dispatches 라우트 4개 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
897511cb55 fix: [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
- storeItemInspection: bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
- transformBendingProductsToRecords: products 배열 → bending EAV 레코드 변환
- getMaterialInputLots: 품목코드별 그룹핑으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
5ee97c2d74 fix: [production] 자재투입 bom_group_key 개별 저장 — 동일 자재 다중 BOM 그룹 지원
- bom_group_key 컬럼 추가 마이그레이션 (work_order_material_inputs)
- WorkOrderMaterialInput 모델 fillable에 bom_group_key 추가
- MaterialInputForItemRequest에 bom_group_key 검증 + replace 옵션 추가
- WorkOrderService.getMaterialsForItem: stock_lot_id+bom_group_key 복합키 기투입 조회 (하위호환)
- WorkOrderService.registerMaterialInputForItem: bom_group_key 저장 + replace 모드 (기존 삭제→재등록)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
8518621432 feat: [shipment] 배차정보 다중 행 시스템 — shipment_vehicle_dispatches 테이블 추가
- 신규 마이그레이션: shipment_vehicle_dispatches 테이블 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
- 신규 모델: ShipmentVehicleDispatch (ShipmentItem 패턴 복제)
- Shipment 모델: vehicleDispatches() HasMany 관계 추가
- ShipmentService: syncDispatches() 추가, store/update/delete/show/index에서 연동
- FormRequest: Store/Update에 vehicle_dispatches 배열 검증 규칙 추가
- delivery_method 검증에 확장 옵션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
fc537898fc fix: [production] 자재투입 모달 개선 — lot_managed 필터링, BOM 그룹키, 셔터박스 순서
- getMaterialsForItem: lot_managed===false 품목 자재투입 목록에서 제외 (L-Bar, 보강평철)
- getMaterialsForItem: bom_group_key 필드 추가 (category+partType 기반 고유키)
- BendingInfoBuilder: shutterPartTypes에서 top_cover/fin_cover 제거 (별도 생성과 중복)
- BendingInfoBuilder: 셔터박스 루프 순서 파트→길이로 변경 (작업일지 순서 일치)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
유병철
fefd129795 refactor: [daily-report] 엑셀 내보내기 화면 데이터 기반으로 리팩토링
- DailyReportService: exportData를 dailyAccounts() 재사용 구조로 변경
- DailyReportExport: 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
- 화면 합계와 엑셀 합계 일치하도록 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:29 +09:00
유병철
1b2363d661 feat: [daily-report] 엑셀 내보내기에 어음/외상매출채권 현황 섹션 추가
- DailyReportExport: 어음 현황 테이블 + 합계 + 스타일링 추가
- DailyReportService: exportData에 noteReceivables 데이터 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:06:48 +09:00
유병철
f1a3e0f164 fix: [dashboard-ceo] 공정명 컬럼 및 근태 부서 조인 수정
- processes 테이블: p.name → p.process_name 컬럼명 수정
- 근태: users.department_id → tenant_user_profiles 경유 조인으로 변경
- 직급: users.position → tup.position_key 컬럼 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:41:54 +09:00
유병철
e8da2ea1b1 feat: [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀 추가
- DashboardCeoController/Service: 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
- DailyReportController/Service: 엑셀 다운로드 API 추가 (GET /daily-report/export)
- 라우트: dashboard 하위 6개 + daily-report/export 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:35:30 +09:00
김보곤
e0bb19a017 fix: [storage] RecordStorageUsage 명령어 tenants 테이블 컬럼명 오류 수정
- Tenant::where('status', 'active') → Tenant::active() 스코프 사용
- tenants 테이블에 status 컬럼 없음, tenant_st_code 사용
2026-03-05 09:16:19 +09:00
유병철
74a60e06bc feat: [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API 추가
- CalendarController/Service: 일정 등록/수정/삭제 API 추가
- VatController/Service: getDetail() 상세 조회 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
- 라우트: POST/PUT/DELETE /calendar/schedules, GET /vat/detail 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:33:04 +09:00
유병철
2f3ec13b24 fix: [entertainment] 분기 사용액 조회에 날짜 필터 적용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:00:04 +09:00
유병철
94b96e22f6 feat: [entertainment] 접대비 상세 조회 날짜 필터 파라미터 추가
- EntertainmentController: detail에 start_date/end_date 파라미터 전달
- EntertainmentService: getDetail 리스크/사용자분포/거래내역에 날짜 필터 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:32:16 +09:00
유병철
a173a5a4fc fix: [loan] getCategoryBreakdown SQL alias 충돌 수정
- outstanding_amount → cat_outstanding alias 변경 (Loan accessor 충돌 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:30:31 +09:00
유병철
66da2972fa feat: [entertainment,loan] 접대비 상세 조회 API 및 가지급금 날짜 필터 추가
- EntertainmentController/Service: getDetail() 상세 조회 API 추가 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
- EntertainmentService: 수입금액별 추가한도 계산(세법 기준), 거래건별 리스크 감지
- LoanController/Service: dashboard에 start_date/end_date 파라미터 지원
- LoanService: getCategoryBreakdown 날짜 필터 적용, 목록 limit 10→50 확대
- 라우트: GET /entertainment/detail 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:11:37 +09:00
김보곤
282bf26eec feat: [approval] 지출결의서 body_template 고도화
- 참조 문서 기반으로 정형 양식 HTML 리디자인
- 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션 포함
2026-03-04 14:52:19 +09:00
김보곤
b86af29cc9 feat: [approval] body_template 컬럼 추가 및 지출결의서 양식 등록
- approval_forms 테이블에 body_template TEXT 컬럼 추가
- 지출결의서(expense) 양식 데이터 등록 (HTML 테이블 본문 템플릿 포함)
2026-03-04 14:52:19 +09:00
유병철
f665d3aea8 fix: [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
- approval_no → approval_num 컬럼명 수정
- use_time 심야 판별: HOUR() → SUBSTRING 문자열 파싱으로 변경
- whereNotNull('bct.use_time') 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:00 +09:00
유병철
e637e3d1f7 feat: [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
- EntertainmentService: 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
- WelfareService: 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
- ReceivablesService: summary를 cards + check_points 구조로 개선 (누적/당월 미수금, Top3 거래처)
- LoanService: getCategoryBreakdown 전체 대상으로 집계 조건 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:21:05 +09:00
김보곤
7cf70dbcaa fix: [address] 주소 필드 255자 → 500자 확장
- DB 마이그레이션: clients, tenants, site_briefings, sites 테이블 address 컬럼 varchar(500)
- FormRequest 8개 파일 max:255 → max:500 변경
2026-03-04 11:29:08 +09:00
김보곤
76192fc177 fix: [cards] cards/stats → card-transactions/dashboard 리다이렉트 추가 2026-03-04 11:10:01 +09:00
김보곤
da04b84bb5 fix: [models] User 모델 import 누락/오류 수정
- Loan.php: User import 누락 → App\Models\Members\User 추가
- TodayIssue.php: App\Models\Users\User → App\Models\Members\User 수정
- Tenants 네임스페이스에서 User::class가 App\Models\Tenants\User로 잘못 해석되는 문제 해결
2026-03-04 11:06:11 +09:00
유병철
1deeafc4de feat: [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
- ExpectedExpenseController/Service: dashboardDetail에 start_date/end_date/search 파라미터 추가
- Loan 모델: category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
- LoanService: dashboard에 category_breakdown 집계 추가
- 마이그레이션: loans 테이블 category 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:53 +09:00
김보곤
4f3467c3b0 feat: [barobill] 바로빌 연동 API 엔드포인트 추가
- GET /api/v1/barobill/status — 연동 현황 조회
- POST /api/v1/barobill/login — 로그인 정보 등록
- POST /api/v1/barobill/signup — 회원가입 정보 등록
- GET /api/v1/barobill/bank-service-url — 은행 서비스 URL
- GET /api/v1/barobill/account-link-url — 계좌 연동 URL
- GET /api/v1/barobill/card-link-url — 카드 연동 URL
- GET /api/v1/barobill/certificate-url — 공인인증서 URL
2026-03-04 09:03:55 +09:00
김보곤
e9fd75fa74 feat: [inspection] 캘린더 스케줄 조회 API 추가
- GET /api/v1/inspections/calendar 엔드포인트 추가
- year, month, inspector, status 파라미터 지원
- React 프론트엔드 CalendarItemApi 형식에 맞춰 응답
2026-03-04 08:33:32 +09:00
김보곤
23c6cf6919 feat: [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
- Leave 타입 6개 추가: business_trip, remote, field_work, early_leave, late_reason, absent_reason
- 그룹 상수 추가: VACATION_TYPES, ATTENDANCE_REQUEST_TYPES, REASON_REPORT_TYPES
- FORM_CODE_MAP: 유형 → 결재양식코드 매핑 상수
- ATTENDANCE_STATUS_MAP: 유형 → 근태상태 매핑 상수
- 결재양식 2개 추가: attendance_request(근태신청), reason_report(사유서)
2026-03-03 23:52:13 +09:00
유병철
42443349dc feat: [stock,client,status-board] 날짜 필터 및 조건 보완
- StockController/StockService: 입출고 이력 기반 날짜 범위 필터 추가
- ClientService: 등록일 기간 필터(start_date/end_date) 추가
- StatusBoardService: 부실채권 현황에 is_active 조건 추가 (목록 페이지 일치)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:53:30 +09:00
유병철
ad27090bfc feat: [daily-report] 자금현황 카드용 필드 추가
- 미수금 잔액(receivable_balance) 계산 로직 구현
- 미지급금 잔액(payable_balance) 계산 로직 구현
- 당월 예상 지출(monthly_expense_total) 계산 로직 구현
- summary API 응답에 자금현황 3개 필드 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:06:09 +09:00
유병철
b7465becab feat: [approval] 결재 수신함 날짜 범위 필터 추가
- InboxIndexRequest에 start_date/end_date 검증 룰 추가
- ApprovalService.inbox()에 created_at 날짜 범위 필터 구현

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:02:07 +09:00
유병철
83a774572a feat: [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
- TodayIssueController에 date 파라미터(YYYY-MM-DD) 추가
- TodayIssueService.summary()에 날짜 기반 필터링 로직 구현
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:48:30 +09:00
김보곤
da1142af62 feat: [ai-quotation] 제조 견적서 마이그레이션 추가
- ai_quotations: quote_mode, quote_number, product_category 컬럼 추가
- ai_quotation_items: specification, unit, quantity, unit_price, total_price, item_category, floor_code 컬럼 추가
- ai_quote_price_tables 테이블 신규 생성
2026-03-03 15:57:19 +09:00
김보곤
b3c7d08b2c feat: [hr] 사업소득자 임금대장 display_name/business_reg_number 컬럼 추가
- user_id nullable 변경 (직접 입력 대상자 지원)
- display_name, business_reg_number 컬럼 추가
- 기존 데이터 earner 프로필에서 자동 채움
2026-03-03 14:33:03 +09:00
7e309e4057 fix: [deploy] 배포 시 .env 권한 640 보장 추가
- Stage/Production 배포 스크립트에 chmod 640 추가
- vi 편집으로 인한 .env 권한 변경(600) 방지
- 2026-03-03 장애 재발 방지 (PHP-FPM이 .env 읽기 실패 → 500)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:51:07 +09:00
김보곤
f79d008777 chore: [ai] Gemini 모델 gemini-2.0-flash → gemini-2.5-flash 마이그레이션
- config/services.php fallback 기본값 변경
- AiReportService fallback 기본값 변경
2026-03-03 08:09:08 +09:00
김보곤
abe04607e4 feat: [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
- ai_quotation_modules: SAM 모듈 카탈로그 (18개 모듈)
- ai_quotations: AI 견적 요청/결과
- ai_quotation_items: AI 추천 모듈 목록
- AiQuotationModuleSeeder: customer-pricing 기반 초기 데이터
2026-03-02 17:43:36 +09:00
김보곤
3ca161e9e2 feat: [roadmap] 중장기 계획 테이블 마이그레이션 추가
- admin_roadmap_plans: 계획 테이블 (제목, 카테고리, 상태, Phase, 진행률 등)
- admin_roadmap_milestones: 마일스톤 테이블 (plan_id FK, 상태, 예정일 등)
2026-03-02 15:50:05 +09:00
김보곤
587bdf5d80 feat: [interview] 마스터 질문 데이터 시드 마이그레이션 추가
- 8개 도메인, 16개 템플릿, 80개 마스터 질문 INSERT
- idempotent 처리: 이미 도메인 카테고리 존재 시 스킵
- Jenkins 자동 배포로 운영서버 데이터 반영 목적
2026-02-28 22:05:59 +09:00
김보곤
f2d36c3616 feat: [interview] 카테고리 계층 구조 parent_id 마이그레이션 추가
- interview_categories 테이블에 parent_id 컬럼 추가
- self-referencing FK, nullOnDelete
2026-02-28 21:23:19 +09:00
김보곤
397d50de1f feat: [interview] 인터뷰 시나리오 고도화 마이그레이션
- interview_projects 테이블 신규 (회사별 프로젝트)
- interview_attachments 테이블 신규 (첨부파일 + AI 분석)
- interview_knowledge 테이블 신규 (AI 추출 지식)
- interview_categories에 project_id, domain 컬럼 추가
- interview_questions에 ai_hint, expected_format, depends_on, domain 추가
- interview_answers에 answer_data, attachments JSON 추가
- interview_sessions에 project_id, session_type, voice_recording_id 추가
2026-02-28 20:02:33 +09:00
김보곤
73949b1282 feat: [document] 블록 빌더 지원 마이그레이션 추가
- document_templates: builder_type, schema, page_config 컬럼 추가
- documents: data JSON, rendered_html, pdf_path 컬럼 추가
2026-02-28 19:31:46 +09:00
김보곤
e40555ad37 feat: [leaves] 휴가-결재 연동을 위한 DB 변경
- leaves 테이블에 approval_id 컬럼 추가 (마이그레이션)
- 휴가신청 결재 양식(approval_forms) 등록 (마이그레이션)
- Leave 모델 fillable에 approval_id 추가
2026-02-28 15:54:34 +09:00
김보곤
79da7a6da7 feat: [equipment] 다중 점검주기 + 부 담당자 DB 스키마 추가
- equipments: sub_manager_id 컬럼 추가
- equipment_inspection_templates: inspection_cycle 컬럼 + 유니크 변경
- equipment_inspections: inspection_cycle 컬럼 + 유니크 변경
2026-02-28 12:37:26 +09:00
김보곤
b04d407fdb feat: [approval] Phase 2 마이그레이션 추가
- approval_steps: parallel_group, acted_by, approval_type 컬럼 추가
- approvals: recall_reason, parent_doc_id 컬럼 추가
- approval_delegations 테이블 생성 (위임/대결)
2026-02-27 23:41:34 +09:00
김보곤
c32d68f069 feat: [approval] 결재관리 Phase 1 마이그레이션
- approvals 테이블: line_id, body, is_urgent, department_id 컬럼 추가
- approval_steps 테이블: approver_name, approver_department, approver_position 스냅샷 컬럼 추가
2026-02-27 23:26:40 +09:00
afc1aa72a8 feat: [inspection] 절곡 검사 마감유형(S1/S2/S3) 프로파일 분리 — 5130 레거시 기준
- BENDING_GAP_PROFILES를 S1/S2/S3 + common 계층 구조로 재구성
  - S1(KSS01): 벽면 4pt, 측면 6pt, 하단 1pt
  - S2(KSS02): 벽면 3pt, 측면 5pt, 하단 1pt
  - S3(KWE01/KSE01+SUS): 벽면 5pt, 측면 7pt, 하단 2pt
- resolveFinishingType() 신규: product_code → 마감유형 자동 판별
- buildBendingInspectionItems()에 마감유형별 프로파일 적용
- getInspectionConfig() 응답에 finishing_type 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:18:09 +09:00
f970b6bf4b feat: [inspection] Phase 3 절곡 검사 동적 구현 — inspection-config API + 트랜잭션 보강
- inspection-config API 신규: GET /work-orders/{id}/inspection-config
  - 공정 자동 판별 (resolveInspectionProcessType)
  - bending_info 기반 구성품 목록 + gap_points 반환
  - BENDING_GAP_PROFILES 상수 (6개 구성품 간격 기준치)
- createInspectionDocument 트랜잭션 보강
  - DB::transaction() + lockForUpdate() 적용
  - 동시 생성 race condition 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:18:09 +09:00
e6c02292d2 feat: [quote/quality] Phase 2B 견적 product_code 자동추출 + inspections work_order_id FK
- QuoteService: extractProductCodeFromInputs() 추가, store/update에서 자동 추출
- BackfillQuoteProductCodeCommand: 기존 quotes 25건 product_code 보정
- inspections 테이블에 work_order_id FK 마이그레이션 (nullable, nullOnDelete)
- Inspection↔WorkOrder 양방향 관계 추가
- InspectionService: store/show/index에 work_order_id 처리 + transformToFrontend
- InspectionStoreRequest: work_order_id 검증 규칙 추가
2026-02-27 23:18:09 +09:00
9e84fa04a6 fix: [production] product_code 전파 버그 수정
- OrderService::createProductionOrder에 product_code/product_name 추가
- WorkOrderService::store 수주복사 경로에 product_code/product_name 추가
- order_nodes.options → work_order_items.options 전파 누락 해결
2026-02-27 23:18:09 +09:00
김보곤
ff8b37670e feat: [hr] 사업소득자 임금대장 테이블 마이그레이션 추가
- business_income_payments 테이블 생성
- 소득세(3%)/지방소득세(0.3%) 고정세율 구조
- (tenant_id, user_id, pay_year, pay_month) 유니크 제약
2026-02-27 20:21:59 +09:00
김보곤
75408d925f feat: [esign] esign_contracts 테이블에 completion_template_name 컬럼 추가
- 완료 알림톡 템플릿명을 저장하기 위한 nullable string 컬럼
2026-02-27 16:28:57 +09:00
김보곤
dd11f780b4 feat: [payroll] 근로소득세 간이세액표 DB 테이블 및 시더 추가
- income_tax_brackets 테이블 마이그레이션 생성
- 2024년 국세청 간이세액표 데이터 시더 (7,117건)
- salary_from/salary_to(천원), family_count(1~11), tax_amount(원)
2026-02-27 13:58:39 +09:00
김보곤
c94bef1dae feat: [hr] 사업소득자관리 worker_type 컬럼 추가
- tenant_user_profiles 테이블에 worker_type 컬럼 추가 (employee/business_income)
- TenantUserProfile 모델 fillable에 worker_type 추가
2026-02-27 13:46:42 +09:00
f53f04de65 fix: [cicd] 배포 시 storage/bootstrap 권한 설정 추가
- mkdir 후 www-data:webservice 소유권 + 775 권한 설정
- Stage/Production 배포 모두 적용
- 원인: PHP-FPM(www-data)이 storage 쓰기 불가 → 500 에러
2026-02-27 10:43:11 +09:00
김보곤
96c4f71607 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-27 10:08:06 +09:00
김보곤
0d1d056b13 feat: [payroll] payrolls 테이블에 long_term_care 컬럼 추가 2026-02-27 10:06:25 +09:00
ac7279606d chore: 운영 배포 승인 단계 비활성화 (개발 단계)
- Production Approval stage 주석처리
- 런칭 후 다시 활성화 예정
- 배포 흐름: main push → Stage → Production (승인 없이 자동)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:32:13 +09:00
김보곤
fcb377a40c feat: [attendance] attendance_requests 테이블 마이그레이션 추가
- 근태 승인 워크플로우용 신청 테이블
- tenant_id, user_id, request_type, start_date, end_date, status 등
2026-02-26 20:56:41 +09:00
김보곤
1a2350db7d feat: [calendar] 달력 일정 관리 API 구현
- GET /api/v1/calendar-schedules — 연도별 일정 목록 조회
- GET /api/v1/calendar-schedules/stats — 통계 조회
- GET /api/v1/calendar-schedules/{id} — 단건 조회
- POST /api/v1/calendar-schedules — 등록
- PUT /api/v1/calendar-schedules/{id} — 수정
- DELETE /api/v1/calendar-schedules/{id} — 삭제
- POST /api/v1/calendar-schedules/bulk — 대량 등록
2026-02-26 14:29:12 +09:00
김보곤
0ff731ab09 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-25 21:35:53 +09:00
0345ddcce3 fix: 세션 만료 예외를 슬랙 알림에서 제외
- '회원정보 정보 없음' AuthenticationException은 API Key 검증 통과 후 발생하므로 세션 만료 정상 케이스
- IP 기반 필터링(EXCEPTION_IGNORED_IPS) 대신 예외 자체를 무조건 제외하도록 단순화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:14:48 +09:00
김보곤
a6e547f40d feat: [equipment] files 테이블에 GCS 컬럼 추가
- gcs_object_name, gcs_uri 컬럼 추가
- 설비 사진 멀티 업로드 기능 지원
2026-02-25 20:14:41 +09:00
김보곤
e819635ea6 feat: [equipment] 설비관리 테이블 마이그레이션 6개 생성
- equipments (설비 마스터)
- equipment_inspection_templates (점검항목 템플릿)
- equipment_inspections (월간 점검 헤더)
- equipment_inspection_details (일자별 점검 결과)
- equipment_repairs (수리이력)
- equipment_process (설비-공정 피봇)
2026-02-25 19:39:52 +09:00
285 changed files with 25038 additions and 1923 deletions

View File

@@ -54,4 +54,4 @@ ## 관련 파일
- `api/app/Services/ComprehensiveAnalysisService.php`
- `api/database/seeders/ComprehensiveAnalysisSeeder.php`
- `docs/plans/react-mock-remaining-tasks.md`
- `docs/dev/dev_plans/react-mock-remaining-tasks.md`

View File

@@ -15,7 +15,7 @@ ## Phase 구성
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
## 핵심 파일
- 계획 문서: docs/plans/db-backup-system-plan.md
- 계획 문서: docs/dev/dev_plans/db-backup-system-plan.md
- 개발서버: 114.203.209.83 (SSH: hskwon)
- DB: sam (메인) + sam_stat (통계)
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)

View File

@@ -16,7 +16,7 @@ ### 생성된 파일
| 파일 | 설명 |
|------|------|
| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest |
| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
| `api/docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
### 수정된 파일
| 파일 | 설명 |
@@ -93,9 +93,9 @@ ### QuoteCalculationService::calculateBomBulk()
- 개별 품목 실패가 전체에 영향 없음 (예외 처리)
## 관련 문서
- 계획 문서: `docs/plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md`
- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/dev/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md`
## 다음 단계
- React 프론트엔드에서 `/calculate/bom/bulk` API 연동

View File

@@ -107,3 +107,19 @@ fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
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: []

View File

@@ -509,6 +509,7 @@ ### 2. Multi-tenancy & Models
- SoftDeletes by default
- Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required)
- FK constraints: Created during design, minimal in production
- **🔴 쿼리 수정 시 모델 스코프 우선**: `where('컬럼', '값')` 하드코딩 전에 반드시 모델에 정의된 스코프(scopeActive 등)를 먼저 확인하고, 스코프가 있으면 `Model::active()` 형태로 사용할 것
### 3. Middleware Stack
- ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper

130
Jenkinsfile vendored
View File

@@ -1,6 +1,12 @@
pipeline {
agent any
parameters {
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
}
options {
disableConcurrentBuilds()
}
@@ -8,10 +14,73 @@ pipeline {
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
PROD_SERVER = '211.117.60.189'
}
stages {
// ── 롤백: 릴리스 목록 조회 ──
stage('Rollback: List Releases') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
echo "현재 활성: ${current}"
echo "사용 가능:\n${releases}"
}
}
}
}
// ── 롤백: symlink 전환 ──
stage('Rollback: Switch Release') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/api' : '/home/webservice/api-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def targetRelease = params.ROLLBACK_RELEASE
if (!targetRelease?.trim()) {
// 비워두면 직전 릴리스로 롤백
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
}
// 릴리스 존재 여부 확인
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
slackSend channel: '#deploy_api', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔄 *api* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} '
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
cd ${basePath}/current &&
php artisan config:cache &&
php artisan route:cache &&
php artisan view:cache &&
sudo systemctl reload php8.4-fpm
'
"""
if (params.ROLLBACK_TARGET == 'production') {
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'sudo supervisorctl restart sam-queue-worker:*'"
}
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
}
// ── 일반 배포: Checkout ──
stage('Checkout') {
when { expression { params.ACTION == 'deploy' } }
steps {
checkout scm
script {
@@ -24,17 +93,22 @@ pipeline {
// ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') {
when { branch 'main' }
when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api-stage/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@${PROD_SERVER} '
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
@@ -71,17 +145,22 @@ pipeline {
// ── main → 운영서버 Production 배포 ──
stage('Deploy Production') {
when { branch 'main' }
when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
rsync -az --delete \
--exclude='.git' --exclude='.env' \
--exclude='storage/app' --exclude='storage/logs' \
--exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
. ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@211.117.60.189 '
. ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/api/releases/${RELEASE_ID}/
ssh ${DEPLOY_USER}@${PROD_SERVER} '
cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
@@ -109,23 +188,32 @@ pipeline {
post {
success {
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script {
if (params.ACTION == 'deploy') {
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
failure {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script {
if (env.BRANCH_NAME == 'main') {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@211.117.60.189 '
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm
' || true
"""
if (params.ACTION == 'deploy') {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
if (env.BRANCH_NAME == 'main') {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} '
PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
[ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
sudo systemctl reload php8.4-fpm
' || true
"""
}
}
} else {
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-02-21 16:28:35
> **자동 생성**: 2026-03-12 13:58:25
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -26,6 +26,68 @@ ### bad_debt_memos
- **badDebt()**: belongsTo → `bad_debts`
- **creator()**: belongsTo → `users`
### barobill_bank_sync_status
**모델**: `App\Models\Barobill\BarobillBankSyncStatus`
- **tenant()**: belongsTo → `tenants`
### barobill_bank_transactions
**모델**: `App\Models\Barobill\BarobillBankTransaction`
- **tenant()**: belongsTo → `tenants`
### barobill_bank_transaction_splits
**모델**: `App\Models\Barobill\BarobillBankTransactionSplit`
- **tenant()**: belongsTo → `tenants`
### barobill_billing_records
**모델**: `App\Models\Barobill\BarobillBillingRecord`
- **member()**: belongsTo → `barobill_members`
### barobill_card_transactions
**모델**: `App\Models\Barobill\BarobillCardTransaction`
- **tenant()**: belongsTo → `tenants`
### barobill_card_transaction_amount_logs
**모델**: `App\Models\Barobill\BarobillCardTransactionAmountLog`
- **cardTransaction()**: belongsTo → `barobill_card_transactions`
### barobill_card_transaction_splits
**모델**: `App\Models\Barobill\BarobillCardTransactionSplit`
- **tenant()**: belongsTo → `tenants`
### barobill_members
**모델**: `App\Models\Barobill\BarobillMember`
- **tenant()**: belongsTo → `tenants`
### barobill_monthly_summarys
**모델**: `App\Models\Barobill\BarobillMonthlySummary`
- **member()**: belongsTo → `barobill_members`
### barobill_subscriptions
**모델**: `App\Models\Barobill\BarobillSubscription`
- **member()**: belongsTo → `barobill_members`
### hometax_invoices
**모델**: `App\Models\Barobill\HometaxInvoice`
- **tenant()**: belongsTo → `tenants`
- **journals()**: hasMany → `hometax_invoice_journals`
### hometax_invoice_journals
**모델**: `App\Models\Barobill\HometaxInvoiceJournal`
- **tenant()**: belongsTo → `tenants`
- **invoice()**: belongsTo → `hometax_invoices`
### biddings
**모델**: `App\Models\Bidding\Bidding`
@@ -309,6 +371,43 @@ ### esign_signers
- **contract()**: belongsTo → `esign_contracts`
- **signFields()**: hasMany → `esign_sign_fields`
### equipments
**모델**: `App\Models\Equipment\Equipment`
- **inspectionTemplates()**: hasMany → `equipment_inspection_templates`
- **inspections()**: hasMany → `equipment_inspections`
- **repairs()**: hasMany → `equipment_repairs`
- **photos()**: hasMany → `files`
- **processes()**: belongsToMany → `processes`
### equipment_inspections
**모델**: `App\Models\Equipment\EquipmentInspection`
- **equipment()**: belongsTo → `equipments`
- **details()**: hasMany → `equipment_inspection_details`
### equipment_inspection_details
**모델**: `App\Models\Equipment\EquipmentInspectionDetail`
- **inspection()**: belongsTo → `equipment_inspections`
- **templateItem()**: belongsTo → `equipment_inspection_templates`
### equipment_inspection_templates
**모델**: `App\Models\Equipment\EquipmentInspectionTemplate`
- **equipment()**: belongsTo → `equipments`
### equipment_process
**모델**: `App\Models\Equipment\EquipmentProcess`
- **equipment()**: belongsTo → `equipments`
- **process()**: belongsTo → `processes`
### equipment_repairs
**모델**: `App\Models\Equipment\EquipmentRepair`
- **equipment()**: belongsTo → `equipments`
### estimates
**모델**: `App\Models\Estimate\Estimate`
@@ -580,17 +679,10 @@ ### roles
**모델**: `App\Models\Permissions\Role`
- **tenant()**: belongsTo → `tenants`
- **menuPermissions()**: hasMany → `role_menu_permissions`
- **userRoles()**: hasMany → `user_roles`
- **users()**: belongsToMany → `users`
- **permissions()**: belongsToMany → `permissions`
### role_menu_permissions
**모델**: `App\Models\Permissions\RoleMenuPermission`
- **role()**: belongsTo → `roles`
- **menu()**: belongsTo → `menus`
### popups
**모델**: `App\Models\Popups\Popup`
@@ -637,6 +729,7 @@ ### work_orders
- **stepProgress()**: hasMany → `work_order_step_progress`
- **materialInputs()**: hasMany → `work_order_material_inputs`
- **shipments()**: hasMany → `shipments`
- **inspections()**: hasMany → `inspections`
- **bendingDetail()**: hasOne → `work_order_bending_details`
- **documents()**: morphMany → `documents`
@@ -740,9 +833,40 @@ ### push_notification_settings
**모델**: `App\Models\PushNotificationSetting`
### audit_checklists
**모델**: `App\Models\Qualitys\AuditChecklist`
- **categories()**: hasMany → `audit_checklist_categories`
### audit_checklist_categorys
**모델**: `App\Models\Qualitys\AuditChecklistCategory`
- **checklist()**: belongsTo → `audit_checklists`
- **items()**: hasMany → `audit_checklist_items`
### audit_checklist_items
**모델**: `App\Models\Qualitys\AuditChecklistItem`
- **category()**: belongsTo → `audit_checklist_categories`
- **standardDocuments()**: hasMany → `audit_standard_documents`
### audit_standard_documents
**모델**: `App\Models\Qualitys\AuditStandardDocument`
- **checklistItem()**: belongsTo → `audit_checklist_items`
- **document()**: belongsTo → `documents`
### checklist_templates
**모델**: `App\Models\Qualitys\ChecklistTemplate`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **documents()**: morphMany → `files`
### inspections
**모델**: `App\Models\Qualitys\Inspection`
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
@@ -758,6 +882,38 @@ ### lot_sales
- **lot()**: belongsTo → `lots`
### performance_reports
**모델**: `App\Models\Qualitys\PerformanceReport`
- **qualityDocument()**: belongsTo → `quality_documents`
- **confirmer()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### quality_documents
**모델**: `App\Models\Qualitys\QualityDocument`
- **client()**: belongsTo → `clients`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
- **qualityDocument()**: belongsTo → `quality_documents`
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
- **orderItem()**: belongsTo → `order_items`
- **document()**: belongsTo → `documents`
### quality_document_orders
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
- **qualityDocument()**: belongsTo → `quality_documents`
- **order()**: belongsTo → `orders`
- **locations()**: hasMany → `quality_document_locations`
### quotes
**모델**: `App\Models\Quote\Quote`
@@ -811,6 +967,11 @@ ### quote_revisions
- **quote()**: belongsTo → `quotes`
- **reviser()**: belongsTo → `users`
### account_codes
**모델**: `App\Models\Tenants\AccountCode`
- **children()**: hasMany → `account_codes`
### ai_reports
**모델**: `App\Models\Tenants\AiReport`
@@ -830,12 +991,23 @@ ### approvals
**모델**: `App\Models\Tenants\Approval`
- **form()**: belongsTo → `approval_forms`
- **line()**: belongsTo → `approval_lines`
- **drafter()**: belongsTo → `users`
- **department()**: belongsTo → `departments`
- **parentDocument()**: belongsTo → `approvals`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **childDocuments()**: hasMany → `approvals`
- **steps()**: hasMany → `approval_steps`
- **approverSteps()**: hasMany → `approval_steps`
- **referenceSteps()**: hasMany → `approval_steps`
- **linkable()**: morphTo → `(Polymorphic)`
### approval_delegations
**모델**: `App\Models\Tenants\ApprovalDelegation`
- **delegator()**: belongsTo → `users`
- **delegate()**: belongsTo → `users`
### approval_forms
**모델**: `App\Models\Tenants\ApprovalForm`
@@ -855,6 +1027,7 @@ ### approval_steps
- **approval()**: belongsTo → `approvals`
- **approver()**: belongsTo → `users`
- **actedBy()**: belongsTo → `users`
### attendances
**모델**: `App\Models\Tenants\Attendance`
@@ -933,6 +1106,16 @@ ### expense_accounts
- **vendor()**: belongsTo → `clients`
### journal_entrys
**모델**: `App\Models\Tenants\JournalEntry`
- **lines()**: hasMany → `journal_entry_lines`
### journal_entry_lines
**모델**: `App\Models\Tenants\JournalEntryLine`
- **journalEntry()**: belongsTo → `journal_entries`
### leaves
**모델**: `App\Models\Tenants\Leave`
@@ -961,7 +1144,15 @@ ### leave_policys
### loans
**모델**: `App\Models\Tenants\Loan`
- **user()**: belongsTo → `users`
- **withdrawal()**: belongsTo → `withdrawals`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### mail_logs
**모델**: `App\Models\Tenants\MailLog`
- **tenant()**: belongsTo → `tenants`
### payments
**모델**: `App\Models\Tenants\Payment`
@@ -1005,6 +1196,7 @@ ### receivings
**모델**: `App\Models\Tenants\Receiving`
- **item()**: belongsTo → `items`
- **certificateFile()**: belongsTo → `files`
- **creator()**: belongsTo → `users`
### salarys
@@ -1043,6 +1235,7 @@ ### shipments
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items`
- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches`
### shipment_items
**모델**: `App\Models\Tenants\ShipmentItem`
@@ -1050,6 +1243,11 @@ ### shipment_items
- **shipment()**: belongsTo → `shipments`
- **stockLot()**: belongsTo → `stock_lots`
### shipment_vehicle_dispatchs
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
- **shipment()**: belongsTo → `shipments`
### sites
**모델**: `App\Models\Tenants\Site`
@@ -1120,6 +1318,11 @@ ### tenant_field_settings
- **fieldDef()**: belongsTo → `setting_field_defs`
- **optionGroup()**: belongsTo → `tenant_option_groups`
### tenant_mail_configs
**모델**: `App\Models\Tenants\TenantMailConfig`
- **tenant()**: belongsTo → `tenants`
### tenant_option_groups
**모델**: `App\Models\Tenants\TenantOptionGroup`
@@ -1147,6 +1350,8 @@ ### tenant_user_profiles
### today_issues
**모델**: `App\Models\Tenants\TodayIssue`
- **reader()**: belongsTo → `users`
- **targetUser()**: belongsTo → `users`
### withdrawals
**모델**: `App\Models\Tenants\Withdrawal`

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Quote\Quote;
use Illuminate\Console\Command;
class BackfillQuoteProductCodeCommand extends Command
{
protected $signature = 'data:backfill-quote-product-code {--dry-run : 실제 저장하지 않고 결과만 출력}';
protected $description = 'quotes.product_code가 비어있는 레코드에 calculation_inputs.items[0].productCode 값 보정';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$quotes = Quote::whereNull('product_code')
->whereNotNull('calculation_inputs')
->get();
$this->info("대상: {$quotes->count()}".($dryRun ? ' (dry-run)' : ''));
$updated = 0;
$skipped = 0;
foreach ($quotes as $quote) {
$inputs = $quote->calculation_inputs;
if (! is_array($inputs)) {
$inputs = json_decode($inputs, true);
}
$productCode = $inputs['items'][0]['productCode'] ?? null;
if (! $productCode) {
$skipped++;
$this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음");
continue;
}
if (! $dryRun) {
$quote->update(['product_code' => $productCode]);
}
$updated++;
$this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}");
}
$this->info("완료: 보정 {$updated}건, 스킵 {$skipped}");
return self::SUCCESS;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,264 @@
<?php
namespace App\Console\Commands;
use App\Models\Commons\File;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\File as FileFacade;
class UploadLocalFilesToR2 extends Command
{
protected $signature = 'r2:upload-local
{--count=3 : Number of files to upload}
{--source=db : Source: "db" (latest DB records) or "disk" (latest local files)}
{--dry-run : Show files without uploading}
{--fix : Delete wrong-path files from R2 before re-uploading}';
protected $description = 'Upload local files to Cloudflare R2 (by DB records or local disk)';
public function handle(): int
{
$count = (int) $this->option('count');
$source = $this->option('source');
$dryRun = $this->option('dry-run');
$this->info("=== R2 Upload Tool ===");
$this->info("Source: {$source} | Count: {$count}");
return $source === 'db'
? $this->uploadFromDb($count, $dryRun)
: $this->uploadFromDisk($count, $dryRun);
}
/**
* Upload files based on DB records (latest by ID desc)
*/
private function uploadFromDb(int $count, bool $dryRun): int
{
$files = File::orderByDesc('id')->limit($count)->get();
if ($files->isEmpty()) {
$this->warn('No files in DB.');
return 0;
}
$this->newLine();
$headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size'];
$rows = [];
foreach ($files as $f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
$r2Exists = Storage::disk('r2')->exists($f->file_path);
$localExists = file_exists($localPath);
$rows[] = [
$f->id,
mb_strimwidth($f->display_name ?? '', 0, 25, '...'),
$f->file_path,
$r2Exists ? '✓ YES' : '✗ NO',
$localExists ? '✓ YES' : '✗ NO',
$f->file_size ? $this->formatSize($f->file_size) : '-',
];
}
$this->table($headers, $rows);
// Filter: local exists but R2 doesn't
$toUpload = $files->filter(function ($f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
});
$alreadyInR2 = $files->filter(function ($f) {
return Storage::disk('r2')->exists($f->file_path);
});
if ($alreadyInR2->isNotEmpty()) {
$this->info("Already in R2: {$alreadyInR2->count()} files (skipped)");
}
if ($toUpload->isEmpty()) {
$missingBoth = $files->filter(function ($f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path);
});
if ($missingBoth->isNotEmpty()) {
$this->warn("Missing both locally and in R2: {$missingBoth->count()} files");
$this->warn("These files may exist on the dev server only.");
}
$this->info('Nothing to upload.');
return 0;
}
if ($dryRun) {
$this->warn("[DRY RUN] Would upload {$toUpload->count()} files.");
return 0;
}
// Test R2 connection
$this->info('Testing R2 connection...');
try {
Storage::disk('r2')->directories('/');
$this->info('✓ R2 connection OK');
} catch (\Exception $e) {
$this->error('✗ R2 connection failed: ' . $e->getMessage());
return 1;
}
// Upload
$bar = $this->output->createProgressBar($toUpload->count());
$bar->start();
$success = 0;
$failed = 0;
foreach ($toUpload as $f) {
$localPath = storage_path("app/tenants/{$f->file_path}");
try {
$content = FileFacade::get($localPath);
$mimeType = $f->mime_type ?: FileFacade::mimeType($localPath);
Storage::disk('r2')->put($f->file_path, $content, [
'ContentType' => $mimeType,
]);
$success++;
} catch (\Exception $e) {
$failed++;
$this->newLine();
$this->error(" ✗ ID {$f->id}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("=== Upload Complete ===");
$this->info("✓ Success: {$success}");
if ($failed > 0) {
$this->error("✗ Failed: {$failed}");
return 1;
}
return 0;
}
/**
* Upload files based on local disk (newest files by mtime)
*/
private function uploadFromDisk(int $count, bool $dryRun): int
{
$storagePath = storage_path('app/tenants');
if (!is_dir($storagePath)) {
$this->error("Path not found: {$storagePath}");
return 1;
}
$allFiles = $this->collectFiles($storagePath);
if (empty($allFiles)) {
$this->warn('No files found.');
return 0;
}
usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a));
$filesToUpload = array_slice($allFiles, 0, $count);
$this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:");
$this->newLine();
$headers = ['#', 'File', 'Size', 'Modified', 'R2 Path'];
$rows = [];
foreach ($filesToUpload as $i => $filePath) {
$r2Path = $this->toR2Path($filePath);
$rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path];
}
$this->table($headers, $rows);
if ($dryRun) {
$this->warn('[DRY RUN] No files uploaded.');
return 0;
}
$this->info('Testing R2 connection...');
try {
Storage::disk('r2')->directories('/');
$this->info('✓ R2 connection OK');
} catch (\Exception $e) {
$this->error('✗ R2 connection failed: ' . $e->getMessage());
return 1;
}
$bar = $this->output->createProgressBar(count($filesToUpload));
$bar->start();
$success = 0;
$failed = 0;
$fix = $this->option('fix');
foreach ($filesToUpload as $filePath) {
$r2Path = $this->toR2Path($filePath);
try {
if ($fix) {
$wrongPath = $this->toRelativePath($filePath);
if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) {
Storage::disk('r2')->delete($wrongPath);
}
}
$content = FileFacade::get($filePath);
$mimeType = FileFacade::mimeType($filePath);
Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]);
$success++;
} catch (\Exception $e) {
$failed++;
$this->newLine();
$this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("=== Upload Complete ===");
$this->info("✓ Success: {$success}");
if ($failed > 0) {
$this->error("✗ Failed: {$failed}");
return 1;
}
return 0;
}
private function toR2Path(string $filePath): string
{
$relative = $this->toRelativePath($filePath);
return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative;
}
private function toRelativePath(string $filePath): string
{
return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath));
}
private function collectFiles(string $dir): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getFilename() !== '.gitignore') {
$files[] = $file->getPathname();
}
}
return $files;
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1024, 1) . ' KB';
}
}

View File

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

View File

@@ -7,6 +7,7 @@
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle
@@ -31,10 +32,10 @@ public function headings(): array
return [
['일일 일보 - '.$this->report['date']],
[],
['전일 잔액', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['당일 잔액', number_format($this->report['current_balance']).'원'],
['전월 이월', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['잔액', number_format($this->report['current_balance']).'원'],
[],
['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'],
];
@@ -47,6 +48,7 @@ public function array(): array
{
$rows = [];
// ── 예금 입출금 내역 ──
foreach ($this->report['details'] as $detail) {
$rows[] = [
$detail['type_label'],
@@ -58,7 +60,7 @@ public function array(): array
];
}
// 합계 행 추가
// 합계 행
$rows[] = [];
$rows[] = [
'합계',
@@ -69,6 +71,37 @@ public function array(): array
'',
];
// ── 어음 및 외상매출채권 현황 ──
$noteReceivables = $this->report['note_receivables'] ?? [];
$rows[] = [];
$rows[] = [];
$rows[] = ['어음 및 외상매출채권 현황'];
$rows[] = ['No.', '내용', '금액', '발행일', '만기일'];
$noteTotal = 0;
$no = 1;
foreach ($noteReceivables as $item) {
$amount = $item['current_balance'] ?? 0;
$noteTotal += $amount;
$rows[] = [
$no++,
$item['content'] ?? '-',
$amount > 0 ? number_format($amount) : '',
$item['issue_date'] ?? '-',
$item['due_date'] ?? '-',
];
}
// 어음 합계
$rows[] = [
'합계',
'',
number_format($noteTotal),
'',
'',
];
return $rows;
}
@@ -77,7 +110,7 @@ public function array(): array
*/
public function styles(Worksheet $sheet): array
{
return [
$styles = [
1 => ['font' => ['bold' => true, 'size' => 14]],
3 => ['font' => ['bold' => true]],
4 => ['font' => ['bold' => true]],
@@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array
8 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
],
];
// 어음 섹션 헤더 스타일 (동적 행 번호)
// headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행
$detailCount = count($this->report['details']);
$noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행
$noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행
$styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]];
$styles[$noteHeaderRow] = [
'font' => ['bold' => true],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
];
// 어음 합계 행
$noteCount = count($this->report['note_receivables'] ?? []);
$noteTotalRow = $noteHeaderRow + $noteCount + 1;
$styles[$noteTotalRow] = ['font' => ['bold' => true]];
return $styles;
}
}

View File

@@ -14,7 +14,7 @@
* - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea)
*
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
*/
class Legacy5130Calculator
{

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
use App\Http\Requests\V1\AccountSubject\UpdateAccountSubjectRequest;
use App\Services\AccountCodeService;
use Illuminate\Http\Request;
class AccountSubjectController extends Controller
{
public function __construct(
private readonly AccountCodeService $service
) {}
/**
* 계정과목 목록 조회
*/
public function index(Request $request)
{
$params = $request->only([
'search', 'category', 'sub_category',
'department_type', 'depth', 'is_active', 'selectable',
]);
$subjects = $this->service->index($params);
return ApiResponse::success($subjects, __('message.fetched'));
}
/**
* 계정과목 등록
*/
public function store(StoreAccountSubjectRequest $request)
{
$subject = $this->service->store($request->validated());
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'));
}
/**
* 계정과목 활성/비활성 토글
*/
public function toggleStatus(int $id, Request $request)
{
$isActive = (bool) $request->input('is_active', true);
$subject = $this->service->toggleStatus($id, $isActive);
return ApiResponse::success($subject, __('message.toggled'));
}
/**
* 계정과목 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
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\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\IndexRequest;
use App\Http\Requests\Approval\PreDecideRequest;
use App\Http\Requests\Approval\ReferenceIndexRequest;
use App\Http\Requests\Approval\RejectRequest;
use App\Http\Requests\Approval\StoreRequest;
@@ -133,10 +139,10 @@ public function submit(int $id, SubmitRequest $request): JsonResponse
* 결재 승인
* 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 $this->service->approve($id, $request->input('comment'));
return $this->service->approve($id, $request->validated()['comment'] ?? null);
}, __('message.approval.approved'));
}
@@ -155,11 +161,99 @@ public function reject(int $id, RejectRequest $request): JsonResponse
* 결재 회수 (기안자만)
* 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 $this->service->cancel($id);
}, __('message.approval.cancelled'));
return $this->service->releaseHold($id);
}, __('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);
}, __('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

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BarobillService;
use Illuminate\Http\Request;
class BarobillController extends Controller
{
public function __construct(
private BarobillService $barobillService
) {}
/**
* 연동 현황 조회
*/
public function status()
{
return ApiResponse::handle(function () {
$setting = $this->barobillService->getSetting();
return [
'bank_service_count' => 0,
'account_link_count' => 0,
'member' => $setting ? [
'barobill_id' => $setting->barobill_id,
'biz_no' => $setting->corp_num,
'status' => $setting->isVerified() ? 'active' : 'inactive',
'server_mode' => $this->barobillService->isTestMode() ? 'test' : 'production',
] : null,
];
}, __('message.fetched'));
}
/**
* 바로빌 로그인 정보 등록
*/
public function login(Request $request)
{
$data = $request->validate([
'barobill_id' => 'required|string',
'password' => 'required|string',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'barobill_id' => $data['barobill_id'],
]);
}, __('message.saved'));
}
/**
* 바로빌 회원가입 정보 등록
*/
public function signup(Request $request)
{
$data = $request->validate([
'business_number' => 'required|string|size:10',
'company_name' => 'required|string',
'ceo_name' => 'required|string',
'business_type' => 'nullable|string',
'business_category' => 'nullable|string',
'address' => 'nullable|string',
'barobill_id' => 'required|string',
'password' => 'required|string',
'manager_name' => 'nullable|string',
'manager_phone' => 'nullable|string',
'manager_email' => 'nullable|email',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'corp_num' => $data['business_number'],
'corp_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['business_type'] ?? null,
'biz_class' => $data['business_category'] ?? null,
'addr' => $data['address'] ?? null,
'barobill_id' => $data['barobill_id'],
'contact_name' => $data['manager_name'] ?? null,
'contact_tel' => $data['manager_phone'] ?? null,
'contact_id' => $data['manager_email'] ?? null,
]);
}, __('message.saved'));
}
/**
* 바로빌 서비스 URL 조회 (공통)
*/
private function getServiceUrl(string $path): array
{
return ['url' => $this->barobillService->getBaseUrl().$path];
}
/**
* 은행 빠른조회 서비스 URL 조회
*/
public function bankServiceUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/BANKACCOUNT.asmx');
}, __('message.fetched'));
}
/**
* 계좌 연동 등록 URL 조회
*/
public function accountLinkUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/BANKACCOUNT.asmx');
}, __('message.fetched'));
}
/**
* 카드 연동 등록 URL 조회
*/
public function cardLinkUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/CARD.asmx');
}, __('message.fetched'));
}
/**
* 공인인증서 등록 URL 조회
*/
public function certificateUrl()
{
return ApiResponse::handle(function () {
return $this->getServiceUrl('/CORPSTATE.asmx');
}, __('message.fetched'));
}
}

View File

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

View File

@@ -51,4 +51,56 @@ public function summary(Request $request)
);
}, __('message.fetched'));
}
/**
* 일정 등록
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
]);
return ApiResponse::handle(function () use ($validated) {
return $this->calendarService->createSchedule($validated);
}, __('message.created'));
}
/**
* 일정 수정
*/
public function update(Request $request, int $id)
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
]);
return ApiResponse::handle(function () use ($id, $validated) {
return $this->calendarService->updateSchedule($id, $validated);
}, __('message.updated'));
}
/**
* 일정 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->calendarService->deleteSchedule($id);
}, __('message.deleted'));
}
}

View File

@@ -4,7 +4,9 @@
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Models\Tenants\JournalEntry;
use App\Services\CardTransactionService;
use App\Services\JournalSyncService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -14,7 +16,8 @@
class CardTransactionController extends Controller
{
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);
}, __('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

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api\V1;
use App\Exports\DailyReportExport;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DailyReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 일일 보고서 컨트롤러
@@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse
return $this->service->summary($params);
}, __('message.fetched'));
}
/**
* 일일 보고서 엑셀 다운로드
*/
public function export(Request $request): BinaryFileResponse
{
$params = $request->validate([
'date' => 'nullable|date',
]);
$reportData = $this->service->exportData($params);
$filename = '일일일보_'.$reportData['date'].'.xlsx';
return Excel::download(new DailyReportExport($reportData), $filename);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DashboardCeoService;
use Illuminate\Http\JsonResponse;
/**
* CEO 대시보드 섹션별 API 컨트롤러
*
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
*/
class DashboardCeoController extends Controller
{
public function __construct(
private readonly DashboardCeoService $service
) {}
/**
* 매출 현황 요약
* GET /api/v1/dashboard/sales/summary
*/
public function salesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->salesSummary(),
__('message.fetched')
);
}
/**
* 매입 현황 요약
* GET /api/v1/dashboard/purchases/summary
*/
public function purchasesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->purchasesSummary(),
__('message.fetched')
);
}
/**
* 생산 현황 요약
* GET /api/v1/dashboard/production/summary
*/
public function productionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->productionSummary(),
__('message.fetched')
);
}
/**
* 미출고 내역 요약
* GET /api/v1/dashboard/unshipped/summary
*/
public function unshippedSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->unshippedSummary(),
__('message.fetched')
);
}
/**
* 시공 현황 요약
* GET /api/v1/dashboard/construction/summary
*/
public function constructionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->constructionSummary(),
__('message.fetched')
);
}
/**
* 근태 현황 요약
* GET /api/v1/dashboard/attendance/summary
*/
public function attendanceSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->attendanceSummary(),
__('message.fetched')
);
}
}

View File

@@ -74,6 +74,22 @@ public function destroy(int $id): JsonResponse
}, __('message.deleted'));
}
/**
* rendered_html 스냅샷 저장 (Lazy Snapshot)
* PATCH /v1/documents/{id}/snapshot
*/
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
$renderedHtml = $request->validated()['rendered_html'] ?? null;
if (! $renderedHtml) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
}
return $this->service->patchSnapshot($id, $renderedHtml);
}, __('message.updated'));
}
// =========================================================================
// FQC 일괄생성 (제품검사)
// =========================================================================

View File

@@ -33,4 +33,20 @@ public function summary(Request $request): JsonResponse
return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
}, __('message.fetched'));
}
/**
* 접대비 상세 조회 (모달용)
*/
public function detail(Request $request): JsonResponse
{
$companyType = $request->query('company_type', 'medium');
$year = $request->query('year') ? (int) $request->query('year') : 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 ($companyType, $year, $quarter, $startDate, $endDate) {
return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate);
}, __('message.fetched'));
}
}

View File

@@ -128,13 +128,16 @@ public function summary(Request $request)
/**
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
*
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체)
* @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
*/
public function dashboardDetail(Request $request)
{
$transactionType = $request->query('transaction_type');
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$search = $request->query('search');
$data = $this->service->dashboardDetail($transactionType);
$data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
return ApiResponse::success($data, __('message.fetched'));
}

View File

@@ -83,14 +83,25 @@ public function trash()
}
/**
* Download file
* Download file (attachment)
*/
public function download(int $id)
{
$service = new FileStorageService;
$file = $service->getFile($id);
return $file->download();
return $file->download(inline: false);
}
/**
* View file inline (이미지/PDF 브라우저에서 바로 표시)
*/
public function view(int $id)
{
$service = new FileStorageService;
$file = $service->getFile($id);
return $file->download(inline: true);
}
/**

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\GeneralJournalEntry\StoreManualJournalRequest;
use App\Http\Requests\V1\GeneralJournalEntry\UpdateJournalRequest;
use App\Services\GeneralJournalEntryService;
use Illuminate\Http\Request;
class GeneralJournalEntryController extends Controller
{
public function __construct(
private readonly GeneralJournalEntryService $service
) {}
/**
* 일반전표 통합 목록 조회
*/
public function index(Request $request)
{
$params = $request->only([
'start_date', 'end_date', 'search', 'page', 'per_page',
]);
$result = $this->service->index($params);
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 요약 통계
*/
public function summary(Request $request)
{
$params = $request->only([
'start_date', 'end_date', 'search',
]);
$summary = $this->service->summary($params);
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 수기전표 등록
*/
public function store(StoreManualJournalRequest $request)
{
$entry = $this->service->store($request->validated());
return ApiResponse::success($entry, __('message.created'), [], 201);
}
/**
* 전표 상세 조회 (분개 수정 모달용)
*/
public function show(int $id)
{
$detail = $this->service->show($id);
return ApiResponse::success($detail, __('message.fetched'));
}
/**
* 분개 수정
*/
public function updateJournal(int $id, UpdateJournalRequest $request)
{
$entry = $this->service->updateJournal($id, $request->validated());
return ApiResponse::success($entry, __('message.updated'));
}
/**
* 분개 삭제
*/
public function destroyJournal(int $id)
{
$this->service->destroyJournal($id);
return ApiResponse::success(null, __('message.deleted'));
}
}

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

@@ -34,6 +34,16 @@ public function stats(Request $request)
}, __('message.inspection.fetched'));
}
/**
* 캘린더 스케줄 조회
*/
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.inspection.fetched'));
}
/**
* 단건 조회
*/

View File

@@ -109,7 +109,7 @@ public function upload(int $id, ItemFileUploadRequest $request)
$filePath = $directory.'/'.$storedName;
// 파일 저장 (tenant 디스크)
Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName);
Storage::disk('r2')->putFileAs($directory, $uploadedFile, $storedName);
// file_type 자동 분류 (MIME 타입 기반)
$mimeType = $uploadedFile->getMimeType();

View File

@@ -11,6 +11,7 @@
use App\Http\Requests\Loan\LoanUpdateRequest;
use App\Services\LoanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LoanController extends Controller
{
@@ -33,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse
*/
public function summary(LoanIndexRequest $request): JsonResponse
{
$userId = $request->validated()['user_id'] ?? null;
$result = $this->loanService->summary($userId);
$validated = $request->validated();
$userId = $validated['user_id'] ?? null;
$category = $validated['category'] ?? null;
$result = $this->loanService->summary($userId, $category);
return ApiResponse::success($result, __('message.fetched'));
}
@@ -42,9 +45,12 @@ public function summary(LoanIndexRequest $request): JsonResponse
/**
* 가지급금 대시보드
*/
public function dashboard(): JsonResponse
public function dashboard(Request $request): JsonResponse
{
$result = $this->loanService->dashboard();
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$result = $this->loanService->dashboard($startDate, $endDate);
return ApiResponse::success($result, __('message.fetched'));
}

View File

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

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
use App\Services\PerformanceReportService;
use Illuminate\Http\Request;
class PerformanceReportController extends Controller
{
public function __construct(private PerformanceReportService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function confirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->confirm($request->validated()['ids']);
}, __('message.updated'));
}
public function unconfirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unconfirm($request->validated()['ids']);
}, __('message.updated'));
}
public function updateMemo(PerformanceReportMemoRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $request->validated();
return $this->service->updateMemo($data['ids'], $data['memo']);
}, __('message.updated'));
}
public function missing(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->missing($request->all());
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
use App\Services\ProductionOrderService;
use Illuminate\Http\JsonResponse;
class ProductionOrderController extends Controller
{
public function __construct(
private readonly ProductionOrderService $service
) {}
/**
* 생산지시 목록 조회
*/
public function index(ProductionOrderIndexRequest $request): JsonResponse
{
$result = $this->service->index($request->validated());
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 생산지시 상태별 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): JsonResponse
{
try {
$detail = $this->service->show($orderId);
return ApiResponse::success($detail, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
}

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

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
use App\Services\QualityDocumentService;
use Illuminate\Http\Request;
class QualityDocumentController extends Controller
{
public function __construct(private QualityDocumentService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.fetched'));
}
public function availableOrders(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->availableOrders($request->all());
}, __('message.fetched'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(QualityDocumentStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
public function update(QualityDocumentUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
public function attachOrders(Request $request, int $id)
{
$request->validate([
'order_ids' => ['required', 'array', 'min:1'],
'order_ids.*' => ['required', 'integer'],
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachOrders($id, $request->input('order_ids'));
}, __('message.updated'));
}
public function detachOrder(int $id, int $orderId)
{
return ApiResponse::handle(function () use ($id, $orderId) {
return $this->service->detachOrder($id, $orderId);
}, __('message.updated'));
}
public function inspectLocation(Request $request, int $id, int $locId)
{
$request->validate([
'post_width' => ['nullable', 'integer'],
'post_height' => ['nullable', 'integer'],
'change_reason' => ['nullable', 'string', 'max:500'],
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
]);
return ApiResponse::handle(function () use ($request, $id, $locId) {
return $this->service->inspectLocation($id, $locId, $request->all());
}, __('message.updated'));
}
public function requestDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->requestDocument($id);
}, __('message.fetched'));
}
public function resultDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->resultDocument($id);
}, __('message.fetched'));
}
}

View File

@@ -8,13 +8,15 @@
use App\Http\Requests\Shipment\ShipmentUpdateRequest;
use App\Http\Requests\Shipment\ShipmentUpdateStatusRequest;
use App\Services\ShipmentService;
use App\Services\WorkOrderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ShipmentController extends Controller
{
public function __construct(
private readonly ShipmentService $service
private readonly ShipmentService $service,
private readonly WorkOrderService $workOrderService
) {}
/**
@@ -83,7 +85,7 @@ public function store(ShipmentStoreRequest $request): JsonResponse
{
$shipment = $this->service->store($request->validated());
return ApiResponse::success($shipment, __('message.created'), 201);
return ApiResponse::success($shipment, __('message.created'), [], 201);
}
/**
@@ -132,6 +134,22 @@ public function destroy(int $id): JsonResponse
}
}
/**
* 수주 기반 출하 생성
*/
public function createFromOrder(int $orderId): JsonResponse
{
try {
$shipment = $this->workOrderService->createShipmentForOrder($orderId);
return ApiResponse::success($shipment, __('message.created'), [], 201);
} catch (\Symfony\Component\HttpKernel\Exception\BadRequestHttpException $e) {
return ApiResponse::error($e->getMessage(), 400);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
/**
* LOT 옵션 조회
*/

View File

@@ -29,6 +29,8 @@ public function index(Request $request): JsonResponse
'sort_dir',
'per_page',
'page',
'start_date',
'end_date',
]);
$stocks = $this->service->index($params);

View File

@@ -10,12 +10,17 @@
use App\Http\Requests\TaxInvoice\TaxInvoiceSummaryRequest;
use App\Http\Requests\TaxInvoice\UpdateTaxInvoiceRequest;
use App\Http\Requests\V1\TaxInvoice\BulkIssueRequest;
use App\Models\Tenants\JournalEntry;
use App\Services\JournalSyncService;
use App\Services\TaxInvoiceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TaxInvoiceController extends Controller
{
public function __construct(
private TaxInvoiceService $taxInvoiceService
private TaxInvoiceService $taxInvoiceService,
private JournalSyncService $journalSyncService,
) {}
/**
@@ -23,12 +28,9 @@ public function __construct(
*/
public function index(TaxInvoiceListRequest $request)
{
$taxInvoices = $this->taxInvoiceService->list($request->validated());
return ApiResponse::handle(
data: $taxInvoices,
message: __('message.fetched')
);
return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->list($request->validated());
}, __('message.fetched'));
}
/**
@@ -36,12 +38,9 @@ public function index(TaxInvoiceListRequest $request)
*/
public function show(int $id)
{
$taxInvoice = $this->taxInvoiceService->show($id);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.fetched')
);
return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->show($id);
}, __('message.fetched'));
}
/**
@@ -49,13 +48,9 @@ public function show(int $id)
*/
public function store(CreateTaxInvoiceRequest $request)
{
$taxInvoice = $this->taxInvoiceService->create($request->validated());
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.created'),
status: 201
);
return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->create($request->validated());
}, __('message.created'));
}
/**
@@ -63,12 +58,9 @@ public function store(CreateTaxInvoiceRequest $request)
*/
public function update(UpdateTaxInvoiceRequest $request, int $id)
{
$taxInvoice = $this->taxInvoiceService->update($id, $request->validated());
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.updated')
);
return ApiResponse::handle(function () use ($request, $id) {
return $this->taxInvoiceService->update($id, $request->validated());
}, __('message.updated'));
}
/**
@@ -76,12 +68,11 @@ public function update(UpdateTaxInvoiceRequest $request, int $id)
*/
public function destroy(int $id)
{
$this->taxInvoiceService->delete($id);
return ApiResponse::handle(function () use ($id) {
$this->taxInvoiceService->delete($id);
return ApiResponse::handle(
data: null,
message: __('message.deleted')
);
return null;
}, __('message.deleted'));
}
/**
@@ -89,12 +80,9 @@ public function destroy(int $id)
*/
public function issue(int $id)
{
$taxInvoice = $this->taxInvoiceService->issue($id);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.tax_invoice.issued')
);
return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->issue($id);
}, __('message.tax_invoice.issued'));
}
/**
@@ -102,12 +90,9 @@ public function issue(int $id)
*/
public function bulkIssue(BulkIssueRequest $request)
{
$result = $this->taxInvoiceService->bulkIssue($request->getIds());
return ApiResponse::handle(
data: $result,
message: __('message.tax_invoice.bulk_issued')
);
return ApiResponse::handle(function () use ($request) {
return $this->taxInvoiceService->bulkIssue($request->getIds());
}, __('message.tax_invoice.bulk_issued'));
}
/**
@@ -115,12 +100,9 @@ public function bulkIssue(BulkIssueRequest $request)
*/
public function cancel(CancelTaxInvoiceRequest $request, int $id)
{
$taxInvoice = $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.tax_invoice.cancelled')
);
return ApiResponse::handle(function () use ($request, $id) {
return $this->taxInvoiceService->cancel($id, $request->validated()['reason']);
}, __('message.tax_invoice.cancelled'));
}
/**
@@ -128,12 +110,9 @@ public function cancel(CancelTaxInvoiceRequest $request, int $id)
*/
public function checkStatus(int $id)
{
$taxInvoice = $this->taxInvoiceService->checkStatus($id);
return ApiResponse::handle(
data: $taxInvoice,
message: __('message.fetched')
);
return ApiResponse::handle(function () use ($id) {
return $this->taxInvoiceService->checkStatus($id);
}, __('message.fetched'));
}
/**
@@ -141,11 +120,79 @@ public function checkStatus(int $id)
*/
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,
message: __('message.fetched')
);
// =========================================================================
// 분개 (Journal Entries)
// =========================================================================
/**
* 세금계산서 분개 조회
*/
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

@@ -20,9 +20,10 @@ public function __construct(
public function summary(Request $request): JsonResponse
{
$limit = (int) $request->input('limit', 30);
$date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용)
return ApiResponse::handle(function () use ($limit) {
return $this->todayIssueService->summary($limit);
return ApiResponse::handle(function () use ($limit, $date) {
return $this->todayIssueService->summary($limit, null, $date);
}, __('message.fetched'));
}

View File

@@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse
return $this->vatService->getSummary($periodType, $year, $period);
}, __('message.fetched'));
}
/**
* 부가세 상세 조회 (모달용)
*/
public function detail(Request $request): JsonResponse
{
$periodType = $request->query('period_type', 'quarter');
$year = $request->query('year') ? (int) $request->query('year') : null;
$period = $request->query('period') ? (int) $request->query('period') : null;
return ApiResponse::handle(function () use ($periodType, $year, $period) {
return $this->vatService->getDetail($periodType, $year, $period);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\VehicleDispatch\VehicleDispatchUpdateRequest;
use App\Services\VehicleDispatchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleDispatchController extends Controller
{
public function __construct(
private readonly VehicleDispatchService $service
) {}
/**
* 배차차량 목록 조회
*/
public function index(Request $request): JsonResponse
{
$params = $request->only([
'search',
'status',
'start_date',
'end_date',
'per_page',
'page',
]);
$dispatches = $this->service->index($params);
return ApiResponse::success($dispatches, __('message.fetched'));
}
/**
* 배차차량 통계 조회
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 배차차량 상세 조회
*/
public function show(int $id): JsonResponse
{
try {
$dispatch = $this->service->show($id);
return ApiResponse::success($dispatch, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.not_found'), 404);
}
}
/**
* 배차차량 수정
*/
public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse
{
try {
$dispatch = $this->service->update($id, $request->validated());
return ApiResponse::success($dispatch, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.not_found'), 404);
}
}
}

View File

@@ -61,14 +61,18 @@ public function detail(Request $request): JsonResponse
: 0.05;
$year = $request->query('year') ? (int) $request->query('year') : 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(
$calculationType,
$fixedAmountPerMonth,
$ratio,
$year,
$quarter
$quarter,
$startDate,
$endDate
);
}, __('message.fetched'));
}

View File

@@ -230,6 +230,16 @@ public function inspectionReport(int $id)
}, __('message.work_order.fetched'));
}
/**
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*/
public function inspectionConfig(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionConfig($id);
}, __('message.work_order.fetched'));
}
/**
* 작업지시의 검사용 문서 템플릿 조회
*/
@@ -310,7 +320,14 @@ public function materialsForItem(int $id, int $itemId)
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
{
return ApiResponse::handle(function () use ($request, $id, $itemId) {
return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']);
$validated = $request->validated();
return $this->service->registerMaterialInputForItem(
$id,
$itemId,
$validated['inputs'],
(bool) ($validated['replace'] ?? false)
);
}, __('message.work_order.material_input_registered'));
}

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -0,0 +1,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

@@ -22,6 +22,8 @@ public function rules(): array
'sort_dir' => 'nullable|string|in:asc,desc',
'per_page' => 'nullable|integer|min:1',
'page' => 'nullable|integer|min:1',
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
];
}
}

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

@@ -28,6 +28,9 @@ public function rules(): array
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',

View File

@@ -27,6 +27,9 @@ public function rules(): array
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',

View File

@@ -30,6 +30,9 @@ public function rules(): array
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',

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

@@ -22,6 +22,7 @@ public function rules(): array
Inspection::TYPE_FQC,
])],
'lot_no' => ['required', 'string', 'max:50'],
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
'item_name' => ['nullable', 'string', 'max:200'],
'process_name' => ['nullable', 'string', 'max:100'],
'quantity' => ['nullable', 'numeric', 'min:0'],

View File

@@ -29,6 +29,7 @@ public function rules(): array
return [
'user_id' => ['nullable', 'integer', 'exists:users,id'],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'search' => ['nullable', 'string', 'max:100'],

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanStoreRequest extends FormRequest
{
@@ -21,12 +23,27 @@ public function authorize(): bool
*/
public function rules(): array
{
$isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE;
return [
'user_id' => ['required', 'integer', 'exists:users,id'],
'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'],
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'metadata' => ['nullable', 'array'],
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
'metadata.memo' => ['nullable', 'string', 'max:2000'],
];
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanUpdateRequest extends FormRequest
{
@@ -27,6 +29,20 @@ public function rules(): array
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)],
'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)],
'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'metadata' => ['nullable', 'array'],
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
'metadata.memo' => ['nullable', 'string', 'max:2000'],
];
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\ProductionOrder;
use Illuminate\Foundation\Http\FormRequest;
class ProductionOrderIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => 'nullable|string|max:100',
'production_status' => 'nullable|in:waiting,in_production,completed',
'sort_by' => 'nullable|in:created_at,delivery_date,order_no',
'sort_dir' => 'nullable|in:asc,desc',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

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,29 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportMemoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
'memo' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'memo.required' => __('validation.required', ['attribute' => '메모']),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['required', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
];
}
public function messages(): array
{
return [
'site_name.required' => __('validation.required', ['attribute' => '현장명']),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['sometimes', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
'locations' => ['nullable', 'array'],
'locations.*.id' => ['required', 'integer'],
'locations.*.post_width' => ['nullable', 'integer'],
'locations.*.post_height' => ['nullable', 'integer'],
'locations.*.change_reason' => ['nullable', 'string', 'max:500'],
'locations.*.inspection_data' => ['nullable', 'array'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class SaveChecklistTemplateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:255'],
'categories' => ['required', 'array', 'min:1'],
'categories.*.id' => ['required', 'string', 'max:50'],
'categories.*.title' => ['required', 'string', 'max:255'],
'categories.*.subItems' => ['required', 'array'],
'categories.*.subItems.*.id' => ['required', 'string', 'max:50'],
'categories.*.subItems.*.name' => ['required', 'string', 'max:255'],
'options' => ['nullable', 'array'],
];
}
public function messages(): array
{
return [
'categories.required' => __('validation.required', ['attribute' => '카테고리']),
'categories.min' => __('validation.min.array', ['attribute' => '카테고리', 'min' => 1]),
'categories.*.id.required' => __('validation.required', ['attribute' => '카테고리 ID']),
'categories.*.title.required' => __('validation.required', ['attribute' => '카테고리 제목']),
'categories.*.subItems.required' => __('validation.required', ['attribute' => '점검항목']),
'categories.*.subItems.*.id.required' => __('validation.required', ['attribute' => '항목 ID']),
'categories.*.subItems.*.name.required' => __('validation.required', ['attribute' => '항목명']),
];
}
}

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ public function rules(): array
'scheduled_date' => 'required|date',
'status' => 'nullable|in:scheduled,ready,shipping,completed',
'priority' => 'nullable|in:urgent,normal,low',
'delivery_method' => 'nullable|in:pickup,direct,logistics',
'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup',
// 발주처/배송 정보
'client_id' => 'nullable|integer|exists:clients,id',
@@ -55,6 +55,16 @@ public function rules(): array
// 기타
'remarks' => 'nullable|string',
// 배차정보
'vehicle_dispatches' => 'nullable|array',
'vehicle_dispatches.*.seq' => 'nullable|integer|min:1',
'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100',
'vehicle_dispatches.*.arrival_datetime' => 'nullable|date',
'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20',
'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20',
'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50',
'vehicle_dispatches.*.remarks' => 'nullable|string',
// 출하 품목
'items' => 'nullable|array',
'items.*.seq' => 'nullable|integer|min:1',

View File

@@ -19,7 +19,7 @@ public function rules(): array
'order_id' => 'nullable|integer|exists:orders,id',
'scheduled_date' => 'nullable|date',
'priority' => 'nullable|in:urgent,normal,low',
'delivery_method' => 'nullable|in:pickup,direct,logistics',
'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup',
// 발주처/배송 정보
'client_id' => 'nullable|integer|exists:clients,id',
@@ -53,6 +53,16 @@ public function rules(): array
// 기타
'remarks' => 'nullable|string',
// 배차정보
'vehicle_dispatches' => 'nullable|array',
'vehicle_dispatches.*.seq' => 'nullable|integer|min:1',
'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100',
'vehicle_dispatches.*.arrival_datetime' => 'nullable|date',
'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20',
'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20',
'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50',
'vehicle_dispatches.*.remarks' => 'nullable|string',
// 출하 품목
'items' => 'nullable|array',
'items.*.seq' => 'nullable|integer|min:1',

View File

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

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\V1\AccountSubject;
use Illuminate\Foundation\Http\FormRequest;
class StoreAccountSubjectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:10'],
'name' => ['required', '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 [
'code.required' => '계정과목 코드를 입력하세요.',
'name.required' => '계정과목명을 입력하세요.',
'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

@@ -17,6 +17,7 @@ public function rules(): array
$tenantId = app('tenant_id') ?? 0;
return [
// === 기존 필드 ===
'bill_number' => [
'nullable',
'string',
@@ -30,16 +31,99 @@ public function rules(): array
'client_name' => ['nullable', 'string', 'max:100'],
'amount' => ['required', 'numeric', 'min:0'],
'issue_date' => ['required', 'date'],
'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'],
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
'status' => ['nullable', 'string', 'max:30'],
'reason' => ['nullable', 'string', 'max:255'],
'installment_count' => ['nullable', 'integer', 'min:0'],
'note' => ['nullable', 'string', 'max:1000'],
'is_electronic' => ['nullable', 'boolean'],
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
// === V8 증권종류/매체/구분 ===
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
'medium' => ['nullable', 'string', 'in:electronic,paper'],
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
// === 전자어음 ===
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
// === 환어음 ===
'drawee' => ['nullable', 'string', 'max:100'],
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
'acceptance_date' => ['nullable', 'date'],
'acceptance_refusal_date' => ['nullable', 'date'],
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
// === 받을어음 전용 ===
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
'endorsement_order' => ['nullable', 'string', 'max:5'],
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
'issuer_bank' => ['nullable', 'string', 'max:100'],
// 할인
'is_discounted' => ['nullable', 'boolean'],
'discount_date' => ['nullable', 'date'],
'discount_bank' => ['nullable', 'string', 'max:100'],
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'discount_amount' => ['nullable', 'numeric', 'min:0'],
// 배서양도
'endorsement_date' => ['nullable', 'date'],
'endorsee' => ['nullable', 'string', 'max:100'],
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
// 추심
'collection_bank' => ['nullable', 'string', 'max:100'],
'collection_request_date' => ['nullable', 'date'],
'collection_fee' => ['nullable', 'numeric', 'min:0'],
'collection_complete_date' => ['nullable', 'date'],
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
'collection_deposit_date' => ['nullable', 'date'],
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
// === 지급어음 전용 ===
'settlement_bank' => ['nullable', 'string', 'max:100'],
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
'actual_payment_date' => ['nullable', 'date'],
// === 공통 ===
'payment_place' => ['nullable', 'string', 'max:30'],
'payment_place_detail' => ['nullable', 'string', 'max:200'],
// 개서
'renewal_date' => ['nullable', 'date'],
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
// 소구
'recourse_date' => ['nullable', 'date'],
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
'recourse_target' => ['nullable', 'string', 'max:100'],
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
// 환매
'buyback_date' => ['nullable', 'date'],
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
'buyback_bank' => ['nullable', 'string', 'max:100'],
// 부도/법적절차
'dishonored_date' => ['nullable', 'date'],
'dishonored_reason' => ['nullable', 'string', 'max:30'],
'has_protest' => ['nullable', 'boolean'],
'protest_date' => ['nullable', 'date'],
'recourse_notice_date' => ['nullable', 'date'],
'recourse_notice_deadline' => ['nullable', 'date'],
// 분할배서
'is_split' => ['nullable', 'boolean'],
// === 차수 관리 ===
'installments' => ['nullable', 'array'],
'installments.*.date' => ['required_with:installments', 'date'],
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
'installments.*.type' => ['nullable', 'string', 'max:30'],
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
'installments.*.note' => ['nullable', 'string', 'max:255'],
];
}

View File

@@ -14,6 +14,7 @@ public function authorize(): bool
public function rules(): array
{
return [
// === 기존 필드 ===
'bill_number' => ['nullable', 'string', 'max:50'],
'bill_type' => ['nullable', 'string', 'in:received,issued'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
@@ -21,15 +22,72 @@ public function rules(): array
'amount' => ['nullable', 'numeric', 'min:0'],
'issue_date' => ['nullable', 'date'],
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
'status' => ['nullable', 'string', 'max:30'],
'reason' => ['nullable', 'string', 'max:255'],
'installment_count' => ['nullable', 'integer', 'min:0'],
'note' => ['nullable', 'string', 'max:1000'],
'is_electronic' => ['nullable', 'boolean'],
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
// === V8 확장 ===
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
'medium' => ['nullable', 'string', 'in:electronic,paper'],
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
'drawee' => ['nullable', 'string', 'max:100'],
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
'acceptance_date' => ['nullable', 'date'],
'acceptance_refusal_date' => ['nullable', 'date'],
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
'endorsement_order' => ['nullable', 'string', 'max:5'],
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
'issuer_bank' => ['nullable', 'string', 'max:100'],
'is_discounted' => ['nullable', 'boolean'],
'discount_date' => ['nullable', 'date'],
'discount_bank' => ['nullable', 'string', 'max:100'],
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'discount_amount' => ['nullable', 'numeric', 'min:0'],
'endorsement_date' => ['nullable', 'date'],
'endorsee' => ['nullable', 'string', 'max:100'],
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
'collection_bank' => ['nullable', 'string', 'max:100'],
'collection_request_date' => ['nullable', 'date'],
'collection_fee' => ['nullable', 'numeric', 'min:0'],
'collection_complete_date' => ['nullable', 'date'],
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
'collection_deposit_date' => ['nullable', 'date'],
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
'settlement_bank' => ['nullable', 'string', 'max:100'],
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
'actual_payment_date' => ['nullable', 'date'],
'payment_place' => ['nullable', 'string', 'max:30'],
'payment_place_detail' => ['nullable', 'string', 'max:200'],
'renewal_date' => ['nullable', 'date'],
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
'recourse_date' => ['nullable', 'date'],
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
'recourse_target' => ['nullable', 'string', 'max:100'],
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
'buyback_date' => ['nullable', 'date'],
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
'buyback_bank' => ['nullable', 'string', 'max:100'],
'dishonored_date' => ['nullable', 'date'],
'dishonored_reason' => ['nullable', 'string', 'max:30'],
'has_protest' => ['nullable', 'boolean'],
'protest_date' => ['nullable', 'date'],
'recourse_notice_date' => ['nullable', 'date'],
'recourse_notice_deadline' => ['nullable', 'date'],
'is_split' => ['nullable', 'boolean'],
// === 차수 관리 ===
'installments' => ['nullable', 'array'],
'installments.*.date' => ['required_with:installments', 'date'],
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
'installments.*.type' => ['nullable', 'string', 'max:30'],
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
'installments.*.note' => ['nullable', 'string', 'max:255'],
];
}

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,42 @@
<?php
namespace App\Http\Requests\V1\GeneralJournalEntry;
use Illuminate\Foundation\Http\FormRequest;
class StoreManualJournalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'journal_date' => ['required', 'date'],
'description' => ['nullable', 'string', 'max:500'],
'rows' => ['required', 'array', 'min:2'],
'rows.*.side' => ['required', 'in:debit,credit'],
'rows.*.account_subject_id' => ['required', 'string', 'max:10'],
'rows.*.vendor_id' => ['nullable', 'integer'],
'rows.*.debit_amount' => ['required', 'integer', 'min:0'],
'rows.*.credit_amount' => ['required', 'integer', 'min:0'],
'rows.*.memo' => ['nullable', 'string', 'max:300'],
];
}
public function messages(): array
{
return [
'journal_date.required' => '전표일자를 입력하세요.',
'rows.required' => '분개 행을 입력하세요.',
'rows.min' => '최소 2개 이상의 분개 행이 필요합니다.',
'rows.*.side.required' => '차/대 구분을 선택하세요.',
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
'rows.*.account_subject_id.required' => '계정과목을 선택하세요.',
'rows.*.debit_amount.required' => '차변 금액을 입력하세요.',
'rows.*.credit_amount.required' => '대변 금액을 입력하세요.',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\V1\GeneralJournalEntry;
use Illuminate\Foundation\Http\FormRequest;
class UpdateJournalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'journal_memo' => ['sometimes', 'nullable', 'string', 'max:1000'],
'rows' => ['sometimes', 'array', 'min:1'],
'rows.*.side' => ['required_with:rows', 'in:debit,credit'],
'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'],
'rows.*.vendor_id' => ['nullable', 'integer'],
'rows.*.debit_amount' => ['required_with:rows', 'integer', 'min:0'],
'rows.*.credit_amount' => ['required_with:rows', 'integer', 'min:0'],
'rows.*.memo' => ['nullable', 'string', 'max:300'],
];
}
public function messages(): array
{
return [
'rows.*.side.required_with' => '차/대 구분을 선택하세요.',
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
'rows.*.account_subject_id.required_with' => '계정과목을 선택하세요.',
'rows.*.debit_amount.required_with' => '차변 금액을 입력하세요.',
'rows.*.credit_amount.required_with' => '대변 금액을 입력하세요.',
];
}
}

View File

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

View File

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

View File

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

View File

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

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