已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -0,0 +1,53 @@
// 功能描述:购物车条目、并发版本和结算快照;版本:1.0.0。
import 'primary_models.dart';
/// 服务端购物车快照;revision 用于阻止过期操作覆盖其他设备的修改。
class CartItem {
const CartItem({
required this.product,
required this.quantity,
required this.selected,
required this.available,
required this.revision,
this.specification = '',
});
final ProductSummary product;
final int quantity;
final bool selected, available;
final String revision;
final String specification;
bool get purchasable => available && quantity > 0 && quantity <= product.stock;
int get amount => product.price * quantity;
factory CartItem.fromJson(Map<String, Object?> json, String Function(String) resolve) {
final price = json['price_amount'], stock = json['stock_quantity'], quantity = json['quantity'];
if (price is! int ||
price < 0 ||
stock is! int ||
quantity is! int ||
quantity < 0 ||
quantity > 999 ||
json['product_identity'] is! String ||
(json['product_identity'] as String).isEmpty ||
json['revision'] is! String ||
json['selected'] is! bool ||
json['available'] is! bool) {
throw const FormatException('购物车数据异常');
}
return CartItem(
product: ProductSummary(
identity: json['product_identity'] as String,
name: json['name'] as String? ?? '',
price: price,
stock: stock,
imageUrl: resolve(json['image_url'] as String? ?? ''),
category: json['category_name'] as String? ?? '',
),
quantity: quantity,
selected: json['selected'] as bool,
available: json['available'] as bool,
revision: json['revision'] as String,
specification: json['specification'] as String? ?? '',
);
}
}

View File

@@ -20,31 +20,54 @@ class UserProfile {
required this.name,
required this.phone,
required this.avatar,
this.realName = '',
});
final String identity;
final String name;
final String phone;
final String avatar;
final String realName;
factory UserProfile.fromJson(Map<String, Object?> json) => UserProfile(
identity: json['identity'] as String? ?? '',
name: json['name'] as String? ?? '',
phone: json['phone'] as String? ?? '',
avatar: json['avatar'] as String? ?? '',
realName: json['real_name'] as String? ?? '',
);
}
class WalletSummary {
const WalletSummary({required this.balance, required this.withdrawalBalance});
const WalletSummary({
required this.balance,
required this.withdrawalBalance,
this.paymentPasswordSet,
});
final int balance;
final int withdrawalBalance;
// 缺少状态时保持未知,不能误当作未设置支付密码。
final bool? paymentPasswordSet;
factory WalletSummary.fromJson(Map<String, Object?> json) => WalletSummary(
balance: (json['balance'] as num?)?.toInt() ?? 0,
withdrawalBalance: (json['withdrawal_balance'] as num?)?.toInt() ?? 0,
paymentPasswordSet: json['payment_password_set'] as bool?,
);
}
/// 手机验证码申请结果,明确区分请求建立与短信已发送。
class PaymentCodeRequest {
const PaymentCodeRequest({
required this.identity,
required this.delivered,
required this.maskedPhone,
required this.retryAfter,
});
final String identity, maskedPhone;
final bool delivered;
final int retryAfter;
}
String moneyText(int cents) => '¥${(cents / 100).toStringAsFixed(2)}';

View File

@@ -0,0 +1,66 @@
// 功能描述:用户可见的配送人员、资质、配送点及订单交付快照;版本:1.0.0。
class DeliveryProduct {
const DeliveryProduct({required this.name, required this.quantity});
final String name;
final int quantity;
}
/// 只接受本人配送接口的显式事实;不可用能力保留布尔状态,不由客户端猜测。
class DeliveryDetail {
DeliveryDetail.fromJson(Map<String, Object?> data)
: identity = _required(data, 'identity'),
orderNo = _required(data, 'order_no'),
statusName = _required(data, 'status_name'),
statusMessage = _required(data, 'status_message'),
address = _required(data, 'address'),
contactName = _required(data, 'contact_name'),
contactPhoneMasked = _required(data, 'contact_phone_masked'),
stationName = _text(data, 'station_name'),
deliveryName = _text(data, 'delivery_name'),
deliveryAddress = _text(data, 'delivery_address'),
staffName = _text(data, 'staff_name'),
staffAvatar = _text(data, 'staff_avatar'),
credentialType = _text(data, 'credential_type'),
appointmentAt = DateTime.parse(_required(data, 'appointment_at')).toLocal(),
trackUpdatedAt = _date(data['track_updated_at']),
staffAssigned = _flag(data, 'staff_assigned'),
credentialVerified = _flag(data, 'credential_verified'),
trackAvailable = _flag(data, 'track_available'),
controlledCallAvailable = _flag(data, 'controlled_call_available'),
messageAvailable = _flag(data, 'message_available'),
vehicleConfigured = _flag(data, 'vehicle_configured'),
products = List.unmodifiable([
for (final row in data['products'] as List)
DeliveryProduct(
name: _required(Map<String, Object?>.from(row as Map), 'name'),
quantity: (row['quantity'] as int?) ?? 0,
),
]) {
if (products.any((item) => item.quantity < 1)) throw const FormatException('配送商品数量异常');
}
final String identity, orderNo, statusName, statusMessage, address, contactName;
final String contactPhoneMasked, stationName, deliveryName, deliveryAddress, staffName;
final String staffAvatar;
final String credentialType;
final DateTime appointmentAt;
final DateTime? trackUpdatedAt;
final bool staffAssigned, credentialVerified, trackAvailable;
final bool controlledCallAvailable, messageAvailable, vehicleConfigured;
final List<DeliveryProduct> products;
static String _text(Map<String, Object?> data, String key) => data[key] as String? ?? '';
static String _required(Map<String, Object?> data, String key) {
final value = _text(data, key);
if (value.trim().isEmpty) throw FormatException('配送资料不完整:$key');
return value;
}
static bool _flag(Map<String, Object?> data, String key) {
if (data[key] is! bool) throw FormatException('配送状态异常:$key');
return data[key] as bool;
}
static DateTime? _date(Object? value) =>
value == null ? null : DateTime.tryParse('$value')?.toLocal();
}

View File

