eff3526840
- `Pet.avatarUrl` 入模型;列表卡与详情头像改用真实预签名 URL(无图仍回退 爪印占位——服务端对非 ready 的 asset 直接给 null,客户端只有一种占位)。 - 详情页铅笔角标自此有功能:接 `MediaUploader`(purpose=pet_avatar);已有 头像时先给「更换 / 移除」二选一,移除即三态显式 null。 - 权限按 **WRITE 档**呈现(ADR-022 D3.5-3:owner + caregiver 可改头像, viewer 无入口),与资料编辑的 MANAGE 档刻意不同。因此纯头像 PATCH 只发 `version` + `avatarAssetId`,**不夹带任何资料字段**——夹带会让服务端按更严 的一半定档,caregiver 立刻 403。 - `UpdatePetRequest.avatarAssetId` 是该 schema 唯一的三态字段,其余字段保持 M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)。 - 40902 冲突后重取档案让用户自行决定重来,不静默重放(头像是用户可见的 覆盖操作,不该自动生效两次)。 测试 588 → 597(+9)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
859 lines
25 KiB
Dart
859 lines
25 KiB
Dart
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.4.0,
|
||
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
|
||
/// 以便契约漂移在测试期暴露而非静默吞掉)。
|
||
library;
|
||
|
||
import 'package:patbond_flutter/core/models/patch_field.dart';
|
||
|
||
export 'package:patbond_flutter/core/models/patch_field.dart';
|
||
export 'package:patbond_flutter/core/models/cursor_page.dart';
|
||
|
||
/// 物种(创建即定,不可修改)。
|
||
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);
|
||
|
||
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
|
||
/// 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.avatarUrl,
|
||
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),
|
||
// 契约 v1.4.0 新增:时效性预签名 GET URL,每次响应现签;
|
||
// 不得持久化、过期即重取。响应**不含 avatarAssetId**(只写不读)。
|
||
avatarUrl: json['avatarUrl'] 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;
|
||
|
||
/// 头像预签名 GET URL;无头像 / asset 非 ready / 对象存储未配置均为 null。
|
||
/// 「有头像」等价于本字段非 null(契约不外露 assetId)。
|
||
final String? avatarUrl;
|
||
|
||
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!),
|
||
};
|
||
}
|
||
|
||
/// 更新宠物请求(部分更新:缺席字段不变;品种对整体替换;species 不可改;
|
||
/// version 乐观锁必填)。
|
||
///
|
||
/// **两态与三态并存**:除 [avatarAssetId] 外的字段沿 M2 两态语义(缺省或
|
||
/// null 皆为「不改」,不支持清空回 null——它们的 CHECK 约束本就不允许空值);
|
||
/// [avatarAssetId] 是本 schema **唯一的三态字段**(契约 v1.4.0),因为
|
||
/// 「删掉我设的那张头像」是一等公民操作,两态根本无法表达。
|
||
///
|
||
/// **权限随本次触及的字段变档**(ADR-022 + 服务端 `requiredLevel`):
|
||
/// 只带 [avatarAssetId] 走 WRITE(owner + caregiver 均可);碰任一资料字段
|
||
/// 即 MANAGE(仅 owner);混合请求取更严的一半(防「夹带改名」)。
|
||
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,
|
||
this.avatarAssetId = const PatchField<String>.absent(),
|
||
});
|
||
|
||
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;
|
||
|
||
/// 宠物头像 asset(两步上传产物,`purpose` 须为 `pet_avatar`)。三态。
|
||
final PatchField<String> avatarAssetId;
|
||
|
||
Map<String, Object?> toJson() {
|
||
final json = <String, Object?>{
|
||
'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,
|
||
};
|
||
avatarAssetId.writeTo(json, 'avatarAssetId');
|
||
return json;
|
||
}
|
||
}
|
||
|
||
/// 品种目录项(只读字典)。
|
||
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;
|
||
}
|