From d603fd5c172021a02ed307c7203492399160c89a Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 11 Nov 2025 16:28:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(inventory):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inbound/outbound/rental controller에 fetchTransactionDetail을 추가해 상세 동기화를 지원 - 각 페이지 초기화 시 결재 초안 로딩 권한을 PermissionScope에서 확인하도록 수정 - 상세 패널의 수정 버튼이 모달과 연동되도록 흐름을 정리하고 생성/수정 후 상세 데이터를 재조회 - 기존 결재 메모 필드는 등록 이후 수정 불가하도록 UI와 입력 상태를 비활성화 - 신규 상세-수정 위젯 테스트와 리포지토리 스텁 fetchDetail 구현을 추가 - flutter analyze, flutter test를 실행해 회귀를 점검 --- .../controllers/inbound_controller.dart | 18 ++++ .../presentation/pages/inbound_page.dart | 41 ++++++++- .../controllers/outbound_controller.dart | 18 ++++ .../presentation/pages/outbound_page.dart | 66 +++++++++++--- .../controllers/rental_controller.dart | 18 ++++ .../presentation/pages/rental_page.dart | 85 ++++++++++++++++--- .../features/inventory/inbound_page_test.dart | 42 +++++++++ .../inventory/outbound_page_test.dart | 41 +++++++++ test/features/inventory/rental_page_test.dart | 41 +++++++++ test/helpers/inventory_test_stubs.dart | 22 +++-- 10 files changed, 354 insertions(+), 38 deletions(-) diff --git a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart index 850a017..8cd72ff 100644 --- a/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart +++ b/lib/features/inventory/inbound/presentation/controllers/inbound_controller.dart @@ -253,6 +253,24 @@ class InboundController extends ChangeNotifier { await fetchTransactions(filter: target); } + /// 단일 입고 트랜잭션 상세 정보를 조회한다. + Future fetchTransactionDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) async { + try { + final transaction = await _transactionRepository.fetchDetail( + id, + include: include, + ); + return InboundRecord.fromTransaction(transaction); + } catch (error, stackTrace) { + debugPrint('[InboundController] 상세 조회 실패(id=$id): $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } + } + void _persistApprovalDraft(StockTransactionApprovalInput approval) { final useCase = _saveDraftUseCase; if (useCase == null) { diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index faf8d36..92a09ee 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -158,7 +158,7 @@ class _InboundPageState extends State { await controller.loadStatusOptions(); await controller.loadApprovalStatuses(); final requester = _resolveCurrentWriter(); - if (requester != null) { + if (requester != null && _canRestoreApprovalDrafts) { await controller.loadApprovalDraftFromServer(requesterId: requester.id); } final hasType = await controller.resolveTransactionType(); @@ -171,6 +171,17 @@ class _InboundPageState extends State { }); } + bool get _canRestoreApprovalDrafts { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return false; + } + return getIt().can( + PermissionResources.approvals, + PermissionAction.view, + ); + } + void _handleControllerChanged() { if (!mounted) { return; @@ -909,7 +920,7 @@ class _InboundPageState extends State { } List _buildDetailActions(InboundRecord record) { - final isProcessing = _isProcessing(record.id) || _isLoading; + final isProcessing = _isProcessing(record.id); final actions = []; if (_canSubmit(record)) { @@ -1729,6 +1740,21 @@ class _InboundPageState extends State { SuperportToast.error(context, '입고 컨트롤러를 찾을 수 없습니다.'); return; } + Future resolveUpdatedRecord(int? id) async { + if (id == null) { + return null; + } + final detail = await controller.fetchTransactionDetail(id); + if (detail != null) { + return detail; + } + for (final record in controller.records) { + if (record.id == id) { + return record; + } + } + return null; + } final transactionTypeLookup = _transactionTypeLookup ?? controller.transactionType; @@ -1814,7 +1840,6 @@ class _InboundPageState extends State { ), refreshAfter: false, ); - result = updated; final currentLines = initialRecord.raw?.lines ?? const []; final currentCustomers = initialRecord.customers; @@ -1850,6 +1875,8 @@ class _InboundPageState extends State { ); } await controller.refresh(); + final refreshed = await resolveUpdatedRecord(transactionId); + result = refreshed ?? updated; updateSaving(false); if (!mounted) { return; @@ -1932,7 +1959,8 @@ class _InboundPageState extends State { return true; }()); final created = await controller.createTransaction(createInput); - result = created; + final refreshed = await resolveUpdatedRecord(created.id); + result = refreshed ?? created; updateSaving(false); if (!mounted) { return; @@ -2157,9 +2185,14 @@ class _InboundPageState extends State { width: 500, child: SuperportFormField( label: '결재 메모', + caption: initial != null + ? '등록된 결재 메모는 수정할 수 없습니다.' + : null, child: ShadInput( controller: approvalNoteController, maxLines: 2, + readOnly: initial != null, + enabled: initial == null, ), ), ), diff --git a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart index 650353f..2e033b2 100644 --- a/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart +++ b/lib/features/inventory/outbound/presentation/controllers/outbound_controller.dart @@ -233,6 +233,24 @@ class OutboundController extends ChangeNotifier { await fetchTransactions(filter: target); } + /// 단일 출고 트랜잭션 상세를 조회한다. + Future fetchTransactionDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) async { + try { + final transaction = await _transactionRepository.fetchDetail( + id, + include: include, + ); + return OutboundRecord.fromTransaction(transaction); + } catch (error, stackTrace) { + debugPrint('[OutboundController] 상세 조회 실패(id=$id): $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } + } + void _persistApprovalDraft(StockTransactionApprovalInput approval) { final useCase = _saveDraftUseCase; if (useCase == null) { diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 8eb0d66..e1589ac 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -165,7 +165,7 @@ class _OutboundPageState extends State { Future.microtask(() async { await controller.loadStatusOptions(); final requester = _resolveCurrentWriter(); - if (requester != null) { + if (requester != null && _canRestoreApprovalDrafts) { await controller.loadApprovalDraftFromServer(requesterId: requester.id); } final hasType = await controller.resolveTransactionType(); @@ -178,6 +178,17 @@ class _OutboundPageState extends State { }); } + bool get _canRestoreApprovalDrafts { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return false; + } + return getIt().can( + PermissionResources.approvals, + PermissionAction.view, + ); + } + Future _loadCustomerOptions() async { final getIt = GetIt.I; if (!getIt.isRegistered()) { @@ -982,7 +993,7 @@ class _OutboundPageState extends State { } List _buildDetailActions(OutboundRecord record) { - final isProcessing = _isProcessing(record.id) || _isLoading; + final isProcessing = _isProcessing(record.id); final actions = []; if (_canSubmit(record)) { @@ -1028,12 +1039,7 @@ class _OutboundPageState extends State { actions.add( ShadButton.outline( leading: const Icon(lucide.LucideIcons.pencil, size: 16), - onPressed: isProcessing - ? null - : () { - Navigator.of(context).maybePop(); - _handleEdit(record); - }, + onPressed: isProcessing ? null : () => _openEditFromDetail(record), child: const Text('수정'), ), ); @@ -1041,6 +1047,17 @@ class _OutboundPageState extends State { return actions; } + void _openEditFromDetail(OutboundRecord record) { + final navigator = Navigator.of(context, rootNavigator: true); + navigator.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) { + return; + } + await _handleEdit(record, reopenOnCancel: true); + }); + } + Future _handleCreate() async { final record = await _showOutboundFormDialog(); if (record != null) { @@ -1048,10 +1065,15 @@ class _OutboundPageState extends State { } } - Future _handleEdit(OutboundRecord record) async { + Future _handleEdit( + OutboundRecord record, { + bool reopenOnCancel = false, + }) async { final updated = await _showOutboundFormDialog(initial: record); if (updated != null) { _selectRecord(updated, openDetail: true); + } else if (reopenOnCancel) { + _selectRecord(record, openDetail: true); } } @@ -1821,6 +1843,21 @@ class _OutboundPageState extends State { SuperportToast.error(context, '출고 컨트롤러를 찾을 수 없습니다.'); return; } + Future resolveUpdatedRecord(int? id) async { + if (id == null) { + return null; + } + final detail = await controller.fetchTransactionDetail(id); + if (detail != null) { + return detail; + } + for (final record in controller.records) { + if (record.id == id) { + return record; + } + } + return null; + } final transactionTypeLookup = _transactionTypeLookup ?? controller.transactionType; @@ -1912,7 +1949,6 @@ class _OutboundPageState extends State { ), refreshAfter: false, ); - result = updated; final StockTransaction? currentTransaction = initialRecord.raw; final currentLines = currentTransaction?.lines ?? const []; @@ -1937,6 +1973,8 @@ class _OutboundPageState extends State { ); } await controller.refresh(); + final refreshed = await resolveUpdatedRecord(transactionId); + result = refreshed ?? updated; updateSaving(false); if (!mounted) { return; @@ -2006,7 +2044,8 @@ class _OutboundPageState extends State { approval: approvalInput, ), ); - result = created; + final refreshed = await resolveUpdatedRecord(created.id); + result = refreshed ?? created; updateSaving(false); if (!mounted) { return; @@ -2227,9 +2266,14 @@ class _OutboundPageState extends State { width: 500, child: SuperportFormField( label: '결재 메모', + caption: initial != null + ? '등록된 결재 메모는 수정할 수 없습니다.' + : null, child: ShadInput( controller: approvalNoteController, maxLines: 2, + readOnly: initial != null, + enabled: initial == null, ), ), ), diff --git a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart index aa893e5..f9a5450 100644 --- a/lib/features/inventory/rental/presentation/controllers/rental_controller.dart +++ b/lib/features/inventory/rental/presentation/controllers/rental_controller.dart @@ -262,6 +262,24 @@ class RentalController extends ChangeNotifier { ); } + /// 단일 대여/반납 트랜잭션 상세를 조회한다. + Future fetchTransactionDetail( + int id, { + List include = const ['lines', 'customers', 'approval'], + }) async { + try { + final transaction = await _transactionRepository.fetchDetail( + id, + include: include, + ); + return RentalRecord.fromTransaction(transaction); + } catch (error, stackTrace) { + debugPrint('[RentalController] 상세 조회 실패(id=$id): $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } + } + void _persistApprovalDraft(StockTransactionApprovalInput approval) { final useCase = _saveDraftUseCase; if (useCase == null) { diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 757b954..cfe1d29 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -162,7 +162,7 @@ class _RentalPageState extends State { Future.microtask(() async { await controller.loadStatusOptions(); final requester = _resolveCurrentWriter(); - if (requester != null) { + if (requester != null && _canRestoreApprovalDrafts) { await controller.loadApprovalDraftFromServer(requesterId: requester.id); } final hasTypes = await controller.resolveTransactionTypes(); @@ -175,6 +175,17 @@ class _RentalPageState extends State { }); } + bool get _canRestoreApprovalDrafts { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + return false; + } + return getIt().can( + PermissionResources.approvals, + PermissionAction.view, + ); + } + void _handleControllerChanged() { if (!mounted) { return; @@ -948,7 +959,7 @@ class _RentalPageState extends State { } List _buildDetailActions(RentalRecord record) { - final isProcessing = _isProcessing(record.id) || _isLoading; + final isProcessing = _isProcessing(record.id); final actions = []; if (_canSubmit(record)) { @@ -994,12 +1005,7 @@ class _RentalPageState extends State { actions.add( ShadButton.outline( leading: const Icon(lucide.LucideIcons.pencil, size: 16), - onPressed: isProcessing - ? null - : () { - Navigator.of(context).maybePop(); - _handleEdit(record); - }, + onPressed: isProcessing ? null : () => _openEditFromDetail(record), child: const Text('수정'), ), ); @@ -1007,6 +1013,17 @@ class _RentalPageState extends State { return actions; } + void _openEditFromDetail(RentalRecord record) { + final navigator = Navigator.of(context, rootNavigator: true); + navigator.pop(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) { + return; + } + await _handleEdit(record, reopenOnCancel: true); + }); + } + Future _handleCreate() async { final record = await _showRentalFormDialog(); if (record != null) { @@ -1014,10 +1031,15 @@ class _RentalPageState extends State { } } - Future _handleEdit(RentalRecord record) async { + Future _handleEdit( + RentalRecord record, { + bool reopenOnCancel = false, + }) async { final updated = await _showRentalFormDialog(initial: record); if (updated != null) { _selectRecord(updated, openDetail: true); + } else if (reopenOnCancel) { + _selectRecord(record, openDetail: true); } } @@ -1805,6 +1827,22 @@ class _RentalPageState extends State { return; } + Future resolveUpdatedRecord(int? id) async { + if (id == null) { + return null; + } + final detail = await controller.fetchTransactionDetail(id); + if (detail != null) { + return detail; + } + for (final record in controller.records) { + if (record.id == id) { + return record; + } + } + return null; + } + final selectedLookup = rentalTypeValue.value == RentalTableSpec.rentalTypes.last ? (_returnTransactionType ?? controller.returnTransactionType) @@ -1898,7 +1936,6 @@ class _RentalPageState extends State { ), refreshAfter: false, ); - result = updated; final currentLines = initialRecord.raw?.lines ?? const []; final currentCustomers = @@ -1922,6 +1959,8 @@ class _RentalPageState extends State { ); } await controller.refresh(); + final refreshed = await resolveUpdatedRecord(transactionId); + result = refreshed ?? updated; updateSaving(false); if (!mounted) { return; @@ -1993,7 +2032,8 @@ class _RentalPageState extends State { approval: approvalInput, ), ); - result = created; + final refreshed = await resolveUpdatedRecord(created.id); + result = refreshed ?? created; updateSaving(false); if (!mounted) { return; @@ -2329,9 +2369,26 @@ class _RentalPageState extends State { width: 500, child: _FormFieldLabel( label: '결재 메모', - child: ShadInput( - controller: approvalNoteController, - maxLines: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: approvalNoteController, + maxLines: 2, + readOnly: initial != null, + enabled: initial == null, + ), + if (initial != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '등록된 결재 메모는 수정할 수 없습니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], ), ), ), diff --git a/test/features/inventory/inbound_page_test.dart b/test/features/inventory/inbound_page_test.dart index 93d04b7..fabc69e 100644 --- a/test/features/inventory/inbound_page_test.dart +++ b/test/features/inventory/inbound_page_test.dart @@ -207,4 +207,46 @@ void main() { expect(find.textContaining('자동완성에서 선택'), findsOneWidget); expect(find.textContaining('창고를 선택'), findsOneWidget); }); + + testWidgets('입고 상세의 수정 버튼은 수정 모달을 연다', (tester) async { + final view = tester.view; + view.physicalSize = const Size(1280, 900); + view.devicePixelRatio = 1.0; + addTearDown(() { + view.resetPhysicalSize(); + view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + MaterialApp( + home: ScaffoldMessenger( + child: PermissionScope( + manager: PermissionManager(), + child: ShadTheme( + data: SuperportShadTheme.light(), + child: Scaffold( + body: InboundPage(routeUri: Uri.parse('/inventory/inbound')), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('TX-20240301-001').first); + await tester.pumpAndSettle(); + + expect(find.text('입고 상세'), findsOneWidget); + + final editButton = find.widgetWithText(ShadButton, '수정').last; + await tester.ensureVisible(editButton); + await tester.tap(editButton); + await tester.pump(); + + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('입고 수정'), findsOneWidget); + expect(find.widgetWithText(ShadButton, '저장'), findsWidgets); + }); } diff --git a/test/features/inventory/outbound_page_test.dart b/test/features/inventory/outbound_page_test.dart index 9aa4862..fb21374 100644 --- a/test/features/inventory/outbound_page_test.dart +++ b/test/features/inventory/outbound_page_test.dart @@ -66,4 +66,45 @@ void main() { expect(find.textContaining('자동완성에서 선택'), findsOneWidget); expect(find.text('창고를 선택하세요.'), findsOneWidget); }); + + testWidgets('출고 상세의 수정 버튼은 수정 모달을 연다', (tester) async { + final view = tester.view; + view.physicalSize = const Size(1280, 900); + view.devicePixelRatio = 1.0; + addTearDown(() { + view.resetPhysicalSize(); + view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + MaterialApp( + home: ScaffoldMessenger( + child: PermissionScope( + manager: PermissionManager(), + child: ShadTheme( + data: SuperportShadTheme.light(), + child: Scaffold( + body: OutboundPage(routeUri: Uri.parse('/inventory/outbound')), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('TX-20240302-010').first); + await tester.pumpAndSettle(); + + expect(find.text('출고 상세'), findsOneWidget); + + final editButton = find.widgetWithText(ShadButton, '수정').last; + await tester.ensureVisible(editButton); + await tester.tap(editButton); + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('출고 수정'), findsOneWidget); + expect(find.widgetWithText(ShadButton, '저장'), findsWidgets); + }); } diff --git a/test/features/inventory/rental_page_test.dart b/test/features/inventory/rental_page_test.dart index b29ebed..c50fb3e 100644 --- a/test/features/inventory/rental_page_test.dart +++ b/test/features/inventory/rental_page_test.dart @@ -66,4 +66,45 @@ void main() { expect(find.textContaining('자동완성에서 선택'), findsOneWidget); expect(find.text('최소 1개의 고객사를 선택하세요.'), findsOneWidget); }); + + testWidgets('대여 상세의 수정 버튼은 수정 모달을 연다', (tester) async { + final view = tester.view; + view.physicalSize = const Size(1280, 900); + view.devicePixelRatio = 1.0; + addTearDown(() { + view.resetPhysicalSize(); + view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + MaterialApp( + home: ScaffoldMessenger( + child: PermissionScope( + manager: PermissionManager(), + child: ShadTheme( + data: SuperportShadTheme.light(), + child: Scaffold( + body: RentalPage(routeUri: Uri.parse('/inventory/rental')), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('TX-20240305-030').first); + await tester.pumpAndSettle(); + + expect(find.text('대여 상세'), findsOneWidget); + + final editButton = find.widgetWithText(ShadButton, '수정').last; + await tester.ensureVisible(editButton); + await tester.tap(editButton); + await tester.pump(); + await tester.pumpAndSettle(const Duration(milliseconds: 500)); + + expect(find.text('대여 수정'), findsOneWidget); + expect(find.widgetWithText(ShadButton, '저장'), findsWidgets); + }); } diff --git a/test/helpers/inventory_test_stubs.dart b/test/helpers/inventory_test_stubs.dart index a7e34bb..b9ca3e0 100644 --- a/test/helpers/inventory_test_stubs.dart +++ b/test/helpers/inventory_test_stubs.dart @@ -240,6 +240,15 @@ class _StubStockTransactionRepository implements StockTransactionRepository { }) : _transactions = transactions; final List _transactions; + StockTransaction _findTransaction(int id) { + final index = _transactions.indexWhere( + (transaction) => transaction.id == id, + ); + if (index == -1) { + throw StateError('Transaction $id not found'); + } + return _transactions[index]; + } @override Future> list({ @@ -302,8 +311,8 @@ class _StubStockTransactionRepository implements StockTransactionRepository { Future fetchDetail( int id, { List include = const ['lines', 'customers', 'approval'], - }) { - throw UnimplementedError(); + }) async { + return _findTransaction(id); } @override @@ -359,14 +368,9 @@ class _StubStockTransactionRepository implements StockTransactionRepository { int id, StockTransaction Function(StockTransaction transaction) transform, ) async { - final index = _transactions.indexWhere( - (transaction) => transaction.id == id, - ); - if (index == -1) { - throw StateError('Transaction $id not found'); - } - final current = _transactions[index]; + final current = _findTransaction(id); final updated = transform(current); + final index = _transactions.indexOf(current); _transactions[index] = updated; return updated; }