@@ -0,0 +1,88 @@
// 功能描述:用户可见的隐私化配送轨迹、履约节点与人员摘要;版本:1.0.0。
class DeliveryTrackPoint {
DeliveryTrackPoint.fromJson(Map<String, Object?> data)
: longitude = _number(data, 'longitude'),
latitude = _number(data, 'latitude'),
occurredAt = DateTime.parse(_required(data, 'occurred_at')).toLocal();
final double longitude, latitude;
final DateTime occurredAt;
static double _number(Map<String, Object?> data, String key) {
final value = data[key];
if (value is! num) throw FormatException('配送轨迹坐标异常:$key');
return value.toDouble();
}
}
class DeliveryTrackEvent {
DeliveryTrackEvent.fromJson(Map<String, Object?> data)
: statusCode = data['status_code'] as int? ?? 0,
title = _required(data, 'title'),
detail = _required(data, 'detail'),
occurredAt = DateTime.parse(_required(data, 'occurred_at')).toLocal();
final int statusCode;
final String title, detail;
final DateTime occurredAt;
}
/// 只接受服务端明确返回的本人订单事实;客户端不推算路线、位置或送达时间。
class DeliveryTrack {
DeliveryTrack.fromJson(Map<String, Object?> data)
: identity = _required(data, 'identity'),
statusCode = data['status_code'] as int? ?? 0,
statusName = _required(data, 'status_name'),
statusMessage = _required(data, 'status_message'),
appointmentAt = DateTime.parse(_required(data, 'appointment_at')).toLocal(),
updatedAt = _date(data['updated_at']),
stationName = _text(data, 'station_name'),
staffName = _text(data, 'staff_name'),
staffAvatar = _text(data, 'staff_avatar'),
staffPhoneMasked = _text(data, 'staff_phone_masked'),
controlledCallAvailable = _flag(data, 'controlled_call_available'),
routeAvailable = _flag(data, 'route_available'),
destinationAvailable = _flag(data, 'destination_available'),
destinationLongitude = _optionalNumber(data['destination_longitude']),
destinationLatitude = _optionalNumber(data['destination_latitude']),
points = List.unmodifiable([
for (final row in data['points'] as List)
DeliveryTrackPoint.fromJson(Map<String, Object?>.from(row as Map)),
]),
timeline = List.unmodifiable([
for (final row in data['timeline'] as List)
DeliveryTrackEvent.fromJson(Map<String, Object?>.from(row as Map)),
]);
final String identity, statusName, statusMessage, stationName, staffName;
final String staffAvatar, staffPhoneMasked;
final int statusCode;
final DateTime appointmentAt;
final DateTime? updatedAt;
final bool controlledCallAvailable, routeAvailable, destinationAvailable;
final double? destinationLongitude, destinationLatitude;
final List<DeliveryTrackPoint> points;
final List<DeliveryTrackEvent> timeline;
static String _text(Map<String, Object?> data, String key) => data[key] as String? ?? '';
static String _required(Map<String, Object?> data, String key) {
final value = _text(data, key);
if (value.trim().isEmpty) throw FormatException('配送轨迹资料不完整:$key');
return value;
}
static bool _flag(Map<String, Object?> data, String key) {
if (data[key] is! bool) throw FormatException('配送轨迹状态异常:$key');
return data[key] as bool;
}
static double? _optionalNumber(Object? value) => value is num ? value.toDouble() : null;
static DateTime? _date(Object? value) =>
value == null ? null : DateTime.tryParse('$value')?.toLocal();
}
String _required(Map<String, Object?> data, String key) {
final value = data[key] as String? ?? '';
if (value.trim().isEmpty) throw FormatException('配送轨迹资料不完整:$key');
return value;
}

View File

@@ -0,0 +1,111 @@
// 功能描述:用户押金汇总与单瓶押金事实;版本:1.0.0。
/// 单只气瓶的押金状态,金额单位为分。
class DepositRecord {
const DepositRecord({
required this.identity,
required this.depositNo,
required this.status,
required this.statusName,
required this.amount,
required this.productName,
required this.productCode,
required this.paidAt,
required this.refundedAt,
required this.allowedActions,
this.returnRequestIdentity = '',
this.returnStatusName = '',
});
final String identity, depositNo, statusName, productName, productCode;
final String returnRequestIdentity, returnStatusName;
final int status, amount;
final DateTime paidAt;
final DateTime? refundedAt;
final List<String> allowedActions;
factory DepositRecord.fromJson(Map<String, Object?> data) {
final paidAt = DateTime.tryParse(data['paid_at']?.toString() ?? '');
if (data['identity'] is! String ||
data['deposit_no'] is! String ||
data['deposit_status'] is! int ||
data['amount'] is! int ||
(data['amount'] as int) < 0 ||
paidAt == null) {
throw const FormatException('押金记录数据异常');
}
return DepositRecord(
identity: data['identity'] as String,
depositNo: data['deposit_no'] as String,
status: data['deposit_status'] as int,
statusName: data['status_name'] as String? ?? '处理中',
amount: data['amount'] as int,
productName: data['product_name'] as String? ?? '',
productCode: data['product_code'] as String? ?? '',
paidAt: paidAt.toLocal(),
refundedAt: data['refunded_at'] == null
? null
: DateTime.tryParse(data['refunded_at'].toString())?.toLocal(),
allowedActions: (data['allowed_actions'] as List? ?? []).whereType<String>().toList(),
returnRequestIdentity: data['return_request_identity'] as String? ?? '',
returnStatusName: data['return_status_name'] as String? ?? '',
);
}
}
/// 押金页一次读取的服务端快照。
class DepositSummary {
const DepositSummary({
required this.refundableAmount,
required this.usingCount,
required this.items,
required this.ruleText,
});
final int refundableAmount, usingCount;
final List<DepositRecord> items;
final String ruleText;
factory DepositSummary.fromJson(Map<String, Object?> data) {
if (data['refundable_amount'] is! int ||
data['using_count'] is! int ||
data['items'] is! List) {
throw const FormatException('押金汇总数据异常');
}
return DepositSummary(
refundableAmount: data['refundable_amount'] as int,
usingCount: data['using_count'] as int,
items: (data['items'] as List)
.whereType<Map<Object?, Object?>>()
.map((item) => DepositRecord.fromJson(Map<String, Object?>.from(item)))
.toList(),
ruleText: data['rule_text'] as String? ?? '',
);
}
}
// DepositReturnRequest 表示退瓶申请的可见状态,不暴露内部关联主键。
class DepositReturnRequest {
const DepositReturnRequest({
required this.identity,
required this.status,
required this.statusName,
required this.estimatedAmount,
required this.refundAmount,
});
final String identity, statusName;
final int status, estimatedAmount, refundAmount;
factory DepositReturnRequest.fromJson(Map<String, Object?> data) {
if (data['identity'] is! String || data['return_status'] is! int) {
throw const FormatException('退瓶申请数据不完整');
}
return DepositReturnRequest(
identity: data['identity'] as String,
status: data['return_status'] as int,
statusName: data['status_name'] as String? ?? '处理中',
estimatedAmount: data['estimated_amount'] as int? ?? 0,
refundAmount: data['refund_amount'] as int? ?? 0,
);
}
}

View File

@@ -0,0 +1,34 @@
// 功能:解析本人设备分组及其公开设备标识;版本:1.0.0。
import 'client_models.dart';
/// 设备分组只表达用户自定义归类,不代表设备在线或可控制。
class DeviceGroup {
const DeviceGroup({
required this.identity,
required this.name,
required this.sortNo,
required this.deviceIdentities,
});
final String identity;
final String name;
final int sortNo;
final Set<String> deviceIdentities;
factory DeviceGroup.fromJson(Map<String, Object?> json) => DeviceGroup(
identity: json['identity'] as String? ?? '',
name: json['name'] as String? ?? '',
sortNo: (json['sort_no'] as num?)?.toInt() ?? 0,
deviceIdentities: {
for (final value in json['device_identities'] as List? ?? const [])
if (value is String && value.isNotEmpty) value,
},
);
}
/// 同一次页面刷新读取的设备与分组快照,避免两次渲染状态互相错配。
class DeviceCatalog {
const DeviceCatalog({required this.devices, required this.groups});
final List<ClientRecord> devices;
final List<DeviceGroup> groups;
}

