修复工作人员作业预检状态与提示
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -35,6 +35,12 @@ class PreflightResult {
|
||||
final bool canWork;
|
||||
final Map<String, Object?> checks;
|
||||
|
||||
/// 仅统计真正阻止进入工作台的检查项。
|
||||
int get blockerCount => checks.values.where((value) {
|
||||
final detail = _map(value);
|
||||
return detail['status'] == 'blocked';
|
||||
}).length;
|
||||
|
||||
factory PreflightResult.fromJson(Map<String, Object?> json) => PreflightResult(
|
||||
roleCode: json['role_code'] as String? ?? '',
|
||||
workStatus: json['work_status'] as String? ?? 'off_duty',
|
||||
|
||||
@@ -46,7 +46,15 @@ class _PreflightPageState extends State<PreflightPage> {
|
||||
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<PreflightPage> {
|
||||
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' => '人员资质已停用,请联系所属组织管理员处理',
|
||||
_ => '尚未配置有效资质,请联系所属组织管理员补充或审核',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,26 @@ class _FailOnceRepository extends ServiceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回包含一个真实阻断项和一个暂不检查项的固定预检结果。
|
||||
class _BlockedRepository extends ServiceRepository {
|
||||
_BlockedRepository()
|
||||
: super(
|
||||
ApiClient(() => '', baseUrl: 'https://api.example.com'),
|
||||
_UnusedLocationService(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<PreflightResult> 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
36
backend/api/internal/logic/client/staff/auth_test.go
Normal file
36
backend/api/internal/logic/client/staff/auth_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
51
docs/开发日志_作业预检修复_20260905.md
Normal file
51
docs/开发日志_作业预检修复_20260905.md
Normal file
@@ -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`;人员资质通过,未启用能力保持非阻断状态。
|
||||
Reference in New Issue
Block a user