import 'dart:collection'; import 'approval.dart'; /// 결재 초안 상태를 표현하는 열거형. enum ApprovalDraftStatus { active, expired, archived } /// 결재 초안 단계 정보를 나타낸다. class ApprovalDraftStep { ApprovalDraftStep({ required this.stepOrder, required this.approverId, this.approverRole, this.note, this.isOptional = false, }); final int stepOrder; final int approverId; final String? approverRole; final String? note; final bool isOptional; ApprovalStepAssignmentItem toAssignment() { return ApprovalStepAssignmentItem( stepOrder: stepOrder, approverId: approverId, note: note, ); } Map toJson() { final trimmedNote = note?.trim(); return { 'step_order': stepOrder, 'approver_id': approverId, if (approverRole != null && approverRole!.trim().isNotEmpty) 'approver_role': approverRole, if (trimmedNote != null && trimmedNote.isNotEmpty) 'note': trimmedNote, 'is_optional': isOptional, }; } } /// 결재 초안 본문을 나타낸다. class ApprovalDraftPayload { ApprovalDraftPayload({ this.title, this.summary, this.note, this.templateId, Map? metadata, List? steps, }) : metadata = metadata == null ? null : Map.unmodifiable(Map.from(metadata)), _steps = steps == null ? const [] : List.unmodifiable(steps); final String? title; final String? summary; final String? note; final int? templateId; final Map? metadata; final List _steps; UnmodifiableListView get steps => UnmodifiableListView(_steps); List toAssignments() { return _steps.map((step) => step.toAssignment()).toList(growable: false); } } /// 결재 초안 요약 정보를 담는다. class ApprovalDraftSummary { ApprovalDraftSummary({ required this.id, required this.requesterId, required this.status, required this.savedAt, this.requestId, this.transactionId, this.templateId, this.title, this.summary, this.expiresAt, this.sessionKey, this.stepCount = 0, }); final int id; final int requesterId; final ApprovalDraftStatus status; final DateTime savedAt; final int? requestId; final int? transactionId; final int? templateId; final String? title; final String? summary; final DateTime? expiresAt; final String? sessionKey; final int stepCount; } /// 결재 초안 상세 정보를 나타낸다. class ApprovalDraftDetail { ApprovalDraftDetail({ required this.id, required this.requesterId, required this.payload, required this.savedAt, this.transactionId, this.templateId, this.expiresAt, this.sessionKey, }); final int id; final int requesterId; final ApprovalDraftPayload payload; final DateTime savedAt; final int? transactionId; final int? templateId; final DateTime? expiresAt; final String? sessionKey; Map? get sanitizedMetadata => _stripClientState(payload.metadata); ApprovalSubmissionInput toSubmissionInput({ int? defaultStatusId, int? transactionIdOverride, }) { final statusId = _extractStatusId(payload.metadata) ?? defaultStatusId ?? 0; final assignments = payload.toAssignments(); final cleanedMetadata = _stripClientState(payload.metadata); return ApprovalSubmissionInput( transactionId: transactionIdOverride ?? transactionId, templateId: payload.templateId ?? templateId, statusId: statusId, requesterId: requesterId, finalApproverId: assignments.isEmpty ? null : assignments.last.approverId, title: payload.title, summary: payload.summary, note: payload.note, metadata: cleanedMetadata, steps: assignments, ); } } /// 결재 초안 목록 필터. class ApprovalDraftListFilter { const ApprovalDraftListFilter({ required this.requesterId, this.page = 1, this.pageSize = 20, this.transactionId, this.includeExpired = false, }) : assert(page > 0, 'page는 1 이상이어야 합니다.'); final int requesterId; final int page; final int pageSize; final int? transactionId; final bool includeExpired; Map toQuery() { return { 'page': page, 'page_size': pageSize, 'requester_id': requesterId, if (transactionId != null) 'transaction_id': transactionId, if (includeExpired) 'include_expired': includeExpired, }; } } /// 결재 초안 저장 입력 모델. class ApprovalDraftSaveInput { ApprovalDraftSaveInput({ required this.requesterId, required List steps, this.requestId, this.transactionId, this.templateId, this.title, this.summary, this.note, Map? metadata, this.sessionKey, this.statusId, }) : metadata = metadata == null ? null : Map.unmodifiable(Map.from(metadata)), steps = List.unmodifiable(steps); final int requesterId; final List steps; final int? requestId; final int? transactionId; final int? templateId; final String? title; final String? summary; final String? note; final Map? metadata; final String? sessionKey; final int? statusId; bool get hasSteps => steps.isNotEmpty; Map toJson() { final payload = { 'requester_id': requesterId, if (requestId != null) 'request_id': requestId, if (transactionId != null) 'transaction_id': transactionId, if (templateId != null) 'template_id': templateId, if (title != null && title!.trim().isNotEmpty) 'title': title, if (summary != null && summary!.trim().isNotEmpty) 'summary': summary, if (note != null && note!.trim().isNotEmpty) 'note': note, if (sessionKey != null && sessionKey!.trim().isNotEmpty) 'session_key': sessionKey, }; final mergedMetadata = _mergeStatus(source: metadata, statusId: statusId); if (mergedMetadata != null && mergedMetadata.isNotEmpty) { payload['metadata'] = mergedMetadata; } payload['steps'] = steps .map((step) => step.toJson()) .toList(growable: false); return payload; } } const _clientStateKey = '_client_state'; const _statusKey = 'status_id'; Map? _mergeStatus({ Map? source, int? statusId, }) { if (statusId == null) { return source; } final merged = source == null ? {} : Map.from(source); final client = merged[_clientStateKey]; final state = client is Map ? Map.from(client) : {}; state[_statusKey] = statusId; merged[_clientStateKey] = state; return merged; } int? _extractStatusId(Map? metadata) { if (metadata == null || metadata.isEmpty) { return null; } final client = metadata[_clientStateKey]; if (client is Map) { final value = client[_statusKey]; if (value is int) { return value; } if (value is String) { return int.tryParse(value); } } return null; } Map? _stripClientState(Map? metadata) { if (metadata == null || metadata.isEmpty) { return metadata; } if (!metadata.containsKey(_clientStateKey)) { return metadata; } final cloned = Map.from(metadata); cloned.remove(_clientStateKey); return cloned.isEmpty ? null : cloned; }