View File

@@ -0,0 +1,137 @@
// 功能:家庭成员、设备及共享权限领域模型;版本:1.0.0。
class FamilyDevicePermission {
const FamilyDevicePermission({
required this.deviceIdentity,
required this.deviceName,
required this.deviceKind,
required this.canView,
required this.canAlert,
required this.canControl,
});
final String deviceIdentity, deviceName, deviceKind;
final bool canView, canAlert, canControl;
factory FamilyDevicePermission.fromJson(Map<String, Object?> json) => FamilyDevicePermission(
deviceIdentity: json['device_identity'] as String? ?? '',
deviceName: json['device_name'] as String? ?? '',
deviceKind: json['device_kind'] as String? ?? 'unknown',
canView: json['can_view'] == true,
canAlert: json['can_alert'] == true,
canControl: json['can_control'] == true,
);
Map<String, Object?> toJson() => {
'device_identity': deviceIdentity,
'can_view': canView,
'can_alert': canAlert,
'can_control': canControl,
};
}
class FamilyMember {
const FamilyMember({
required this.identity,
required this.name,
required this.phoneMasked,
required this.relationship,
required this.inviteStatus,
required this.isOwner,
required this.permissions,
this.expiresAt,
});
final String identity, name, phoneMasked, relationship;
final int inviteStatus;
final bool isOwner;
final List<FamilyDevicePermission> permissions;
final DateTime? expiresAt;
bool get pending => inviteStatus == 10;
factory FamilyMember.fromJson(Map<String, Object?> json) => FamilyMember(
identity: json['identity'] as String? ?? '',
name: json['name'] as String? ?? '',
phoneMasked: json['phone_masked'] as String? ?? '',
relationship: json['relationship'] as String? ?? '',
inviteStatus: (json['invite_status'] as num?)?.toInt() ?? 10,
isOwner: json['is_owner'] == true,
permissions: (json['device_permissions'] as List? ?? const [])
.whereType<Map<Object?, Object?>>()
.map((e) => FamilyDevicePermission.fromJson(Map<String, Object?>.from(e)))
.toList(),
expiresAt: DateTime.tryParse(json['expires_at'] as String? ?? ''),
);
}
class FamilyDevice {
const FamilyDevice({
required this.identity,
required this.name,
required this.kind,
required this.shareCount,
required this.mappingConfigured,
});
final String identity, name, kind;
final int shareCount;
final bool mappingConfigured;
factory FamilyDevice.fromJson(Map<String, Object?> json) => FamilyDevice(
identity: json['identity'] as String? ?? '',
name: json['name'] as String? ?? '',
kind: json['kind'] as String? ?? 'unknown',
shareCount: (json['share_count'] as num?)?.toInt() ?? 0,
mappingConfigured: json['mapping_configured'] == true,
);
}
class FamilyDashboardData {
const FamilyDashboardData({
required this.householdName,
required this.ownerName,
required this.memberCount,
required this.deviceCount,
required this.members,
required this.devices,
this.incomingInvitations = const [],
});
final String householdName, ownerName;
final int memberCount, deviceCount;
final List<FamilyMember> members;
final List<FamilyDevice> devices;
final List<FamilyInvitation> incomingInvitations;
factory FamilyDashboardData.fromJson(Map<String, Object?> json) => FamilyDashboardData(
householdName: json['household_name'] as String? ?? '我的家庭',
ownerName: json['owner_name'] as String? ?? '',
memberCount: (json['member_count'] as num?)?.toInt() ?? 0,
deviceCount: (json['device_count'] as num?)?.toInt() ?? 0,
members: (json['members'] as List? ?? const [])
.whereType<Map<Object?, Object?>>()
.map((e) => FamilyMember.fromJson(Map<String, Object?>.from(e)))
.toList(),
devices: (json['devices'] as List? ?? const [])
.whereType<Map<Object?, Object?>>()
.map((e) => FamilyDevice.fromJson(Map<String, Object?>.from(e)))
.toList(),
incomingInvitations: (json['incoming_invitations'] as List? ?? const [])
.whereType<Map<Object?, Object?>>()
.map((e) => FamilyInvitation.fromJson(Map<String, Object?>.from(e)))
.toList(),
);
}
class FamilyInvitation {
const FamilyInvitation({
required this.identity,
required this.ownerName,
required this.name,
required this.relationship,
required this.expiresAt,
});
final String identity, ownerName, name, relationship;
final DateTime? expiresAt;
factory FamilyInvitation.fromJson(Map<String, Object?> json) => FamilyInvitation(
identity: json['identity'] as String? ?? '',
ownerName: json['owner_name'] as String? ?? '',
name: json['name'] as String? ?? '',
relationship: json['relationship'] as String? ?? '',
expiresAt: DateTime.tryParse(json['expires_at'] as String? ?? ''),
);
}

View File

@@ -0,0 +1,94 @@
// 功能描述:气瓶下单报价、规格和服务端预约时段强类型模型;版本:1.0.0。
import 'shipping_address.dart';
class GasOrderProductOption {
const GasOrderProductOption({
required this.name,
required this.description,
required this.unitPrice,
required this.depositAmount,
required this.itemIdentities,
this.orderable = true,
this.unavailableReason = '',
});
final String name, description;
final int unitPrice, depositAmount;
final List<String> itemIdentities;
final bool orderable;
final String unavailableReason;
factory GasOrderProductOption.fromJson(Map<String, Object?> json) => GasOrderProductOption(
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
unitPrice: (json['unit_price'] as num?)?.toInt() ?? 0,
depositAmount: (json['deposit_amount'] as num?)?.toInt() ?? 0,
orderable: json['orderable'] != false,
unavailableReason: json['unavailable_reason'] as String? ?? '',
itemIdentities: List<String>.unmodifiable(
(json['item_identities'] as List? ?? const []).whereType<String>(),
),
);
}
class GasOrderAppointmentSlot {
const GasOrderAppointmentSlot({required this.startAt, required this.label});
final DateTime startAt;
final String label;
factory GasOrderAppointmentSlot.fromJson(Map<String, Object?> json) {
final start = DateTime.tryParse(json['start_at'] as String? ?? '');
if (start == null) throw const FormatException('预约时段无效');
return GasOrderAppointmentSlot(startAt: start, label: json['label'] as String? ?? '');
}
}
class GasOrderCheckoutData {
const GasOrderCheckoutData({
required this.stationName,
required this.stationStatus,
required this.deliveryScope,
required this.deliveryFee,
required this.products,
required this.addresses,
required this.slots,
});
final String stationName, stationStatus, deliveryScope;
final int deliveryFee;
final List<GasOrderProductOption> products;
final List<ShippingAddress> addresses;
final List<GasOrderAppointmentSlot> slots;
factory GasOrderCheckoutData.fromJson(Map<String, Object?> json) => GasOrderCheckoutData(
stationName: json['station_name'] as String? ?? '',
stationStatus: json['station_status'] as String? ?? '',
deliveryScope: json['delivery_scope'] as String? ?? '',
deliveryFee: (json['delivery_fee'] as num?)?.toInt() ?? 0,
products: List.unmodifiable([
for (final item in json['products'] as List? ?? const [])
GasOrderProductOption.fromJson(Map<String, Object?>.from(item as Map)),
]),
addresses: List.unmodifiable([
for (final item in json['addresses'] as List? ?? const [])
ShippingAddress.fromJson(Map<String, Object?>.from(item as Map)),
]),
slots: List.unmodifiable([
for (final item in json['appointment_slots'] as List? ?? const [])
GasOrderAppointmentSlot.fromJson(Map<String, Object?>.from(item as Map)),
]),
);
}
class GasOrderCreateResult {
const GasOrderCreateResult({required this.identity, required this.payableAmount});
final String identity;
final int payableAmount;
factory GasOrderCreateResult.fromJson(Map<String, Object?> json) {
final identity = json['identity'] as String? ?? '';
if (identity.isEmpty) throw const FormatException('订单标识缺失');
return GasOrderCreateResult(
identity: identity,
payableAmount: (json['payable_amount'] as num?)?.toInt() ?? 0,
);
}
}

