fix(inventory): 파트너 연동 및 상세 모달 동작 안정화

- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출

- 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정

- 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장

- 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화

- 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일

- 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
This commit is contained in:
JiWoong Sul
2025-10-27 20:02:21 +09:00
parent 14624c4165
commit 259b056072
14 changed files with 1054 additions and 155 deletions

View File

@@ -14,8 +14,8 @@ import 'package:superport_v2/widgets/components/empty_state.dart';
import 'package:superport_v2/widgets/components/responsive.dart';
import 'package:superport_v2/widgets/components/superport_dialog.dart';
import 'package:superport_v2/widgets/components/superport_date_picker.dart';
import 'package:superport_v2/features/inventory/shared/widgets/partner_select_field.dart';
import 'package:superport_v2/features/inventory/shared/widgets/product_autocomplete_field.dart';
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart';
import 'package:superport_v2/features/inventory/shared/widgets/warehouse_select_field.dart';
import 'package:superport_v2/core/config/environment.dart';
import 'package:superport_v2/core/network/failure.dart';
@@ -28,7 +28,10 @@ import 'package:superport_v2/features/inventory/transactions/domain/entities/sto
import 'package:superport_v2/features/inventory/transactions/domain/entities/stock_transaction_input.dart';
import 'package:superport_v2/features/inventory/transactions/domain/repositories/stock_transaction_repository.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/services/transaction_detail_sync_service.dart';
import 'package:superport_v2/features/auth/application/auth_service.dart';
import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart';
import 'package:superport_v2/features/inventory/shared/widgets/employee_autocomplete_field.dart'
show InventoryEmployeeSuggestion;
import '../../../lookups/domain/entities/lookup_item.dart';
import '../../../lookups/domain/repositories/inventory_lookup_repository.dart';
import '../widgets/inbound_detail_view.dart';
@@ -112,12 +115,14 @@ class _InboundPageState extends State<InboundPage> {
final getIt = GetIt.I;
if (!getIt.isRegistered<StockTransactionRepository>() ||
!getIt.isRegistered<TransactionLineRepository>() ||
!getIt.isRegistered<TransactionCustomerRepository>() ||
!getIt.isRegistered<InventoryLookupRepository>()) {
return null;
}
return InboundController(
transactionRepository: getIt<StockTransactionRepository>(),
lineRepository: getIt<TransactionLineRepository>(),
customerRepository: getIt<TransactionCustomerRepository>(),
lookupRepository: getIt<InventoryLookupRepository>(),
fallbackStatusOptions: InboundTableSpec.fallbackStatusOptions,
transactionTypeKeywords: InboundTableSpec.transactionTypeKeywords,
@@ -131,6 +136,7 @@ class _InboundPageState extends State<InboundPage> {
}
Future.microtask(() async {
await controller.loadStatusOptions();
await controller.loadApprovalStatuses();
final hasType = await controller.resolveTransactionType();
if (!mounted) {
return;
@@ -804,12 +810,7 @@ class _InboundPageState extends State<InboundPage> {
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('수정'),
),
);
@@ -817,6 +818,17 @@ class _InboundPageState extends State<InboundPage> {
return actions;
}
void _openEditFromDetail(InboundRecord record) {
final navigator = Navigator.of(context, rootNavigator: true);
navigator.pop();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) {
return;
}
await _handleEdit(record, reopenOnCancel: true);
});
}
Future<void> _handleCreate() async {
final record = await _showInboundFormDialog();
if (record != null) {
@@ -824,10 +836,18 @@ class _InboundPageState extends State<InboundPage> {
}
}
Future<void> _handleEdit(InboundRecord record) async {
Future<void> _handleEdit(
InboundRecord record, {
bool reopenOnCancel = false,
}) async {
final updated = await _showInboundFormDialog(initial: record);
if (!mounted) {
return;
}
if (updated != null) {
_selectRecord(updated, openDetail: true);
} else if (reopenOnCancel) {
_selectRecord(record, openDetail: true);
}
}
@@ -1364,6 +1384,26 @@ class _InboundPageState extends State<InboundPage> {
};
}
InventoryEmployeeSuggestion? _resolveCurrentWriter() {
final getIt = GetIt.I;
if (!getIt.isRegistered<AuthService>()) {
return null;
}
final session = getIt<AuthService>().session;
final user = session?.user;
if (user == null) {
return null;
}
final employeeNo = (user.employeeNo ?? '').trim().isEmpty
? user.id.toString()
: user.employeeNo!.trim();
return InventoryEmployeeSuggestion(
id: user.id,
employeeNo: employeeNo,
name: user.name,
);
}
int _calculateTotalPages(int totalItems) {
if (totalItems <= 0) {
return 1;
@@ -1397,6 +1437,27 @@ class _InboundPageState extends State<InboundPage> {
final statusValue = ValueNotifier<String>(
initial?.status ?? _statusOptions.first,
);
final initialPartnerOption = () {
final customers = initial?.raw?.customers;
if (customers != null && customers.isNotEmpty) {
final summary = customers.first.customer;
return InventoryPartnerOption(
id: summary.id,
name: summary.name,
code: summary.code,
);
}
if (initial?.partnerId != null &&
(initial?.partnerName?.trim().isNotEmpty ?? false)) {
return InventoryPartnerOption(
id: initial!.partnerId!,
name: initial.partnerName!.trim(),
code: (initial.partnerCode ?? '').trim(),
);
}
return null;
}();
InventoryPartnerOption? partnerSelection = initialPartnerOption;
InventoryEmployeeSuggestion? writerSelection;
final initialWriter = initial?.raw?.createdBy;
if (initialWriter != null) {
@@ -1412,6 +1473,8 @@ class _InboundPageState extends State<InboundPage> {
employeeNo: initial.writer,
name: initial.writer,
);
} else if (initial == null) {
writerSelection = _resolveCurrentWriter();
}
String writerLabel(InventoryEmployeeSuggestion? suggestion) {
if (suggestion == null) {
@@ -1448,6 +1511,7 @@ class _InboundPageState extends State<InboundPage> {
String? writerError;
String? warehouseError;
String? partnerError;
String? statusError;
String? headerNotice;
StateSetter? refreshForm;
@@ -1457,7 +1521,7 @@ class _InboundPageState extends State<InboundPage> {
InboundRecord? result;
final navigator = Navigator.of(context);
final navigator = Navigator.of(context, rootNavigator: true);
void updateSaving(bool next) {
isSaving = next;
@@ -1476,12 +1540,14 @@ class _InboundPageState extends State<InboundPage> {
writerSelection: writerSelection,
requireWriterSelection: initial == null,
warehouseSelection: warehouseSelection,
partnerSelection: partnerSelection,
statusValue: statusValue.value,
drafts: drafts,
lineErrors: lineErrors,
);
writerError = validationResult.writerError;
warehouseError = validationResult.warehouseError;
partnerError = validationResult.partnerError;
statusError = validationResult.statusError;
headerNotice = validationResult.headerNotice;
refreshForm?.call(() {});
@@ -1556,6 +1622,14 @@ class _InboundPageState extends State<InboundPage> {
return;
}
final createCustomers = () {
final partner = partnerSelection;
if (partner == null) {
return const <TransactionCustomerCreateInput>[];
}
return [TransactionCustomerCreateInput(customerId: partner.id)];
}();
try {
updateSaving(true);
@@ -1576,12 +1650,37 @@ class _InboundPageState extends State<InboundPage> {
result = updated;
final currentLines =
initialRecord.raw?.lines ?? const <StockTransactionLine>[];
final plan = _detailSyncService.buildLinePlan(
final currentCustomers = initialRecord.customers;
final customerDrafts = () {
final partner = partnerSelection;
if (partner == null) {
return const <TransactionCustomerDraft>[];
}
final existing = initialRecord.primaryCustomer;
return [
TransactionCustomerDraft(
id: existing?.id ?? initialRecord.partnerLinkId,
customerId: partner.id,
note: existing?.note,
),
];
}();
final linePlan = _detailSyncService.buildLinePlan(
drafts: lineDrafts,
currentLines: currentLines,
);
if (plan.hasChanges) {
await controller.syncTransactionLines(transactionId, plan);
final customerPlan = _detailSyncService.buildCustomerPlan(
drafts: customerDrafts,
currentCustomers: currentCustomers,
);
if (linePlan.hasChanges) {
await controller.syncTransactionLines(transactionId, linePlan);
}
if (customerPlan.hasChanges) {
await controller.syncTransactionCustomers(
transactionId,
customerPlan,
);
}
await controller.refresh();
updateSaving(false);
@@ -1602,6 +1701,23 @@ class _InboundPageState extends State<InboundPage> {
return;
}
final approvalStatusId = () {
final defaultStatus = controller.defaultApprovalStatus;
if (defaultStatus != null) {
return defaultStatus.id;
}
final statuses = controller.approvalStatuses;
if (statuses.isNotEmpty) {
return statuses.first.id;
}
return null;
}();
if (approvalStatusId == null) {
updateSaving(false);
SuperportToast.error(context, '결재 상태 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.');
return;
}
final createLines = lineDrafts
.map(
(draft) => TransactionLineCreateInput(
@@ -1613,21 +1729,29 @@ class _InboundPageState extends State<InboundPage> {
),
)
.toList(growable: false);
final created = await controller.createTransaction(
StockTransactionCreateInput(
transactionTypeId: transactionTypeLookup.id,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
transactionDate: processedAt.value,
createdById: createdById,
note: remarkValue,
lines: createLines,
approval: StockTransactionApprovalInput(
requestedById: createdById,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
final createInput = StockTransactionCreateInput(
transactionTypeId: transactionTypeLookup.id,
transactionStatusId: statusItem.id,
warehouseId: warehouseId,
transactionDate: processedAt.value,
createdById: createdById,
note: remarkValue,
lines: createLines,
customers: createCustomers,
approval: StockTransactionApprovalInput(
requestedById: createdById,
approvalStatusId: approvalStatusId,
note: approvalNoteValue.isEmpty ? null : approvalNoteValue,
),
);
assert(() {
debugPrint(
'[InboundForm] POST /stock-transactions payload: '
'${createInput.toPayload()}',
);
return true;
}());
final created = await controller.createTransaction(createInput);
result = created;
updateSaving(false);
if (!mounted) {
@@ -1643,6 +1767,20 @@ class _InboundPageState extends State<InboundPage> {
if (!mounted) {
return;
}
assert(() {
final failure = Failure.from(error);
debugPrint(
'[InboundForm] 저장 실패: status=${failure.statusCode} '
'message="${failure.describe()}"',
);
debugPrint(
'[InboundForm] 요청 페이로드: '
'{transactionTypeId: ${transactionTypeLookup.id}, '
'statusId: ${statusItem.id}, warehouseId: $warehouseId, '
'createdById: $createdById, lineCount: ${lineDrafts.length}}',
);
return true;
}());
SuperportToast.error(
context,
_failureMessage(error, '저장 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.'),
@@ -1734,6 +1872,26 @@ class _InboundPageState extends State<InboundPage> {
),
),
),
SizedBox(
width: 240,
child: SuperportFormField(
label: '파트너사',
required: true,
errorText: partnerError,
child: InventoryPartnerSelectField(
initialPartner: partnerSelection,
enabled: !isSaving,
onChanged: (option) {
partnerSelection = option;
setState(() {
if (partnerError != null) {
partnerError = null;
}
});
},
),
),
),
SizedBox(
width: 240,
child: SuperportFormField(
@@ -1808,35 +1966,10 @@ class _InboundPageState extends State<InboundPage> {
label: '작성자',
required: true,
errorText: writerError,
child: InventoryEmployeeAutocompleteField(
child: ShadInput(
controller: writerController,
initialSuggestion: writerSelection,
enabled: initial == null,
onSuggestionSelected: (suggestion) {
writerSelection = suggestion;
if (writerError != null) {
setState(() {
writerError = null;
});
}
},
onChanged: () {
if (initial == null) {
final currentText = writerController.text.trim();
final selectedLabel = writerLabel(
writerSelection,
);
if (currentText.isEmpty ||
currentText != selectedLabel) {
writerSelection = null;
}
if (writerError != null) {
setState(() {
writerError = null;
});
}
}
},
readOnly: true,
enabled: false,
),
),
),
@@ -1927,32 +2060,39 @@ class _InboundPageState extends State<InboundPage> {
const SizedBox(height: 16),
Column(
children: [
for (final draft in drafts)
for (var index = 0; index < drafts.length; index++)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _LineItemRow(
draft: draft,
onRemove: drafts.length == 1
? null
: () => setState(() {
draft.dispose();
drafts.remove(draft);
child: Builder(
builder: (_) {
final draft = drafts[index];
return _LineItemRow(
draft: draft,
showLabels: index == 0,
onRemove: drafts.length == 1
? null
: () => setState(() {
draft.dispose();
drafts.remove(draft);
headerNotice = null;
lineErrors.remove(draft);
}),
errors:
lineErrors[draft] ??
_LineItemFieldErrors.empty(),
onFieldChanged: (field) {
setState(() {
headerNotice = null;
lineErrors.remove(draft);
}),
errors:
lineErrors[draft] ?? _LineItemFieldErrors.empty(),
onFieldChanged: (field) {
setState(() {
headerNotice = null;
final error = lineErrors[draft];
if (error == null) {
lineErrors[draft] =
_LineItemFieldErrors.empty();
} else {
error.clearField(field);
}
});
final error = lineErrors[draft];
if (error == null) {
lineErrors[draft] =
_LineItemFieldErrors.empty();
} else {
error.clearField(field);
}
});
},
);
},
),
),
@@ -1965,15 +2105,25 @@ class _InboundPageState extends State<InboundPage> {
),
);
for (final draft in drafts) {
draft.dispose();
}
statusValue.dispose();
writerController.dispose();
remarkController.dispose();
approvalNoteController.dispose();
transactionTypeController.dispose();
processedAt.dispose();
final disposeDrafts = List<_LineItemDraft>.from(drafts);
final disposeStatus = statusValue;
final disposeWriterController = writerController;
final disposeRemarkController = remarkController;
final disposeApprovalNoteController = approvalNoteController;
final disposeTransactionTypeController = transactionTypeController;
final disposeProcessedAt = processedAt;
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final draft in disposeDrafts) {
draft.dispose();
}
disposeStatus.dispose();
disposeWriterController.dispose();
disposeRemarkController.dispose();
disposeApprovalNoteController.dispose();
disposeTransactionTypeController.dispose();
disposeProcessedAt.dispose();
});
return result;
}
@@ -2315,6 +2465,7 @@ _InboundFormValidation _validateInboundForm({
required InventoryEmployeeSuggestion? writerSelection,
required bool requireWriterSelection,
required InventoryWarehouseOption? warehouseSelection,
required InventoryPartnerOption? partnerSelection,
required String statusValue,
required List<_LineItemDraft> drafts,
required Map<_LineItemDraft, _LineItemFieldErrors> lineErrors,
@@ -2322,6 +2473,7 @@ _InboundFormValidation _validateInboundForm({
var isValid = true;
String? writerError;
String? warehouseError;
String? partnerError;
String? statusError;
String? headerNotice;
@@ -2341,6 +2493,11 @@ _InboundFormValidation _validateInboundForm({
isValid = false;
}
if (partnerSelection == null) {
partnerError = '파트너사를 선택하세요.';
isValid = false;
}
if (statusValue.trim().isEmpty) {
statusError = '상태를 선택하세요.';
isValid = false;
@@ -2369,7 +2526,6 @@ _InboundFormValidation _validateInboundForm({
hasLineError = true;
isValid = false;
}
final quantity = int.tryParse(
draft.quantity.text.trim().isEmpty ? '0' : draft.quantity.text.trim(),
);
@@ -2395,6 +2551,7 @@ _InboundFormValidation _validateInboundForm({
isValid: isValid,
writerError: writerError,
warehouseError: warehouseError,
partnerError: partnerError,
statusError: statusError,
headerNotice: headerNotice,
);
@@ -2410,6 +2567,7 @@ class _InboundFormValidation {
required this.isValid,
this.writerError,
this.warehouseError,
this.partnerError,
this.statusError,
this.headerNotice,
});
@@ -2417,6 +2575,7 @@ class _InboundFormValidation {
final bool isValid;
final String? writerError;
final String? warehouseError;
final String? partnerError;
final String? statusError;
final String? headerNotice;
}
@@ -2454,12 +2613,14 @@ enum _InboundSortField { processedAt, warehouse, status, writer }
class _LineItemRow extends StatelessWidget {
const _LineItemRow({
required this.draft,
required this.showLabels,
required this.onRemove,
required this.errors,
required this.onFieldChanged,
});
final _LineItemDraft draft;
final bool showLabels;
final VoidCallback? onRemove;
final _LineItemFieldErrors errors;
final void Function(_LineItemField field) onFieldChanged;
@@ -2471,8 +2632,8 @@ class _LineItemRow extends StatelessWidget {
children: [
Expanded(
child: SuperportFormField(
label: '제품',
required: true,
label: showLabels ? '제품' : '',
required: showLabels,
errorText: errors.product,
child: InventoryProductAutocompleteField(
productController: draft.product,
@@ -2492,8 +2653,8 @@ class _LineItemRow extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: SuperportFormField(
label: '제조사',
caption: '제품 선택 시 자동 입력됩니다.',
label: showLabels ? '제조사' : '',
caption: showLabels ? '제품 선택 시 자동 입력됩니다.' : null,
child: ShadInput(
controller: draft.manufacturer,
placeholder: const Text('자동 입력'),
@@ -2506,8 +2667,8 @@ class _LineItemRow extends StatelessWidget {
SizedBox(
width: 80,
child: SuperportFormField(
label: '단위',
caption: '제품에 연결된 단위입니다.',
label: showLabels ? '단위' : '',
caption: showLabels ? '제품에 연결된 단위입니다.' : null,
child: ShadInput(
controller: draft.unit,
placeholder: const Text('자동'),
@@ -2520,8 +2681,8 @@ class _LineItemRow extends StatelessWidget {
SizedBox(
width: 100,
child: SuperportFormField(
label: '수량',
required: true,
label: showLabels ? '수량' : '',
required: showLabels,
errorText: errors.quantity,
child: ShadInput(
controller: draft.quantity,
@@ -2535,8 +2696,8 @@ class _LineItemRow extends StatelessWidget {
SizedBox(
width: 120,
child: SuperportFormField(
label: '단가',
required: true,
label: showLabels ? '단가' : '',
required: showLabels,
errorText: errors.price,
child: ShadInput(
controller: draft.price,
@@ -2549,7 +2710,7 @@ class _LineItemRow extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: SuperportFormField(
label: '비고',
label: showLabels ? '비고' : '',
child: ShadInput(
controller: draft.remark,
placeholder: const Text('비고'),