feat(client): 购买模型/仓库切 pay v2 契约(render_type 多态 + 分金额 + 订单列表)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 23:34:21 +08:00
parent 45a43c20a4
commit 179051a8fa
6 changed files with 536 additions and 8 deletions
+20
View File
@@ -0,0 +1,20 @@
/// 分转元字符串,千分位分组 + 两位小数,纯整数运算(金额禁 float)。
///
/// 例:299900 → "2,999.00"100 → "1.00"0 → "0.00";负数保留符号。
String yuanFromMinor(int minor) {
final negative = minor < 0;
final abs = negative ? -minor : minor;
final yuan = abs ~/ 100;
final cents = abs % 100;
final yuanDigits = yuan.toString();
final len = yuanDigits.length;
final buffer = StringBuffer();
for (var i = 0; i < len; i++) {
if (i > 0 && (len - i) % 3 == 0) buffer.write(',');
buffer.write(yuanDigits[i]);
}
final centsStr = cents.toString().padLeft(2, '0');
return '${negative ? '-' : ''}$buffer.$centsStr';
}
+138 -8
View File
@@ -1,4 +1,5 @@
import '../core/config/app_constants.dart';
import '../core/utils/money.dart';
class LicenseInfo {
final int id;
@@ -65,24 +66,41 @@ class LicenseInfo {
phase == 'grace' || phase == 'readonly' || phase == 'locked';
}
/// POST /license/purchase 响应:pay 收银台跳转信息
/// POST /license/purchase 响应:pay v2 收银台会话信息(render_type 多态)
class PurchaseOrder {
final String payUrl;
final String outTradeNo;
final String amount; // 实付金额(pay 侧权威价,如 "2999.00"
final String renderType; // redirect | qr | ...
final Map<String, dynamic> payload;
final int amountMinor;
final String currency;
final String subject;
const PurchaseOrder({
required this.payUrl,
required this.outTradeNo,
required this.amount,
required this.renderType,
required this.payload,
required this.amountMinor,
required this.currency,
required this.subject,
});
/// render_type == redirect 时的跳转链接(payload['url'])。
String get redirectUrl => payload['url'] as String? ?? '';
/// render_type == qr 时的二维码内容(payload['qr_content'])。
String get qrContent => payload['qr_content'] as String? ?? '';
/// 兼容旧字段:purchase_card.dartTask 7 改造前)仍读 payUrl/amount。
String get payUrl => redirectUrl;
String get amount => yuanFromMinor(amountMinor);
factory PurchaseOrder.fromJson(Map<String, dynamic> json) => PurchaseOrder(
payUrl: json['pay_url'] as String? ?? '',
outTradeNo: json['out_trade_no'] as String? ?? '',
amount: json['amount'] as String? ?? '',
renderType: json['render_type'] as String? ?? 'redirect',
payload:
(json['payload'] as Map?)?.cast<String, dynamic>() ?? const {},
amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0,
currency: json['currency'] as String? ?? 'CNY',
subject: json['subject'] as String? ?? '',
);
}
@@ -91,13 +109,17 @@ class PurchaseOrder {
class PurchaseStatusInfo {
final String outTradeNo;
final String status; // pending | paid | failed
final String amount;
final int amountMinor;
final String currency;
final String amount; // Deprecated:后端 formatMinor 串回退,串失败时用 amountMinor
final DateTime? paidAt;
final DateTime? expiresAt; // 续期后的门店授权到期时间(仅 paid 时有值)
const PurchaseStatusInfo({
required this.outTradeNo,
required this.status,
required this.amountMinor,
required this.currency,
required this.amount,
this.paidAt,
this.expiresAt,
@@ -105,10 +127,16 @@ class PurchaseStatusInfo {
bool get isPaid => status == 'paid';
/// 展示用金额:优先按 amount_minor 换算,退回旧 amount 串兜底。
String get displayAmount =>
amountMinor > 0 ? yuanFromMinor(amountMinor) : amount;
factory PurchaseStatusInfo.fromJson(Map<String, dynamic> json) =>
PurchaseStatusInfo(
outTradeNo: json['out_trade_no'] as String? ?? '',
status: json['status'] as String? ?? 'pending',
amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0,
currency: json['currency'] as String? ?? 'CNY',
amount: json['amount'] as String? ?? '',
paidAt: json['paid_at'] != null
? DateTime.tryParse(json['paid_at'] as String)
@@ -118,3 +146,105 @@ class PurchaseStatusInfo {
: null,
);
}
/// GET /license/purchases 列表项。
class PurchaseRecord {
final String outTradeNo;
final String productBizCode;
final int amountMinor;
final String currency;
final String amount; // Deprecated:后端 formatMinor 串回退
final String status; // pending | paid | failed
final String payUrl;
final String userName;
final DateTime? createdAt;
final DateTime? paidAt;
final DateTime? renewedTo;
const PurchaseRecord({
required this.outTradeNo,
required this.productBizCode,
required this.amountMinor,
required this.currency,
required this.amount,
required this.status,
required this.payUrl,
required this.userName,
this.createdAt,
this.paidAt,
this.renewedTo,
});
bool get isPaid => status == 'paid';
/// 展示用金额:优先按 amount_minor 换算,退回旧 amount 串兜底。
String get displayAmount =>
amountMinor > 0 ? yuanFromMinor(amountMinor) : amount;
factory PurchaseRecord.fromJson(Map<String, dynamic> json) =>
PurchaseRecord(
outTradeNo: json['out_trade_no'] as String? ?? '',
productBizCode: json['product_biz_code'] as String? ?? '',
amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0,
currency: json['currency'] as String? ?? 'CNY',
amount: json['amount'] as String? ?? '',
status: json['status'] as String? ?? 'pending',
payUrl: json['pay_url'] as String? ?? '',
userName: json['user_name'] as String? ?? '',
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
: null,
paidAt: json['paid_at'] != null
? DateTime.tryParse(json['paid_at'] as String)
: null,
renewedTo: json['renewed_to'] != null
? DateTime.tryParse(json['renewed_to'] as String)
: null,
);
}
/// GET /license/purchases 汇总(订单管理 tab KPI)。
class PurchaseSummary {
final int paidTotalMinor;
final int paidCount;
final int pendingCount;
final int totalCount;
const PurchaseSummary({
required this.paidTotalMinor,
required this.paidCount,
required this.pendingCount,
required this.totalCount,
});
factory PurchaseSummary.fromJson(Map<String, dynamic> json) =>
PurchaseSummary(
paidTotalMinor: (json['paid_total_minor'] as num?)?.toInt() ?? 0,
paidCount: (json['paid_count'] as num?)?.toInt() ?? 0,
pendingCount: (json['pending_count'] as num?)?.toInt() ?? 0,
totalCount: (json['total_count'] as num?)?.toInt() ?? 0,
);
}
/// GET /license/purchases 响应整体。
class PurchaseListResult {
final List<PurchaseRecord> items;
final int total;
final PurchaseSummary summary;
const PurchaseListResult({
required this.items,
required this.total,
required this.summary,
});
factory PurchaseListResult.fromJson(Map<String, dynamic> json) =>
PurchaseListResult(
items: (json['items'] as List<dynamic>? ?? const [])
.map((e) => PurchaseRecord.fromJson(e as Map<String, dynamic>))
.toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
summary: PurchaseSummary.fromJson(
(json['summary'] as Map<String, dynamic>?) ?? const {}),
);
}
@@ -74,6 +74,46 @@ class LicenseRepository {
}
}
/// 取消一笔未支付订单(防 pending 单堆积)。仅管理员可用。
/// 返回 true=已取消;订单不存在(404)或不可取消(409,如已支付/已取消)
/// 视为业务性「取消不了」,返回 false 而非抛异常。
Future<bool> cancelPurchase(String outTradeNo) async {
try {
final resp = await _client.post('/license/purchase/$outTradeNo/cancel');
final data = resp.data['data'] as Map<String, dynamic>?;
return data?['canceled'] as bool? ?? false;
} on DioException catch (e) {
final status = e.response?.statusCode;
if (status == 404 || status == 409) return false;
throw AppException(
e.response?.data?['error'] as String? ?? '取消订单失败,请稍后重试',
statusCode: status,
);
}
}
/// 购买订单列表(订单管理 tab,分页 + 状态筛选)。仅管理员可用。
Future<PurchaseListResult> listPurchases({
int page = 1,
int pageSize = 20,
String? status,
}) async {
try {
final resp = await _client.get('/license/purchases', params: {
'page': page,
'page_size': pageSize,
if (status != null) 'status': status,
});
return PurchaseListResult.fromJson(
resp.data['data'] as Map<String, dynamic>);
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '获取订单列表失败',
statusCode: e.response?.statusCode,
);
}
}
/// 本店首月特惠是否已享用(购买弹窗据此置灰特惠档)。
Future<bool> promoUsed() async {
try {