View File

@@ -0,0 +1,135 @@
// 功能描述:供气订单履约、支付记录及合同正文适配;版本:1.0.0。
import 'shop_order_detail.dart';
/// 复用成交明细与整数分金额,供气配送费、人员和状态历史单独保存。
class GasOrderDetail extends ShopOrderDetail {
GasOrderDetail.fromJson(super.data, super.resolve)
: deliveryFee = _amount(data['delivery_fee']),
depositAmount = _amount(data['deposit_amount'] ?? 0),
stationName = data['station_name'] as String? ?? '',
staffName = data['delivery_staff_name'] as String? ?? '',
contractIdentity = data['contract_identity'] as String? ?? '',
contractNo = data['contract_no'] as String? ?? '',
timeline = List.unmodifiable(
(data['timeline'] as List).map(
(row) => GasOrderEvent.fromJson(Map<String, Object?>.from(row as Map)),
),
),
payments = List.unmodifiable(
(data['payments'] as List).map(
(row) => GasOrderPayment.fromJson(Map<String, Object?>.from(row as Map)),
),
),
super.fromJson();
final int deliveryFee, depositAmount;
final String stationName, staffName, contractIdentity, contractNo;
final List<GasOrderEvent> timeline;
final List<GasOrderPayment> payments;
static int _amount(Object? value) {
if (value is! int || value < 0) throw const FormatException('供气金额异常');
return value;
}
}
class GasOrderEvent {
GasOrderEvent.fromJson(Map<String, Object?> data)
: name = data['status_name'] as String,
time = DateTime.parse(data['occurred_at'] as String);
final String name;
final DateTime time;
}
class GasOrderPayment {
GasOrderPayment.fromJson(Map<String, Object?> data)
: amount = GasOrderDetail._amount(data['amount']),
channel = data['channel'] as String,
time = data['paid_at'] == null ? null : DateTime.parse(data['paid_at'] as String);
final int amount;
final String channel;
final DateTime? time;
String get channelName => switch (channel) {
'alipay' => '支付宝',
'wechat' => '微信',
'wallet' => '余额',
_ => '其他支付渠道',
};
}
/// 合同仅用于阅读,未实现的签署/下载能力不能伪装为成功。
class GasContractDetail {
GasContractDetail.fromJson(Map<String, Object?> data)
: identity = data['identity'] as String,
number = data['contract_no'] as String,
title = data['title'] as String,
terms = data['terms'] as String,
status = data['contract_status'] as int,
signedAt = _date(data['signed_at']),
effectiveAt = _date(data['effective_at']),
expiredAt = _date(data['expired_at']),
hasAttachment = data['has_attachment'] as bool;
final String identity, number, title, terms;
final int status;
final DateTime? signedAt, effectiveAt, expiredAt;
final bool hasAttachment;
static DateTime? _date(Object? value) {
final date = DateTime.tryParse(value?.toString() ?? '');
return date == null || date.year < 1900 ? null : date;
}
}
/// 合同列表保留服务端业务状态,不将草稿推断为待用户签署。
class GasContractSummary extends GasContractDetail {
GasContractSummary.fromJson(super.data)
: stationName = data['station_name'] as String? ?? '',
super.fromJson();
final String stationName;
String get statusName => switch (status) {
0 => '草稿',
11 => '生效中',
12 => '已到期',
13 => '已终止',
_ => '状态待更新',
};
}
/// 不可变的合同状态与有效期快照,区别于电子签名/签署证据。
class GasContractEvent {
GasContractEvent.fromJson(Map<String, Object?> data)
: identity = data['identity'] as String,
action = data['action'] as String,
status = data['contract_status'] as int,
occurredAt = DateTime.parse(data['occurred_at'] as String),
effectiveAt = GasContractDetail._date(data['effective_at']),
expiredAt = GasContractDetail._date(data['expired_at']);
final String identity, action;
final int status;
final DateTime occurredAt;
final DateTime? effectiveAt, expiredAt;
String get actionName => switch (action) {
'activate' => '合同生效',
'renew' => '合同续期',
'terminate' => '合同终止',
'create' => '合同建立',
_ => '合同变更',
};
String get statusName => switch (status) {
0 => '草稿',
11 => '生效中',
12 => '已到期',
13 => '已终止',
_ => '状态待更新',
};
}
/// 合同变更申请是客服受理记录,不表示合同条款已经发生变化。
class ContractChangeRequest {
ContractChangeRequest.fromJson(Map<String, Object?> data)
: identity = data['identity'] as String,
number = data['ticket_no'] as String,
statusName = data['status_name'] as String,
description = data['description'] as String,
result = data['result'] as String? ?? '',
actions = List<String>.from(data['allowed_actions'] as List);
final String identity, number, statusName, description, result;
final List<String> actions;
}

View File

