Compare commits

...

8 Commits

Author SHA1 Message Date
Misaka_Company
ce80e1a1f6 feat(boxing): support editing assigned box records 2026-05-15 08:19:34 +08:00
Misaka_Company
a165bfeabe feat(boxing): handle multi-code paichan switches 2026-05-14 14:04:25 +08:00
Misaka_Company
28871eb21f feat(boxing): support v2.5 dual modes 2026-05-14 12:29:33 +08:00
Misaka_Company
077a1ab1b9 fix(settings): save all config atomically and strip trailing slash from API URL
- Save API URL and sound paths in a single load-modify-write cycle to
  prevent race conditions that wiped previously saved config
- Strip trailing slashes from API URL to avoid double-slash 404 errors
- Make AppConfigService.saveConfig public for batch config writes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 16:58:39 +08:00
Misaka_Company
42a9cc7204 feat(boxing): update detail page quantity display and paichan_no style
- Change quantity display from "数量:N" to "N/total" format (boxed/total)
- Enlarge paichan_no font to 18px bold and right-align

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 15:51:41 +08:00
Misaka_Company
396a19ff43 feat(boxing): update many-to-one packing flow 2026-05-13 15:14:14 +08:00
Misaka_Company
b32c9c5aad feat: handle off-shelf registration results 2026-05-13 12:43:22 +08:00
Misaka_Company
b975b15ba7 feat: improve one-to-many boxing workflow 2026-05-13 11:22:49 +08:00
10 changed files with 1968 additions and 247 deletions

View File

