- pet_models.dart:pets 域 DTO 逐字段对齐冻结契约(宠物/品种/体重/ 疫苗目录/疫苗/健康事件/照护提醒/聚合摘要 + cursor 分页信封), 枚举严格解析,请求体部分更新语义(缺席字段不出现) - pets_repository.dart:PetsRepository 抽象 + ApiPetsRepository, 12 路径 18 操作全覆盖;四个 POST 携带 Idempotency-Key; 复用既有 ApiClient token 拦截与单飞刷新重放 - pet_exceptions.dart:新 8 错误码映射为类型化异常 (40300/40401/40402/40902/40903/40904/42201/42202), 仍可按 ApiBusinessException 基类捕获 - pets_controller.dart:宠物档案状态自 AppState 拆出 (Controller → Repository → API Client 分层,四态就绪供 T2-12) - money.dart:元/分换算工具(DTO 层保持整数分,UI 层 T2-14 使用) - api_client.dart:新增 patbondPetApiBaseUrl(:8083,--dart-define 可覆盖)与 query 参数支持;api_exception.dart 补 pets 域错误码 - 测试 64 → 126:DTO 映射、错误类型化映射、请求线路 (路径/方法/鉴权/分页/幂等键/tz)、控制器状态机、金额换算 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,14 @@ const String patbondUserApiBaseUrl = String.fromEnvironment(
|
|||||||
defaultValue: 'http://127.0.0.1:8082',
|
defaultValue: 'http://127.0.0.1:8082',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// pet 服务基地址(/api/v1/pets、/api/v1/breeds、/api/v1/vaccine-catalog 等
|
||||||
|
/// pets 域 12 路径):沿用 auth :8081 / user :8082 的分端口直连模式,
|
||||||
|
/// `--dart-define=PATBOND_PET_API_BASE_URL=...` 注入。
|
||||||
|
const String patbondPetApiBaseUrl = String.fromEnvironment(
|
||||||
|
'PATBOND_PET_API_BASE_URL',
|
||||||
|
defaultValue: 'http://127.0.0.1:8083',
|
||||||
|
);
|
||||||
|
|
||||||
/// 构建全局共用的 Dio 实例。
|
/// 构建全局共用的 Dio 实例。
|
||||||
///
|
///
|
||||||
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
/// `validateStatus` 放行所有状态码:错误信封由 [ApiClient] 统一解析成
|
||||||
@@ -82,6 +90,7 @@ class ApiClient {
|
|||||||
String path, {
|
String path, {
|
||||||
String method = 'POST',
|
String method = 'POST',
|
||||||
Object? body,
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
Map<String, Object?>? headers,
|
Map<String, Object?>? headers,
|
||||||
bool requiresAuth = false,
|
bool requiresAuth = false,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -89,6 +98,7 @@ class ApiClient {
|
|||||||
path,
|
path,
|
||||||
method: method,
|
method: method,
|
||||||
body: body,
|
body: body,
|
||||||
|
query: query,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
requiresAuth: requiresAuth,
|
requiresAuth: requiresAuth,
|
||||||
);
|
);
|
||||||
@@ -99,6 +109,7 @@ class ApiClient {
|
|||||||
path,
|
path,
|
||||||
method: method,
|
method: method,
|
||||||
body: body,
|
body: body,
|
||||||
|
query: query,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
requiresAuth: requiresAuth,
|
requiresAuth: requiresAuth,
|
||||||
);
|
);
|
||||||
@@ -116,12 +127,14 @@ class ApiClient {
|
|||||||
required String method,
|
required String method,
|
||||||
required bool requiresAuth,
|
required bool requiresAuth,
|
||||||
Object? body,
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
Map<String, Object?>? headers,
|
Map<String, Object?>? headers,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
return await _dio.request<Object?>(
|
return await _dio.request<Object?>(
|
||||||
path,
|
path,
|
||||||
data: body,
|
data: body,
|
||||||
|
queryParameters: query,
|
||||||
options: Options(
|
options: Options(
|
||||||
method: method,
|
method: method,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
|||||||
@@ -22,6 +22,32 @@ abstract final class ApiCodes {
|
|||||||
|
|
||||||
/// 登录失败次数过多,账号临时锁定(HTTP 423,见 openapi.yaml)。
|
/// 登录失败次数过多,账号临时锁定(HTTP 423,见 openapi.yaml)。
|
||||||
static const loginLocked = 42300;
|
static const loginLocked = 42300;
|
||||||
|
|
||||||
|
// ------ pets 域(契约 v1.2.0 冻结,M2 第二波定型 8 个)------
|
||||||
|
|
||||||
|
/// 对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
|
||||||
|
static const petAccessDenied = 40300;
|
||||||
|
|
||||||
|
/// 宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
|
||||||
|
static const petNotFound = 40401;
|
||||||
|
|
||||||
|
/// 记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
|
||||||
|
static const recordNotFound = 40402;
|
||||||
|
|
||||||
|
/// 乐观锁版本冲突(提交的 version 已过期;提醒的条件更新守卫落空同码)。
|
||||||
|
static const versionConflict = 40902;
|
||||||
|
|
||||||
|
/// 芯片号已被登记(跨用户唯一)。
|
||||||
|
static const microchipTaken = 40903;
|
||||||
|
|
||||||
|
/// 同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在。
|
||||||
|
static const vaccinationDoseExists = 40904;
|
||||||
|
|
||||||
|
/// 疫苗状态机 / 状态-日期规则违反(HTTP 422)。
|
||||||
|
static const vaccinationRuleViolation = 42201;
|
||||||
|
|
||||||
|
/// 提醒状态机 / completed-completedAt 一致性违反(HTTP 422)。
|
||||||
|
static const careReminderRuleViolation = 42202;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
|
/// API 调用的类型化异常。页面按类型映射为三层错误呈现
|
||||||
@@ -43,7 +69,8 @@ final class ApiNetworkException extends ApiException {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 业务错误:错误信封 code != 0(40000/40100/40900/40901 等)。
|
/// 业务错误:错误信封 code != 0(40000/40100/40900/40901 等)。
|
||||||
final class ApiBusinessException extends ApiException {
|
/// 领域层可按错误码细分子类型(见 features/pets/pet_exceptions.dart)。
|
||||||
|
class ApiBusinessException extends ApiException {
|
||||||
const ApiBusinessException({required this.code, required String message})
|
const ApiBusinessException({required this.code, required String message})
|
||||||
: super(message);
|
: super(message);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/// 金额换算工具:契约传输整数分(amountCents),UI 展示以元。
|
||||||
|
/// DTO 层保持整数分不换算(开发计划 §4.3);本工具供 UI 层(T2-14)使用。
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// 整数分 → 元展示字符串。整元不带小数("128"),
|
||||||
|
/// 非整元固定两位("128.50")。负数入参非法(契约金额非负)。
|
||||||
|
String formatCentsAsYuan(int cents) {
|
||||||
|
if (cents < 0) {
|
||||||
|
throw ArgumentError.value(cents, 'cents', '金额不得为负');
|
||||||
|
}
|
||||||
|
final yuan = cents ~/ 100;
|
||||||
|
final remainder = cents % 100;
|
||||||
|
if (remainder == 0) return '$yuan';
|
||||||
|
return '$yuan.${remainder.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元输入串 → 整数分。接受整数或最多两位小数("12"、"12.5"、"12.50");
|
||||||
|
/// 空白、非法字符、超两位小数、负数返回 null(表单层据此报「金额格式不正确」)。
|
||||||
|
int? parseYuanToCents(String input) {
|
||||||
|
final trimmed = input.trim();
|
||||||
|
final match = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(trimmed);
|
||||||
|
if (match == null) return null;
|
||||||
|
final yuan = int.parse(match.group(1)!);
|
||||||
|
final fractional = match.group(2);
|
||||||
|
final cents = switch (fractional) {
|
||||||
|
null => 0,
|
||||||
|
_ => int.parse(fractional.padRight(2, '0')),
|
||||||
|
};
|
||||||
|
return yuan * 100 + cents;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
|
||||||
|
/// pets 域类型化业务异常(契约 v1.2.0 定型的 8 个新错误码,
|
||||||
|
/// 21 号收口报告 §1)。全部继承 [ApiBusinessException],
|
||||||
|
/// 既有按基类捕获的通用错误处理不受影响。
|
||||||
|
|
||||||
|
/// 40300:对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
|
||||||
|
final class PetAccessDeniedException extends ApiBusinessException {
|
||||||
|
const PetAccessDeniedException({required super.message})
|
||||||
|
: super(code: ApiCodes.petAccessDenied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40401:宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
|
||||||
|
final class PetNotFoundException extends ApiBusinessException {
|
||||||
|
const PetNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.petNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40402:记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
|
||||||
|
final class PetRecordNotFoundException extends ApiBusinessException {
|
||||||
|
const PetRecordNotFoundException({required super.message})
|
||||||
|
: super(code: ApiCodes.recordNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40902:乐观锁版本冲突(version 过期;提醒的条件更新守卫落空同码)。
|
||||||
|
/// 客户端处理:刷新取新数据后重提。
|
||||||
|
final class PetVersionConflictException extends ApiBusinessException {
|
||||||
|
const PetVersionConflictException({required super.message})
|
||||||
|
: super(code: ApiCodes.versionConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40903:芯片号已被登记(跨用户唯一)。
|
||||||
|
final class MicrochipTakenException extends ApiBusinessException {
|
||||||
|
const MicrochipTakenException({required super.message})
|
||||||
|
: super(code: ApiCodes.microchipTaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 40904:同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在
|
||||||
|
/// (cancel 后同剂次可重新登记)。
|
||||||
|
final class VaccinationDoseExistsException extends ApiBusinessException {
|
||||||
|
const VaccinationDoseExistsException({required super.message})
|
||||||
|
: super(code: ApiCodes.vaccinationDoseExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42201:疫苗状态机非法迁移或状态-日期规则违反(HTTP 422)。
|
||||||
|
final class VaccinationRuleException extends ApiBusinessException {
|
||||||
|
const VaccinationRuleException({required super.message})
|
||||||
|
: super(code: ApiCodes.vaccinationRuleViolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 42202:提醒状态机非法迁移或 completed-completedAt 一致性违反(HTTP 422)。
|
||||||
|
final class CareReminderRuleException extends ApiBusinessException {
|
||||||
|
const CareReminderRuleException({required super.message})
|
||||||
|
: super(code: ApiCodes.careReminderRuleViolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把通用业务异常按 pets 域错误码升格为类型化异常;
|
||||||
|
/// 未覆盖的码(40000 参数错误等)原样返回,沿用通用处理。
|
||||||
|
ApiBusinessException mapPetBusinessException(ApiBusinessException error) {
|
||||||
|
return switch (error.code) {
|
||||||
|
ApiCodes.petAccessDenied => PetAccessDeniedException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.petNotFound => PetNotFoundException(message: error.message),
|
||||||
|
ApiCodes.recordNotFound => PetRecordNotFoundException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.versionConflict => PetVersionConflictException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.microchipTaken => MicrochipTakenException(message: error.message),
|
||||||
|
ApiCodes.vaccinationDoseExists => VaccinationDoseExistsException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.vaccinationRuleViolation => VaccinationRuleException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
ApiCodes.careReminderRuleViolation => CareReminderRuleException(
|
||||||
|
message: error.message,
|
||||||
|
),
|
||||||
|
_ => error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,854 @@
|
|||||||
|
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0,
|
||||||
|
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||||||
|
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// 物种(创建即定,不可修改)。
|
||||||
|
enum PetSpecies {
|
||||||
|
dog,
|
||||||
|
cat,
|
||||||
|
other;
|
||||||
|
|
||||||
|
static PetSpecies fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'species');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 性别。
|
||||||
|
enum PetSex {
|
||||||
|
male,
|
||||||
|
female,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
static PetSex fromJson(String value) => _enumFromJson(values, value, 'sex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宠物状态(deleted 为内部软删态,接口永不返回)。
|
||||||
|
enum PetStatus {
|
||||||
|
active,
|
||||||
|
lost,
|
||||||
|
deceased,
|
||||||
|
archived;
|
||||||
|
|
||||||
|
static PetStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用者对宠物的权限角色(客户端据此显隐写入口)。
|
||||||
|
enum PetRole {
|
||||||
|
owner,
|
||||||
|
caregiver,
|
||||||
|
viewer;
|
||||||
|
|
||||||
|
static PetRole fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'myRole');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重记录来源。
|
||||||
|
enum WeightSource {
|
||||||
|
manual,
|
||||||
|
clinic,
|
||||||
|
device;
|
||||||
|
|
||||||
|
static WeightSource fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'source');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗记录状态。
|
||||||
|
enum VaccinationStatus {
|
||||||
|
scheduled,
|
||||||
|
completed,
|
||||||
|
cancelled;
|
||||||
|
|
||||||
|
static VaccinationStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康事件六类类型。
|
||||||
|
enum HealthEventType {
|
||||||
|
medical,
|
||||||
|
feeding,
|
||||||
|
deworming,
|
||||||
|
grooming,
|
||||||
|
measurement,
|
||||||
|
note;
|
||||||
|
|
||||||
|
static HealthEventType fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'eventType');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照护提醒四类类型。
|
||||||
|
enum CareReminderType {
|
||||||
|
deworming,
|
||||||
|
checkup,
|
||||||
|
medication,
|
||||||
|
other;
|
||||||
|
|
||||||
|
static CareReminderType fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'reminderType');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照护提醒状态。
|
||||||
|
enum CareReminderStatus {
|
||||||
|
pending,
|
||||||
|
completed,
|
||||||
|
dismissed;
|
||||||
|
|
||||||
|
static CareReminderStatus fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要「下次接种」的取值来源。
|
||||||
|
enum NextVaccinationSource {
|
||||||
|
planned,
|
||||||
|
nextDue;
|
||||||
|
|
||||||
|
static NextVaccinationSource fromJson(String value) =>
|
||||||
|
_enumFromJson(values, value, 'source');
|
||||||
|
}
|
||||||
|
|
||||||
|
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
|
||||||
|
for (final value in values) {
|
||||||
|
if (value.name == raw) return value;
|
||||||
|
}
|
||||||
|
throw FormatException('未知的 $field 取值:$raw');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日期字段(契约 format: date)序列化为 YYYY-MM-DD。
|
||||||
|
String dateToJson(DateTime date) {
|
||||||
|
final y = date.year.toString().padLeft(4, '0');
|
||||||
|
final m = date.month.toString().padLeft(2, '0');
|
||||||
|
final d = date.day.toString().padLeft(2, '0');
|
||||||
|
return '$y-$m-$d';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateOrNull(Object? value) =>
|
||||||
|
value == null ? null : DateTime.parse(value as String);
|
||||||
|
|
||||||
|
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。
|
||||||
|
class CursorPage<T> {
|
||||||
|
const CursorPage({
|
||||||
|
required this.items,
|
||||||
|
required this.nextCursor,
|
||||||
|
required this.hasMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CursorPage.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(Map<String, dynamic>) itemFromJson,
|
||||||
|
) {
|
||||||
|
return CursorPage(
|
||||||
|
items: (json['items'] as List)
|
||||||
|
.map((item) => itemFromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
|
||||||
|
nextCursor: json['nextCursor'] as String?,
|
||||||
|
hasMore: json['hasMore'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<T> items;
|
||||||
|
final String? nextCursor;
|
||||||
|
final bool hasMore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||||||
|
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
|
||||||
|
class Pet {
|
||||||
|
const Pet({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.species,
|
||||||
|
required this.breedId,
|
||||||
|
required this.breedDisplayName,
|
||||||
|
required this.customBreedName,
|
||||||
|
required this.sex,
|
||||||
|
required this.birthDate,
|
||||||
|
required this.birthDateEstimated,
|
||||||
|
required this.personality,
|
||||||
|
required this.microchipNo,
|
||||||
|
required this.sterilizedOn,
|
||||||
|
required this.status,
|
||||||
|
required this.myRole,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Pet.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Pet(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
species: PetSpecies.fromJson(json['species'] as String),
|
||||||
|
breedId: json['breedId'] as String?,
|
||||||
|
breedDisplayName: json['breedDisplayName'] as String?,
|
||||||
|
customBreedName: json['customBreedName'] as String?,
|
||||||
|
sex: PetSex.fromJson(json['sex'] as String),
|
||||||
|
birthDate: _dateOrNull(json['birthDate']),
|
||||||
|
birthDateEstimated: json['birthDateEstimated'] as bool,
|
||||||
|
personality: json['personality'] as String?,
|
||||||
|
microchipNo: json['microchipNo'] as String?,
|
||||||
|
sterilizedOn: _dateOrNull(json['sterilizedOn']),
|
||||||
|
status: PetStatus.fromJson(json['status'] as String),
|
||||||
|
myRole: PetRole.fromJson(json['myRole'] as String),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final PetSpecies species;
|
||||||
|
final String? breedId;
|
||||||
|
final String? breedDisplayName;
|
||||||
|
final String? customBreedName;
|
||||||
|
final PetSex sex;
|
||||||
|
final DateTime? birthDate;
|
||||||
|
final bool birthDateEstimated;
|
||||||
|
final String? personality;
|
||||||
|
final String? microchipNo;
|
||||||
|
final DateTime? sterilizedOn;
|
||||||
|
final PetStatus status;
|
||||||
|
final PetRole myRole;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建宠物请求。breedId 与 customBreedName 二选一且互斥(400/40000 兜底在服务端)。
|
||||||
|
class CreatePetRequest {
|
||||||
|
const CreatePetRequest({
|
||||||
|
required this.name,
|
||||||
|
required this.species,
|
||||||
|
required this.sex,
|
||||||
|
this.breedId,
|
||||||
|
this.customBreedName,
|
||||||
|
this.birthDate,
|
||||||
|
this.birthDateEstimated,
|
||||||
|
this.personality,
|
||||||
|
this.microchipNo,
|
||||||
|
this.sterilizedOn,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final PetSpecies species;
|
||||||
|
final PetSex sex;
|
||||||
|
final String? breedId;
|
||||||
|
final String? customBreedName;
|
||||||
|
final DateTime? birthDate;
|
||||||
|
final bool? birthDateEstimated;
|
||||||
|
final String? personality;
|
||||||
|
final String? microchipNo;
|
||||||
|
final DateTime? sterilizedOn;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'species': species.name,
|
||||||
|
'sex': sex.name,
|
||||||
|
if (breedId != null) 'breedId': breedId,
|
||||||
|
if (customBreedName != null) 'customBreedName': customBreedName,
|
||||||
|
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
|
||||||
|
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
|
||||||
|
if (personality != null) 'personality': personality,
|
||||||
|
if (microchipNo != null) 'microchipNo': microchipNo,
|
||||||
|
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null;
|
||||||
|
/// 品种对整体替换;species 不可改;version 乐观锁必填)。
|
||||||
|
class UpdatePetRequest {
|
||||||
|
const UpdatePetRequest({
|
||||||
|
required this.version,
|
||||||
|
this.name,
|
||||||
|
this.breedId,
|
||||||
|
this.customBreedName,
|
||||||
|
this.sex,
|
||||||
|
this.birthDate,
|
||||||
|
this.birthDateEstimated,
|
||||||
|
this.personality,
|
||||||
|
this.microchipNo,
|
||||||
|
this.sterilizedOn,
|
||||||
|
this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? name;
|
||||||
|
final String? breedId;
|
||||||
|
final String? customBreedName;
|
||||||
|
final PetSex? sex;
|
||||||
|
final DateTime? birthDate;
|
||||||
|
final bool? birthDateEstimated;
|
||||||
|
final String? personality;
|
||||||
|
final String? microchipNo;
|
||||||
|
final DateTime? sterilizedOn;
|
||||||
|
final PetStatus? status;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (name != null) 'name': name,
|
||||||
|
if (breedId != null) 'breedId': breedId,
|
||||||
|
if (customBreedName != null) 'customBreedName': customBreedName,
|
||||||
|
if (sex != null) 'sex': sex!.name,
|
||||||
|
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
|
||||||
|
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
|
||||||
|
if (personality != null) 'personality': personality,
|
||||||
|
if (microchipNo != null) 'microchipNo': microchipNo,
|
||||||
|
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 品种目录项(只读字典)。
|
||||||
|
class Breed {
|
||||||
|
const Breed({
|
||||||
|
required this.id,
|
||||||
|
required this.species,
|
||||||
|
required this.code,
|
||||||
|
required this.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Breed.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Breed(
|
||||||
|
id: json['id'] as String,
|
||||||
|
species: PetSpecies.fromJson(json['species'] as String),
|
||||||
|
code: json['code'] as String,
|
||||||
|
displayName: json['displayName'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final PetSpecies species;
|
||||||
|
final String code;
|
||||||
|
final String displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 体重记录(append-only,无乐观锁)。
|
||||||
|
class WeightRecord {
|
||||||
|
const WeightRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.weightKg,
|
||||||
|
required this.measuredAt,
|
||||||
|
required this.source,
|
||||||
|
required this.note,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WeightRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WeightRecord(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
// 服务端 numeric(6,2),整数值也可能以 int 下发。
|
||||||
|
weightKg: (json['weightKg'] as num).toDouble(),
|
||||||
|
measuredAt: DateTime.parse(json['measuredAt'] as String),
|
||||||
|
source: WeightSource.fromJson(json['source'] as String),
|
||||||
|
note: json['note'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final double weightKg;
|
||||||
|
final DateTime measuredAt;
|
||||||
|
final WeightSource source;
|
||||||
|
final String? note;
|
||||||
|
final DateTime createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加体重记录请求。
|
||||||
|
class CreateWeightRequest {
|
||||||
|
const CreateWeightRequest({
|
||||||
|
required this.weightKg,
|
||||||
|
required this.measuredAt,
|
||||||
|
this.source,
|
||||||
|
this.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double weightKg;
|
||||||
|
final DateTime measuredAt;
|
||||||
|
final WeightSource? source;
|
||||||
|
final String? note;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'weightKg': weightKg,
|
||||||
|
'measuredAt': measuredAt.toIso8601String(),
|
||||||
|
if (source != null) 'source': source!.name,
|
||||||
|
if (note != null) 'note': note,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗目录项(只读字典)。
|
||||||
|
class VaccineCatalogItem {
|
||||||
|
const VaccineCatalogItem({
|
||||||
|
required this.id,
|
||||||
|
required this.code,
|
||||||
|
required this.name,
|
||||||
|
required this.species,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VaccineCatalogItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return VaccineCatalogItem(
|
||||||
|
id: json['id'] as String,
|
||||||
|
code: json['code'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
species: PetSpecies.fromJson(json['species'] as String),
|
||||||
|
description: json['description'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String code;
|
||||||
|
final String name;
|
||||||
|
final PetSpecies species;
|
||||||
|
final String? description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 疫苗记录。vaccineName 由目录解出(列表页免二次查字典)。
|
||||||
|
class Vaccination {
|
||||||
|
const Vaccination({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.vaccineId,
|
||||||
|
required this.vaccineName,
|
||||||
|
required this.seriesKey,
|
||||||
|
required this.doseNo,
|
||||||
|
required this.doseLabel,
|
||||||
|
required this.status,
|
||||||
|
required this.plannedOn,
|
||||||
|
required this.administeredOn,
|
||||||
|
required this.nextDueOn,
|
||||||
|
required this.manufacturer,
|
||||||
|
required this.batchNo,
|
||||||
|
required this.notes,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Vaccination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Vaccination(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
vaccineId: json['vaccineId'] as String,
|
||||||
|
vaccineName: json['vaccineName'] as String,
|
||||||
|
seriesKey: json['seriesKey'] as String,
|
||||||
|
doseNo: json['doseNo'] as int,
|
||||||
|
doseLabel: json['doseLabel'] as String?,
|
||||||
|
status: VaccinationStatus.fromJson(json['status'] as String),
|
||||||
|
plannedOn: _dateOrNull(json['plannedOn']),
|
||||||
|
administeredOn: _dateOrNull(json['administeredOn']),
|
||||||
|
nextDueOn: _dateOrNull(json['nextDueOn']),
|
||||||
|
manufacturer: json['manufacturer'] as String?,
|
||||||
|
batchNo: json['batchNo'] as String?,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final String vaccineId;
|
||||||
|
final String vaccineName;
|
||||||
|
final String seriesKey;
|
||||||
|
final int doseNo;
|
||||||
|
final String? doseLabel;
|
||||||
|
final VaccinationStatus status;
|
||||||
|
final DateTime? plannedOn;
|
||||||
|
final DateTime? administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? notes;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建疫苗记录请求(创建状态仅 scheduled / completed)。
|
||||||
|
class CreateVaccinationRequest {
|
||||||
|
const CreateVaccinationRequest({
|
||||||
|
required this.vaccineId,
|
||||||
|
required this.seriesKey,
|
||||||
|
required this.doseNo,
|
||||||
|
required this.status,
|
||||||
|
this.doseLabel,
|
||||||
|
this.plannedOn,
|
||||||
|
this.administeredOn,
|
||||||
|
this.nextDueOn,
|
||||||
|
this.manufacturer,
|
||||||
|
this.batchNo,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String vaccineId;
|
||||||
|
final String seriesKey;
|
||||||
|
final int doseNo;
|
||||||
|
|
||||||
|
/// 契约允许 scheduled / completed(cancelled 创建无业务意义,服务端 400)。
|
||||||
|
final VaccinationStatus status;
|
||||||
|
final String? doseLabel;
|
||||||
|
final DateTime? plannedOn;
|
||||||
|
final DateTime? administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'vaccineId': vaccineId,
|
||||||
|
'seriesKey': seriesKey,
|
||||||
|
'doseNo': doseNo,
|
||||||
|
'status': status.name,
|
||||||
|
if (doseLabel != null) 'doseLabel': doseLabel,
|
||||||
|
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
|
||||||
|
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
|
||||||
|
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
|
||||||
|
if (manufacturer != null) 'manufacturer': manufacturer,
|
||||||
|
if (batchNo != null) 'batchNo': batchNo,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新疫苗记录请求(vaccineId / seriesKey / doseNo 不可改;version 必填)。
|
||||||
|
class UpdateVaccinationRequest {
|
||||||
|
const UpdateVaccinationRequest({
|
||||||
|
required this.version,
|
||||||
|
this.status,
|
||||||
|
this.plannedOn,
|
||||||
|
this.administeredOn,
|
||||||
|
this.nextDueOn,
|
||||||
|
this.doseLabel,
|
||||||
|
this.manufacturer,
|
||||||
|
this.batchNo,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final VaccinationStatus? status;
|
||||||
|
final DateTime? plannedOn;
|
||||||
|
final DateTime? administeredOn;
|
||||||
|
final DateTime? nextDueOn;
|
||||||
|
final String? doseLabel;
|
||||||
|
final String? manufacturer;
|
||||||
|
final String? batchNo;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (status != null) 'status': status!.name,
|
||||||
|
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
|
||||||
|
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
|
||||||
|
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
|
||||||
|
if (doseLabel != null) 'doseLabel': doseLabel,
|
||||||
|
if (manufacturer != null) 'manufacturer': manufacturer,
|
||||||
|
if (batchNo != null) 'batchNo': batchNo,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康事件(六类;金额整数分传输,元/分换算留给 UI 层)。
|
||||||
|
class HealthEvent {
|
||||||
|
const HealthEvent({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.eventType,
|
||||||
|
required this.occurredAt,
|
||||||
|
required this.title,
|
||||||
|
required this.notes,
|
||||||
|
required this.amountCents,
|
||||||
|
required this.createdByUserId,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.version,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory HealthEvent.fromJson(Map<String, dynamic> json) {
|
||||||
|
return HealthEvent(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
eventType: HealthEventType.fromJson(json['eventType'] as String),
|
||||||
|
occurredAt: DateTime.parse(json['occurredAt'] as String),
|
||||||
|
title: json['title'] as String,
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
amountCents: json['amountCents'] as int?,
|
||||||
|
createdByUserId: json['createdByUserId'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
version: json['version'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final HealthEventType eventType;
|
||||||
|
final DateTime occurredAt;
|
||||||
|
final String title;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// 金额(整数分,非负,可空)。DTO 层保持整数分不换算。
|
||||||
|
final int? amountCents;
|
||||||
|
final String createdByUserId;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final int version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加健康事件请求(createdByUserId 取自 token,不收请求体)。
|
||||||
|
class CreateHealthEventRequest {
|
||||||
|
const CreateHealthEventRequest({
|
||||||
|
required this.eventType,
|
||||||
|
required this.occurredAt,
|
||||||
|
required this.title,
|
||||||
|
this.notes,
|
||||||
|
this.amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
final HealthEventType eventType;
|
||||||
|
final DateTime occurredAt;
|
||||||
|
final String title;
|
||||||
|
final String? notes;
|
||||||
|
|
||||||
|
/// 整数分;提交小数服务端 400/40000(不做静默截断)。
|
||||||
|
final int? amountCents;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'eventType': eventType.name,
|
||||||
|
'occurredAt': occurredAt.toIso8601String(),
|
||||||
|
'title': title,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
if (amountCents != null) 'amountCents': amountCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新健康事件请求(仅 title / notes / amountCents 可编辑;version 必填)。
|
||||||
|
class UpdateHealthEventRequest {
|
||||||
|
const UpdateHealthEventRequest({
|
||||||
|
required this.version,
|
||||||
|
this.title,
|
||||||
|
this.notes,
|
||||||
|
this.amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String? title;
|
||||||
|
final String? notes;
|
||||||
|
final int? amountCents;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
if (title != null) 'title': title,
|
||||||
|
if (notes != null) 'notes': notes,
|
||||||
|
if (amountCents != null) 'amountCents': amountCents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 照护提醒(无 version 字段;completedAt 非空当且仅当 status=completed)。
|
||||||
|
class CareReminder {
|
||||||
|
const CareReminder({
|
||||||
|
required this.id,
|
||||||
|
required this.petId,
|
||||||
|
required this.reminderType,
|
||||||
|
required this.title,
|
||||||
|
required this.dueAt,
|
||||||
|
required this.status,
|
||||||
|
required this.completedAt,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CareReminder.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CareReminder(
|
||||||
|
id: json['id'] as String,
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
reminderType: CareReminderType.fromJson(json['reminderType'] as String),
|
||||||
|
title: json['title'] as String,
|
||||||
|
dueAt: DateTime.parse(json['dueAt'] as String),
|
||||||
|
status: CareReminderStatus.fromJson(json['status'] as String),
|
||||||
|
completedAt: json['completedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['completedAt'] as String),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String petId;
|
||||||
|
final CareReminderType reminderType;
|
||||||
|
final String title;
|
||||||
|
final DateTime dueAt;
|
||||||
|
final CareReminderStatus status;
|
||||||
|
final DateTime? completedAt;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建照护提醒请求(创建恒为 pending,不收 status)。
|
||||||
|
class CreateCareReminderRequest {
|
||||||
|
const CreateCareReminderRequest({
|
||||||
|
required this.reminderType,
|
||||||
|
required this.title,
|
||||||
|
required this.dueAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CareReminderType reminderType;
|
||||||
|
final String title;
|
||||||
|
final DateTime dueAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'reminderType': reminderType.name,
|
||||||
|
'title': title,
|
||||||
|
'dueAt': dueAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新提醒状态请求(状态流转专用:仅 status + completedAt)。
|
||||||
|
class UpdateCareReminderRequest {
|
||||||
|
const UpdateCareReminderRequest({required this.status, this.completedAt});
|
||||||
|
|
||||||
|
final CareReminderStatus status;
|
||||||
|
|
||||||
|
/// status=completed 时必填;其余状态禁带(违反 422/42202)。
|
||||||
|
/// 由客户端提交(而非服务端 now()),允许补记实际完成时刻。
|
||||||
|
final DateTime? completedAt;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'status': status.name,
|
||||||
|
if (completedAt != null) 'completedAt': completedAt!.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:最新体重(无记录 → 整体 null)。
|
||||||
|
class SummaryLatestWeight {
|
||||||
|
const SummaryLatestWeight({required this.weightKg, required this.measuredAt});
|
||||||
|
|
||||||
|
factory SummaryLatestWeight.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryLatestWeight(
|
||||||
|
weightKg: (json['weightKg'] as num).toDouble(),
|
||||||
|
measuredAt: DateTime.parse(json['measuredAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final double weightKg;
|
||||||
|
final DateTime measuredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:疫苗进度(totalDoses=0 → 整体 null,不是 0/0)。
|
||||||
|
class SummaryVaccinationProgress {
|
||||||
|
const SummaryVaccinationProgress({
|
||||||
|
required this.completedDoses,
|
||||||
|
required this.totalDoses,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SummaryVaccinationProgress.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryVaccinationProgress(
|
||||||
|
completedDoses: json['completedDoses'] as int,
|
||||||
|
totalDoses: json['totalDoses'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int completedDoses;
|
||||||
|
final int totalDoses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:下次接种(候选集空 → 整体 null;dueOn 可为过去日期——逾期针仍是下一针)。
|
||||||
|
class SummaryNextVaccination {
|
||||||
|
const SummaryNextVaccination({
|
||||||
|
required this.vaccinationId,
|
||||||
|
required this.vaccineId,
|
||||||
|
required this.vaccineName,
|
||||||
|
required this.doseNo,
|
||||||
|
required this.doseLabel,
|
||||||
|
required this.dueOn,
|
||||||
|
required this.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SummaryNextVaccination.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryNextVaccination(
|
||||||
|
vaccinationId: json['vaccinationId'] as String,
|
||||||
|
vaccineId: json['vaccineId'] as String,
|
||||||
|
vaccineName: json['vaccineName'] as String,
|
||||||
|
doseNo: json['doseNo'] as int,
|
||||||
|
doseLabel: json['doseLabel'] as String?,
|
||||||
|
dueOn: DateTime.parse(json['dueOn'] as String),
|
||||||
|
source: NextVaccinationSource.fromJson(json['source'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String vaccinationId;
|
||||||
|
final String vaccineId;
|
||||||
|
final String vaccineName;
|
||||||
|
final int doseNo;
|
||||||
|
final String? doseLabel;
|
||||||
|
final DateTime dueOn;
|
||||||
|
final NextVaccinationSource source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要:当月花费(恒非 null;无支出 amountCents=0;月边界随 tz 移动)。
|
||||||
|
class SummaryMonthlyExpense {
|
||||||
|
const SummaryMonthlyExpense({
|
||||||
|
required this.month,
|
||||||
|
required this.timezone,
|
||||||
|
required this.amountCents,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SummaryMonthlyExpense.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SummaryMonthlyExpense(
|
||||||
|
month: json['month'] as String,
|
||||||
|
timezone: json['timezone'] as String,
|
||||||
|
amountCents: json['amountCents'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO year-month(如 2026-09)。
|
||||||
|
final String month;
|
||||||
|
final String timezone;
|
||||||
|
final int amountCents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 档案聚合摘要(四项聚合实时计算;前三项无记录时为 null,
|
||||||
|
/// monthlyExpense 恒非 null——契约 PetSummary null 语义)。
|
||||||
|
class PetSummary {
|
||||||
|
const PetSummary({
|
||||||
|
required this.petId,
|
||||||
|
required this.latestWeight,
|
||||||
|
required this.vaccinationProgress,
|
||||||
|
required this.nextVaccination,
|
||||||
|
required this.monthlyExpense,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PetSummary.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PetSummary(
|
||||||
|
petId: json['petId'] as String,
|
||||||
|
latestWeight: json['latestWeight'] == null
|
||||||
|
? null
|
||||||
|
: SummaryLatestWeight.fromJson(
|
||||||
|
json['latestWeight'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
vaccinationProgress: json['vaccinationProgress'] == null
|
||||||
|
? null
|
||||||
|
: SummaryVaccinationProgress.fromJson(
|
||||||
|
json['vaccinationProgress'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
nextVaccination: json['nextVaccination'] == null
|
||||||
|
? null
|
||||||
|
: SummaryNextVaccination.fromJson(
|
||||||
|
json['nextVaccination'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
monthlyExpense: SummaryMonthlyExpense.fromJson(
|
||||||
|
json['monthlyExpense'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String petId;
|
||||||
|
final SummaryLatestWeight? latestWeight;
|
||||||
|
final SummaryVaccinationProgress? vaccinationProgress;
|
||||||
|
final SummaryNextVaccination? nextVaccination;
|
||||||
|
final SummaryMonthlyExpense monthlyExpense;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
/// 网络数据四态(第 9 节硬要求:loading / empty / error / retry,
|
||||||
|
/// data 态含空列表判定,页面据 [PetsLoadPhase] + 数据渲染四态)。
|
||||||
|
enum PetsLoadPhase { initial, loading, ready, error }
|
||||||
|
|
||||||
|
/// pets feature 状态控制器(开发计划 §4.2 分层:
|
||||||
|
/// Page/Widget → PetsController → PetsRepository → ApiClient)。
|
||||||
|
///
|
||||||
|
/// 宠物档案状态自此从 `AppState` 拆出:本 feature 不读写 AppState 的
|
||||||
|
/// 宠物/疫苗 demo 数据;服务端是唯一事实来源,内存副本仅作展示缓存
|
||||||
|
/// (无本地持久化——档案数据以 [refresh] 为失效/刷新策略)。
|
||||||
|
///
|
||||||
|
/// 本单只承载列表 + 详情主链路状态;体重/疫苗/事件/提醒的页面级
|
||||||
|
/// 状态由 T2-12~14 按页面直接经 Repository 取数或扩展本控制器。
|
||||||
|
class PetsController extends ChangeNotifier {
|
||||||
|
PetsController({required this._repository});
|
||||||
|
|
||||||
|
final PetsRepository _repository;
|
||||||
|
|
||||||
|
PetsLoadPhase _phase = PetsLoadPhase.initial;
|
||||||
|
List<Pet> _pets = const [];
|
||||||
|
ApiException? _lastError;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
PetsLoadPhase get phase => _phase;
|
||||||
|
|
||||||
|
/// 当前用户可见宠物(服务端 created_at DESC 排序原样保留)。
|
||||||
|
List<Pet> get pets => _pets;
|
||||||
|
|
||||||
|
/// ready 且列表为空 → 空态(新用户建档引导)。
|
||||||
|
bool get isEmpty => _phase == PetsLoadPhase.ready && _pets.isEmpty;
|
||||||
|
|
||||||
|
/// 最近一次加载失败的类型化错误(error 态时非 null)。
|
||||||
|
ApiException? get lastError => _lastError;
|
||||||
|
|
||||||
|
/// 加载 / 重试宠物列表。错误不外抛,收敛为 error 态供页面渲染 + retry。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_phase = PetsLoadPhase.loading;
|
||||||
|
_lastError = null;
|
||||||
|
_notify();
|
||||||
|
try {
|
||||||
|
_pets = await _repository.listPets();
|
||||||
|
_phase = PetsLoadPhase.ready;
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
_lastError = error;
|
||||||
|
_phase = PetsLoadPhase.error;
|
||||||
|
}
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建宠物:成功后就地插入列表头(服务端 created_at DESC,新建最前),
|
||||||
|
/// 失败按类型化异常外抛给表单层处理(40903 芯片号冲突等)。
|
||||||
|
Future<Pet> createPet(CreatePetRequest request) async {
|
||||||
|
final pet = await _repository.createPet(request);
|
||||||
|
_pets = [pet, ..._pets];
|
||||||
|
if (_phase != PetsLoadPhase.ready) _phase = PetsLoadPhase.ready;
|
||||||
|
_notify();
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取单只宠物详情并同步列表内存副本。
|
||||||
|
Future<Pet> getPet(String petId) async {
|
||||||
|
final pet = await _repository.getPet(petId);
|
||||||
|
_replaceInList(pet);
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新宠物档案:成功后同步列表副本;40902 版本冲突等外抛,
|
||||||
|
/// 页面提示后应调用 [getPet] 刷新取新 version 重提。
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
|
||||||
|
final pet = await _repository.updatePet(petId, request);
|
||||||
|
_replaceInList(pet);
|
||||||
|
return pet;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _replaceInList(Pet pet) {
|
||||||
|
final index = _pets.indexWhere((item) => item.id == pet.id);
|
||||||
|
if (index == -1) return;
|
||||||
|
_pets = [..._pets]..[index] = pet;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// pets 域仓库接口(契约 v1.2.0 的 12 路径 / 18 操作全覆盖;
|
||||||
|
/// 页面依赖此抽象,widget 测试注入假实现)。
|
||||||
|
abstract class PetsRepository {
|
||||||
|
// ---- 宠物 CRUD ----
|
||||||
|
Future<List<Pet>> listPets();
|
||||||
|
Future<Pet> createPet(CreatePetRequest request);
|
||||||
|
Future<Pet> getPet(String petId);
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request);
|
||||||
|
|
||||||
|
// ---- 字典 ----
|
||||||
|
Future<List<Breed>> listBreeds({PetSpecies? species});
|
||||||
|
Future<List<VaccineCatalogItem>> listVaccineCatalog({PetSpecies? species});
|
||||||
|
|
||||||
|
// ---- 体重(cursor 分页)----
|
||||||
|
Future<CursorPage<WeightRecord>> listWeights(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<WeightRecord> createWeight(String petId, CreateWeightRequest request);
|
||||||
|
|
||||||
|
// ---- 疫苗 ----
|
||||||
|
Future<List<Vaccination>> listVaccinations(String petId);
|
||||||
|
Future<Vaccination> createVaccination(
|
||||||
|
String petId,
|
||||||
|
CreateVaccinationRequest request,
|
||||||
|
);
|
||||||
|
Future<Vaccination> updateVaccination(
|
||||||
|
String vaccinationId,
|
||||||
|
UpdateVaccinationRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 健康事件(cursor 分页)----
|
||||||
|
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
});
|
||||||
|
Future<HealthEvent> createHealthEvent(
|
||||||
|
String petId,
|
||||||
|
CreateHealthEventRequest request,
|
||||||
|
);
|
||||||
|
Future<HealthEvent> updateHealthEvent(
|
||||||
|
String eventId,
|
||||||
|
UpdateHealthEventRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 照护提醒 ----
|
||||||
|
Future<List<CareReminder>> listCareReminders(
|
||||||
|
String petId, {
|
||||||
|
CareReminderStatus? status,
|
||||||
|
});
|
||||||
|
Future<CareReminder> createCareReminder(
|
||||||
|
String petId,
|
||||||
|
CreateCareReminderRequest request,
|
||||||
|
);
|
||||||
|
Future<CareReminder> updateCareReminder(
|
||||||
|
String reminderId,
|
||||||
|
UpdateCareReminderRequest request,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 聚合摘要 ----
|
||||||
|
Future<PetSummary> getPetSummary(String petId, {String? tz});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
|
||||||
|
/// 拦截 + 401/40101 单飞刷新重放);pets 域业务错误码升格为类型化异常。
|
||||||
|
///
|
||||||
|
/// 幂等:weights / vaccinations / health-events / care-reminders 四个 POST
|
||||||
|
/// 按契约携带可选 `Idempotency-Key`(每次逻辑提交换新键;token 刷新后的
|
||||||
|
/// 自动重放沿用同一个键,与 auth register 先例一致)。
|
||||||
|
class ApiPetsRepository implements PetsRepository {
|
||||||
|
ApiPetsRepository({required this._api, this._uuid = const Uuid()});
|
||||||
|
|
||||||
|
final ApiClient _api;
|
||||||
|
final Uuid _uuid;
|
||||||
|
|
||||||
|
Future<Object?> _request(
|
||||||
|
String path, {
|
||||||
|
String method = 'GET',
|
||||||
|
Object? body,
|
||||||
|
Map<String, Object?>? query,
|
||||||
|
bool idempotent = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await _api.request(
|
||||||
|
path,
|
||||||
|
method: method,
|
||||||
|
body: body,
|
||||||
|
query: query,
|
||||||
|
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} on ApiBusinessException catch (error) {
|
||||||
|
throw mapPetBusinessException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
|
||||||
|
|
||||||
|
List<T> _asList<T>(Object? data, T Function(Map<String, dynamic>) fromJson) {
|
||||||
|
return (data! as List)
|
||||||
|
.map((item) => fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Pet>> listPets() async {
|
||||||
|
final data = await _request('/api/v1/pets');
|
||||||
|
return _asList(data, Pet.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> createPet(CreatePetRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Pet.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> getPet(String petId) async {
|
||||||
|
final data = await _request('/api/v1/pets/$petId');
|
||||||
|
return Pet.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Pet.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Breed>> listBreeds({PetSpecies? species}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/breeds',
|
||||||
|
query: {if (species != null) 'species': species.name},
|
||||||
|
);
|
||||||
|
return _asList(data, Breed.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<VaccineCatalogItem>> listVaccineCatalog({
|
||||||
|
PetSpecies? species,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/vaccine-catalog',
|
||||||
|
query: {if (species != null) 'species': species.name},
|
||||||
|
);
|
||||||
|
return _asList(data, VaccineCatalogItem.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<WeightRecord>> listWeights(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/weights',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), WeightRecord.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WeightRecord> createWeight(
|
||||||
|
String petId,
|
||||||
|
CreateWeightRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/weights',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return WeightRecord.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Vaccination>> listVaccinations(String petId) async {
|
||||||
|
final data = await _request('/api/v1/pets/$petId/vaccinations');
|
||||||
|
return _asList(data, Vaccination.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> createVaccination(
|
||||||
|
String petId,
|
||||||
|
CreateVaccinationRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/vaccinations',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return Vaccination.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Vaccination> updateVaccination(
|
||||||
|
String vaccinationId,
|
||||||
|
UpdateVaccinationRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/vaccinations/$vaccinationId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return Vaccination.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPage<HealthEvent>> listHealthEvents(
|
||||||
|
String petId, {
|
||||||
|
int? limit,
|
||||||
|
String? cursor,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/health-events',
|
||||||
|
query: {'limit': ?limit, 'cursor': ?cursor},
|
||||||
|
);
|
||||||
|
return CursorPage.fromJson(_asMap(data), HealthEvent.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> createHealthEvent(
|
||||||
|
String petId,
|
||||||
|
CreateHealthEventRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/health-events',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return HealthEvent.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HealthEvent> updateHealthEvent(
|
||||||
|
String eventId,
|
||||||
|
UpdateHealthEventRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/health-events/$eventId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return HealthEvent.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<CareReminder>> listCareReminders(
|
||||||
|
String petId, {
|
||||||
|
CareReminderStatus? status,
|
||||||
|
}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/care-reminders',
|
||||||
|
query: {if (status != null) 'status': status.name},
|
||||||
|
);
|
||||||
|
return _asList(data, CareReminder.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> createCareReminder(
|
||||||
|
String petId,
|
||||||
|
CreateCareReminderRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/care-reminders',
|
||||||
|
method: 'POST',
|
||||||
|
body: request.toJson(),
|
||||||
|
idempotent: true,
|
||||||
|
);
|
||||||
|
return CareReminder.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CareReminder> updateCareReminder(
|
||||||
|
String reminderId,
|
||||||
|
UpdateCareReminderRequest request,
|
||||||
|
) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/care-reminders/$reminderId',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: request.toJson(),
|
||||||
|
);
|
||||||
|
return CareReminder.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PetSummary> getPetSummary(String petId, {String? tz}) async {
|
||||||
|
final data = await _request(
|
||||||
|
'/api/v1/pets/$petId/summary',
|
||||||
|
query: {'tz': ?tz},
|
||||||
|
);
|
||||||
|
return PetSummary.fromJson(_asMap(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/money.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('formatCentsAsYuan', () {
|
||||||
|
test('整元不带小数', () {
|
||||||
|
expect(formatCentsAsYuan(0), '0');
|
||||||
|
expect(formatCentsAsYuan(100), '1');
|
||||||
|
expect(formatCentsAsYuan(12800), '128');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非整元固定两位小数', () {
|
||||||
|
expect(formatCentsAsYuan(12850), '128.50');
|
||||||
|
expect(formatCentsAsYuan(12805), '128.05');
|
||||||
|
expect(formatCentsAsYuan(5), '0.05');
|
||||||
|
expect(formatCentsAsYuan(50), '0.50');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('负数入参抛 ArgumentError(契约金额非负)', () {
|
||||||
|
expect(() => formatCentsAsYuan(-1), throwsArgumentError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('parseYuanToCents', () {
|
||||||
|
test('整数与一/两位小数', () {
|
||||||
|
expect(parseYuanToCents('128'), 12800);
|
||||||
|
expect(parseYuanToCents('128.5'), 12850);
|
||||||
|
expect(parseYuanToCents('128.50'), 12850);
|
||||||
|
expect(parseYuanToCents('0.05'), 5);
|
||||||
|
expect(parseYuanToCents('0'), 0);
|
||||||
|
expect(parseYuanToCents(' 12.30 '), 1230);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法输入返回 null', () {
|
||||||
|
expect(parseYuanToCents(''), isNull);
|
||||||
|
expect(parseYuanToCents('abc'), isNull);
|
||||||
|
expect(parseYuanToCents('12.345'), isNull);
|
||||||
|
expect(parseYuanToCents('-5'), isNull);
|
||||||
|
expect(parseYuanToCents('12.'), isNull);
|
||||||
|
expect(parseYuanToCents('.5'), isNull);
|
||||||
|
expect(parseYuanToCents('1,200'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('往返一致:format(parse(x)) 保持数值', () {
|
||||||
|
for (final input in ['128', '128.50', '0.05', '99.90']) {
|
||||||
|
final cents = parseYuanToCents(input)!;
|
||||||
|
expect(parseYuanToCents(formatCentsAsYuan(cents)), cents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
|
||||||
|
/// 契约样本(openapi.yaml v1.2.0 各 schema 的全字段实例)。
|
||||||
|
Map<String, dynamic> petJson({Map<String, Object?> overrides = const {}}) => {
|
||||||
|
'id': '019212aa-0000-7000-8000-00000000p001',
|
||||||
|
'name': '豆豆',
|
||||||
|
'species': 'dog',
|
||||||
|
'breedId': '019212aa-0000-7000-8000-00000000b001',
|
||||||
|
'breedDisplayName': '柴犬',
|
||||||
|
'customBreedName': null,
|
||||||
|
'sex': 'male',
|
||||||
|
'birthDate': '2024-03-15',
|
||||||
|
'birthDateEstimated': true,
|
||||||
|
'personality': '粘人',
|
||||||
|
'microchipNo': '900123456789012',
|
||||||
|
'sterilizedOn': '2025-06-01',
|
||||||
|
'status': 'active',
|
||||||
|
'myRole': 'owner',
|
||||||
|
'createdAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'updatedAt': '2026-09-02T10:00:00+08:00',
|
||||||
|
'version': 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Pet', () {
|
||||||
|
test('全字段映射', () {
|
||||||
|
final pet = Pet.fromJson(petJson());
|
||||||
|
|
||||||
|
expect(pet.id, '019212aa-0000-7000-8000-00000000p001');
|
||||||
|
expect(pet.name, '豆豆');
|
||||||
|
expect(pet.species, PetSpecies.dog);
|
||||||
|
expect(pet.breedId, '019212aa-0000-7000-8000-00000000b001');
|
||||||
|
expect(pet.breedDisplayName, '柴犬');
|
||||||
|
expect(pet.customBreedName, isNull);
|
||||||
|
expect(pet.sex, PetSex.male);
|
||||||
|
expect(pet.birthDate, DateTime.parse('2024-03-15'));
|
||||||
|
expect(pet.birthDateEstimated, isTrue);
|
||||||
|
expect(pet.personality, '粘人');
|
||||||
|
expect(pet.microchipNo, '900123456789012');
|
||||||
|
expect(pet.sterilizedOn, DateTime.parse('2025-06-01'));
|
||||||
|
expect(pet.status, PetStatus.active);
|
||||||
|
expect(pet.myRole, PetRole.owner);
|
||||||
|
expect(pet.version, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('可空字段缺席 / null 均映射为 null(自定义品种侧)', () {
|
||||||
|
final pet = Pet.fromJson(
|
||||||
|
petJson(
|
||||||
|
overrides: {
|
||||||
|
'breedId': null,
|
||||||
|
'breedDisplayName': null,
|
||||||
|
'customBreedName': '串串',
|
||||||
|
'birthDate': null,
|
||||||
|
'personality': null,
|
||||||
|
'microchipNo': null,
|
||||||
|
'sterilizedOn': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pet.breedId, isNull);
|
||||||
|
expect(pet.breedDisplayName, isNull);
|
||||||
|
expect(pet.customBreedName, '串串');
|
||||||
|
expect(pet.birthDate, isNull);
|
||||||
|
expect(pet.personality, isNull);
|
||||||
|
expect(pet.microchipNo, isNull);
|
||||||
|
expect(pet.sterilizedOn, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未知枚举取值抛 FormatException(契约漂移显式暴露)', () {
|
||||||
|
expect(
|
||||||
|
() => Pet.fromJson(petJson(overrides: {'status': 'deleted'})),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => Pet.fromJson(petJson(overrides: {'myRole': 'admin'})),
|
||||||
|
throwsFormatException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CreatePetRequest / UpdatePetRequest', () {
|
||||||
|
test('创建请求:必填 + 可选字段序列化,日期为 YYYY-MM-DD', () {
|
||||||
|
final json = CreatePetRequest(
|
||||||
|
name: '豆豆',
|
||||||
|
species: PetSpecies.dog,
|
||||||
|
sex: PetSex.male,
|
||||||
|
breedId: 'b-1',
|
||||||
|
birthDate: DateTime(2024, 3, 5),
|
||||||
|
birthDateEstimated: false,
|
||||||
|
microchipNo: '900123456789012',
|
||||||
|
sterilizedOn: DateTime(2025, 6, 1),
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(json, {
|
||||||
|
'name': '豆豆',
|
||||||
|
'species': 'dog',
|
||||||
|
'sex': 'male',
|
||||||
|
'breedId': 'b-1',
|
||||||
|
'birthDate': '2024-03-05',
|
||||||
|
'birthDateEstimated': false,
|
||||||
|
'microchipNo': '900123456789012',
|
||||||
|
'sterilizedOn': '2025-06-01',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建请求:可选字段缺席不出现在请求体(部分语义)', () {
|
||||||
|
final json = const CreatePetRequest(
|
||||||
|
name: '咪咪',
|
||||||
|
species: PetSpecies.cat,
|
||||||
|
sex: PetSex.female,
|
||||||
|
customBreedName: '狸花',
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(json, {
|
||||||
|
'name': '咪咪',
|
||||||
|
'species': 'cat',
|
||||||
|
'sex': 'female',
|
||||||
|
'customBreedName': '狸花',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更新请求:version 必带,缺席字段不出现(不支持清空回 null)', () {
|
||||||
|
final json = const UpdatePetRequest(
|
||||||
|
version: 3,
|
||||||
|
name: '豆豆二世',
|
||||||
|
status: PetStatus.lost,
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(json, {'version': 3, 'name': '豆豆二世', 'status': 'lost'});
|
||||||
|
expect(json.containsKey('species'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Breed / VaccineCatalogItem', () {
|
||||||
|
test('品种映射', () {
|
||||||
|
final breed = Breed.fromJson({
|
||||||
|
'id': 'b-1',
|
||||||
|
'species': 'cat',
|
||||||
|
'code': 'ragdoll',
|
||||||
|
'displayName': '布偶猫',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breed.id, 'b-1');
|
||||||
|
expect(breed.species, PetSpecies.cat);
|
||||||
|
expect(breed.code, 'ragdoll');
|
||||||
|
expect(breed.displayName, '布偶猫');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('疫苗目录映射(description 可空)', () {
|
||||||
|
final item = VaccineCatalogItem.fromJson({
|
||||||
|
'id': 'v-1',
|
||||||
|
'code': 'rabies',
|
||||||
|
'name': '狂犬疫苗',
|
||||||
|
'species': 'dog',
|
||||||
|
'description': null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item.name, '狂犬疫苗');
|
||||||
|
expect(item.description, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('WeightRecord', () {
|
||||||
|
test('全字段映射;weightKg 接受 int 与 double 下发', () {
|
||||||
|
final record = WeightRecord.fromJson({
|
||||||
|
'id': 'w-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'weightKg': 12,
|
||||||
|
'measuredAt': '2026-09-07T09:00:00+08:00',
|
||||||
|
'source': 'clinic',
|
||||||
|
'note': '年检称重',
|
||||||
|
'createdAt': '2026-09-07T09:01:00+08:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(record.weightKg, 12.0);
|
||||||
|
expect(record.source, WeightSource.clinic);
|
||||||
|
expect(record.note, '年检称重');
|
||||||
|
|
||||||
|
final decimal = WeightRecord.fromJson({
|
||||||
|
'id': 'w-2',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'weightKg': 4.35,
|
||||||
|
'measuredAt': '2026-09-07T09:00:00Z',
|
||||||
|
'source': 'manual',
|
||||||
|
'note': null,
|
||||||
|
'createdAt': '2026-09-07T09:01:00Z',
|
||||||
|
});
|
||||||
|
expect(decimal.weightKg, 4.35);
|
||||||
|
expect(decimal.note, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建请求序列化(source/note 可选缺席)', () {
|
||||||
|
final json = CreateWeightRequest(
|
||||||
|
weightKg: 4.35,
|
||||||
|
measuredAt: DateTime.utc(2026, 9, 7, 1, 0),
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(json['weightKg'], 4.35);
|
||||||
|
expect(json['measuredAt'], '2026-09-07T01:00:00.000Z');
|
||||||
|
expect(json.containsKey('source'), isFalse);
|
||||||
|
expect(json.containsKey('note'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Vaccination', () {
|
||||||
|
Map<String, dynamic> vaccinationJson({
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) => {
|
||||||
|
'id': 'vx-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'vaccineId': 'v-1',
|
||||||
|
'vaccineName': '狂犬疫苗',
|
||||||
|
'seriesKey': 'rabies-initial',
|
||||||
|
'doseNo': 2,
|
||||||
|
'doseLabel': '第二针',
|
||||||
|
'status': 'completed',
|
||||||
|
'plannedOn': '2026-08-01',
|
||||||
|
'administeredOn': '2026-08-03',
|
||||||
|
'nextDueOn': '2027-08-03',
|
||||||
|
'manufacturer': '硕腾',
|
||||||
|
'batchNo': 'B20260801',
|
||||||
|
'notes': '无不良反应',
|
||||||
|
'createdAt': '2026-08-03T10:00:00+08:00',
|
||||||
|
'updatedAt': '2026-08-03T10:00:00+08:00',
|
||||||
|
'version': 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('全字段映射', () {
|
||||||
|
final record = Vaccination.fromJson(vaccinationJson());
|
||||||
|
|
||||||
|
expect(record.vaccineName, '狂犬疫苗');
|
||||||
|
expect(record.seriesKey, 'rabies-initial');
|
||||||
|
expect(record.doseNo, 2);
|
||||||
|
expect(record.status, VaccinationStatus.completed);
|
||||||
|
expect(record.administeredOn, DateTime.parse('2026-08-03'));
|
||||||
|
expect(record.nextDueOn, DateTime.parse('2027-08-03'));
|
||||||
|
expect(record.version, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled 态:日期可空字段为 null', () {
|
||||||
|
final record = Vaccination.fromJson(
|
||||||
|
vaccinationJson(
|
||||||
|
overrides: {
|
||||||
|
'status': 'scheduled',
|
||||||
|
'administeredOn': null,
|
||||||
|
'nextDueOn': null,
|
||||||
|
'doseLabel': null,
|
||||||
|
'manufacturer': null,
|
||||||
|
'batchNo': null,
|
||||||
|
'notes': null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(record.status, VaccinationStatus.scheduled);
|
||||||
|
expect(record.administeredOn, isNull);
|
||||||
|
expect(record.nextDueOn, isNull);
|
||||||
|
expect(record.doseLabel, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建请求:completed 携带 administeredOn,日期 YYYY-MM-DD', () {
|
||||||
|
final json = CreateVaccinationRequest(
|
||||||
|
vaccineId: 'v-1',
|
||||||
|
seriesKey: 'rabies-initial',
|
||||||
|
doseNo: 1,
|
||||||
|
status: VaccinationStatus.completed,
|
||||||
|
administeredOn: DateTime(2026, 8, 3),
|
||||||
|
nextDueOn: DateTime(2027, 8, 3),
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(json, {
|
||||||
|
'vaccineId': 'v-1',
|
||||||
|
'seriesKey': 'rabies-initial',
|
||||||
|
'doseNo': 1,
|
||||||
|
'status': 'completed',
|
||||||
|
'administeredOn': '2026-08-03',
|
||||||
|
'nextDueOn': '2027-08-03',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('更新请求:version 必带,仅提交的字段出现', () {
|
||||||
|
final json = const UpdateVaccinationRequest(
|
||||||
|
version: 1,
|
||||||
|
status: VaccinationStatus.cancelled,
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(json, {'version': 1, 'status': 'cancelled'});
|
||||||
|
expect(json.containsKey('vaccineId'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('HealthEvent', () {
|
||||||
|
test('全字段映射;amountCents 保持整数分不换算', () {
|
||||||
|
final event = HealthEvent.fromJson({
|
||||||
|
'id': 'e-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'eventType': 'medical',
|
||||||
|
'occurredAt': '2026-09-05T14:00:00+08:00',
|
||||||
|
'title': '皮肤检查',
|
||||||
|
'notes': '轻微湿疹',
|
||||||
|
'amountCents': 12850,
|
||||||
|
'createdByUserId': 'u-1',
|
||||||
|
'createdAt': '2026-09-05T14:05:00+08:00',
|
||||||
|
'updatedAt': '2026-09-05T14:05:00+08:00',
|
||||||
|
'version': 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.eventType, HealthEventType.medical);
|
||||||
|
expect(event.title, '皮肤检查');
|
||||||
|
expect(event.amountCents, 12850);
|
||||||
|
expect(event.createdByUserId, 'u-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('amountCents / notes 可空', () {
|
||||||
|
final event = HealthEvent.fromJson({
|
||||||
|
'id': 'e-2',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'eventType': 'note',
|
||||||
|
'occurredAt': '2026-09-05T14:00:00Z',
|
||||||
|
'title': '记录',
|
||||||
|
'notes': null,
|
||||||
|
'amountCents': null,
|
||||||
|
'createdByUserId': 'u-1',
|
||||||
|
'createdAt': '2026-09-05T14:05:00Z',
|
||||||
|
'updatedAt': '2026-09-05T14:05:00Z',
|
||||||
|
'version': 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event.amountCents, isNull);
|
||||||
|
expect(event.notes, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建 / 更新请求序列化(amountCents 整数分)', () {
|
||||||
|
final create = CreateHealthEventRequest(
|
||||||
|
eventType: HealthEventType.deworming,
|
||||||
|
occurredAt: DateTime.utc(2026, 9, 5, 6, 0),
|
||||||
|
title: '体内驱虫',
|
||||||
|
amountCents: 6800,
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(create, {
|
||||||
|
'eventType': 'deworming',
|
||||||
|
'occurredAt': '2026-09-05T06:00:00.000Z',
|
||||||
|
'title': '体内驱虫',
|
||||||
|
'amountCents': 6800,
|
||||||
|
});
|
||||||
|
|
||||||
|
final update = const UpdateHealthEventRequest(
|
||||||
|
version: 2,
|
||||||
|
title: '体内外驱虫',
|
||||||
|
amountCents: 9800,
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(update, {'version': 2, 'title': '体内外驱虫', 'amountCents': 9800});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CareReminder', () {
|
||||||
|
test('全字段映射;completedAt 非空当且仅当 completed', () {
|
||||||
|
final reminder = CareReminder.fromJson({
|
||||||
|
'id': 'r-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'reminderType': 'deworming',
|
||||||
|
'title': '体外驱虫',
|
||||||
|
'dueAt': '2026-09-15T00:00:00+08:00',
|
||||||
|
'status': 'completed',
|
||||||
|
'completedAt': '2026-09-14T20:00:00+08:00',
|
||||||
|
'createdAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'updatedAt': '2026-09-14T20:00:00+08:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reminder.reminderType, CareReminderType.deworming);
|
||||||
|
expect(reminder.status, CareReminderStatus.completed);
|
||||||
|
expect(reminder.completedAt, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending 态 completedAt 为 null', () {
|
||||||
|
final reminder = CareReminder.fromJson({
|
||||||
|
'id': 'r-2',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'reminderType': 'checkup',
|
||||||
|
'title': '年度体检',
|
||||||
|
'dueAt': '2026-10-01T00:00:00Z',
|
||||||
|
'status': 'pending',
|
||||||
|
'completedAt': null,
|
||||||
|
'createdAt': '2026-09-01T10:00:00Z',
|
||||||
|
'updatedAt': '2026-09-01T10:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reminder.status, CareReminderStatus.pending);
|
||||||
|
expect(reminder.completedAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创建请求恒不含 status;流转请求 dismissed 不带 completedAt', () {
|
||||||
|
final create = CreateCareReminderRequest(
|
||||||
|
reminderType: CareReminderType.medication,
|
||||||
|
title: '心丝虫药',
|
||||||
|
dueAt: DateTime.utc(2026, 10, 1),
|
||||||
|
).toJson();
|
||||||
|
|
||||||
|
expect(create, {
|
||||||
|
'reminderType': 'medication',
|
||||||
|
'title': '心丝虫药',
|
||||||
|
'dueAt': '2026-10-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(create.containsKey('status'), isFalse);
|
||||||
|
|
||||||
|
final complete = UpdateCareReminderRequest(
|
||||||
|
status: CareReminderStatus.completed,
|
||||||
|
completedAt: DateTime.utc(2026, 9, 14, 12),
|
||||||
|
).toJson();
|
||||||
|
expect(complete, {
|
||||||
|
'status': 'completed',
|
||||||
|
'completedAt': '2026-09-14T12:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
final dismiss = const UpdateCareReminderRequest(
|
||||||
|
status: CareReminderStatus.dismissed,
|
||||||
|
).toJson();
|
||||||
|
expect(dismiss, {'status': 'dismissed'});
|
||||||
|
expect(dismiss.containsKey('completedAt'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('CursorPage', () {
|
||||||
|
test('分页信封 {items, nextCursor, hasMore} 映射', () {
|
||||||
|
final page = CursorPage.fromJson({
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'id': 'w-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'weightKg': 4.2,
|
||||||
|
'measuredAt': '2026-09-07T09:00:00Z',
|
||||||
|
'source': 'manual',
|
||||||
|
'note': null,
|
||||||
|
'createdAt': '2026-09-07T09:01:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'nextCursor': 'b64cursor',
|
||||||
|
'hasMore': true,
|
||||||
|
}, WeightRecord.fromJson);
|
||||||
|
|
||||||
|
expect(page.items.single.weightKg, 4.2);
|
||||||
|
expect(page.nextCursor, 'b64cursor');
|
||||||
|
expect(page.hasMore, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('末页:hasMore=false 且 nextCursor 为 null(缺席同义)', () {
|
||||||
|
final page = CursorPage.fromJson({
|
||||||
|
'items': <Object?>[],
|
||||||
|
'hasMore': false,
|
||||||
|
}, WeightRecord.fromJson);
|
||||||
|
|
||||||
|
expect(page.items, isEmpty);
|
||||||
|
expect(page.nextCursor, isNull);
|
||||||
|
expect(page.hasMore, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PetSummary', () {
|
||||||
|
test('四聚合全量映射', () {
|
||||||
|
final summary = PetSummary.fromJson({
|
||||||
|
'petId': 'p-1',
|
||||||
|
'latestWeight': {
|
||||||
|
'weightKg': 4.35,
|
||||||
|
'measuredAt': '2026-09-07T09:00:00+08:00',
|
||||||
|
},
|
||||||
|
'vaccinationProgress': {'completedDoses': 2, 'totalDoses': 3},
|
||||||
|
'nextVaccination': {
|
||||||
|
'vaccinationId': 'vx-3',
|
||||||
|
'vaccineId': 'v-1',
|
||||||
|
'vaccineName': '狂犬疫苗',
|
||||||
|
'doseNo': 3,
|
||||||
|
'doseLabel': null,
|
||||||
|
'dueOn': '2026-08-01',
|
||||||
|
'source': 'planned',
|
||||||
|
},
|
||||||
|
'monthlyExpense': {
|
||||||
|
'month': '2026-09',
|
||||||
|
'timezone': 'Asia/Shanghai',
|
||||||
|
'amountCents': 12850,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.petId, 'p-1');
|
||||||
|
expect(summary.latestWeight!.weightKg, 4.35);
|
||||||
|
expect(summary.vaccinationProgress!.completedDoses, 2);
|
||||||
|
expect(summary.vaccinationProgress!.totalDoses, 3);
|
||||||
|
expect(summary.nextVaccination!.vaccinationId, 'vx-3');
|
||||||
|
expect(summary.nextVaccination!.doseLabel, isNull);
|
||||||
|
// dueOn 可为过去日期(逾期针仍是下一针)
|
||||||
|
expect(summary.nextVaccination!.dueOn, DateTime.parse('2026-08-01'));
|
||||||
|
expect(summary.nextVaccination!.source, NextVaccinationSource.planned);
|
||||||
|
expect(summary.monthlyExpense.month, '2026-09');
|
||||||
|
expect(summary.monthlyExpense.timezone, 'Asia/Shanghai');
|
||||||
|
expect(summary.monthlyExpense.amountCents, 12850);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null 语义:前三项无记录为 null,monthlyExpense 恒非 null、无支出为 0', () {
|
||||||
|
final summary = PetSummary.fromJson({
|
||||||
|
'petId': 'p-1',
|
||||||
|
'latestWeight': null,
|
||||||
|
'vaccinationProgress': null,
|
||||||
|
'nextVaccination': null,
|
||||||
|
'monthlyExpense': {
|
||||||
|
'month': '2026-09',
|
||||||
|
'timezone': 'UTC',
|
||||||
|
'amountCents': 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary.latestWeight, isNull);
|
||||||
|
expect(summary.vaccinationProgress, isNull);
|
||||||
|
expect(summary.nextVaccination, isNull);
|
||||||
|
expect(summary.monthlyExpense.amountCents, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nextDue 来源枚举', () {
|
||||||
|
final next = SummaryNextVaccination.fromJson({
|
||||||
|
'vaccinationId': 'vx-1',
|
||||||
|
'vaccineId': 'v-1',
|
||||||
|
'vaccineName': '猫三联',
|
||||||
|
'doseNo': 1,
|
||||||
|
'doseLabel': '首针',
|
||||||
|
'dueOn': '2027-08-03',
|
||||||
|
'source': 'nextDue',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.source, NextVaccinationSource.nextDue);
|
||||||
|
expect(next.doseLabel, '首针');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_controller.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
/// 假仓库:各方法可注入行为,未注入的方法抛 UnimplementedError。
|
||||||
|
class FakePetsRepository implements PetsRepository {
|
||||||
|
Future<List<Pet>> Function()? listPetsHandler;
|
||||||
|
Future<Pet> Function(CreatePetRequest)? createPetHandler;
|
||||||
|
Future<Pet> Function(String)? getPetHandler;
|
||||||
|
Future<Pet> Function(String, UpdatePetRequest)? updatePetHandler;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Pet>> listPets() =>
|
||||||
|
listPetsHandler?.call() ?? Future.value(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> createPet(CreatePetRequest request) => createPetHandler!(request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> getPet(String petId) => getPetHandler!(petId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Pet> updatePet(String petId, UpdatePetRequest request) =>
|
||||||
|
updatePetHandler!(petId, request);
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) =>
|
||||||
|
throw UnimplementedError('${invocation.memberName}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Pet pet(String id, {String name = '豆豆', int version = 1}) => Pet.fromJson(
|
||||||
|
samplePetJson(overrides: {'id': id, 'name': name, 'version': version}),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakePetsRepository repository;
|
||||||
|
late PetsController controller;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
repository = FakePetsRepository();
|
||||||
|
controller = PetsController(repository: repository);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('初始态为 initial,不发请求', () {
|
||||||
|
expect(controller.phase, PetsLoadPhase.initial);
|
||||||
|
expect(controller.pets, isEmpty);
|
||||||
|
expect(controller.isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh 成功:loading → ready,列表就位', () async {
|
||||||
|
repository.listPetsHandler = () async => [pet('p-1'), pet('p-2')];
|
||||||
|
final phases = <PetsLoadPhase>[];
|
||||||
|
controller.addListener(() => phases.add(controller.phase));
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
expect(phases, [PetsLoadPhase.loading, PetsLoadPhase.ready]);
|
||||||
|
expect(controller.pets.map((p) => p.id), ['p-1', 'p-2']);
|
||||||
|
expect(controller.isEmpty, isFalse);
|
||||||
|
expect(controller.lastError, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh 空列表:ready 且 isEmpty(新用户空态)', () async {
|
||||||
|
repository.listPetsHandler = () async => [];
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
expect(controller.phase, PetsLoadPhase.ready);
|
||||||
|
expect(controller.isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refresh 失败:error 态保留类型化错误,重试可恢复', () async {
|
||||||
|
repository.listPetsHandler = () async =>
|
||||||
|
throw const ApiNetworkException('断网');
|
||||||
|
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
expect(controller.phase, PetsLoadPhase.error);
|
||||||
|
expect(controller.lastError, isA<ApiNetworkException>());
|
||||||
|
|
||||||
|
repository.listPetsHandler = () async => [pet('p-1')];
|
||||||
|
await controller.refresh();
|
||||||
|
|
||||||
|
expect(controller.phase, PetsLoadPhase.ready);
|
||||||
|
expect(controller.lastError, isNull);
|
||||||
|
expect(controller.pets, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createPet:成功后插入列表头', () async {
|
||||||
|
repository.listPetsHandler = () async => [pet('p-1')];
|
||||||
|
await controller.refresh();
|
||||||
|
repository.createPetHandler = (request) async => pet('p-2', name: '咪咪');
|
||||||
|
|
||||||
|
final created = await controller.createPet(
|
||||||
|
const CreatePetRequest(
|
||||||
|
name: '咪咪',
|
||||||
|
species: PetSpecies.cat,
|
||||||
|
sex: PetSex.female,
|
||||||
|
customBreedName: '狸花',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(created.id, 'p-2');
|
||||||
|
expect(controller.pets.map((p) => p.id), ['p-2', 'p-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createPet 失败(40903):类型化异常外抛,列表不变', () async {
|
||||||
|
repository.listPetsHandler = () async => [pet('p-1')];
|
||||||
|
await controller.refresh();
|
||||||
|
repository.createPetHandler = (request) async =>
|
||||||
|
throw const MicrochipTakenException(message: '芯片号已被登记');
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.createPet(
|
||||||
|
const CreatePetRequest(
|
||||||
|
name: '咪咪',
|
||||||
|
species: PetSpecies.cat,
|
||||||
|
sex: PetSex.female,
|
||||||
|
customBreedName: '狸花',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
throwsA(isA<MicrochipTakenException>()),
|
||||||
|
);
|
||||||
|
expect(controller.pets, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePet:成功后同步列表内存副本', () async {
|
||||||
|
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
|
||||||
|
await controller.refresh();
|
||||||
|
repository.updatePetHandler = (petId, request) async =>
|
||||||
|
pet('p-1', name: '豆豆二世', version: 2);
|
||||||
|
|
||||||
|
await controller.updatePet(
|
||||||
|
'p-1',
|
||||||
|
const UpdatePetRequest(version: 1, name: '豆豆二世'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(controller.pets.single.name, '豆豆二世');
|
||||||
|
expect(controller.pets.single.version, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updatePet 版本冲突(40902):异常外抛供页面提示刷新重提', () async {
|
||||||
|
repository.updatePetHandler = (petId, request) async =>
|
||||||
|
throw const PetVersionConflictException(message: '数据已被修改');
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
controller.updatePet('p-1', const UpdatePetRequest(version: 1)),
|
||||||
|
throwsA(isA<PetVersionConflictException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPet:详情结果回写列表副本', () async {
|
||||||
|
repository.listPetsHandler = () async => [pet('p-1', version: 1)];
|
||||||
|
await controller.refresh();
|
||||||
|
repository.getPetHandler = (petId) async => pet('p-1', version: 5);
|
||||||
|
|
||||||
|
final detail = await controller.getPet('p-1');
|
||||||
|
|
||||||
|
expect(detail.version, 5);
|
||||||
|
expect(controller.pets.single.version, 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_client.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
||||||
|
import 'package:patbond_flutter/core/network/token_refresher.dart';
|
||||||
|
import 'package:patbond_flutter/features/auth/session_manager.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pet_models.dart';
|
||||||
|
import 'package:patbond_flutter/features/pets/pets_repository.dart';
|
||||||
|
|
||||||
|
import '../../helpers/auth_test_helpers.dart';
|
||||||
|
import '../../helpers/pet_test_helpers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late SessionManager session;
|
||||||
|
late FakeHttpAdapter adapter;
|
||||||
|
late ApiPetsRepository repository;
|
||||||
|
|
||||||
|
Future<void> setUpWith(
|
||||||
|
Future<ResponseBody> Function(RequestOptions) handler,
|
||||||
|
) async {
|
||||||
|
session = SessionManager(store: InMemoryTokenStore());
|
||||||
|
await session.updateTokens(sampleTokens(access: 'pet-access'));
|
||||||
|
final dio = buildPatbondDio(session: session, baseUrl: 'http://pet.local');
|
||||||
|
adapter = FakeHttpAdapter(handler);
|
||||||
|
dio.httpClientAdapter = adapter;
|
||||||
|
final refresher = TokenRefresher(dio: dio, session: session);
|
||||||
|
repository = ApiPetsRepository(
|
||||||
|
api: ApiClient(dio: dio, session: session, refresher: refresher),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('请求线路(路径 / 方法 / 鉴权 / 参数)', () {
|
||||||
|
test('listPets:GET /api/v1/pets,携带 Bearer', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(200, okListEnvelope([samplePetJson()])),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pets = await repository.listPets();
|
||||||
|
|
||||||
|
final request = adapter.requests.single;
|
||||||
|
expect(request.path, '/api/v1/pets');
|
||||||
|
expect(request.method, 'GET');
|
||||||
|
expect(request.headers['Authorization'], 'Bearer pet-access');
|
||||||
|
expect(pets.single.name, '豆豆');
|
||||||
|
expect(pets.single.myRole, PetRole.owner);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createPet:POST 201,不带 Idempotency-Key(契约:唯一约束兜底)', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(201, okEnvelope(samplePetJson())),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pet = await repository.createPet(
|
||||||
|
const CreatePetRequest(
|
||||||
|
name: '豆豆',
|
||||||
|
species: PetSpecies.dog,
|
||||||
|
sex: PetSex.male,
|
||||||
|
breedId: 'b-1',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final request = adapter.requests.single;
|
||||||
|
expect(request.method, 'POST');
|
||||||
|
expect(request.data, {
|
||||||
|
'name': '豆豆',
|
||||||
|
'species': 'dog',
|
||||||
|
'sex': 'male',
|
||||||
|
'breedId': 'b-1',
|
||||||
|
});
|
||||||
|
expect(request.headers.containsKey('Idempotency-Key'), isFalse);
|
||||||
|
expect(pet.id, isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPet / updatePet:路径带 petId,PATCH 提交 version', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(200, okEnvelope(samplePetJson())),
|
||||||
|
);
|
||||||
|
|
||||||
|
await repository.getPet('p-1');
|
||||||
|
await repository.updatePet(
|
||||||
|
'p-1',
|
||||||
|
const UpdatePetRequest(version: 3, name: '豆豆二世'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/pets/p-1');
|
||||||
|
expect(adapter.requests[0].method, 'GET');
|
||||||
|
expect(adapter.requests[1].method, 'PATCH');
|
||||||
|
expect(adapter.requests[1].data, {'version': 3, 'name': '豆豆二世'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字典:species 过滤参数按枚举名传递,缺省不传', () async {
|
||||||
|
await setUpWith((options) async => jsonResponse(200, okListEnvelope([])));
|
||||||
|
|
||||||
|
await repository.listBreeds(species: PetSpecies.cat);
|
||||||
|
await repository.listBreeds();
|
||||||
|
await repository.listVaccineCatalog(species: PetSpecies.dog);
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/breeds');
|
||||||
|
expect(adapter.requests[0].queryParameters, {'species': 'cat'});
|
||||||
|
expect(adapter.requests[1].queryParameters, isEmpty);
|
||||||
|
expect(adapter.requests[2].path, '/api/v1/vaccine-catalog');
|
||||||
|
expect(adapter.requests[2].queryParameters, {'species': 'dog'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listWeights:cursor 分页参数与信封解析', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({
|
||||||
|
'items': [sampleWeightJson()],
|
||||||
|
'nextCursor': 'c2',
|
||||||
|
'hasMore': true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await repository.listWeights('p-1', limit: 50, cursor: 'c1');
|
||||||
|
|
||||||
|
final request = adapter.requests.single;
|
||||||
|
expect(request.path, '/api/v1/pets/p-1/weights');
|
||||||
|
expect(request.queryParameters, {'limit': 50, 'cursor': 'c1'});
|
||||||
|
expect(page.items.single.weightKg, 4.35);
|
||||||
|
expect(page.nextCursor, 'c2');
|
||||||
|
expect(page.hasMore, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'四个 POST 携带 Idempotency-Key(weights/vaccinations/health-events/care-reminders)',
|
||||||
|
() async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(201, okEnvelope(sampleWeightJson())),
|
||||||
|
2 => jsonResponse(201, okEnvelope(sampleVaccinationJson())),
|
||||||
|
3 => jsonResponse(201, okEnvelope(sampleHealthEventJson())),
|
||||||
|
_ => jsonResponse(201, okEnvelope(sampleCareReminderJson())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.createWeight(
|
||||||
|
'p-1',
|
||||||
|
CreateWeightRequest(
|
||||||
|
weightKg: 4.35,
|
||||||
|
measuredAt: DateTime.utc(2026, 9, 7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repository.createVaccination(
|
||||||
|
'p-1',
|
||||||
|
const CreateVaccinationRequest(
|
||||||
|
vaccineId: 'v-1',
|
||||||
|
seriesKey: 'rabies-initial',
|
||||||
|
doseNo: 1,
|
||||||
|
status: VaccinationStatus.scheduled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repository.createHealthEvent(
|
||||||
|
'p-1',
|
||||||
|
CreateHealthEventRequest(
|
||||||
|
eventType: HealthEventType.note,
|
||||||
|
occurredAt: DateTime.utc(2026, 9, 7),
|
||||||
|
title: '记录',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repository.createCareReminder(
|
||||||
|
'p-1',
|
||||||
|
CreateCareReminderRequest(
|
||||||
|
reminderType: CareReminderType.checkup,
|
||||||
|
title: '体检',
|
||||||
|
dueAt: DateTime.utc(2026, 10, 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final keys = adapter.requests
|
||||||
|
.map((r) => r.headers['Idempotency-Key'] as String?)
|
||||||
|
.toList();
|
||||||
|
expect(keys, everyElement(isNotNull));
|
||||||
|
expect(keys, everyElement(isNotEmpty));
|
||||||
|
// 每次逻辑提交换新键
|
||||||
|
expect(keys.toSet().length, 4);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('疫苗 / 事件 / 提醒 PATCH:顶层短路径(不含 petId)', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return switch (call) {
|
||||||
|
1 => jsonResponse(200, okEnvelope(sampleVaccinationJson())),
|
||||||
|
2 => jsonResponse(200, okEnvelope(sampleHealthEventJson())),
|
||||||
|
_ => jsonResponse(200, okEnvelope(sampleCareReminderJson())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.updateVaccination(
|
||||||
|
'vx-1',
|
||||||
|
const UpdateVaccinationRequest(
|
||||||
|
version: 1,
|
||||||
|
status: VaccinationStatus.cancelled,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await repository.updateHealthEvent(
|
||||||
|
'e-1',
|
||||||
|
const UpdateHealthEventRequest(version: 1, title: '复查'),
|
||||||
|
);
|
||||||
|
await repository.updateCareReminder(
|
||||||
|
'r-1',
|
||||||
|
const UpdateCareReminderRequest(status: CareReminderStatus.dismissed),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/vaccinations/vx-1');
|
||||||
|
expect(adapter.requests[1].path, '/api/v1/health-events/e-1');
|
||||||
|
expect(adapter.requests[2].path, '/api/v1/care-reminders/r-1');
|
||||||
|
expect(adapter.requests.map((r) => r.method), everyElement('PATCH'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listVaccinations / listCareReminders:不分页数组 + status 过滤', () async {
|
||||||
|
var call = 0;
|
||||||
|
await setUpWith((options) async {
|
||||||
|
call += 1;
|
||||||
|
return call == 1
|
||||||
|
? jsonResponse(200, okListEnvelope([sampleVaccinationJson()]))
|
||||||
|
: jsonResponse(200, okListEnvelope([sampleCareReminderJson()]));
|
||||||
|
});
|
||||||
|
|
||||||
|
final vaccinations = await repository.listVaccinations('p-1');
|
||||||
|
final reminders = await repository.listCareReminders(
|
||||||
|
'p-1',
|
||||||
|
status: CareReminderStatus.pending,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adapter.requests[0].path, '/api/v1/pets/p-1/vaccinations');
|
||||||
|
expect(adapter.requests[0].queryParameters, isEmpty);
|
||||||
|
expect(adapter.requests[1].path, '/api/v1/pets/p-1/care-reminders');
|
||||||
|
expect(adapter.requests[1].queryParameters, {'status': 'pending'});
|
||||||
|
expect(vaccinations.single.vaccineName, '狂犬疫苗');
|
||||||
|
expect(reminders.single.status, CareReminderStatus.pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listHealthEvents:分页参数缺省不传', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope({'items': <Object?>[], 'hasMore': false}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = await repository.listHealthEvents('p-1');
|
||||||
|
|
||||||
|
expect(adapter.requests.single.path, '/api/v1/pets/p-1/health-events');
|
||||||
|
expect(adapter.requests.single.queryParameters, isEmpty);
|
||||||
|
expect(page.items, isEmpty);
|
||||||
|
expect(page.hasMore, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPetSummary:tz 参数传递与四聚合解析', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async =>
|
||||||
|
jsonResponse(200, okEnvelope(samplePetSummaryJson())),
|
||||||
|
);
|
||||||
|
|
||||||
|
final summary = await repository.getPetSummary(
|
||||||
|
'p-1',
|
||||||
|
tz: 'Asia/Shanghai',
|
||||||
|
);
|
||||||
|
|
||||||
|
final request = adapter.requests.single;
|
||||||
|
expect(request.path, '/api/v1/pets/p-1/summary');
|
||||||
|
expect(request.queryParameters, {'tz': 'Asia/Shanghai'});
|
||||||
|
expect(summary.monthlyExpense.timezone, 'Asia/Shanghai');
|
||||||
|
expect(summary.latestWeight!.weightKg, 4.35);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('错误映射(新 8 码 → 类型化异常)', () {
|
||||||
|
Future<void> expectMapped(
|
||||||
|
int httpStatus,
|
||||||
|
int code,
|
||||||
|
TypeMatcher<ApiBusinessException> matcher,
|
||||||
|
) async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(httpStatus, errorEnvelope(code, 'err')),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
repository.getPet('p-1'),
|
||||||
|
throwsA(matcher.having((e) => e.code, 'code', code)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('40300 → PetAccessDeniedException', () async {
|
||||||
|
await expectMapped(403, 40300, isA<PetAccessDeniedException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40401 → PetNotFoundException(防枚举 404)', () async {
|
||||||
|
await expectMapped(404, 40401, isA<PetNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40402 → PetRecordNotFoundException(记录级防枚举)', () async {
|
||||||
|
await expectMapped(404, 40402, isA<PetRecordNotFoundException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40902 → PetVersionConflictException', () async {
|
||||||
|
await expectMapped(409, 40902, isA<PetVersionConflictException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40903 → MicrochipTakenException', () async {
|
||||||
|
await expectMapped(409, 40903, isA<MicrochipTakenException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('40904 → VaccinationDoseExistsException', () async {
|
||||||
|
await expectMapped(409, 40904, isA<VaccinationDoseExistsException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42201 → VaccinationRuleException', () async {
|
||||||
|
await expectMapped(422, 42201, isA<VaccinationRuleException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('42202 → CareReminderRuleException', () async {
|
||||||
|
await expectMapped(422, 42202, isA<CareReminderRuleException>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未覆盖码(40000)保持通用 ApiBusinessException', () async {
|
||||||
|
await setUpWith(
|
||||||
|
(options) async => jsonResponse(400, errorEnvelope(40000, '参数校验失败')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
repository.getPet('p-1'),
|
||||||
|
throwsA(
|
||||||
|
isA<ApiBusinessException>()
|
||||||
|
.having((e) => e.code, 'code', 40000)
|
||||||
|
.having(
|
||||||
|
(e) => e,
|
||||||
|
'runtimeType',
|
||||||
|
isNot(isA<PetNotFoundException>()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('类型化异常仍可按基类 ApiBusinessException 捕获', () {
|
||||||
|
const error = PetVersionConflictException(message: '数据已被修改');
|
||||||
|
expect(error, isA<ApiBusinessException>());
|
||||||
|
expect(error.code, ApiCodes.versionConflict);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('token 拦截复用', () {
|
||||||
|
test('40101:单飞刷新后重放,重放沿用同一 Idempotency-Key', () async {
|
||||||
|
await setUpWith((options) async {
|
||||||
|
if (options.path == '/api/v1/auth/refresh') {
|
||||||
|
return jsonResponse(
|
||||||
|
200,
|
||||||
|
okEnvelope(tokenDataJson(access: 'new-access')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.headers['Authorization'] == 'Bearer pet-access') {
|
||||||
|
return jsonResponse(401, errorEnvelope(40101, 'token 过期'));
|
||||||
|
}
|
||||||
|
return jsonResponse(201, okEnvelope(sampleWeightJson()));
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.createWeight(
|
||||||
|
'p-1',
|
||||||
|
CreateWeightRequest(
|
||||||
|
weightKg: 4.35,
|
||||||
|
measuredAt: DateTime.utc(2026, 9, 7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final weightRequests = adapter.requests
|
||||||
|
.where((r) => r.path == '/api/v1/pets/p-1/weights')
|
||||||
|
.toList();
|
||||||
|
expect(weightRequests, hasLength(2));
|
||||||
|
expect(weightRequests.last.headers['Authorization'], 'Bearer new-access');
|
||||||
|
expect(
|
||||||
|
weightRequests.first.headers['Idempotency-Key'],
|
||||||
|
weightRequests.last.headers['Idempotency-Key'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pet 服务基地址常量存在且默认指向 :8083', () {
|
||||||
|
expect(patbondPetApiBaseUrl, 'http://127.0.0.1:8083');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/// pets 域测试样本(契约 openapi.yaml v1.2.0 各 schema 全字段 JSON)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
Map<String, Object?> okListEnvelope(List<Object?> data) => {
|
||||||
|
'code': 0,
|
||||||
|
'message': 'ok',
|
||||||
|
'data': data,
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> samplePetJson({
|
||||||
|
Map<String, Object?> overrides = const {},
|
||||||
|
}) => {
|
||||||
|
'id': 'p-1',
|
||||||
|
'name': '豆豆',
|
||||||
|
'species': 'dog',
|
||||||
|
'breedId': 'b-1',
|
||||||
|
'breedDisplayName': '柴犬',
|
||||||
|
'customBreedName': null,
|
||||||
|
'sex': 'male',
|
||||||
|
'birthDate': '2024-03-15',
|
||||||
|
'birthDateEstimated': false,
|
||||||
|
'personality': null,
|
||||||
|
'microchipNo': null,
|
||||||
|
'sterilizedOn': null,
|
||||||
|
'status': 'active',
|
||||||
|
'myRole': 'owner',
|
||||||
|
'createdAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'updatedAt': '2026-09-02T10:00:00+08:00',
|
||||||
|
'version': 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleWeightJson() => {
|
||||||
|
'id': 'w-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'weightKg': 4.35,
|
||||||
|
'measuredAt': '2026-09-07T09:00:00+08:00',
|
||||||
|
'source': 'manual',
|
||||||
|
'note': null,
|
||||||
|
'createdAt': '2026-09-07T09:01:00+08:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleVaccinationJson() => {
|
||||||
|
'id': 'vx-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'vaccineId': 'v-1',
|
||||||
|
'vaccineName': '狂犬疫苗',
|
||||||
|
'seriesKey': 'rabies-initial',
|
||||||
|
'doseNo': 1,
|
||||||
|
'doseLabel': null,
|
||||||
|
'status': 'scheduled',
|
||||||
|
'plannedOn': '2026-10-01',
|
||||||
|
'administeredOn': null,
|
||||||
|
'nextDueOn': null,
|
||||||
|
'manufacturer': null,
|
||||||
|
'batchNo': null,
|
||||||
|
'notes': null,
|
||||||
|
'createdAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'updatedAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'version': 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleHealthEventJson() => {
|
||||||
|
'id': 'e-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'eventType': 'medical',
|
||||||
|
'occurredAt': '2026-09-05T14:00:00+08:00',
|
||||||
|
'title': '皮肤检查',
|
||||||
|
'notes': null,
|
||||||
|
'amountCents': 12850,
|
||||||
|
'createdByUserId': 'u-1',
|
||||||
|
'createdAt': '2026-09-05T14:05:00+08:00',
|
||||||
|
'updatedAt': '2026-09-05T14:05:00+08:00',
|
||||||
|
'version': 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> sampleCareReminderJson() => {
|
||||||
|
'id': 'r-1',
|
||||||
|
'petId': 'p-1',
|
||||||
|
'reminderType': 'checkup',
|
||||||
|
'title': '年度体检',
|
||||||
|
'dueAt': '2026-10-01T00:00:00+08:00',
|
||||||
|
'status': 'pending',
|
||||||
|
'completedAt': null,
|
||||||
|
'createdAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
'updatedAt': '2026-09-01T10:00:00+08:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
Map<String, dynamic> samplePetSummaryJson() => {
|
||||||
|
'petId': 'p-1',
|
||||||
|
'latestWeight': {'weightKg': 4.35, 'measuredAt': '2026-09-07T09:00:00+08:00'},
|
||||||
|
'vaccinationProgress': {'completedDoses': 2, 'totalDoses': 3},
|
||||||
|
'nextVaccination': {
|
||||||
|
'vaccinationId': 'vx-3',
|
||||||
|
'vaccineId': 'v-1',
|
||||||
|
'vaccineName': '狂犬疫苗',
|
||||||
|
'doseNo': 3,
|
||||||
|
'doseLabel': null,
|
||||||
|
'dueOn': '2026-08-01',
|
||||||
|
'source': 'planned',
|
||||||
|
},
|
||||||
|
'monthlyExpense': {
|
||||||
|
'month': '2026-09',
|
||||||
|
'timezone': 'Asia/Shanghai',
|
||||||
|
'amountCents': 12850,
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user