@@ -0,0 +1,54 @@
// 功能描述:解析消息中心真实业务消息及分类计数;版本:1.0.0。
class UserMessage {
const UserMessage({
required this.key,
required this.category,
required this.title,
required this.summary,
required this.statusText,
required this.target,
required this.targetIdentity,
required this.occurredAt,
required this.read,
});
factory UserMessage.fromJson(Map<String, dynamic> json) => UserMessage(
key: json['key']?.toString() ?? '',
category: json['category']?.toString() ?? '',
title: json['title']?.toString() ?? '',
summary: json['summary']?.toString() ?? '',
statusText: json['status_text']?.toString() ?? '',
target: json['target']?.toString() ?? '',
targetIdentity: json['target_identity']?.toString() ?? '',
occurredAt:
DateTime.tryParse(json['occurred_at']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
read: json['read'] == true,
);
final String key, category, title, summary, statusText, target, targetIdentity;
final DateTime occurredAt;
final bool read;
}
class MessageCenterData {
const MessageCenterData({required this.items, required this.counts});
factory MessageCenterData.fromJson(Map<String, dynamic> json) {
final rawItems = json['items'] is List ? json['items'] as List<dynamic> : const <dynamic>[];
final rawCounts = json['counts'] is Map
? Map<String, dynamic>.from(json['counts'] as Map<dynamic, dynamic>)
: const <String, dynamic>{};
return MessageCenterData(
items: List.unmodifiable(
rawItems.whereType<Map<dynamic, dynamic>>().map(
(item) => UserMessage.fromJson(Map<String, dynamic>.from(item)),
),
),
counts: Map.unmodifiable(
rawCounts.map((key, value) => MapEntry(key, value is num ? value.toInt() : 0)),
),
);
}
final List<UserMessage> items;
final Map<String, int> counts;
}

View File

@@ -0,0 +1,93 @@
// 功能描述:订单中心强类型摘要,状态、金额和动作由服务端提供。
// 版本:1.0.0
import 'dart:convert';
import 'client_models.dart';
/// 订单项退款输入,只保留公开标识和服务端数量。
class OrderItemSummary {
const OrderItemSummary(
this.identity,
this.quantity, {
this.name = '',
this.specification = '',
this.imageUrl = '',
});
final String identity, name, specification, imageUrl;
final int quantity;
/// 商城使用成交快照、气瓶订单使用类型快照,不以当前商品覆盖历史事实。
factory OrderItemSummary.fromJson(
Map<Object?, Object?> item, {
String Function(String value)? resolveImage,
}) {
Object? snapshot = item['product_snapshot'];
if (snapshot is String) {
try {
snapshot = jsonDecode(snapshot);
} on FormatException {
snapshot = null;
}
}
final name = snapshot is Map ? snapshot['name'] : item['product_type_name'];
final image = snapshot is Map ? snapshot['image_url'] : null;
Object? params = item['product_params'];
if (params is String) {
try {
params = jsonDecode(params);
} on FormatException {
params = null;
}
}
return OrderItemSummary(
item['identity'] as String? ?? '',
(item['quantity'] as num?)?.toInt() ?? 1,
name: name is String ? name : '',
specification: params is Map && params['weight'] is String ? params['weight'] as String : '',
imageUrl: image is String && image.isNotEmpty ? (resolveImage?.call(image) ?? image) : '',
);
}
}
/// 兼容旧订单记录的领域适配模型;页面不再直接读取原始 Map。
class OrderSummary {
const OrderSummary({
required this.record,
required this.amount,
required this.statusName,
required this.actions,
required this.items,
required this.orderNo,
required this.stationName,
this.createdAt,
});
final ClientRecord record;
final int? amount;
final String statusName;
final Set<String> actions;
final List<OrderItemSummary> items;
final String orderNo, stationName;
final DateTime? createdAt;
/// 解析服务端状态和权限;旧响应缺少动作时不自行推导权限。
factory OrderSummary.fromRecord(
ClientRecord record, {
String Function(String value)? resolveImage,
}) {
final raw = record.raw;
final rawOrderNo = raw['order_no']?.toString() ?? record.title;
return OrderSummary(
record: record,
amount: (raw['payable_amount'] as num?)?.toInt(),
statusName: raw['status_name'] as String? ?? '状态待更新',
orderNo: rawOrderNo.startsWith('订单号:') ? rawOrderNo.substring(4) : rawOrderNo,
stationName: raw['station_name'] as String? ?? '',
createdAt: DateTime.tryParse(raw['created_at']?.toString() ?? ''),
actions: Set.unmodifiable((raw['allowed_actions'] as List? ?? []).whereType<String>()),
items: List.unmodifiable(
(raw['items'] as List? ?? []).whereType<Map<Object?, Object?>>().map(
(item) => OrderItemSummary.fromJson(item, resolveImage: resolveImage),
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
// 功能描述:一级页面使用的不可变领域数据,金额统一为整数分。
// 版本:1.0.0
/// 已发布内容;正文和版本由内容后台维护。
class PublishedContent {
const PublishedContent({
required this.identity,
required this.title,
required this.body,
required this.type,
required this.version,
this.publishedAt,
});
final String identity, title, body, type;
final int version;
final DateTime? publishedAt;
}
/// 商品公开摘要,只使用公开标识和服务端价格库存。
class ProductSummary {
const ProductSummary({
required this.identity,
required this.name,
required this.price,
required this.stock,
this.category = '',
this.imageUrl = '',
});
final String identity, name, category, imageUrl;
final int price, stock;
}
/// 有效服务归属;配送点为空表示气站直接服务。
class ServiceRelation {
const ServiceRelation({required this.gasName, required this.deliveryName});
final String gasName, deliveryName;
}

View File

@@ -0,0 +1,51 @@
// 功能描述:商品详情公开字段及严格金额校验,不将缺失参数或图片伪装成真实数据。
// 版本:1.0.0。
import '../../data/services/api_client.dart';
class ProductDetail {
const ProductDetail({
required this.identity,
required this.name,
required this.price,
required this.stock,
this.category = '',
this.images = const [],
this.attributes = const [],
});
final String identity, name, category;
final int price, stock;
final List<String> images;
final List<({String name, String value})> attributes;
factory ProductDetail.fromJson(Map<String, Object?> data, String Function(String) resolveImage) {
final price = data['price_amount'],
stock = data['stock_quantity'],
identity = data['identity'],
name = data['name'];
if (price is! int ||
price < 0 ||
stock is! int ||
stock < 0 ||
identity is! String ||
identity.isEmpty ||
name is! String) {
throw const ApiException(1714, '商品数据异常,请重试');
}
return ProductDetail(
identity: identity,
name: name,
price: price,
stock: stock,
category: data['category_name'] as String? ?? '',
images: [
for (final image in data['images'] as List? ?? [])
if (image is Map && image['image_url'] is String)
resolveImage(image['image_url'] as String),
].where((url) => url.isNotEmpty).toSet().toList(),
attributes: [
for (final value in data['attributes'] as List? ?? [])
if (value is Map && value['name'] is String && value['value'] is String)
(name: value['name'] as String, value: value['value'] as String),
],
);
}
}

View File

@@ -0,0 +1,62 @@
// 功能描述:商品收藏状态、并发版本和列表公开数据;版本:1.0.0。
import 'primary_models.dart';
/// 每次操作必须携带读取时的状态版本,不能把切换命令作为可重试写入。
class ProductFavoriteState {
const ProductFavoriteState({
required this.productIdentity,
required this.active,
required this.revision,
});
final String productIdentity, revision;
final bool active;
factory ProductFavoriteState.fromJson(Map<String, Object?> data) {
if (data['product_identity'] is! String ||
(data['product_identity'] as String).isEmpty ||
data['favorite'] is! bool ||
data['revision'] is! String) {
throw const FormatException('收藏状态数据异常');
}
return ProductFavoriteState(
productIdentity: data['product_identity'] as String,
active: data['favorite'] as bool,
revision: data['revision'] as String,
);
}
}
/// 下架收藏仍保留展示信息,available仅用于禁用购买,结算仍由服务端验证。
class ProductFavorite {
const ProductFavorite({
required this.state,
required this.product,
required this.available,
this.specifications = const [],
});
final ProductFavoriteState state;
final ProductSummary product;
final bool available;
final List<String> specifications;
factory ProductFavorite.fromJson(Map<String, Object?> data, String Function(String) resolve) {
final state = ProductFavoriteState.fromJson(data);
if (data['price_amount'] is! int ||
(data['price_amount'] as int) < 0 ||
data['stock_quantity'] is! int ||
data['available'] is! bool) {
throw const FormatException('收藏商品数据异常');
}
return ProductFavorite(
state: state,
product: ProductSummary(
identity: state.productIdentity,
name: data['name'] as String? ?? '',
price: data['price_amount'] as int,
stock: data['stock_quantity'] as int,
category: data['category_name'] as String? ?? '',
imageUrl: resolve(data['image_url'] as String? ?? ''),
),
available: data['available'] as bool,
specifications: (data['specifications'] as List? ?? []).whereType<String>().toList(),
);
}
}

View File

@@ -0,0 +1,49 @@
// 功能描述:推荐商品分页与真实金额校验;版本:1.0.0。
import 'primary_models.dart';
class ProductRecommendations {
const ProductRecommendations({required this.items, required this.page, required this.hasMore});
final List<ProductSummary> items;
final int page;
final bool hasMore;
factory ProductRecommendations.fromJson(
Map<String, Object?> data,
String Function(String) resolve,
) {
if (data['items'] is! List ||
data['page'] is! int ||
(data['page'] as int) < 1 ||
data['has_more'] is! bool) {
throw const FormatException('推荐分页数据异常');
}
final items = <ProductSummary>[];
for (final row in data['items'] as List) {
if (row is! Map ||
row['identity'] is! String ||
(row['identity'] as String).isEmpty ||
row['name'] is! String ||
row['price_amount'] is! int ||
(row['price_amount'] as int) < 0 ||
row['stock_quantity'] is! int ||
(row['stock_quantity'] as int) < 0) {
throw const FormatException('推荐商品数据异常');
}
items.add(
ProductSummary(
identity: row['identity'] as String,
name: row['name'] as String,
price: row['price_amount'] as int,
stock: row['stock_quantity'] as int,
category: row['category_name'] as String? ?? '',
imageUrl: resolve(row['image_url'] as String? ?? ''),
),
);
}
if (items.isEmpty && data['has_more'] == true) throw const FormatException('推荐分页为空');
return ProductRecommendations(
items: items,
page: data['page'] as int,
hasMore: data['has_more'] as bool,
);
}
}

View File

@@ -0,0 +1,132 @@
// 功能描述:充值配置、创建结果和入账事实的严格类型;版本:1.0.0。
import 'client_models.dart';
int rechargeCents(Object? value) {
if (value is! int || value < 0) throw const FormatException('充值金额格式异常');
return value;
}
/// 十进制文本直接转换为分,禁止浮点舍入、指数或负数。
int? parseRechargeAmount(String value) {
final text = value.trim();
if (!RegExp(r'^\d{1,9}(\.\d{1,2})?$').hasMatch(text)) return null;
final parts = text.split('.');
return int.parse(parts[0]) * 100 + (parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0')));
}
class RechargeOptions {
const RechargeOptions({
required this.min,
required this.max,
required this.channels,
this.agreement,
});
final int min, max;
final List<RechargeChannel> channels;
final RechargeAgreement? agreement;
factory RechargeOptions.fromJson(Map<String, Object?> json) {
final min = rechargeCents(json['min_amount']), max = rechargeCents(json['max_amount']);
if (min < 1 || max < min || json['channels'] is! List) throw const FormatException('充值配置异常');
return RechargeOptions(
min: min,
max: max,
channels: [
for (final item in json['channels'] as List)
RechargeChannel.fromJson(Map<String, Object?>.from(item as Map)),
],
agreement: json['agreement'] == null
? null
: RechargeAgreement.fromJson(Map<String, Object?>.from(json['agreement'] as Map)),
);
}
}
class RechargeChannel {
const RechargeChannel(this.name, {required this.app, required this.wap});
final String name;
final bool app, wap;
String get title => name == 'wechat' ? '微信支付' : '支付宝';
factory RechargeChannel.fromJson(Map<String, Object?> json) {
if (!['wechat', 'alipay'].contains(json['channel']) ||
json['app'] is! bool ||
json['wap'] is! bool) {
throw const FormatException('支付方式异常');
}
return RechargeChannel(
json['channel'] as String,
app: json['app'] as bool,
wap: json['wap'] as bool,
);
}
}
class RechargeAgreement {
const RechargeAgreement(this.identity, this.version, this.title, this.body);
final String identity, title, body;
final int version;
factory RechargeAgreement.fromJson(Map<String, Object?> json) {
if (json['identity'] is! String ||
json['body'] is! String ||
json['version'] is! int ||
(json['identity'] as String).trim().isEmpty ||
(json['body'] as String).trim().isEmpty ||
(json['version'] as int) < 1) {
throw const FormatException('充值协议不完整');
}
return RechargeAgreement(
json['identity'] as String,
json['version'] as int,
json['title'] as String? ?? '充值协议',
json['body'] as String,
);
}
}
/// 只有充值状态23表示钱包已经入账;渠道关闭或SDK返回都不能替代这一事实。
class RechargeRecord {
const RechargeRecord({
required this.identity,
required this.number,
required this.amount,
required this.status,
required this.channel,
required this.createdAt,
this.paymentStatus,
});
final String identity, number, channel;
final int amount, status;
final int? paymentStatus;
final DateTime createdAt;
bool get credited => status == 23;
String get statusText => credited
? '已到账'
: paymentStatus == 30
? '支付已关闭,未入账'
: status == 10
? '待确认到账'
: '状态待确认';
String get amountText => moneyText(amount);
factory RechargeRecord.fromJson(Map<String, Object?> json) {
final date = DateTime.tryParse(json['created_at'] as String? ?? '');
if ((json['identity'] as String? ?? '').isEmpty ||
json['recharge_status'] is! int ||
date == null) {
throw const FormatException('充值记录不完整');
}
return RechargeRecord(
identity: json['identity'] as String,
number: json['recharge_no'] as String? ?? '',
amount: rechargeCents(json['amount']),
status: json['recharge_status'] as int,
channel: json['channel'] as String? ?? '',
createdAt: date.toLocal(),
paymentStatus: json['payment_status'] as int?,
);
}
}
class RechargeRecordPage {
const RechargeRecordPage(this.items, this.nextCursor);
final List<RechargeRecord> items;
final String nextCursor;
}

View File

@@ -0,0 +1,49 @@
// 功能描述:工单展示适配,状态动作只取服务端许可,不猜测派单和处理时间。
// 版本:1.0.0。
import 'client_models.dart';
class ServiceTicket {
const ServiceTicket(this.record);
final ClientRecord record;
String get identity => record.identity;
String get number => text('ticket_no', record.title);
String get state => text('status_name', '状态待核实');
String get description => text('description', '暂无描述');
String get result => text('result');
String get address => text('address', '未填写地址');
List<Map<String, Object?>> get photos => (record.raw['photos'] as List? ?? [])
.whereType<Map<Object?, Object?>>()
.map((value) => Map<String, Object?>.from(value))
.where((value) => value['identity'] is String)
.toList();
String get fault =>
const {'valve': '阀门故障', 'alarm': '报警器故障', 'leak': '燃气泄漏', 'other': '其他问题'}[text(
'fault_type',
)] ??
category;
String get category =>
const {
'repair': '维修',
'installation': '安装',
'inspection': '安检',
'reinspection': '复检',
'customer_service': '客户服务',
}[text('category')] ??
'其他服务';
List<String> get actions =>
(record.raw['allowed_actions'] as List?)?.whereType<String>().toList() ?? [];
/// 返回可展示文字,空值采用调用方指定的占位。
String text(String key, [String fallback = '']) {
final value = record.raw[key];
return value is String && value.trim().isNotEmpty ? value : fallback;
}
/// 使用接口的真实时间转换到本地,缺失时不生成推测进度。
String time(String key) {
final value = DateTime.tryParse(text(key))?.toLocal();
if (value == null) return '未记录';
String pad(int n) => n.toString().padLeft(2, '0');
return '${value.year}-${pad(value.month)}-${pad(value.day)} ${pad(value.hour)}:${pad(value.minute)}';
}
}

View File

@@ -0,0 +1,40 @@
// 功能描述:用户收货地址模型,列表脱敏展示、编辑使用本人完整数据。
// 版本:1.0.0。
class ShippingAddress {
const ShippingAddress({
this.identity = '',
required this.address,
required this.contactName,
required this.contactPhone,
this.longitude = '',
this.latitude = '',
this.isDefault = false,
});
final String identity, address, contactName, contactPhone, longitude, latitude;
final bool isDefault;
/// 从兼容地址响应读取字段;历史空联系人在编辑时要求补齐。
factory ShippingAddress.fromJson(Map<String, Object?> json) => ShippingAddress(
identity: json['identity'] as String? ?? '',
address: json['address'] as String? ?? '',
contactName: json['contact_name'] as String? ?? '',
contactPhone: json['contact_phone'] as String? ?? '',
longitude: json['longitude'] as String? ?? '',
latitude: json['latitude'] as String? ?? '',
isDefault: json['is_default'] == true,
);
String get maskedPhone => contactPhone.length == 11
? '${contactPhone.substring(0, 3)}****${contactPhone.substring(7)}'
: contactPhone;
/// 保存地址的完整字段;不接收内部账户编号。
Map<String, Object?> toJson() => {
'address': address,
'contact_name': contactName,
'contact_phone': contactPhone,
'longitude': longitude,
'latitude': latitude,
'is_default': isDefault,
};
}

View File

@@ -0,0 +1,91 @@
// 功能描述:商城订单成交快照、费用和履约时间;版本:1.0.0。
import 'client_models.dart';
import 'order_summary.dart';
/// 历史商品名称与单价不随商品目录改动;图片仅接受服务端快照。
class ShopOrderLine {
const ShopOrderLine({
required this.identity,
required this.productIdentity,
required this.name,
required this.quantity,
required this.unitAmount,
required this.imageUrl,
});
final String identity, productIdentity, name, imageUrl;
final int quantity, unitAmount;
int get amount => quantity * unitAmount;
}
/// 金额一律为整数分;缺失时间保留未知,不依据当前时间补写历史。
class ShopOrderDetail {
ShopOrderDetail.fromJson(Map<String, Object?> data, String Function(String) resolve)
: identity = _text(data, 'identity'),
orderNo = _text(data, 'order_no'),
statusName = _text(data, 'status_name'),
address = _text(data, 'address'),
contactName = _text(data, 'contact_name'),
contactPhone = _text(data, 'contact_phone'),
remark = _text(data, 'remark'),
logisticsNo = _text(data, 'logistics_no'),
logisticsCompany = _text(data, 'logistics_company'),
productAmount = _amount(data, 'product_amount'),
discountAmount = _amount(data, 'discount_amount'),
payableAmount = _amount(data, 'payable_amount'),
createdAt = _date(data['created_at']),
paidAt = _date(data['paid_at']),
shippedAt = _date(data['shipped_at']),
receivedAt = _date(data['received_at']),
actions = Set.unmodifiable((data['allowed_actions'] as List).whereType<String>()),
items = List.unmodifiable(
(data['items'] as List).map((raw) {
final row = Map<String, Object?>.from(raw as Map);
final qty = _amount(row, 'quantity');
if (qty < 1) throw const FormatException('订单商品数量异常');
return ShopOrderLine(
identity: _text(row, 'identity'),
productIdentity: _text(row, 'product_identity'),
name: _text(row, 'name'),
quantity: qty,
unitAmount: _amount(row, 'sale_amount'),
imageUrl: resolve(_text(row, 'image_url')),
);
}),
) {
if (identity.isEmpty || orderNo.isEmpty || statusName.isEmpty) {
throw const FormatException('订单资料不完整');
}
}
final String identity,
orderNo,
statusName,
address,
contactName,
contactPhone,
remark,
logisticsNo,
logisticsCompany;
final int productAmount, discountAmount, payableAmount;
final DateTime? createdAt, paidAt, shippedAt, receivedAt;
final Set<String> actions;
final List<ShopOrderLine> items;
OrderSummary get summary => OrderSummary(
record: ClientRecord(identity: identity, title: orderNo, subtitle: '', raw: const {}),
amount: payableAmount,
statusName: statusName,
orderNo: orderNo,
stationName: '',
actions: actions,
createdAt: createdAt,
items: [for (final i in items) OrderItemSummary(i.identity, i.quantity, name: i.name)],
);
static String _text(Map<String, Object?> data, String key) => data[key] as String? ?? '';
static int _amount(Map<String, Object?> data, String key) {
final value = data[key];
if (value is! int || value < 0) throw FormatException('订单金额或数量异常:$key');
return value;
}
static DateTime? _date(Object? value) =>
value == null ? null : DateTime.tryParse(value.toString());
}

View File

@@ -0,0 +1,78 @@
// 功能描述:定义用气统计的服务端事实模型;版本:1.0.0。
class UsagePoint {
const UsagePoint({required this.date, required this.usage});
final DateTime date;
final double usage;
factory UsagePoint.fromJson(Map<String, Object?> json) => UsagePoint(
date: DateTime.parse(json['date'] as String),
usage: (json['usage'] as num).toDouble(),
);
}
class UsageComposition {
const UsageComposition({required this.breakfast, required this.lunch, required this.dinner});
final double breakfast, lunch, dinner;
factory UsageComposition.fromJson(Map<String, Object?> json) => UsageComposition(
breakfast: (json['breakfast'] as num? ?? 0).toDouble(),
lunch: (json['lunch'] as num? ?? 0).toDouble(),
dinner: (json['dinner'] as num? ?? 0).toDouble(),
);
}
class UsageStatistics {
const UsageStatistics({
required this.available,
required this.deviceIdentity,
required this.deviceName,
required this.deviceCode,
required this.period,
required this.periodStart,
required this.periodEnd,
required this.unit,
required this.points,
required this.totalUsage,
required this.averageUsage,
required this.composition,
required this.source,
required this.calcVersion,
required this.updatedAt,
required this.unavailableReason,
});
final bool available;
final String deviceIdentity, deviceName, deviceCode, period, unit;
final DateTime periodStart, periodEnd;
final List<UsagePoint> points;
final double totalUsage, averageUsage;
final UsageComposition composition;
final String source, calcVersion, unavailableReason;
final DateTime? updatedAt;
factory UsageStatistics.fromJson(Map<String, Object?> json) {
final device = json['device'] as Map<String, Object?>? ?? const {};
final composition = json['composition'] as Map<String, Object?>? ?? const {};
return UsageStatistics(
available: json['available'] == true,
deviceIdentity: device['identity'] as String? ?? '',
deviceName: device['name'] as String? ?? '',
deviceCode: device['code'] as String? ?? '',
period: json['period'] as String? ?? 'month',
periodStart: DateTime.parse(json['period_start'] as String),
periodEnd: DateTime.parse(json['period_end'] as String),
unit: json['unit'] as String? ?? 'kg',
points: (json['points'] as List<Object?>? ?? const [])
.map((item) => UsagePoint.fromJson(item as Map<String, Object?>))
.toList(),
totalUsage: (json['total_usage'] as num? ?? 0).toDouble(),
averageUsage: (json['average_usage'] as num? ?? 0).toDouble(),
composition: UsageComposition.fromJson(composition),
source: json['source'] as String? ?? '',
calcVersion: json['calc_version'] as String? ?? '',
updatedAt: DateTime.tryParse(json['updated_at']?.toString() ?? '')?.toLocal(),
unavailableReason: json['unavailable_reason'] as String? ?? '',
);
}
}

View File

@@ -0,0 +1,93 @@
// 功能描述:银行卡与提现申请的严格领域模型;版本:1.0.0。
/// 本人已绑定银行卡只保留服务端返回的脱敏字段。
class WalletBank {
const WalletBank({
required this.identity,
required this.maskedNumber,
required this.bankName,
required this.owner,
required this.type,
required this.isDefault,
});
final String identity, maskedNumber, bankName, owner, type;
final bool isDefault;
factory WalletBank.fromJson(Map<String, Object?> json) {
final identity = json['identity'], number = json['card_no_masked'];
if (identity is! String || identity.isEmpty || number is! String || number.isEmpty) {
throw const FormatException('银行卡数据不完整');
}
return WalletBank(
identity: identity,
maskedNumber: number,
bankName: json['bank_name'] as String? ?? '银行卡',
owner: json['card_owner'] as String? ?? '',
type: json['bank_type'] as String? ?? '',
isDefault: json['is_default'] == true,
);
}
String get typeName => switch (type) {
'debit' || 'saving' => '储蓄卡',
'credit' => '信用卡',
_ => type,
};
}
/// 提现状态必须来自服务端,申请成功只表示进入审核流程。
class WalletWithdrawal {
const WalletWithdrawal({
required this.identity,
required this.number,
required this.amount,
required this.fee,
required this.status,
required this.createdAt,
});
final String identity, number;
final int amount, fee, status;
final DateTime createdAt;
factory WalletWithdrawal.fromJson(Map<String, Object?> json) {
final identity = json['identity'], amount = json['amount'], fee = json['fee'];
final createdAt = DateTime.tryParse(json['created_at'] as String? ?? '');
if (identity is! String ||
identity.isEmpty ||
amount is! int ||
amount <= 0 ||
fee is! int ||
fee < 0 ||
json['apply_status'] is! int ||
createdAt == null) {
throw const FormatException('提现记录数据不完整');
}
return WalletWithdrawal(
identity: identity,
number: json['cash_no'] as String? ?? '',
amount: amount,
fee: fee,
status: json['apply_status'] as int,
createdAt: createdAt.toLocal(),
);
}
String get statusName => switch (status) {
10 => '待审核',
18 => '处理中',
23 => '已到账',
30 => '已拒绝',
31 => '已退回',
_ => '状态待确认',
};
}
/// 金额文本直接转整数分,禁止浮点舍入和指数格式。
int? parseWithdrawalAmount(String value) {
final text = value.trim();
if (!RegExp(r'^\d{1,9}(\.\d{1,2})?$').hasMatch(text)) return null;
final parts = text.split('.');
return int.parse(parts[0]) * 100 + (parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0')));
}

View File

@@ -0,0 +1,67 @@
// 功能描述:钱包账单的严格金额与收支语义模型;版本:1.0.0。
import 'client_models.dart';
/// 账单是已记账事实,不把未完成订单或提现申请伪装成成功流水。
class WalletBill {
const WalletBill({
required this.identity,
required this.number,
required this.direction,
required this.tradeType,
required this.amount,
required this.fee,
required this.balanceAfter,
required this.createdAt,
required this.channel,
});
final String identity, number, direction, tradeType, channel;
final int amount, fee, balanceAfter;
final DateTime createdAt;
bool get income => direction == 'income';
String get signedAmount => '${income ? '+' : '-'}${moneyText(amount)}';
String get title => switch (tradeType) {
'recharge' => '余额充值',
'refund' => '退款入账',
'ec_order' => '商城订单',
'gas_order' => '气瓶订单',
'withdrawal_reserve' => '提现资金冻结',
'withdrawal_release' => '提现资金退回',
'withdrawal_complete' => '提现完成',
_ => '其他账单',
};
factory WalletBill.fromJson(Map<String, Object?> json) {
int amount(String key, {bool positive = false}) {
final value = json[key];
if (value is! int || value < (positive ? 1 : 0)) {
throw const FormatException('账单金额格式异常');
}
return value;
}
final direction = json['direction'];
final date = DateTime.tryParse(json['created_at'] as String? ?? '');
if (!['income', 'expense'].contains(direction) ||
date == null ||
(json['identity'] as String? ?? '').isEmpty) {
throw const FormatException('账单信息不完整');
}
return WalletBill(
identity: json['identity'] as String,
number: json['record_no'] as String? ?? '',
direction: direction as String,
tradeType: json['trade_type'] as String? ?? '',
amount: amount('amount', positive: true),
fee: amount('fee'),
balanceAfter: amount('balance_after'),
createdAt: date.toLocal(),
channel: json['pay_channel'] as String? ?? '',
);
}
}
/// 分页游标由服务端提供,不依据客户端条数猜测是否还有数据。
class WalletBillPage {
const WalletBillPage(this.items, this.nextCursor);
final List<WalletBill> items;
final String nextCursor;
}