@@ -22,9 +22,10 @@ class BoxingDetailPage extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
paichanNo, paichanNo,
textAlign: TextAlign.end,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 18,
fontWeight: FontWeight.normal, fontWeight: FontWeight.bold,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -60,14 +61,37 @@ class BoxingDetailPage extends StatelessWidget {
} }
Widget _buildBoxGroup(BuildContext context, BoxDetailData box) { Widget _buildBoxGroup(BuildContext context, BoxDetailData box) {
final boxTotalQuantity = box.items.fold<int>(
0,
(sum, item) => sum + item.quantity,
);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
children: [
Expanded(
child: Text(
'箱号 ${box.boxNo}', '箱号 ${box.boxNo}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
Text(
'$boxTotalQuantity',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(
@@ -102,7 +126,7 @@ class BoxingDetailPage extends StatelessWidget {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'数量:${item.quantity}', '${item.quantity}/${item.totalQuantity ?? '--'}',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
], ],

File diff suppressed because it is too large Load Diff

View File

@@ -49,8 +49,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
} }
void _onKeyEvent(KeyEvent event) { void _onKeyEvent(KeyEvent event) {
if (event is KeyDownEvent && if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
event.logicalKey == LogicalKeyboardKey.enter) {
_submit(); _submit();
} }
} }
@@ -195,11 +194,24 @@ class _RegistrationPageState extends State<RegistrationPage> {
_locationType = null; _locationType = null;
} }
}); });
final msg = _isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功'; final msg = result.isOffShelfSuccess
_showStatusOverride(msg, StatusDotColor.green, const Duration(milliseconds: 1500)); ? '下架成功'
: (_isLocked ? '货位已锁定,请扫描下一张执行卡' : '上架成功');
_showStatusOverride(
msg,
StatusDotColor.green,
const Duration(milliseconds: 1500),
);
} else if (result.isDuplicate) { } else if (result.isDuplicate) {
_feedbackService.trigger(FeedbackEvent.duplicateError); _feedbackService.trigger(FeedbackEvent.duplicateError);
_showDuplicateDialog(zongpaiNo, result.duplicateInfo); _showDuplicateDialog(zongpaiNo, result.duplicateInfo);
} else if (result.isAlreadyOffShelf) {
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
} else { } else {
final isNetwork = result.errorMessage == '网络异常,请检查网络连接'; final isNetwork = result.errorMessage == '网络异常,请检查网络连接';
_feedbackService.trigger( _feedbackService.trigger(
@@ -215,6 +227,7 @@ class _RegistrationPageState extends State<RegistrationPage> {
Future<void> _submitBatch(String baseUrl) async { Future<void> _submitBatch(String baseUrl) async {
int successCount = 0; int successCount = 0;
int offShelfCount = 0;
final failed = <String>[]; final failed = <String>[];
final toRemove = <String>[]; final toRemove = <String>[];
@@ -226,6 +239,9 @@ class _RegistrationPageState extends State<RegistrationPage> {
); );
if (result.success) { if (result.success) {
successCount++; successCount++;
if (result.isOffShelfSuccess) {
offShelfCount++;
}
toRemove.add(zp); toRemove.add(zp);
} else { } else {
failed.add(zp); failed.add(zp);
@@ -238,6 +254,19 @@ class _RegistrationPageState extends State<RegistrationPage> {
_showDuplicateDialog(zp, result.duplicateInfo); _showDuplicateDialog(zp, result.duplicateInfo);
return; return;
} }
if (result.isAlreadyOffShelf) {
setState(() {
_zongpaiNos.removeWhere((e) => toRemove.contains(e));
_isSubmitting = false;
});
_feedbackService.trigger(FeedbackEvent.submitFailure);
_showStatusOverride(
result.errorMessage ?? '该总排号已下架至转运区域,不可重新上架',
StatusDotColor.red,
const Duration(seconds: 2),
);
return;
}
} }
} }
@@ -250,8 +279,11 @@ class _RegistrationPageState extends State<RegistrationPage> {
if (failed.isEmpty) { if (failed.isEmpty) {
_feedbackService.trigger(FeedbackEvent.submitSuccess); _feedbackService.trigger(FeedbackEvent.submitSuccess);
final msg = offShelfCount == successCount
? '批量下架成功($successCount 条)'
: '批量上架成功($successCount 条)';
_showStatusOverride( _showStatusOverride(
'批量上架成功($successCount 条)', msg,
StatusDotColor.green, StatusDotColor.green,
const Duration(milliseconds: 1500), const Duration(milliseconds: 1500),
); );
@@ -492,7 +524,9 @@ class _RegistrationPageState extends State<RegistrationPage> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400), border: Border.all(
color: Colors.grey.shade400,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Text( child: const Text(
@@ -509,7 +543,9 @@ class _RegistrationPageState extends State<RegistrationPage> {
const SizedBox(height: 6), const SizedBox(height: 6),
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Dismissible( return Dismissible(
key: ValueKey('${_zongpaiNos[index]}-$index'), key: ValueKey(
'${_zongpaiNos[index]}-$index',
),
direction: DismissDirection.endToStart, direction: DismissDirection.endToStart,
onDismissed: (_) => _removeZongpai(index), onDismissed: (_) => _removeZongpai(index),
background: Container( background: Container(

View File

@@ -57,14 +57,27 @@ class _SettingsPageState extends State<SettingsPage> {
} }
Future<void> _saveUrl() async { Future<void> _saveUrl() async {
final url = _controller.text.trim(); final url = _controller.text.trim().replaceAll(RegExp(r'/+$'), '');
if (url.isEmpty) { if (url.isEmpty) {
_showSnackBar('请输入 API 地址', isError: true); _showSnackBar('请输入 API 地址', isError: true);
return; return;
} }
setState(() => _saving = true); setState(() => _saving = true);
// Load config once, apply all changes, then save once to avoid race conditions
final configService = AppConfigService(); final configService = AppConfigService();
await configService.setString('api_url', url); final config = await configService.loadConfig();
config['api_url'] = url;
if (_successPath != null) config['sound_success'] = _successPath;
else config.remove('sound_success');
if (_failurePath != null) config['sound_failure'] = _failurePath;
else config.remove('sound_failure');
if (_beepPath != null) config['sound_beep'] = _beepPath;
else config.remove('sound_beep');
if (_errorPath != null) config['sound_error'] = _errorPath;
else config.remove('sound_error');
if (_alertPath != null) config['sound_alert'] = _alertPath;
else config.remove('sound_alert');
await configService.saveConfig(config);
setState(() => _saving = false); setState(() => _saving = false);
if (mounted) { if (mounted) {
_showSnackBar('设置已保存'); _showSnackBar('设置已保存');
@@ -91,18 +104,6 @@ class _SettingsPageState extends State<SettingsPage> {
); );
if (result != null && result.files.single.path != null) { if (result != null && result.files.single.path != null) {
final path = result.files.single.path!; final path = result.files.single.path!;
switch (key) {
case 'success':
await _soundService.setSuccessPath(path);
case 'failure':
await _soundService.setFailurePath(path);
case 'beep':
await _soundService.setBeepPath(path);
case 'error':
await _soundService.setErrorPath(path);
case 'alert':
await _soundService.setAlertPath(path);
}
setState(() { setState(() {
switch (key) { switch (key) {
case 'success': case 'success':
@@ -121,18 +122,6 @@ class _SettingsPageState extends State<SettingsPage> {
} }
Future<void> _clearSound(String key) async { Future<void> _clearSound(String key) async {
switch (key) {
case 'success':
await _soundService.setSuccessPath(null);
case 'failure':
await _soundService.setFailurePath(null);
case 'beep':
await _soundService.setBeepPath(null);
case 'error':
await _soundService.setErrorPath(null);
case 'alert':
await _soundService.setAlertPath(null);
}
setState(() { setState(() {
switch (key) { switch (key) {
case 'success': case 'success':

View File

@@ -5,23 +5,41 @@ import 'package:http/http.dart' as http;
class RegistrationResult { class RegistrationResult {
final bool success; final bool success;
final bool isDuplicate; final bool isDuplicate;
final bool isAlreadyOffShelf;
final bool isOffShelfSuccess;
final String? errorMessage; final String? errorMessage;
final Map<String, dynamic>? duplicateInfo; final Map<String, dynamic>? duplicateInfo;
final Map<String, dynamic>? conflictInfo;
RegistrationResult({ RegistrationResult({
required this.success, required this.success,
this.isDuplicate = false, this.isDuplicate = false,
this.isAlreadyOffShelf = false,
this.isOffShelfSuccess = false,
this.errorMessage, this.errorMessage,
this.duplicateInfo, this.duplicateInfo,
this.conflictInfo,
}); });
factory RegistrationResult.ok() => RegistrationResult(success: true); factory RegistrationResult.ok() => RegistrationResult(success: true);
factory RegistrationResult.offShelfSuccess() =>
RegistrationResult(success: true, isOffShelfSuccess: true);
factory RegistrationResult.duplicate(Map<String, dynamic> info) => factory RegistrationResult.duplicate(Map<String, dynamic> info) =>
RegistrationResult( RegistrationResult(
success: false, success: false,
isDuplicate: true, isDuplicate: true,
duplicateInfo: info, duplicateInfo: info,
conflictInfo: info,
);
factory RegistrationResult.alreadyOffShelf(Map<String, dynamic> info) =>
RegistrationResult(
success: false,
isAlreadyOffShelf: true,
errorMessage: info['message']?.toString() ?? '该总排号已下架至转运区域,不可重新上架',
conflictInfo: info,
); );
factory RegistrationResult.error(String message) => factory RegistrationResult.error(String message) =>
@@ -32,21 +50,27 @@ class RegistrationResult {
/// 箱号内单个总排号明细 /// 箱号内单个总排号明细
class BoxItemData { class BoxItemData {
final int? boxItemId;
final String zongpaiNo; final String zongpaiNo;
final String? workOrderNo; final String? workOrderNo;
final int quantity; final int quantity;
final int? totalQuantity;
BoxItemData({ BoxItemData({
this.boxItemId,
required this.zongpaiNo, required this.zongpaiNo,
this.workOrderNo, this.workOrderNo,
required this.quantity, required this.quantity,
this.totalQuantity,
}); });
factory BoxItemData.fromJson(Map<String, dynamic> json) { factory BoxItemData.fromJson(Map<String, dynamic> json) {
return BoxItemData( return BoxItemData(
boxItemId: json['box_item_id'] as int?,
zongpaiNo: json['zongpai_no'] as String, zongpaiNo: json['zongpai_no'] as String,
workOrderNo: json['work_order_no']?.toString(), workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int, quantity: json['quantity'] as int,
totalQuantity: json['total_quantity'] as int?,
); );
} }
} }
@@ -68,6 +92,27 @@ class BoxDetailData {
} }
} }
/// 当前总排号已分配的装箱明细
class CurrentZongpaiBoxData {
final int boxItemId;
final int boxNo;
final int quantity;
CurrentZongpaiBoxData({
required this.boxItemId,
required this.boxNo,
required this.quantity,
});
factory CurrentZongpaiBoxData.fromJson(Map<String, dynamic> json) {
return CurrentZongpaiBoxData(
boxItemId: json['box_item_id'] as int,
boxNo: json['box_no'] as int,
quantity: json['quantity'] as int,
);
}
}
/// 装箱信息查询结果 /// 装箱信息查询结果
class BoxInfoResult { class BoxInfoResult {
final bool success; final bool success;
@@ -76,6 +121,7 @@ class BoxInfoResult {
final String? paichanNo; final String? paichanNo;
final String? workOrderNo; final String? workOrderNo;
final int? quantity; final int? quantity;
final List<CurrentZongpaiBoxData> currentZongpaiBoxes;
final List<BoxDetailData> existingBoxes; final List<BoxDetailData> existingBoxes;
final int maxBoxNo; final int maxBoxNo;
final int suggestedBoxNo; final int suggestedBoxNo;
@@ -87,6 +133,7 @@ class BoxInfoResult {
this.paichanNo, this.paichanNo,
this.workOrderNo, this.workOrderNo,
this.quantity, this.quantity,
this.currentZongpaiBoxes = const [],
this.existingBoxes = const [], this.existingBoxes = const [],
this.maxBoxNo = 0, this.maxBoxNo = 0,
this.suggestedBoxNo = 1, this.suggestedBoxNo = 1,
@@ -98,12 +145,20 @@ class BoxInfoResult {
?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>)) ?.map((b) => BoxDetailData.fromJson(b as Map<String, dynamic>))
.toList() ?? .toList() ??
[]; [];
final currentBoxes =
(json['current_zongpai_boxes'] as List<dynamic>?)
?.map(
(b) => CurrentZongpaiBoxData.fromJson(b as Map<String, dynamic>),
)
.toList() ??
[];
return BoxInfoResult( return BoxInfoResult(
success: true, success: true,
zongpaiNo: json['zongpai_no'] as String?, zongpaiNo: json['zongpai_no'] as String?,
paichanNo: json['paichan_no'] as String?, paichanNo: json['paichan_no'] as String?,
workOrderNo: json['work_order_no']?.toString(), workOrderNo: json['work_order_no']?.toString(),
quantity: json['quantity'] as int?, quantity: json['quantity'] as int?,
currentZongpaiBoxes: currentBoxes,
existingBoxes: boxes, existingBoxes: boxes,
maxBoxNo: json['max_box_no'] as int? ?? 0, maxBoxNo: json['max_box_no'] as int? ?? 0,
suggestedBoxNo: json['suggested_box_no'] as int? ?? 1, suggestedBoxNo: json['suggested_box_no'] as int? ?? 1,
@@ -120,22 +175,33 @@ class BoxSaveResult {
final bool success; final bool success;
final bool isDuplicate; final bool isDuplicate;
final String? errorMessage; final String? errorMessage;
final String? errorCode;
final int? boxItemId;
final String? paichanNo; final String? paichanNo;
final String? zongpaiNo;
final int? boxNo; final int? boxNo;
final int? quantity;
BoxSaveResult({ BoxSaveResult({
required this.success, required this.success,
this.isDuplicate = false, this.isDuplicate = false,
this.errorMessage, this.errorMessage,
this.errorCode,
this.boxItemId,
this.paichanNo, this.paichanNo,
this.zongpaiNo,
this.boxNo, this.boxNo,
this.quantity,
}); });
factory BoxSaveResult.ok(Map<String, dynamic> json) { factory BoxSaveResult.ok(Map<String, dynamic> json) {
return BoxSaveResult( return BoxSaveResult(
success: true, success: true,
boxItemId: json['box_item_id'] as int?,
paichanNo: json['paichan_no'] as String?, paichanNo: json['paichan_no'] as String?,
zongpaiNo: json['zongpai_no'] as String?,
boxNo: json['box_no'] as int?, boxNo: json['box_no'] as int?,
quantity: json['quantity'] as int?,
); );
} }
@@ -143,7 +209,10 @@ class BoxSaveResult {
return BoxSaveResult( return BoxSaveResult(
success: false, success: false,
isDuplicate: true, isDuplicate: true,
errorCode: json['error_code']?.toString(),
errorMessage: json['message']?.toString(),
paichanNo: json['paichan_no'] as String?, paichanNo: json['paichan_no'] as String?,
zongpaiNo: json['zongpai_no'] as String?,
boxNo: json['box_no'] as int?, boxNo: json['box_no'] as int?,
); );
} }
@@ -153,6 +222,13 @@ class BoxSaveResult {
} }
} }
class BoxDeleteResult {
final bool success;
final String? errorMessage;
BoxDeleteResult({required this.success, this.errorMessage});
}
class ApiService { class ApiService {
final http.Client _client; final http.Client _client;
final Duration timeout; final Duration timeout;
@@ -181,6 +257,10 @@ class ApiService {
switch (response.statusCode) { switch (response.statusCode) {
case 200: case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
if (body['previous_location'] != null) {
return RegistrationResult.offShelfSuccess();
}
return RegistrationResult.ok(); return RegistrationResult.ok();
case 400: case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>; final body = jsonDecode(response.body) as Map<String, dynamic>;
@@ -188,7 +268,15 @@ class ApiService {
return RegistrationResult.error(msg); return RegistrationResult.error(msg);
case 409: case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>; final body = jsonDecode(response.body) as Map<String, dynamic>;
final errorCode = body['error_code']?.toString();
if (errorCode == 'DUPLICATE_LOCATION') {
return RegistrationResult.duplicate(body); return RegistrationResult.duplicate(body);
}
if (errorCode == 'ALREADY_OFF_SHELF') {
return RegistrationResult.alreadyOffShelf(body);
}
final msg = body['message']?.toString() ?? '提交失败';
return RegistrationResult.error(msg);
default: default:
final body = jsonDecode(response.body) as Map<String, dynamic>; final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = final msg =
@@ -237,7 +325,6 @@ class ApiService {
required String zongpaiNo, required String zongpaiNo,
required int boxNo, required int boxNo,
required int quantity, required int quantity,
required String boxMode,
}) async { }) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box'); final uri = Uri.parse('$baseUrl/CargoTrace/box');
try { try {
@@ -249,7 +336,6 @@ class ApiService {
'zongpai_no': zongpaiNo, 'zongpai_no': zongpaiNo,
'box_no': boxNo, 'box_no': boxNo,
'quantity': quantity, 'quantity': quantity,
'box_mode': boxMode,
}), }),
) )
.timeout(timeout); .timeout(timeout);
@@ -275,6 +361,71 @@ class ApiService {
} }
} }
/// 更新装箱明细 — PATCH /CargoTrace/box/{boxItemId}
Future<BoxSaveResult> updateBoxRecord({
required String baseUrl,
required int boxItemId,
required int boxNo,
required int quantity,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
try {
final response = await _client
.patch(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'box_no': boxNo, 'quantity': quantity}),
)
.timeout(timeout);
switch (response.statusCode) {
case 200:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.ok(body);
case 400:
final body = jsonDecode(response.body) as Map<String, dynamic>;
final msg = body['message']?.toString() ?? '请求参数错误';
return BoxSaveResult.error(msg);
case 409:
final body = jsonDecode(response.body) as Map<String, dynamic>;
return BoxSaveResult.duplicate(body);
case 404:
return BoxSaveResult.error('指定装箱明细不存在');
default:
return BoxSaveResult.error('修改失败 (${response.statusCode})');
}
} catch (e) {
return BoxSaveResult.error('网络异常,请检查网络连接');
}
}
/// 删除装箱明细 — DELETE /CargoTrace/box/{boxItemId}
Future<BoxDeleteResult> deleteBoxRecord({
required String baseUrl,
required int boxItemId,
}) async {
final uri = Uri.parse('$baseUrl/CargoTrace/box/$boxItemId');
try {
final response = await _client
.delete(uri, headers: {'Content-Type': 'application/json'})
.timeout(timeout);
switch (response.statusCode) {
case 200:
return BoxDeleteResult(success: true);
case 404:
return BoxDeleteResult(success: false, errorMessage: '指定装箱明细不存在');
default:
return BoxDeleteResult(
success: false,
errorMessage: '删除失败 (${response.statusCode})',
);
}
} catch (e) {
return BoxDeleteResult(success: false, errorMessage: '网络异常,请检查网络连接');
}
}
/// Test connectivity by making a HEAD request to the base URL. /// Test connectivity by making a HEAD request to the base URL.
Future<bool> testConnection(String baseUrl) async { Future<bool> testConnection(String baseUrl) async {
try { try {

View File

@@ -38,11 +38,11 @@ class AppConfigService {
Future<void> setString(String key, String value) async { Future<void> setString(String key, String value) async {
final config = await loadConfig(); final config = await loadConfig();
config[key.toString()] = value.toString(); config[key.toString()] = value.toString();
await _saveConfig(config); await saveConfig(config);
} }
/// 完整写入 JSON 文件 /// 完整写入 JSON 文件
Future<void> _saveConfig(Map<String, dynamic> config) async { Future<void> saveConfig(Map<String, dynamic> config) async {
try { try {
final file = File(await _filePath); final file = File(await _filePath);
await file.writeAsString(jsonEncode(config)); await file.writeAsString(jsonEncode(config));

View File

@@ -0,0 +1,38 @@
enum PaichanContextChange { firstScan, samePaichan, switchedPaichan }
class PaichanContextDecision {
final PaichanContextChange change;
final int? boxNoToApply;
const PaichanContextDecision({required this.change, this.boxNoToApply});
bool get isSwitched => change == PaichanContextChange.switchedPaichan;
}
PaichanContextDecision decideMultiCodePaichanContext({
required String? lastPaichanNo,
required String? currentPaichanNo,
required String currentBoxNoText,
required int maxBoxNo,
}) {
final recommendedBoxNo = maxBoxNo + 1;
if (currentPaichanNo == null || currentPaichanNo.isEmpty) {
return const PaichanContextDecision(change: PaichanContextChange.firstScan);
}
if (lastPaichanNo == null || lastPaichanNo.isEmpty) {
return PaichanContextDecision(
change: PaichanContextChange.firstScan,
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
);
}
if (lastPaichanNo == currentPaichanNo) {
return PaichanContextDecision(
change: PaichanContextChange.samePaichan,
boxNoToApply: currentBoxNoText.trim().isEmpty ? recommendedBoxNo : null,
);
}
return PaichanContextDecision(
change: PaichanContextChange.switchedPaichan,
boxNoToApply: recommendedBoxNo,
);
}

View File

@@ -11,6 +11,7 @@ enum FeedbackEvent {
networkError, // Network error → failure sound + short vibrate networkError, // Network error → failure sound + short vibrate
duplicateBoxNo, // Duplicate box no → failure sound + short vibrate duplicateBoxNo, // Duplicate box no → failure sound + short vibrate
modeSwitch, // Mode toggle → beep only modeSwitch, // Mode toggle → beep only
paichanSwitch, // Paichan changed → beep + short vibrate
} }
/// Centralized feedback dispatcher. /// Centralized feedback dispatcher.
@@ -56,6 +57,10 @@ class FeedbackService {
case FeedbackEvent.modeSwitch: case FeedbackEvent.modeSwitch:
_soundService.playBeep(); _soundService.playBeep();
case FeedbackEvent.paichanSwitch:
_soundService.playBeep();
_vibrateShort();
} }
} }

View File

@@ -8,7 +8,11 @@ void main() {
test('returns ok on 200', () async { test('returns ok on 200', () async {
final mockClient = _MockClient((request) async { final mockClient = _MockClient((request) async {
return http.Response( return http.Response(
jsonEncode({'zongpai_no': '26B1', 'location_code': 'A01-02-03', 'created_at': '2026-05-11T10:00:00'}), jsonEncode({
'zongpai_no': '26B1',
'location_code': 'A01-02-03',
'created_at': '2026-05-11T10:00:00',
}),
200, 200,
headers: {'content-type': 'application/json; charset=utf-8'}, headers: {'content-type': 'application/json; charset=utf-8'},
); );
@@ -21,9 +25,36 @@ void main() {
); );
expect(result.success, isTrue); expect(result.success, isTrue);
expect(result.isDuplicate, isFalse); expect(result.isDuplicate, isFalse);
expect(result.isOffShelfSuccess, isFalse);
}); });
test('returns duplicate on 409 with location_code and registered_at', () async { test('returns off-shelf success on 200 with previous_location', () async {
final mockClient = _MockClient((request) async {
return http.Response(
jsonEncode({
'zongpai_no': '26B1',
'location_code': 'TRANS-01',
'previous_location': 'A01-02-03',
'created_at': '2026-05-13T10:00:00',
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.registerLocation(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
locationCode: 'TRANS-01',
);
expect(result.success, isTrue);
expect(result.isOffShelfSuccess, isTrue);
expect(result.isDuplicate, isFalse);
});
test(
'returns duplicate on 409 with location_code and registered_at',
() async {
final mockClient = _MockClient((request) async { final mockClient = _MockClient((request) async {
return http.Response( return http.Response(
jsonEncode({ jsonEncode({
@@ -46,12 +77,42 @@ void main() {
expect(result.isDuplicate, isTrue); expect(result.isDuplicate, isTrue);
expect(result.duplicateInfo?['location_code'], 'A01-02-03'); expect(result.duplicateInfo?['location_code'], 'A01-02-03');
expect(result.duplicateInfo?['registered_at'], '2026-05-11T10:00:00'); expect(result.duplicateInfo?['registered_at'], '2026-05-11T10:00:00');
},
);
test('returns already off shelf on 409 ALREADY_OFF_SHELF', () async {
final mockClient = _MockClient((request) async {
return http.Response(
jsonEncode({
'error_code': 'ALREADY_OFF_SHELF',
'message': '该总排号已下架至转运区域,不可重新上架',
'location_code': 'TRANS-01',
'registered_at': '2026-05-13T10:00:00',
}),
409,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.registerLocation(
baseUrl: 'http://localhost',
zongpaiNo: '26B1',
locationCode: 'A02-01-01',
);
expect(result.success, isFalse);
expect(result.isDuplicate, isFalse);
expect(result.isAlreadyOffShelf, isTrue);
expect(result.errorMessage, '该总排号已下架至转运区域,不可重新上架');
expect(result.conflictInfo?['location_code'], 'TRANS-01');
}); });
test('returns error on 400 with message', () async { test('returns error on 400 with message', () async {
final mockClient = _MockClient((request) async { final mockClient = _MockClient((request) async {
return http.Response( return http.Response(
jsonEncode({'error_code': 'INVALID_ZONGPAI', 'message': 'INVALID_ZONGPAI'}), jsonEncode({
'error_code': 'INVALID_ZONGPAI',
'message': 'INVALID_ZONGPAI',
}),
400, 400,
headers: {'content-type': 'application/json; charset=utf-8'}, headers: {'content-type': 'application/json; charset=utf-8'},
); );
@@ -81,6 +142,156 @@ void main() {
expect(result.errorMessage, '网络异常,请检查网络连接'); expect(result.errorMessage, '网络异常,请检查网络连接');
}); });
}); });
group('ApiService boxing APIs', () {
test('fetchBoxInfo parses current boxes and box item ids', () async {
final mockClient = _MockClient((request) async {
return http.Response(
jsonEncode({
'zongpai_no': '26BW0011',
'paichan_no': 'W00009',
'work_order_no': '6-1(7)',
'quantity': 80,
'current_zongpai_boxes': [
{'box_item_id': 101, 'box_no': 3, 'quantity': 30},
],
'existing_boxes': [
{
'box_no': 3,
'items': [
{
'box_item_id': 101,
'zongpai_no': '26BW0011',
'work_order_no': '6-1(7)',
'quantity': 30,
'total_quantity': 80,
},
],
},
],
'max_box_no': 3,
'suggested_box_no': 4,
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.fetchBoxInfo(
baseUrl: 'http://localhost',
zongpaiNo: '26BW0011',
);
expect(result.success, isTrue);
expect(result.currentZongpaiBoxes.single.boxItemId, 101);
expect(result.existingBoxes.single.items.single.boxItemId, 101);
expect(result.existingBoxes.single.items.single.totalQuantity, 80);
expect(result.suggestedBoxNo, 4);
});
test('saveBoxRecord sends simplified request body', () async {
late Map<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
body = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response(
jsonEncode({
'box_item_id': 102,
'paichan_no': 'W00009',
'box_no': 4,
'zongpai_no': '26BW0012',
'quantity': 34,
'created_at': '2026-05-13T10:00:00',
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.saveBoxRecord(
baseUrl: 'http://localhost',
zongpaiNo: '26BW0012',
boxNo: 4,
quantity: 34,
);
expect(result.success, isTrue);
expect(result.boxItemId, 102);
expect(result.zongpaiNo, '26BW0012');
expect(body, {'zongpai_no': '26BW0012', 'box_no': 4, 'quantity': 34});
expect(body.containsKey('box_mode'), isFalse);
});
test('updateBoxRecord sends simplified request body', () async {
late Map<String, dynamic> body;
final mockClient = _MockClient((request) async {
final req = request as http.Request;
body = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response(
jsonEncode({
'box_item_id': 102,
'paichan_no': 'W00009',
'box_no': 4,
'zongpai_no': '26BW0012',
'quantity': 20,
'updated_at': '2026-05-13T10:00:00',
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final result = await svc.updateBoxRecord(
baseUrl: 'http://localhost',
boxItemId: 102,
boxNo: 4,
quantity: 20,
);
expect(result.success, isTrue);
expect(body, {'box_no': 4, 'quantity': 20});
expect(body.containsKey('box_mode'), isFalse);
});
test('deleteBoxRecord handles success and not found', () async {
var calls = 0;
final mockClient = _MockClient((request) async {
calls += 1;
if (calls == 1) {
return http.Response(
jsonEncode({'box_item_id': 102, 'deleted': true}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}
return http.Response(
jsonEncode({
'error_code': 'BOX_ITEM_NOT_FOUND',
'message': '指定装箱明细不存在',
}),
404,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
final svc = ApiService(client: mockClient);
final ok = await svc.deleteBoxRecord(
baseUrl: 'http://localhost',
boxItemId: 102,
);
final missing = await svc.deleteBoxRecord(
baseUrl: 'http://localhost',
boxItemId: 999,
);
expect(ok.success, isTrue);
expect(missing.success, isFalse);
expect(missing.errorMessage, '指定装箱明细不存在');
});
});
} }
class _MockClient extends http.BaseClient { class _MockClient extends http.BaseClient {

View File

@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/services/boxing_context.dart';
void main() {
group('decideMultiCodePaichanContext', () {
test('first scan records context and fills recommended box if empty', () {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: null,
currentPaichanNo: 'W00009',
currentBoxNoText: '',
maxBoxNo: 3,
);
expect(decision.change, PaichanContextChange.firstScan);
expect(decision.boxNoToApply, 4);
expect(decision.isSwitched, isFalse);
});
test('same paichan keeps current box number', () {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: 'W00009',
currentPaichanNo: 'W00009',
currentBoxNoText: '8',
maxBoxNo: 3,
);
expect(decision.change, PaichanContextChange.samePaichan);
expect(decision.boxNoToApply, isNull);
expect(decision.isSwitched, isFalse);
});
test('switched paichan resets to new paichan max box plus one', () {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: 'W00009',
currentPaichanNo: 'W00010',
currentBoxNoText: '8',
maxBoxNo: 1,
);
expect(decision.change, PaichanContextChange.switchedPaichan);
expect(decision.boxNoToApply, 2);
expect(decision.isSwitched, isTrue);
});
test(
'same paichan defensively fills recommended box when box is empty',
() {
final decision = decideMultiCodePaichanContext(
lastPaichanNo: 'W00009',
currentPaichanNo: 'W00009',
currentBoxNoText: '',
maxBoxNo: 4,
);
expect(decision.change, PaichanContextChange.samePaichan);
expect(decision.boxNoToApply, 5);
},
);
});
}