전역 구조 리팩터링 및 테스트 확장
This commit is contained in:
232
lib/features/inventory/shared/catalogs.dart
Normal file
232
lib/features/inventory/shared/catalogs.dart
Normal file
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 인벤토리 폼에서 공유하는 제품 카탈로그 항목.
|
||||
class InventoryProductCatalogItem {
|
||||
const InventoryProductCatalogItem({
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.manufacturer,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String name;
|
||||
final String manufacturer;
|
||||
final String unit;
|
||||
}
|
||||
|
||||
String _normalizeText(String value) {
|
||||
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
|
||||
}
|
||||
|
||||
/// 제품 카탈로그 유틸리티.
|
||||
class InventoryProductCatalog {
|
||||
static final List<InventoryProductCatalogItem> items = List.unmodifiable([
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-100',
|
||||
name: 'XR-5000',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-101',
|
||||
name: 'XR-5001',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-102',
|
||||
name: 'Eco-200',
|
||||
manufacturer: '그린텍',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-201',
|
||||
name: 'Delta-One',
|
||||
manufacturer: '델타',
|
||||
unit: 'SET',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-210',
|
||||
name: 'SmartGauge A1',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-305',
|
||||
name: 'PowerPack Mini',
|
||||
manufacturer: '에이치솔루션',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-320',
|
||||
name: 'Hydra-Flow 2',
|
||||
manufacturer: '블루하이드',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-401',
|
||||
name: 'SolarEdge Pro',
|
||||
manufacturer: '그린텍',
|
||||
unit: 'EA',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-430',
|
||||
name: 'Alpha-Kit 12',
|
||||
manufacturer: '테크솔루션',
|
||||
unit: 'SET',
|
||||
),
|
||||
const InventoryProductCatalogItem(
|
||||
code: 'P-501',
|
||||
name: 'LogiSense 5',
|
||||
manufacturer: '슈퍼벤더',
|
||||
unit: 'EA',
|
||||
),
|
||||
]);
|
||||
|
||||
static final Map<String, InventoryProductCatalogItem> _byKey = {
|
||||
for (final item in items) _normalizeText(item.name): item,
|
||||
};
|
||||
|
||||
static InventoryProductCatalogItem? match(String value) {
|
||||
if (value.isEmpty) return null;
|
||||
return _byKey[_normalizeText(value)];
|
||||
}
|
||||
|
||||
static List<InventoryProductCatalogItem> filter(String query) {
|
||||
final normalized = _normalizeText(query.trim());
|
||||
if (normalized.isEmpty) {
|
||||
return items.take(12).toList();
|
||||
}
|
||||
final lower = query.trim().toLowerCase();
|
||||
return [
|
||||
for (final item in items)
|
||||
if (_normalizeText(item.name).contains(normalized) ||
|
||||
item.code.toLowerCase().contains(lower))
|
||||
item,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 고객 카탈로그 항목.
|
||||
class InventoryCustomerCatalogItem {
|
||||
const InventoryCustomerCatalogItem({
|
||||
required this.code,
|
||||
required this.name,
|
||||
required this.industry,
|
||||
required this.region,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String name;
|
||||
final String industry;
|
||||
final String region;
|
||||
}
|
||||
|
||||
/// 고객 카탈로그 유틸리티.
|
||||
class InventoryCustomerCatalog {
|
||||
static final List<InventoryCustomerCatalogItem> items = List.unmodifiable([
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1001',
|
||||
name: '슈퍼포트 파트너',
|
||||
industry: '물류',
|
||||
region: '서울',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1002',
|
||||
name: '그린에너지',
|
||||
industry: '에너지',
|
||||
region: '대전',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1003',
|
||||
name: '테크솔루션',
|
||||
industry: 'IT 서비스',
|
||||
region: '부산',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1004',
|
||||
name: '에이치솔루션',
|
||||
industry: '제조',
|
||||
region: '인천',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1005',
|
||||
name: '블루하이드',
|
||||
industry: '해양장비',
|
||||
region: '울산',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1010',
|
||||
name: '넥스트파워',
|
||||
industry: '발전설비',
|
||||
region: '광주',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1011',
|
||||
name: '씨에스테크',
|
||||
industry: '반도체',
|
||||
region: '수원',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1012',
|
||||
name: '알파시스템',
|
||||
industry: '장비임대',
|
||||
region: '대구',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1013',
|
||||
name: '스타트랩',
|
||||
industry: '연구개발',
|
||||
region: '세종',
|
||||
),
|
||||
const InventoryCustomerCatalogItem(
|
||||
code: 'C-1014',
|
||||
name: '메가스틸',
|
||||
industry: '철강',
|
||||
region: '포항',
|
||||
),
|
||||
]);
|
||||
|
||||
static final Map<String, InventoryCustomerCatalogItem> _byName = {
|
||||
for (final item in items) item.name: item,
|
||||
};
|
||||
|
||||
static InventoryCustomerCatalogItem? byName(String name) => _byName[name];
|
||||
|
||||
static List<InventoryCustomerCatalogItem> filter(String query) {
|
||||
final normalized = _normalizeText(query.trim());
|
||||
if (normalized.isEmpty) {
|
||||
return items;
|
||||
}
|
||||
final lower = query.trim().toLowerCase();
|
||||
return [
|
||||
for (final item in items)
|
||||
if (_normalizeText(item.name).contains(normalized) ||
|
||||
item.code.toLowerCase().contains(lower) ||
|
||||
_normalizeText(item.industry).contains(normalized) ||
|
||||
_normalizeText(item.region).contains(normalized))
|
||||
item,
|
||||
];
|
||||
}
|
||||
|
||||
static String displayLabel(String name) {
|
||||
final item = byName(name);
|
||||
if (item == null) {
|
||||
return name;
|
||||
}
|
||||
return '${item.name} (${item.code})';
|
||||
}
|
||||
}
|
||||
|
||||
/// 검색 결과가 없을 때 노출할 기본 위젯.
|
||||
Widget buildEmptySearchResult(
|
||||
ShadTextTheme textTheme, {
|
||||
String message = '검색 결과가 없습니다.',
|
||||
}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(message, style: textTheme.muted),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../catalogs.dart';
|
||||
|
||||
/// 제품명 입력 시 카탈로그 자동완성을 제공하는 필드.
|
||||
class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
const InventoryProductAutocompleteField({
|
||||
super.key,
|
||||
required this.productController,
|
||||
required this.productFocusNode,
|
||||
required this.manufacturerController,
|
||||
required this.unitController,
|
||||
required this.onCatalogMatched,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController productController;
|
||||
final FocusNode productFocusNode;
|
||||
final TextEditingController manufacturerController;
|
||||
final TextEditingController unitController;
|
||||
final ValueChanged<InventoryProductCatalogItem?> onCatalogMatched;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
State<InventoryProductAutocompleteField> createState() =>
|
||||
_InventoryProductAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _InventoryProductAutocompleteFieldState
|
||||
extends State<InventoryProductAutocompleteField> {
|
||||
InventoryProductCatalogItem? _catalogMatch;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryProductAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.productController, widget.productController)) {
|
||||
oldWidget.productController.removeListener(_handleTextChanged);
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
final text = widget.productController.text.trim();
|
||||
final match = InventoryProductCatalog.match(text);
|
||||
if (match != null) {
|
||||
_applyCatalog(match);
|
||||
return;
|
||||
}
|
||||
if (_catalogMatch != null) {
|
||||
setState(() {
|
||||
_catalogMatch = null;
|
||||
});
|
||||
widget.onCatalogMatched(null);
|
||||
if (widget.manufacturerController.text.isNotEmpty) {
|
||||
widget.manufacturerController.clear();
|
||||
}
|
||||
if (widget.unitController.text.isNotEmpty) {
|
||||
widget.unitController.clear();
|
||||
}
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
void _applyCatalog(
|
||||
InventoryProductCatalogItem match, {
|
||||
bool updateProduct = true,
|
||||
}) {
|
||||
setState(() {
|
||||
_catalogMatch = match;
|
||||
});
|
||||
widget.onCatalogMatched(match);
|
||||
if (updateProduct && widget.productController.text != match.name) {
|
||||
widget.productController.text = match.name;
|
||||
widget.productController.selection = TextSelection.collapsed(
|
||||
offset: widget.productController.text.length,
|
||||
);
|
||||
}
|
||||
if (widget.manufacturerController.text != match.manufacturer) {
|
||||
widget.manufacturerController.text = match.manufacturer;
|
||||
}
|
||||
if (widget.unitController.text != match.unit) {
|
||||
widget.unitController.text = match.unit;
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
Iterable<InventoryProductCatalogItem> _options(String query) {
|
||||
return InventoryProductCatalog.filter(query);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.productController.removeListener(_handleTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<InventoryProductCatalogItem>(
|
||||
textEditingController: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
return _options(textEditingValue.text);
|
||||
},
|
||||
displayStringForOption: (option) => option.name,
|
||||
onSelected: (option) {
|
||||
_applyCatalog(option);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return ShadInput(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
placeholder: const Text('제품명'),
|
||||
onChanged: (_) => widget.onChanged?.call(),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 240,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: buildEmptySearchResult(theme.textTheme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 260,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(option.name, style: theme.textTheme.p),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${option.code} · ${option.manufacturer} · ${option.unit}',
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:superport_v2/core/common/models/paginated_result.dart';
|
||||
import 'package:superport_v2/core/common/utils/json_utils.dart';
|
||||
|
||||
import '../../domain/entities/stock_transaction.dart';
|
||||
|
||||
/// 재고 트랜잭션 DTO
|
||||
///
|
||||
/// - API 응답(JSON)을 도메인 엔티티로 변환하고, 요청 페이로드를 구성한다.
|
||||
class StockTransactionDto {
|
||||
StockTransactionDto({
|
||||
this.id,
|
||||
required this.transactionNo,
|
||||
required this.transactionDate,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.warehouse,
|
||||
required this.createdBy,
|
||||
this.note,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.lines = const [],
|
||||
this.customers = const [],
|
||||
this.approval,
|
||||
this.expectedReturnDate,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String transactionNo;
|
||||
final DateTime transactionDate;
|
||||
final StockTransactionType type;
|
||||
final StockTransactionStatus status;
|
||||
final StockTransactionWarehouse warehouse;
|
||||
final StockTransactionEmployee createdBy;
|
||||
final String? note;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<StockTransactionLine> lines;
|
||||
final List<StockTransactionCustomer> customers;
|
||||
final StockTransactionApprovalSummary? approval;
|
||||
final DateTime? expectedReturnDate;
|
||||
|
||||
/// JSON 객체를 DTO로 변환한다.
|
||||
factory StockTransactionDto.fromJson(Map<String, dynamic> json) {
|
||||
final typeJson = json['transaction_type'] as Map<String, dynamic>?;
|
||||
final statusJson = json['transaction_status'] as Map<String, dynamic>?;
|
||||
final warehouseJson = json['warehouse'] as Map<String, dynamic>?;
|
||||
final createdByJson = json['created_by'] as Map<String, dynamic>?;
|
||||
|
||||
return StockTransactionDto(
|
||||
id: json['id'] as int?,
|
||||
transactionNo: json['transaction_no'] as String? ?? '',
|
||||
transactionDate: _parseDate(json['transaction_date']) ?? DateTime.now(),
|
||||
type: _parseType(typeJson),
|
||||
status: _parseStatus(statusJson),
|
||||
warehouse: _parseWarehouse(warehouseJson),
|
||||
createdBy: _parseEmployee(createdByJson),
|
||||
note: json['note'] as String?,
|
||||
isActive: (json['is_active'] as bool?) ?? true,
|
||||
createdAt: _parseDateTime(json['created_at']),
|
||||
updatedAt: _parseDateTime(json['updated_at']),
|
||||
lines: _parseLines(json),
|
||||
customers: _parseCustomers(json),
|
||||
approval: _parseApproval(json['approval']),
|
||||
expectedReturnDate:
|
||||
_parseDate(json['expected_return_date']) ??
|
||||
_parseDate(json['planned_return_date']) ??
|
||||
_parseDate(json['return_due_date']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 도메인 엔티티로 변환한다.
|
||||
StockTransaction toEntity() {
|
||||
return StockTransaction(
|
||||
id: id,
|
||||
transactionNo: transactionNo,
|
||||
transactionDate: transactionDate,
|
||||
type: type,
|
||||
status: status,
|
||||
warehouse: warehouse,
|
||||
createdBy: createdBy,
|
||||
note: note,
|
||||
isActive: isActive,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
lines: lines,
|
||||
customers: customers,
|
||||
approval: approval,
|
||||
expectedReturnDate: expectedReturnDate,
|
||||
);
|
||||
}
|
||||
|
||||
/// 페이지네이션 응답을 파싱한다.
|
||||
static PaginatedResult<StockTransaction> parsePaginated(dynamic json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['items']);
|
||||
final items = raw
|
||||
.map(StockTransactionDto.fromJson)
|
||||
.map((dto) => dto.toEntity())
|
||||
.toList(growable: false);
|
||||
final map = json is Map<String, dynamic> ? json : <String, dynamic>{};
|
||||
return PaginatedResult<StockTransaction>(
|
||||
items: items,
|
||||
page: JsonUtils.readInt(map, 'page', fallback: 1),
|
||||
pageSize: JsonUtils.readInt(map, 'page_size', fallback: items.length),
|
||||
total: JsonUtils.readInt(map, 'total', fallback: items.length),
|
||||
);
|
||||
}
|
||||
|
||||
/// 단건 응답을 파싱한다.
|
||||
static StockTransaction? parseSingle(dynamic json) {
|
||||
final map = JsonUtils.extractMap(json, keys: const ['data']);
|
||||
if (map.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return StockTransactionDto.fromJson(map).toEntity();
|
||||
}
|
||||
}
|
||||
|
||||
StockTransactionType _parseType(Map<String, dynamic>? json) {
|
||||
return StockTransactionType(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
name: json?['type_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionStatus _parseStatus(Map<String, dynamic>? json) {
|
||||
return StockTransactionStatus(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
name: json?['status_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionWarehouse _parseWarehouse(Map<String, dynamic>? json) {
|
||||
final zipcode = json?['zipcode'] as Map<String, dynamic>?;
|
||||
return StockTransactionWarehouse(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['warehouse_code'] as String? ?? '',
|
||||
name: json?['warehouse_name'] as String? ?? '',
|
||||
zipcode: zipcode?['zipcode'] as String?,
|
||||
addressLine: zipcode?['road_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionEmployee _parseEmployee(Map<String, dynamic>? json) {
|
||||
return StockTransactionEmployee(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
employeeNo: json?['employee_no'] as String? ?? '',
|
||||
name: json?['employee_name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
List<StockTransactionLine> _parseLines(Map<String, dynamic> json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['lines']);
|
||||
return [
|
||||
for (final item in raw)
|
||||
StockTransactionLine(
|
||||
id: item['id'] as int?,
|
||||
lineNo: JsonUtils.readInt(item, 'line_no', fallback: 1),
|
||||
product: _parseProduct(item['product'] as Map<String, dynamic>?),
|
||||
quantity: JsonUtils.readInt(item, 'quantity', fallback: 0),
|
||||
unitPrice: _readDouble(item['unit_price']),
|
||||
note: item['note'] as String?,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
StockTransactionProduct _parseProduct(Map<String, dynamic>? json) {
|
||||
final vendorJson = json?['vendor'] as Map<String, dynamic>?;
|
||||
final uomJson = json?['uom'] as Map<String, dynamic>?;
|
||||
return StockTransactionProduct(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['product_code'] as String? ?? json?['code'] as String? ?? '',
|
||||
name: json?['product_name'] as String? ?? json?['name'] as String? ?? '',
|
||||
vendor: vendorJson == null
|
||||
? null
|
||||
: StockTransactionVendorSummary(
|
||||
id: vendorJson['id'] as int? ?? 0,
|
||||
name:
|
||||
vendorJson['vendor_name'] as String? ??
|
||||
vendorJson['name'] as String? ??
|
||||
'',
|
||||
),
|
||||
uom: uomJson == null
|
||||
? null
|
||||
: StockTransactionUomSummary(
|
||||
id: uomJson['id'] as int? ?? 0,
|
||||
name:
|
||||
uomJson['uom_name'] as String? ??
|
||||
uomJson['name'] as String? ??
|
||||
'',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<StockTransactionCustomer> _parseCustomers(Map<String, dynamic> json) {
|
||||
final raw = JsonUtils.extractList(json, keys: const ['customers']);
|
||||
return [
|
||||
for (final item in raw)
|
||||
StockTransactionCustomer(
|
||||
id: item['id'] as int?,
|
||||
customer: _parseCustomer(item['customer'] as Map<String, dynamic>?),
|
||||
note: item['note'] as String?,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
StockTransactionCustomerSummary _parseCustomer(Map<String, dynamic>? json) {
|
||||
return StockTransactionCustomerSummary(
|
||||
id: json?['id'] as int? ?? 0,
|
||||
code: json?['customer_code'] as String? ?? json?['code'] as String? ?? '',
|
||||
name: json?['customer_name'] as String? ?? json?['name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
StockTransactionApprovalSummary? _parseApproval(dynamic raw) {
|
||||
if (raw is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final status = raw['approval_status'] as Map<String, dynamic>?;
|
||||
return StockTransactionApprovalSummary(
|
||||
id: raw['id'] as int? ?? 0,
|
||||
approvalNo: raw['approval_no'] as String? ?? '',
|
||||
status: status == null
|
||||
? null
|
||||
: StockTransactionApprovalStatusSummary(
|
||||
id: status['id'] as int? ?? 0,
|
||||
name:
|
||||
status['status_name'] as String? ??
|
||||
status['name'] as String? ??
|
||||
'',
|
||||
isBlocking: status['is_blocking_next'] as bool?,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? _parseDate(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value;
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? _parseDateTime(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value;
|
||||
}
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
double _readDouble(Object? value) {
|
||||
if (value is double) {
|
||||
return value;
|
||||
}
|
||||
if (value is int) {
|
||||
return value.toDouble();
|
||||
}
|
||||
if (value is String) {
|
||||
return double.tryParse(value) ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/// 재고 트랜잭션 도메인 엔티티
|
||||
///
|
||||
/// - 입고/출고/대여 공통으로 사용되는 헤더와 라인, 고객 연결 정보를 포함한다.
|
||||
class StockTransaction {
|
||||
StockTransaction({
|
||||
this.id,
|
||||
required this.transactionNo,
|
||||
required this.transactionDate,
|
||||
required this.type,
|
||||
required this.status,
|
||||
required this.warehouse,
|
||||
required this.createdBy,
|
||||
this.note,
|
||||
this.isActive = true,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.lines = const [],
|
||||
this.customers = const [],
|
||||
this.approval,
|
||||
this.expectedReturnDate,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final String transactionNo;
|
||||
final DateTime transactionDate;
|
||||
final StockTransactionType type;
|
||||
final StockTransactionStatus status;
|
||||
final StockTransactionWarehouse warehouse;
|
||||
final StockTransactionEmployee createdBy;
|
||||
final String? note;
|
||||
final bool isActive;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final List<StockTransactionLine> lines;
|
||||
final List<StockTransactionCustomer> customers;
|
||||
final StockTransactionApprovalSummary? approval;
|
||||
final DateTime? expectedReturnDate;
|
||||
|
||||
int get itemCount => lines.length;
|
||||
|
||||
int get totalQuantity => lines.fold<int>(
|
||||
0,
|
||||
(previousValue, line) => previousValue + line.quantity,
|
||||
);
|
||||
|
||||
StockTransaction copyWith({
|
||||
int? id,
|
||||
String? transactionNo,
|
||||
DateTime? transactionDate,
|
||||
StockTransactionType? type,
|
||||
StockTransactionStatus? status,
|
||||
StockTransactionWarehouse? warehouse,
|
||||
StockTransactionEmployee? createdBy,
|
||||
String? note,
|
||||
bool? isActive,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
List<StockTransactionLine>? lines,
|
||||
List<StockTransactionCustomer>? customers,
|
||||
StockTransactionApprovalSummary? approval,
|
||||
DateTime? expectedReturnDate,
|
||||
}) {
|
||||
return StockTransaction(
|
||||
id: id ?? this.id,
|
||||
transactionNo: transactionNo ?? this.transactionNo,
|
||||
transactionDate: transactionDate ?? this.transactionDate,
|
||||
type: type ?? this.type,
|
||||
status: status ?? this.status,
|
||||
warehouse: warehouse ?? this.warehouse,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
note: note ?? this.note,
|
||||
isActive: isActive ?? this.isActive,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
lines: lines ?? this.lines,
|
||||
customers: customers ?? this.customers,
|
||||
approval: approval ?? this.approval,
|
||||
expectedReturnDate: expectedReturnDate ?? this.expectedReturnDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 유형 요약 정보
|
||||
class StockTransactionType {
|
||||
StockTransactionType({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 상태 요약 정보
|
||||
class StockTransactionStatus {
|
||||
StockTransactionStatus({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 작성자 정보
|
||||
class StockTransactionEmployee {
|
||||
StockTransactionEmployee({
|
||||
required this.id,
|
||||
required this.employeeNo,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String employeeNo;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 창고 정보 요약
|
||||
class StockTransactionWarehouse {
|
||||
StockTransactionWarehouse({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.zipcode,
|
||||
this.addressLine,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final String? zipcode;
|
||||
final String? addressLine;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 품목(라인)
|
||||
class StockTransactionLine {
|
||||
StockTransactionLine({
|
||||
this.id,
|
||||
required this.lineNo,
|
||||
required this.product,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final int? id;
|
||||
final int lineNo;
|
||||
final StockTransactionProduct product;
|
||||
final int quantity;
|
||||
final double unitPrice;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션 품목의 제품 정보 요약
|
||||
class StockTransactionProduct {
|
||||
StockTransactionProduct({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
this.vendor,
|
||||
this.uom,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
final StockTransactionVendorSummary? vendor;
|
||||
final StockTransactionUomSummary? uom;
|
||||
}
|
||||
|
||||
/// 재고 트랜잭션에 연결된 고객 정보
|
||||
class StockTransactionCustomer {
|
||||
StockTransactionCustomer({this.id, required this.customer, this.note});
|
||||
|
||||
final int? id;
|
||||
final StockTransactionCustomerSummary customer;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
/// 고객 요약 정보
|
||||
class StockTransactionCustomerSummary {
|
||||
StockTransactionCustomerSummary({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String code;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 제품의 공급사 요약 정보
|
||||
class StockTransactionVendorSummary {
|
||||
StockTransactionVendorSummary({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 제품 단위 요약 정보
|
||||
class StockTransactionUomSummary {
|
||||
StockTransactionUomSummary({required this.id, required this.name});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
/// 결재 요약 정보
|
||||
class StockTransactionApprovalSummary {
|
||||
StockTransactionApprovalSummary({
|
||||
required this.id,
|
||||
required this.approvalNo,
|
||||
this.status,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String approvalNo;
|
||||
final StockTransactionApprovalStatusSummary? status;
|
||||
}
|
||||
|
||||
/// 결재 상태 요약 정보
|
||||
class StockTransactionApprovalStatusSummary {
|
||||
StockTransactionApprovalStatusSummary({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.isBlocking,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final bool? isBlocking;
|
||||
}
|
||||
|
||||
extension StockTransactionLineX on List<StockTransactionLine> {
|
||||
/// 라인 품목 가격 총액을 계산한다.
|
||||
double get totalAmount =>
|
||||
fold<double>(0, (sum, line) => sum + (line.quantity * line.unitPrice));
|
||||
}
|
||||
Reference in New Issue
Block a user