feat(client): 入库表单关联商品属性字典(产地/保质期/储存方式/描述文档)
- product_option.dart: 新增 4 个字典 model(Origin/ShelfLife/Storage/DescriptionDoc) - product_option_repository.dart: 新增 4 个 list 方法(listOrigins/ShelfLives/Storages/DescriptionDocs) - product_option_provider.dart: 新增 4 个 FutureProvider 供表单使用 - product_repository.dart: findOrCreate 加 originId/shelfLifeId/storageId/descriptionDocId 可选参数 - stock_in_form_screen.dart: _ItemRow 加 4 个可选 ID 字段;新增 4 个 SearchableOptionField 方法;移动端卡片视图增加 4 个可选字段;提交时传入属性 ID Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,3 +66,95 @@ class ProductSpecOption {
|
|||||||
remark: json['remark'] as String?,
|
remark: json['remark'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 商品属性字典(产地/保质期/储存方式) ─────────────────────────
|
||||||
|
|
||||||
|
class ProductOriginOption {
|
||||||
|
final int id;
|
||||||
|
final String? code;
|
||||||
|
final String name;
|
||||||
|
final String? remark;
|
||||||
|
|
||||||
|
const ProductOriginOption({
|
||||||
|
required this.id,
|
||||||
|
this.code,
|
||||||
|
required this.name,
|
||||||
|
this.remark,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProductOriginOption.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ProductOriginOption(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
code: json['code'] as String?,
|
||||||
|
name: json['name'] as String,
|
||||||
|
remark: json['remark'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductShelfLifeOption {
|
||||||
|
final int id;
|
||||||
|
final String? code;
|
||||||
|
final String name;
|
||||||
|
final String? remark;
|
||||||
|
|
||||||
|
const ProductShelfLifeOption({
|
||||||
|
required this.id,
|
||||||
|
this.code,
|
||||||
|
required this.name,
|
||||||
|
this.remark,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProductShelfLifeOption.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ProductShelfLifeOption(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
code: json['code'] as String?,
|
||||||
|
name: json['name'] as String,
|
||||||
|
remark: json['remark'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductStorageOption {
|
||||||
|
final int id;
|
||||||
|
final String? code;
|
||||||
|
final String name;
|
||||||
|
final String? remark;
|
||||||
|
|
||||||
|
const ProductStorageOption({
|
||||||
|
required this.id,
|
||||||
|
this.code,
|
||||||
|
required this.name,
|
||||||
|
this.remark,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProductStorageOption.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ProductStorageOption(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
code: json['code'] as String?,
|
||||||
|
name: json['name'] as String,
|
||||||
|
remark: json['remark'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 描述文档 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ProductDescriptionDoc {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String? content;
|
||||||
|
final String? remark;
|
||||||
|
|
||||||
|
const ProductDescriptionDoc({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.content,
|
||||||
|
this.remark,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProductDescriptionDoc.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ProductDescriptionDoc(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
title: json['title'] as String,
|
||||||
|
content: json['content'] as String?,
|
||||||
|
remark: json['remark'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,3 +125,39 @@ class ProductSpecListNotifier extends AsyncNotifier<List<ProductSpecOption>> {
|
|||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 产地 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final productOriginListProvider =
|
||||||
|
FutureProvider<List<ProductOriginOption>>((ref) async {
|
||||||
|
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||||
|
ref.watch(networkRecoveryCountProvider);
|
||||||
|
return ref.read(productOptionRepositoryProvider).listOrigins();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 保质期 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final productShelfLifeListProvider =
|
||||||
|
FutureProvider<List<ProductShelfLifeOption>>((ref) async {
|
||||||
|
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||||
|
ref.watch(networkRecoveryCountProvider);
|
||||||
|
return ref.read(productOptionRepositoryProvider).listShelfLives();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 储存方式 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final productStorageListProvider =
|
||||||
|
FutureProvider<List<ProductStorageOption>>((ref) async {
|
||||||
|
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||||
|
ref.watch(networkRecoveryCountProvider);
|
||||||
|
return ref.read(productOptionRepositoryProvider).listStorages();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 描述文档 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
final productDescriptionDocListProvider =
|
||||||
|
FutureProvider<List<ProductDescriptionDoc>>((ref) async {
|
||||||
|
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||||
|
ref.watch(networkRecoveryCountProvider);
|
||||||
|
return ref.read(productOptionRepositoryProvider).listDescriptionDocs();
|
||||||
|
});
|
||||||
|
|||||||
@@ -127,4 +127,56 @@ class ProductOptionRepository {
|
|||||||
statusCode: e.response?.statusCode);
|
statusCode: e.response?.statusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 产地 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<ProductOriginOption>> listOrigins() async {
|
||||||
|
try {
|
||||||
|
final resp = await _client.get('/product-options/origins');
|
||||||
|
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||||
|
return data.map((e) => ProductOriginOption.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(e.response?.data?['error'] as String? ?? '获取产地列表失败',
|
||||||
|
statusCode: e.response?.statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 保质期 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<ProductShelfLifeOption>> listShelfLives() async {
|
||||||
|
try {
|
||||||
|
final resp = await _client.get('/product-options/shelf-lives');
|
||||||
|
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||||
|
return data.map((e) => ProductShelfLifeOption.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(e.response?.data?['error'] as String? ?? '获取保质期列表失败',
|
||||||
|
statusCode: e.response?.statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 储存方式 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<ProductStorageOption>> listStorages() async {
|
||||||
|
try {
|
||||||
|
final resp = await _client.get('/product-options/storages');
|
||||||
|
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||||
|
return data.map((e) => ProductStorageOption.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(e.response?.data?['error'] as String? ?? '获取储存方式列表失败',
|
||||||
|
statusCode: e.response?.statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 描述文档 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<ProductDescriptionDoc>> listDescriptionDocs() async {
|
||||||
|
try {
|
||||||
|
final resp = await _client.get('/product-options/description-docs');
|
||||||
|
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
|
||||||
|
return data.map((e) => ProductDescriptionDoc.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(e.response?.data?['error'] as String? ?? '获取描述文档列表失败',
|
||||||
|
statusCode: e.response?.statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,13 +135,22 @@ class ProductRepository {
|
|||||||
required String name,
|
required String name,
|
||||||
String series = '',
|
String series = '',
|
||||||
String spec = '',
|
String spec = '',
|
||||||
|
int? originId,
|
||||||
|
int? shelfLifeId,
|
||||||
|
int? storageId,
|
||||||
|
int? descriptionDocId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final resp = await _client.post('/products/find-or-create', data: {
|
final body = <String, dynamic>{
|
||||||
'name': name,
|
'name': name,
|
||||||
'series': series,
|
'series': series,
|
||||||
'spec': spec,
|
'spec': spec,
|
||||||
});
|
};
|
||||||
|
if (originId != null) body['origin_id'] = originId;
|
||||||
|
if (shelfLifeId != null) body['shelf_life_id'] = shelfLifeId;
|
||||||
|
if (storageId != null) body['storage_id'] = storageId;
|
||||||
|
if (descriptionDocId != null) body['description_doc_id'] = descriptionDocId;
|
||||||
|
final resp = await _client.post('/products/find-or-create', data: body);
|
||||||
return Product.fromJson(
|
return Product.fromJson(
|
||||||
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
|||||||
@@ -226,7 +226,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
try {
|
try {
|
||||||
final product = await ref
|
final product = await ref
|
||||||
.read(productRepositoryProvider)
|
.read(productRepositoryProvider)
|
||||||
.findOrCreate(name: name, series: series, spec: spec);
|
.findOrCreate(
|
||||||
|
name: name,
|
||||||
|
series: series,
|
||||||
|
spec: spec,
|
||||||
|
originId: item.selectedOriginId,
|
||||||
|
shelfLifeId: item.selectedShelfLifeId,
|
||||||
|
storageId: item.selectedStorageId,
|
||||||
|
descriptionDocId: item.selectedDescriptionDocId,
|
||||||
|
);
|
||||||
item.productId = product.id;
|
item.productId = product.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -761,6 +769,78 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _originField(_ItemRow item) {
|
||||||
|
final asyncOrigins = ref.watch(productOriginListProvider);
|
||||||
|
return asyncOrigins.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (origins) => SearchableOptionField(
|
||||||
|
options: origins
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedOriginId,
|
||||||
|
hint: '选择产地(可选)',
|
||||||
|
dialogTitle: '选择产地',
|
||||||
|
isRequired: false,
|
||||||
|
onChanged: (v) => setState(() => item.selectedOriginId = v),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _shelfLifeField(_ItemRow item) {
|
||||||
|
final asyncShelfLives = ref.watch(productShelfLifeListProvider);
|
||||||
|
return asyncShelfLives.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (shelfLives) => SearchableOptionField(
|
||||||
|
options: shelfLives
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedShelfLifeId,
|
||||||
|
hint: '选择保质期(可选)',
|
||||||
|
dialogTitle: '选择保质期',
|
||||||
|
isRequired: false,
|
||||||
|
onChanged: (v) => setState(() => item.selectedShelfLifeId = v),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _storageField(_ItemRow item) {
|
||||||
|
final asyncStorages = ref.watch(productStorageListProvider);
|
||||||
|
return asyncStorages.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (storages) => SearchableOptionField(
|
||||||
|
options: storages
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedStorageId,
|
||||||
|
hint: '选择储存方式(可选)',
|
||||||
|
dialogTitle: '选择储存方式',
|
||||||
|
isRequired: false,
|
||||||
|
onChanged: (v) => setState(() => item.selectedStorageId = v),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _descriptionDocField(_ItemRow item) {
|
||||||
|
final asyncDocs = ref.watch(productDescriptionDocListProvider);
|
||||||
|
return asyncDocs.when(
|
||||||
|
loading: () => const LinearProgressIndicator(),
|
||||||
|
error: (_, __) => const Text('加载失败'),
|
||||||
|
data: (docs) => SearchableOptionField(
|
||||||
|
options: docs
|
||||||
|
.map((o) => OptionItem(id: o.id, name: o.title))
|
||||||
|
.toList(),
|
||||||
|
selectedId: item.selectedDescriptionDocId,
|
||||||
|
hint: '选择介绍文档(可选)',
|
||||||
|
dialogTitle: '选择介绍文档',
|
||||||
|
isRequired: false,
|
||||||
|
onChanged: (v) => setState(() => item.selectedDescriptionDocId = v),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _specQtyOf(_ItemRow item) =>
|
int _specQtyOf(_ItemRow item) =>
|
||||||
ref.read(productSpecListProvider).valueOrNull
|
ref.read(productSpecListProvider).valueOrNull
|
||||||
?.where((o) => o.id == item.selectedSpecId)
|
?.where((o) => o.id == item.selectedSpecId)
|
||||||
@@ -864,6 +944,10 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
|||||||
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
|
||||||
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
MobileCardField('批次号', null, valueWidget: _batchField(item)),
|
||||||
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
|
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
|
||||||
|
MobileCardField('产地', null, valueWidget: _originField(item)),
|
||||||
|
MobileCardField('保质期', null, valueWidget: _shelfLifeField(item)),
|
||||||
|
MobileCardField('储存方式', null, valueWidget: _storageField(item)),
|
||||||
|
MobileCardField('介绍文档', null, valueWidget: _descriptionDocField(item)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -908,6 +992,11 @@ class _ItemRow {
|
|||||||
int? selectedNameId;
|
int? selectedNameId;
|
||||||
int? selectedSeriesId;
|
int? selectedSeriesId;
|
||||||
int? selectedSpecId;
|
int? selectedSpecId;
|
||||||
|
// 商品属性字典(可选,入库时关联到商品)
|
||||||
|
int? selectedOriginId;
|
||||||
|
int? selectedShelfLifeId;
|
||||||
|
int? selectedStorageId;
|
||||||
|
int? selectedDescriptionDocId;
|
||||||
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
final TextEditingController qtyCtrl = TextEditingController(text: '1');
|
||||||
final TextEditingController priceCtrl = TextEditingController();
|
final TextEditingController priceCtrl = TextEditingController();
|
||||||
final TextEditingController batchNoCtrl = TextEditingController();
|
final TextEditingController batchNoCtrl = TextEditingController();
|
||||||
|
|||||||
Reference in New Issue
Block a user