From cec02c8887a3cacff91e4ec3f84182cfae6cdc90 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Sat, 5 Sep 2026 20:17:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E4=BD=9C=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E4=BD=9C=E4=B8=9A=E9=A2=84=E6=A3=80=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/data/services/location_service.dart | 2 +- .../lib/domain/models/service_models.dart | 6 +++ .../ui/features/preflight/preflight_page.dart | 27 ++++++++-- .../test/ui/preflight_page_test.dart | 37 ++++++++++++++ .../api/internal/logic/client/staff/auth.go | 35 ++++++++++--- .../internal/logic/client/staff/auth_test.go | 36 +++++++++++++ backend/api/internal/seed/mock.go | 9 ++++ docs/开发日志_作业预检修复_20260905.md | 51 +++++++++++++++++++ 8 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 backend/api/internal/logic/client/staff/auth_test.go create mode 100644 docs/开发日志_作业预检修复_20260905.md diff --git a/apps/service_app/lib/data/services/location_service.dart b/apps/service_app/lib/data/services/location_service.dart index 6cc48a6..2025113 100644 --- a/apps/service_app/lib/data/services/location_service.dart +++ b/apps/service_app/lib/data/services/location_service.dart @@ -29,7 +29,7 @@ class GeolocatorLocationService implements LocationService { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { - throw StateError('定位权限未授权,无法完成该在线动作'); + throw StateError('定位权限未授权,请在浏览器站点权限或系统设置中允许定位后重试'); } final position = await Geolocator.getCurrentPosition(); return LocationPoint( diff --git a/apps/service_app/lib/domain/models/service_models.dart b/apps/service_app/lib/domain/models/service_models.dart index c77af47..1fe7f1b 100644 --- a/apps/service_app/lib/domain/models/service_models.dart +++ b/apps/service_app/lib/domain/models/service_models.dart @@ -35,6 +35,12 @@ class PreflightResult { final bool canWork; final Map checks; + /// 仅统计真正阻止进入工作台的检查项。 + int get blockerCount => checks.values.where((value) { + final detail = _map(value); + return detail['status'] == 'blocked'; + }).length; + factory PreflightResult.fromJson(Map json) => PreflightResult( roleCode: json['role_code'] as String? ?? '', workStatus: json['work_status'] as String? ?? 'off_duty', diff --git a/apps/service_app/lib/ui/features/preflight/preflight_page.dart b/apps/service_app/lib/ui/features/preflight/preflight_page.dart index 5599e9d..915c5bd 100644 --- a/apps/service_app/lib/ui/features/preflight/preflight_page.dart +++ b/apps/service_app/lib/ui/features/preflight/preflight_page.dart @@ -46,7 +46,15 @@ class _PreflightPageState extends State { return; } catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + final message = error is StateError ? error.message : error.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + action: error is StateError + ? SnackBarAction(label: '重新授权', onPressed: () => _attendance(action)) + : null, + ), + ); } } finally { if (mounted) setState(() => _busy = false); @@ -107,7 +115,7 @@ class _PreflightPageState extends State { else ...[ FilledButton( onPressed: result.canWork ? () => context.go('/work') : null, - child: const Text('进入工作台'), + child: Text(result.canWork ? '进入工作台' : '完成 ${result.blockerCount} 项阻断后可进入'), ), const SizedBox(height: 8), TextButton( @@ -200,17 +208,28 @@ class _CheckRow extends StatelessWidget { final tone = passed ? StatusTone.success : status == 'not_configured' - ? StatusTone.warning + ? StatusTone.neutral : StatusTone.danger; final text = status == 'not_configured' - ? '平台暂未启用' + ? '暂不检查' : passed ? '已通过' : '未通过'; + final description = entry.key == 'credential' && !passed + ? _credentialDescription(detail['reason']?.toString()) + : null; return ListTile( leading: Icon(passed ? Icons.check_circle_outline : Icons.info_outline_rounded), title: Text(label), + subtitle: description == null ? null : Text(description), trailing: StatusPill(label: text, tone: tone), ); } + + /// 将服务端稳定原因码转换为可执行的中文处理说明。 + String _credentialDescription(String? reason) => switch (reason) { + 'expired' => '人员资质已过期,请联系所属组织管理员更新', + 'disabled' => '人员资质已停用,请联系所属组织管理员处理', + _ => '尚未配置有效资质,请联系所属组织管理员补充或审核', + }; } diff --git a/apps/service_app/test/ui/preflight_page_test.dart b/apps/service_app/test/ui/preflight_page_test.dart index 2d892b8..b044790 100644 --- a/apps/service_app/test/ui/preflight_page_test.dart +++ b/apps/service_app/test/ui/preflight_page_test.dart @@ -51,6 +51,26 @@ class _FailOnceRepository extends ServiceRepository { } } +/// 返回包含一个真实阻断项和一个暂不检查项的固定预检结果。 +class _BlockedRepository extends ServiceRepository { + _BlockedRepository() + : super( + ApiClient(() => '', baseUrl: 'https://api.example.com'), + _UnusedLocationService(), + ); + + @override + Future preflight() async => const PreflightResult( + roleCode: 'delivery', + workStatus: 'on_duty', + canWork: false, + checks: { + 'credential': {'status': 'blocked', 'reason': 'missing'}, + 'daily_training': {'status': 'not_configured'}, + }, + ); +} + /// 点击重新加载后应成功刷新,且测试过程不得出现 Flutter 断言。 void main() { testWidgets('作业检查失败后可重新加载', (tester) async { @@ -74,4 +94,21 @@ void main() { expect(repository.calls, 2); expect(tester.takeException(), isNull); }); + + testWidgets('仅统计阻断项并显示资质处理指引', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: PreflightPage( + session: StaffSession(_EmptySessionStore()), + repository: _BlockedRepository(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('完成 1 项阻断后可进入'), findsOneWidget); + expect(find.text('暂不检查'), findsOneWidget); + expect(find.text('尚未配置有效资质,请联系所属组织管理员补充或审核'), findsOneWidget); + expect(find.text('下班打卡'), findsOneWidget); + }); } diff --git a/backend/api/internal/logic/client/staff/auth.go b/backend/api/internal/logic/client/staff/auth.go index ac44ded..0302b10 100644 --- a/backend/api/internal/logic/client/staff/auth.go +++ b/backend/api/internal/logic/client/staff/auth.go @@ -70,12 +70,10 @@ func Preflight(ctx *gin.Context) { return } - var credential models.StaffCredential - credentialFound := impl.DBService. - Where("staff_account_id = ? AND status = ?", account.ID, common.StatusEnable). - Order("expired_at desc"). - First(&credential).Error == nil - credentialValid := credentialFound && (credential.ExpiredAt == nil || credential.ExpiredAt.After(time.Now())) + var credentials []models.StaffCredential + _ = impl.DBService.Where("staff_account_id = ? AND status <> ?", account.ID, common.StatusArchived). + Order("created_at desc").Find(&credentials).Error + credential, credentialValid, credentialReason := evaluateCredential(credentials, time.Now()) organizationIdentity, organizationName, organizationType := "", "", "" if account.DeliveryBasicID != 0 { @@ -94,7 +92,7 @@ func Preflight(ctx *gin.Context) { "account": gin.H{"status": "passed"}, "role": gin.H{"status": "passed", "role_code": account.RoleCode}, "organization": gin.H{"status": checkStatus(organizationIdentity != ""), "identity": organizationIdentity, "name": organizationName, "type": organizationType}, - "credential": gin.H{"status": checkStatus(credentialValid), "expired_at": credential.ExpiredAt}, + "credential": gin.H{"status": checkStatus(credentialValid), "reason": credentialReason, "credential_type": credential.CredentialType, "expired_at": credential.ExpiredAt}, "attendance": gin.H{"status": checkStatus(account.WorkStatus == "on_duty"), "work_status": account.WorkStatus}, "daily_training": gin.H{"status": "not_configured"}, "service_area": gin.H{"status": "not_configured"}, @@ -107,6 +105,29 @@ func Preflight(ctx *gin.Context) { }) } +// evaluateCredential 返回最适合作为提示依据的资质及稳定原因码。 +func evaluateCredential(credentials []models.StaffCredential, now time.Time) (models.StaffCredential, bool, string) { + var expired models.StaffCredential + for _, credential := range credentials { + if credential.Status != common.StatusEnable { + continue + } + if credential.ExpiredAt == nil || credential.ExpiredAt.After(now) { + return credential, true, "valid" + } + if expired.ID == 0 || (credential.ExpiredAt != nil && expired.ExpiredAt != nil && credential.ExpiredAt.After(*expired.ExpiredAt)) { + expired = credential + } + } + if expired.ID != 0 { + return expired, false, "expired" + } + if len(credentials) > 0 { + return credentials[0], false, "disabled" + } + return models.StaffCredential{}, false, "missing" +} + func checkStatus(passed bool) string { if passed { return "passed" diff --git a/backend/api/internal/logic/client/staff/auth_test.go b/backend/api/internal/logic/client/staff/auth_test.go new file mode 100644 index 0000000..453cb40 --- /dev/null +++ b/backend/api/internal/logic/client/staff/auth_test.go @@ -0,0 +1,36 @@ +// 功能描述:验证工作人员资质预检原因判定。 +// 版本:1.0.0 +package staff + +import ( + "testing" + "time" + + common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" +) + +// TestEvaluateCredential 验证缺失、停用、过期和有效资质均返回稳定原因码。 +func TestEvaluateCredential(t *testing.T) { + now := time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC) + past, future := now.Add(-time.Hour), now.Add(time.Hour) + tests := []struct { + name string + credentials []models.StaffCredential + valid bool + reason string + }{ + {name: "缺失", reason: "missing"}, + {name: "停用", credentials: []models.StaffCredential{{Entity: models.Entity{ID: 1, Status: common.StatusDisable}}}, reason: "disabled"}, + {name: "过期", credentials: []models.StaffCredential{{Entity: models.Entity{ID: 2, Status: common.StatusEnable}, ExpiredAt: &past}}, reason: "expired"}, + {name: "有效", credentials: []models.StaffCredential{{Entity: models.Entity{ID: 3, Status: common.StatusEnable}, ExpiredAt: &future}}, valid: true, reason: "valid"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, valid, reason := evaluateCredential(test.credentials, now) + if valid != test.valid || reason != test.reason { + t.Fatalf("valid=%v reason=%q,期望 valid=%v reason=%q", valid, reason, test.valid, test.reason) + } + }) + } +} diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index add1bf2..d6122e0 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -602,6 +602,15 @@ func seedAdditionalCoreScenarios(database *gorm.DB, passwordHash string, now tim return err } + // 为每组模拟工作人员补齐独立有效资质,保证预检数据与在岗状态一致。 + credential := models.StaffCredential{ + Entity: entity(sequence+21, common.StatusEnable), StaffAccountID: staff.ID, + CredentialType: "delivery", CredentialNo: "MOCK-CERT-" + suffix, ExpiredAt: &nextYear, + } + if err := put(database, &credential); err != nil { + return err + } + user := models.UserAccount{ Entity: entity(sequence+4, common.StatusEnable), Username: "mock_customer_" + suffix, PasswordHash: passwordHash, Name: fmt.Sprintf("示例客户%d", scenario), Phone: "138" + phoneSuffix, diff --git a/docs/开发日志_作业预检修复_20260905.md b/docs/开发日志_作业预检修复_20260905.md new file mode 100644 index 0000000..f59a825 --- /dev/null +++ b/docs/开发日志_作业预检修复_20260905.md @@ -0,0 +1,51 @@ +# 作业预检修复操作日志 + +操作时间:2026-09-05 +操作类型:修改 +影响模块:工作人员 App 作业预检、后端工作人员预检接口、模拟数据 + +## 操作前状态 + +- 示例配送员 2–10 缺少人员资质,导致在岗账号无法进入工作台。 +- 预检接口仅返回资质阻断状态,无法区分缺失、过期和停用。 +- 未启用检查项使用警告样式,容易被误认为阻断项。 +- 定位权限错误直接展示 Dart 的 `Bad state:` 前缀。 + +## 具体操作 + +- 为示例配送员 2–10 增加幂等的有效配送资质种子数据。 +- 预检接口增加资质原因码、资质类型和到期时间,同时保持原字段兼容。 +- 页面仅统计 `blocked` 状态,未配置能力显示为“暂不检查”。 +- 人员资质阻断时显示中文原因和联系管理员的处理指引。 +- 下班打卡保持可用;定位权限失败时提供重新授权入口和设置指引。 + +## 代码变更 + +- `backend/api/internal/seed/mock.go`:补齐模拟工作人员资质。 +- `backend/api/internal/logic/client/staff/auth.go`:增加资质状态判定和原因码。 +- `backend/api/internal/logic/client/staff/auth_test.go`:覆盖资质判定边界。 +- `apps/service_app/lib/domain/models/service_models.dart`:增加真实阻断项计数。 +- `apps/service_app/lib/ui/features/preflight/preflight_page.dart`:优化状态、提示和定位错误展示。 +- `apps/service_app/lib/data/services/location_service.dart`:补充中文授权路径。 +- `apps/service_app/test/ui/preflight_page_test.dart`:覆盖预检页面关键行为。 + +## 行为变化 + +- 修改前:示例配送员普遍因缺失资质被阻断;修改后:重启并执行模拟数据初始化后自动补齐。 +- 修改前:“暂未启用”与失败状态视觉接近;修改后:中性显示且不计入阻断数量。 +- 修改前:资质统一显示“未通过”;修改后:按缺失、过期、停用给出明确中文说明。 + +## 风险评估 + +- 接口仅新增响应字段,不删除或修改既有字段,兼容现有调用方。 +- 种子记录使用未占用的场景内编号并采用 `FirstOrCreate`,不会覆盖已有资质。 +- 进入工作台的服务端准入规则未放宽;下班打卡仍要求真实定位。 + +## 验证结果 + +- `go test ./internal/logic/client/staff ./internal/seed`:通过。 +- `flutter analyze`:通过,无静态分析问题。 +- `flutter test test/ui/preflight_page_test.dart`:2 项测试全部通过。 +- Release Web 构建成功,`http://127.0.0.1:5181/` 返回 HTTP 200。 +- 新后端已监听 `12426`,健康检查返回 HTTP 200。 +- 示例配送员9实测预检返回 `can_work=true`;人员资质通过,未启用能力保持非阻断状态。