Przeglądaj źródła

OA单据统一优化草稿存储和恢复功能等

chengc 1 tydzień temu
rodzic
commit
c65bd904c4

+ 2 - 1
lib/features/expense/expense_apply_import_page.dart

@@ -220,7 +220,8 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
     FocusScope.of(context).unfocus();
     if (_startDateCtrl.text.isNotEmpty &&
         _endDateCtrl.text.isNotEmpty &&
-        _startDateCtrl.text.compareTo(_endDateCtrl.text) > 0) {
+        (DateTime.tryParse(_startDateCtrl.text) ?? DateTime.now())
+            .isAfter(DateTime.tryParse(_endDateCtrl.text) ?? DateTime.now())) {
       TDToast.showText(
         AppLocalizations.of(context).get('filterDateStartAfterEnd'),
         context: context,

+ 34 - 3
lib/features/expense/expense_create_controller.dart

@@ -180,7 +180,38 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
   Future<bool> submit() async {
     state = state.copyWith(isSubmitting: true);
     try {
-      await _api.submit(state.expense.copyWith(status: 'pending').toJson());
+      final expense = state.expense.copyWith(status: 'pending');
+      // 手动拼 Map(提交流程专用,保持 camelCase key,与 BillSave 接口一致)
+      final data = <String, dynamic>{
+        'id': expense.id,
+        'expenseNo': expense.expenseNo,
+        'expenseDate': expense.expenseDate?.toIso8601String(),
+        'applicantId': expense.applicantId,
+        'applicantName': expense.applicantName,
+        'deptId': expense.deptId,
+        'deptName': expense.deptName,
+        'currencyCode': expense.currencyCode,
+        'excRto': expense.excRto,
+        'isGenerateVoucher': expense.isGenerateVoucher,
+        'voucherNo': expense.voucherNo,
+        'approvedAmount': expense.approvedAmount,
+        'totalAmount': expense.totalAmount,
+        'totalAmtnSh': expense.totalAmtnSh,
+        'purpose': expense.purpose,
+        'remark': expense.remark,
+        'paymentMethod': expense.paymentMethod,
+        'status': expense.status,
+        'approvalInstanceId': expense.approvalInstanceId,
+        'previousInstanceIds': expense.previousInstanceIds,
+        'effectiveDate': expense.effectiveDate?.toIso8601String(),
+        'auditorId': expense.auditorId,
+        'version': expense.version,
+        'createTime': expense.createTime.toIso8601String(),
+        'updateTime': expense.updateTime.toIso8601String(),
+        'details': expense.details.map((d) => d.toJson()).toList(),
+        'attachments': expense.attachments,
+      };
+      await _api.submit(data);
       await DraftStorage.delete(expenseDraftKey);
       return true;
     } catch (_) {
@@ -194,9 +225,9 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
     state = state.copyWith(isSubmitting: true);
     try {
       final data = state.expense.toJson();
-      data.remove('deptId');
+      data.remove('dept');
       data.remove('deptName');
-      data.remove('applicantId');
+      data.remove('usrNo');
       data.remove('applicantName');
       await DraftStorage.save(expenseDraftKey, data);
       return true;

+ 9 - 4
lib/features/expense/expense_create_page.dart

@@ -25,6 +25,7 @@ import '../../shared/models/bill_file_rights.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/attachment_picker.dart';
+import '../../shared/helpers/audit_flow_helper.dart';
 import 'expense_apply_import_page.dart';
 
 class ExpenseCreatePage extends ConsumerStatefulWidget {
@@ -280,6 +281,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
       builder: (ctx) => TDAlertDialog(
         title: l10n.get('draftFound'),
         content: l10n.get('draftRestorePrompt'),
+        buttonStyle: TDDialogButtonStyle.text,
         leftBtn: TDDialogButtonOptions(
           title: l10n.get('discard'),
           titleColor: colors.textSecondary,
@@ -1141,11 +1143,14 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
           if (mounted) {
             LoadingDialog.hide(context);
             TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
-            // BillSave 成功后异步提交审核流,不阻塞不弹窗
+            // BillSave 成功后提交审核流,不阻塞不弹窗
             if (billNo != null) {
-              api.shSubmit(
-                bilNo: billNo,
-                bilDd: data['HeadData']['BX_DD']?.toString() ?? '',
+              final dd = data['HeadData']['BX_DD']?.toString() ?? '';
+              await AuditFlowHelper.handle(
+                context: context,
+                l10n: l10n,
+                getConfig: () => api.getBillAuditConfig('BX'),
+                onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd),
               );
             }
             GoRouter.of(context).go('/expense/list');

+ 29 - 32
lib/features/expense/expense_model.dart

@@ -171,20 +171,20 @@ class ExpenseModel {
     'id': id,
     'expenseNo': expenseNo,
     'expenseDate': expenseDate?.toIso8601String(),
-    'applicantId': applicantId,
+    'usrNo': applicantId,
     'applicantName': applicantName,
-    'deptId': deptId,
+    'dept': deptId,
     'deptName': deptName,
-    'currencyCode': currencyCode,
+    'curId': currencyCode,
     'excRto': excRto,
-    'isGenerateVoucher': isGenerateVoucher,
-    'voucherNo': voucherNo,
+    'vohId': isGenerateVoucher ? 'T' : 'F',
+    'vohNo': voucherNo,
     'approvedAmount': approvedAmount,
     'totalAmount': totalAmount,
     'totalAmtnSh': totalAmtnSh,
-    'purpose': purpose,
-    'remark': remark,
-    'paymentMethod': paymentMethod,
+    'reason': purpose,
+    'rem': remark,
+    'payId': paymentMethod,
     'isInvoiceVerified': isInvoiceVerified,
     'isTaxIdMatched': isTaxIdMatched,
     'isCategoryCompliant': isCategoryCompliant,
@@ -193,8 +193,8 @@ class ExpenseModel {
     'status': status,
     'approvalInstanceId': approvalInstanceId,
     'previousInstanceIds': previousInstanceIds,
-    'effectiveDate': effectiveDate?.toIso8601String(),
-    'auditorId': auditorId,
+    'effDd': effectiveDate?.toIso8601String(),
+    'chkMan': auditorId,
     'version': version,
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),
@@ -386,7 +386,7 @@ class ExpenseDetailModel {
       expenseId: json['expenseId'] as String? ?? '',
       expenseApplyId: json['aeNo'] as String? ?? '',
       expenseApplyNo: json['aeNo'] as String? ?? '',
-      expenseApplyDate: json['aeDd'] != null
+      expenseApplyDate: (json['aeDd'] is String && (json['aeDd'] as String).isNotEmpty)
           ? DateTime.parse(json['aeDd'] as String)
           : null,
       expenseCategory:
@@ -457,40 +457,37 @@ class ExpenseDetailModel {
   Map<String, dynamic> toJson() => {
     'id': id,
     'expenseId': expenseId,
-    'expenseApplyId': expenseApplyId,
-    'expenseApplyNo': expenseApplyNo,
-    'expenseApplyDate': expenseApplyDate?.toIso8601String(),
-    'expenseCategory': expenseCategory,
-    'categoryName': categoryName,
+    'aeDd': aeDd,
+    'idxNo': expenseCategory,
+    'idxName': categoryName,
     'priority': priority,
     'purpose': purpose,
-    'projectId': projectId,
-    'projectName': projectName,
-    'costDeptId': costDeptId,
-    'costDeptName': costDeptName,
-    'acctSubjectId': acctSubjectId,
-    'acctSubjectName': acctSubjectName,
-    'amount': amount,
-    'taxRate': taxRate,
-    'taxAmount': taxAmount,
-    'totalAmount': totalAmount,
+    'objNo': projectId,
+    'objName': projectName,
+    'dep': costDeptId,
+    'depName': costDeptName,
+    'accNo': acctSubjectId,
+    'accName': acctSubjectName,
+    'amtn': amount,
+    'taxRto': taxRate,
+    'tax': taxAmount,
+    'amt': totalAmount,
     'currencyCode': currencyCode,
     'exchangeRate': exchangeRate,
     'baseAmount': baseAmount,
     'approvedAmount': approvedAmount,
-    'customerVendorId': customerVendorId,
-    'customerVendorName': customerVendorName,
+    'cust': customerVendorId,
+    'custName': customerVendorName,
     'offsetAmount': offsetAmount,
     'bankName': bankName,
     'bankAccountName': bankAccountName,
     'bankAccount': bankAccount,
     'sqMan': sqMan,
-    'sqManName': sqManName,
+    'sqName': sqManName,
     'aeNo': aeNo,
-    'aeDd': aeDd,
-    'remark': remark,
+    'rem': remark,
     'preItm': preItm,
-    'sortOrder': sortOrder,
+    'itm': sortOrder,
     'attachments': attachments,
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),

+ 98 - 71
lib/features/expense_apply/expense_apply_create_page.dart

@@ -18,6 +18,8 @@ import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/widgets/nav_bar_config.dart';
 import '../../shared/widgets/attachment_picker.dart';
 import '../../core/theme/app_colors.dart';
+import 'expense_apply_model.dart';
+import '../../shared/helpers/audit_flow_helper.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/constants/enums.dart';
 import 'expense_apply_api.dart';
@@ -259,81 +261,102 @@ class _ExpenseApplyCreatePageState
   Future<void> _restoreDraft() async {
     final data = await DraftStorage.load(_draftKey);
     if (data == null) return;
-    final attData = data['attachments'] as List<dynamic>?;
-    if (attData != null) {
-      await _attachmentController.restoreFromPaths(attData.cast<String>());
-    }
-    setState(() {
-      _urgency = data['urgency'] as String? ?? Urgency.normal.value;
-      _purposeController.text = data['purpose'] as String? ?? '';
-      _validUntil = data['validUntil'] as String? ?? '';
-      _referenceNoController.text = data['referenceNo'] as String? ?? '';
-      _remarkController.text = data['remark'] as String? ?? '';
-      _details.clear();
-      final detailList = data['details'] as List<dynamic>?;
-      if (detailList != null) {
-        for (final d in detailList) {
-          final m = d as Map<String, dynamic>;
+    try {
+      final model = ExpenseApplyModel.fromJson(data);
+      final attData = data['attachments'] as List<dynamic>?;
+      if (attData != null) {
+        await _attachmentController.restoreFromPaths(attData.cast<String>());
+      }
+      setState(() {
+        _urgency = model.urgency;
+        _purposeController.text = model.purpose;
+        _validUntil = model.validUntil?.toIso8601String() ?? '';
+        _referenceNoController.text = model.referenceNo;
+        _remarkController.text = model.remark;
+        _details.clear();
+        for (final d in model.details) {
           _details.add(
             _DetailItem(
-              id: m['id'] as int? ?? _detailIdCounter++,
-              category: m['category'] as String? ?? '',
-              categoryName: m['categoryName'] as String? ?? '',
-              acctSubjectId: m['acctSubjectId'] as String? ?? '',
-              acctSubjectName: m['acctSubjectName'] as String? ?? '',
-              purpose: m['purpose'] as String? ?? '',
-              projectId: m['projectId'] as String? ?? '',
-              projectName: m['projectName'] as String? ?? '',
-              costDeptId: m['costDeptId'] as String? ?? '',
-              costDeptName: m['costDeptName'] as String? ?? '',
-              startDate: m['startDate'] as String? ?? '',
-              endDate: m['endDate'] as String? ?? '',
-              estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
-              remark: m['remark'] as String? ?? '',
-              sqMan: m['sqMan'] as String? ?? '',
-              sqManName: m['sqManName'] as String? ?? '',
+              id: _detailIdCounter++,
+              category: d.expenseCategory,
+              categoryName: d.categoryName,
+              acctSubjectId: d.acctSubjectId,
+              acctSubjectName: d.acctSubjectName,
+              purpose: d.purpose,
+              projectId: d.projectId,
+              projectName: d.projectName,
+              costDeptId: d.costDeptId,
+              costDeptName: d.costDeptName,
+              startDate: d.estimatedStartDate != null
+                  ? '${d.estimatedStartDate!.year}-${d.estimatedStartDate!.month.toString().padLeft(2, '0')}-${d.estimatedStartDate!.day.toString().padLeft(2, '0')}'
+                  : '',
+              endDate: d.estimatedEndDate != null
+                  ? '${d.estimatedEndDate!.year}-${d.estimatedEndDate!.month.toString().padLeft(2, '0')}-${d.estimatedEndDate!.day.toString().padLeft(2, '0')}'
+                  : '',
+              estimatedAmount: d.estimatedAmount,
+              remark: d.remark,
+              sqMan: d.sqMan,
+              sqManName: d.sqName,
             ),
           );
         }
-      }
-      _detailIdCounter = _details.isEmpty
-          ? 1
-          : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
-    });
+        _detailIdCounter = _details.isEmpty
+            ? 1
+            : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
+      });
+    } catch (_) {
+      // 旧格式草稿兼容:fromJson 失败则静默丢弃
+    }
   }
 
   Future<void> _saveDraftToStorage() async {
-    final detailList = _details
-        .map(
-          (d) => {
-            'id': d.id,
-            'category': d.category,
-            'categoryName': d.categoryName,
-            'acctSubjectId': d.acctSubjectId,
-            'acctSubjectName': d.acctSubjectName,
-            'purpose': d.purpose,
-            'projectId': d.projectId,
-            'projectName': d.projectName,
-            'costDeptId': d.costDeptId,
-            'costDeptName': d.costDeptName,
-            'startDate': d.startDate,
-            'endDate': d.endDate,
-            'estimatedAmount': d.estimatedAmount,
-            'remark': d.remark,
-            'sqMan': d.sqMan,
-            'sqManName': d.sqManName,
-          },
-        )
-        .toList();
-    await DraftStorage.save(_draftKey, {
-      'urgency': _urgency,
-      'purpose': _purposeController.text,
-      'validUntil': _validUntil,
-      'referenceNo': _referenceNoController.text,
-      'remark': _remarkController.text,
-      'attachments': _attachmentController.toPathList(),
-      'details': detailList,
-    });
+    final now = DateTime.now();
+    final model = ExpenseApplyModel(
+      id: '',
+      expenseApplyNo: '',
+      expenseApplyDate: now,
+      applicantId: '',
+      applicantName: '',
+      deptId: '',
+      deptName: '',
+      urgency: _urgency,
+      purpose: _purposeController.text,
+      remark: _remarkController.text,
+      validUntil: _validUntil.isNotEmpty ? DateTime.tryParse(_validUntil) : null,
+      referenceNo: _referenceNoController.text,
+      createTime: now,
+      updateTime: now,
+      details: _details
+          .map(
+            (d) => ExpenseApplyDetailModel(
+              id: '${d.id}',
+              expenseCategory: d.category,
+              categoryName: d.categoryName,
+              acctSubjectId: d.acctSubjectId,
+              acctSubjectName: d.acctSubjectName,
+              purpose: d.purpose,
+              projectId: d.projectId,
+              projectName: d.projectName,
+              costDeptId: d.costDeptId,
+              costDeptName: d.costDeptName,
+              estimatedStartDate: d.startDate.isNotEmpty
+                  ? DateTime.tryParse(d.startDate)
+                  : null,
+              estimatedEndDate: d.endDate.isNotEmpty
+                  ? DateTime.tryParse(d.endDate)
+                  : null,
+              estimatedAmount: d.estimatedAmount,
+              remark: d.remark,
+              sqMan: d.sqMan,
+              sqName: d.sqManName,
+              createTime: now,
+              updateTime: now,
+            ),
+          )
+          .toList(),
+      attachments: _attachmentController.toPathList(),
+    );
+    await DraftStorage.save(_draftKey, model.toJson());
   }
 
   // ═══ 草稿弹窗 ═══
@@ -348,6 +371,7 @@ class _ExpenseApplyCreatePageState
       builder: (ctx) => TDAlertDialog(
         title: l10n.get('draftFound'),
         content: l10n.get('draftRestorePrompt'),
+        buttonStyle: TDDialogButtonStyle.text,
         leftBtn: TDDialogButtonOptions(
           title: l10n.get('discard'),
           titleColor: colors.textSecondary,
@@ -977,11 +1001,14 @@ class _ExpenseApplyCreatePageState
           if (mounted) {
             LoadingDialog.hide(context);
             TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
-            // BillSave 成功后异步提交审核流,不阻塞不弹窗
+            // BillSave 成功后提交审核流,不阻塞不弹窗
             if (billNo != null) {
-              api.shSubmit(
-                bilNo: billNo,
-                bilDd: data['HeadData']['AE_DD']?.toString() ?? '',
+              final dd = data['HeadData']['AE_DD']?.toString() ?? '';
+              await AuditFlowHelper.handle(
+                context: context,
+                l10n: l10n,
+                getConfig: () => api.getBillAuditConfig('AE'),
+                onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd),
               );
             }
             GoRouter.of(context).go('/expense-apply/list');

+ 22 - 22
lib/features/expense_apply/expense_apply_model.dart

@@ -136,18 +136,18 @@ class ExpenseApplyModel {
 
   Map<String, dynamic> toJson() => {
     'id': id,
-    'expenseApplyNo': expenseApplyNo,
-    'expenseApplyDate': expenseApplyDate?.toIso8601String(),
-    'applicantId': applicantId,
+    'applyNo': expenseApplyNo,
+    'applyDate': expenseApplyDate?.toIso8601String(),
+    'usr': applicantId,
     'applicantName': applicantName,
-    'deptId': deptId,
+    'dept': deptId,
     'deptName': deptName,
     'estimatedAmount': estimatedAmount,
     'urgency': urgency,
-    'purpose': purpose,
-    'remark': remark,
-    'effectiveDate': effectiveDate?.toIso8601String(),
-    'auditorId': auditorId,
+    'reason': purpose,
+    'rem': remark,
+    'effDd': effectiveDate?.toIso8601String(),
+    'chkMan': auditorId,
     'status': status,
     'usageStatus': usageStatus,
     'validUntil': validUntil?.toIso8601String(),
@@ -324,25 +324,25 @@ class ExpenseApplyDetailModel {
 
   Map<String, dynamic> toJson() => {
     'id': id,
-    'expenseApplyId': expenseApplyId,
-    'expenseCategory': expenseCategory,
-    'categoryName': categoryName,
+    'aeNo': expenseApplyId,
+    'typeNo': expenseCategory,
+    'typeName': categoryName,
     'purpose': purpose,
-    'projectId': projectId,
-    'projectName': projectName,
-    'costDeptId': costDeptId,
-    'costDeptName': costDeptName,
-    'acctSubjectId': acctSubjectId,
-    'acctSubjectName': acctSubjectName,
+    'objNo': projectId,
+    'objName': projectName,
+    'dep': costDeptId,
+    'depName': costDeptName,
+    'accNo': acctSubjectId,
+    'accName': acctSubjectName,
     'sqMan': sqMan,
     'sqName': sqName,
-    'estimatedStartDate': estimatedStartDate?.toIso8601String(),
-    'estimatedEndDate': estimatedEndDate?.toIso8601String(),
-    'estimatedAmount': estimatedAmount,
+    'startDd': estimatedStartDate?.toIso8601String(),
+    'endDd': estimatedEndDate?.toIso8601String(),
+    'amtnYj': estimatedAmount,
     'bxNo': bxNo,
-    'remark': remark,
+    'rem': remark,
     'preItm': preItm,
-    'sortOrder': sortOrder,
+    'itm': sortOrder,
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),
     'isDeleted': isDeleted,

+ 2 - 1
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -228,7 +228,8 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
     }
     if (_startDate.isNotEmpty &&
         _endDate.isNotEmpty &&
-        _startDate.compareTo(_endDate) > 0) {
+        (DateTime.tryParse(_startDate) ?? DateTime.now())
+            .isAfter(DateTime.tryParse(_endDate) ?? DateTime.now())) {
       TDToast.showText(_l10n.get('startDateNotAfterEndDate'), context: context);
       return;
     }

+ 117 - 84
lib/features/overtime/overtime_apply_create_page.dart

@@ -19,6 +19,7 @@ import '../../shared/helpers/audit_flow_helper.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import 'overtime_apply_api.dart';
+import 'overtime_apply_model.dart';
 import '../expense_apply/expense_apply_api.dart';
 import 'widgets/overtime_apply_detail_dialog.dart';
 
@@ -269,86 +270,103 @@ class _OvertimeApplyCreatePageState
   Future<void> _restoreDraft() async {
     final data = await DraftStorage.load(_draftKey);
     if (data == null) return;
-    setState(() {
-      _reasonController.text = data['reason'] as String? ?? '';
-      _remarkController.text = data['remark'] as String? ?? '';
-      _selectedDeptId = data['deptId'] as String? ?? '';
-      _selectedDeptName = data['deptName'] as String? ?? '';
-      _selectedApplicantId = data['applicantId'] as String? ?? '';
-      _selectedApplicantName = data['applicantName'] as String? ?? '';
-      _details.clear();
-      final detailList = data['details'] as List<dynamic>?;
-      if (detailList != null) {
-        for (final d in detailList) {
-          final m = d as Map<String, dynamic>;
+    try {
+      final model = OvertimeApplyModel.fromJson(data);
+      setState(() {
+        _reasonController.text = model.reason;
+        _remarkController.text = model.rem;
+        _selectedDeptId = model.dep;
+        _selectedDeptName = model.depName;
+        _selectedApplicantId = model.salNo;
+        _selectedApplicantName = model.salName;
+        _details.clear();
+        for (final d in model.details) {
           _details.add(
             _DetailItem(
-              id: m['id'] as int? ?? _detailIdCounter++,
-              jbNo: m['jbNo'] as String?,
-              itm: m['itm'] as int? ?? 0,
-              salNo: m['salNo'] as String? ?? '',
-              salName: m['salName'] as String? ?? '',
-              dep: m['dep'] as String? ?? '',
-              depName: m['depName'] as String? ?? '',
-              jbType: m['jbType'] as String? ?? 'WORKING_DAY',
-              jbDate: m['jbDate'] as String? ?? '',
-              startTime: m['startTime'] as String? ?? '',
-              endTime: m['endTime'] as String? ?? '',
-              jbHours: (m['jbHours'] as num?)?.toDouble() ?? 0.0,
-              jbDays: (m['jbDays'] as num?)?.toDouble() ?? 0.0,
-              attPeriod: m['attPeriod'] as String? ?? '',
-              reason: m['detailReason'] as String? ?? '',
-              compensationType:
-                  m['compensationType'] as String? ?? 'OVERTIME_PAY',
-              compensationCount:
-                  (m['compensationCount'] as num?)?.toDouble() ?? 0.0,
-              adr: m['adr'] as String? ?? '',
-              rem: m['rem'] as String? ?? '',
+              id: _detailIdCounter++,
+              jbNo: d.jbNo,
+              itm: d.itm,
+              salNo: d.salNo,
+              salName: d.salName,
+              dep: d.dep,
+              depName: d.depName,
+              jbType: d.jbType,
+              jbDate: d.jbDate != null
+                  ? '${d.jbDate!.year}-${d.jbDate!.month.toString().padLeft(2, '0')}-${d.jbDate!.day.toString().padLeft(2, '0')}'
+                  : '',
+              startTime: d.startTime != null
+                  ? '${d.startTime!.year}-${d.startTime!.month.toString().padLeft(2, '0')}-${d.startTime!.day.toString().padLeft(2, '0')} '
+                      '${d.startTime!.hour.toString().padLeft(2, '0')}:${d.startTime!.minute.toString().padLeft(2, '0')}'
+                  : '',
+              endTime: d.endTime != null
+                  ? '${d.endTime!.year}-${d.endTime!.month.toString().padLeft(2, '0')}-${d.endTime!.day.toString().padLeft(2, '0')} '
+                      '${d.endTime!.hour.toString().padLeft(2, '0')}:${d.endTime!.minute.toString().padLeft(2, '0')}'
+                  : '',
+              jbHours: d.jbHours,
+              jbDays: d.jbDays,
+              attPeriod: d.attPeriod,
+              reason: d.reason,
+              compensationType: d.compensationType,
+              compensationCount: d.compensationCount,
+              adr: d.adr,
+              rem: d.rem,
             ),
           );
         }
-      }
-      _detailIdCounter = _details.isEmpty
-          ? 1
-          : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
-    });
+        _detailIdCounter = _details.isEmpty
+            ? 1
+            : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
+      });
+    } catch (_) {
+      // 旧格式草稿兼容
+    }
   }
 
   Future<void> _saveDraftToStorage() async {
-    final detailList = _details
-        .map(
-          (d) => {
-            'id': d.id,
-            'jbNo': d.jbNo,
-            'itm': d.itm,
-            'salNo': d.salNo,
-            'salName': d.salName,
-            'dep': d.dep,
-            'depName': d.depName,
-            'jbType': d.jbType,
-            'jbDate': d.jbDate,
-            'startTime': d.startTime,
-            'endTime': d.endTime,
-            'jbHours': d.jbHours,
-            'jbDays': d.jbDays,
-            'attPeriod': d.attPeriod,
-            'detailReason': d.reason,
-            'compensationType': d.compensationType,
-            'compensationCount': d.compensationCount,
-            'adr': d.adr,
-            'rem': d.rem,
-          },
-        )
-        .toList();
-    await DraftStorage.save(_draftKey, {
-      'reason': _reasonController.text,
-      'remark': _remarkController.text,
-      'deptId': _selectedDeptId,
-      'deptName': _selectedDeptName,
-      'applicantId': _selectedApplicantId,
-      'applicantName': _selectedApplicantName,
-      'details': detailList,
-    });
+    final now = DateTime.now();
+    final model = OvertimeApplyModel(
+      jbNo: '',
+      jbDd: now,
+      salNo: _selectedApplicantId,
+      salName: _selectedApplicantName,
+      dep: _selectedDeptId,
+      depName: _selectedDeptName,
+      reason: _reasonController.text,
+      rem: _remarkController.text,
+      usr: HostAppChannel.usr,
+      recordDd: now,
+      details: _details
+          .map(
+            (d) => OvertimeApplyDetailModel(
+              itm: d.itm,
+              jbNo: d.jbNo,
+              salNo: d.salNo,
+              salName: d.salName,
+              dep: d.dep,
+              depName: d.depName,
+              jbType: d.jbType,
+              jbDate: d.jbDate.isNotEmpty
+                  ? DateTime.tryParse(d.jbDate)
+                  : null,
+              startTime: d.startTime.isNotEmpty
+                  ? DateTime.tryParse(d.startTime)
+                  : null,
+              endTime: d.endTime.isNotEmpty
+                  ? DateTime.tryParse(d.endTime)
+                  : null,
+              jbHours: d.jbHours,
+              jbDays: d.jbDays,
+              attPeriod: d.attPeriod,
+              reason: d.reason,
+              compensationType: d.compensationType,
+              compensationCount: d.compensationCount,
+              adr: d.adr,
+              rem: d.rem,
+            ),
+          )
+          .toList(),
+    );
+    await DraftStorage.save(_draftKey, model.toJson());
   }
 
   // ═══ 草稿弹窗 ═══
@@ -365,7 +383,7 @@ class _OvertimeApplyCreatePageState
         buttonStyle: TDDialogButtonStyle.text,
         leftBtn: TDDialogButtonOptions(
           title: l10n.get('discard'),
-          titleColor: colors.danger,
+          titleColor: colors.textSecondary,
           action: () {
             Navigator.pop(ctx);
             DraftStorage.delete(_draftKey);
@@ -525,6 +543,11 @@ class _OvertimeApplyCreatePageState
                             _detailLabel(
                               '${l10n.get('jbDate')}: ${d.jbDate}',
                               colors,
+                              trailing: _weekdayLabel(d.jbDate, l10n) != null
+                                  ? _weekdayTag(
+                                      _weekdayLabel(d.jbDate, l10n)!,
+                                    )
+                                  : null,
                             ),
                           ],
                           if (d.startTime.isNotEmpty) ...[
@@ -661,7 +684,7 @@ class _OvertimeApplyCreatePageState
     return dt;
   }
 
-  String? _dayOfWeekLabel(String dateTimeStr, AppLocalizations l10n) {
+  String? _weekdayLabel(String dateTimeStr, AppLocalizations l10n) {
     final dt = DateTime.tryParse(dateTimeStr);
     if (dt == null) return null;
     switch (dt.weekday) {
@@ -699,22 +722,32 @@ class _OvertimeApplyCreatePageState
     }
   }
 
-  Widget _detailLabel(String text, AppColorsExtension colors) {
+  Widget _detailLabel(String text, AppColorsExtension colors, {Widget? trailing}) {
     return Padding(
       padding: const EdgeInsets.only(top: 2),
-      child: Text(
-        text,
-        maxLines: 2,
-        overflow: TextOverflow.ellipsis,
-        style: TextStyle(
-          fontSize: AppFontSizes.caption,
-          color: colors.textSecondary,
-        ),
+      child: Row(
+        children: [
+          Flexible(
+            child: Text(
+              text,
+              maxLines: 2,
+              overflow: TextOverflow.ellipsis,
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textSecondary,
+              ),
+            ),
+          ),
+          if (trailing != null) ...[
+            const SizedBox(width: 6),
+            trailing,
+          ],
+        ],
       ),
     );
   }
 
-  Widget _dayOfWeekTag(String label) {
+  Widget _weekdayTag(String label) {
     final tdTheme = TDTheme.of(context);
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 6),

+ 24 - 11
lib/features/overtime/overtime_apply_detail_page.dart

@@ -387,6 +387,9 @@ class _OvertimeApplyDetailPageState
                 _detailLabel(
                   '${l10n.get('jbDate')}: ${du.DateUtils.formatDate(d.jbDate!)}',
                   colors,
+                  trailing: _weekdayTag(
+                    _weekdayLabel(d.jbDate!, l10n),
+                  ),
                 ),
             ],
             if (d.startTime != null) ...[
@@ -450,7 +453,7 @@ class _OvertimeApplyDetailPageState
     );
   }
 
-  String _dayOfWeekLabelFromDate(DateTime dt, AppLocalizations l10n) {
+  String _weekdayLabel(DateTime dt, AppLocalizations l10n) {
     switch (dt.weekday) {
       case 1:
         return l10n.get('monday');
@@ -471,22 +474,32 @@ class _OvertimeApplyDetailPageState
     }
   }
 
-  Widget _detailLabel(String text, AppColorsExtension colors) {
+  Widget _detailLabel(String text, AppColorsExtension colors, {Widget? trailing}) {
     return Padding(
       padding: const EdgeInsets.only(top: 2),
-      child: Text(
-        text,
-        maxLines: 2,
-        overflow: TextOverflow.ellipsis,
-        style: TextStyle(
-          fontSize: AppFontSizes.caption,
-          color: colors.textSecondary,
-        ),
+      child: Row(
+        children: [
+          Flexible(
+            child: Text(
+              text,
+              maxLines: 2,
+              overflow: TextOverflow.ellipsis,
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textSecondary,
+              ),
+            ),
+          ),
+          if (trailing != null) ...[
+            const SizedBox(width: 6),
+            trailing,
+          ],
+        ],
       ),
     );
   }
 
-  Widget _dayOfWeekTag(String label) {
+  Widget _weekdayTag(String label) {
     final tdTheme = TDTheme.of(context);
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 6),

+ 26 - 11
lib/features/overtime/overtime_apply_edit_page.dart

@@ -497,6 +497,11 @@ class _OvertimeApplyEditPageState extends ConsumerState<OvertimeApplyEditPage> {
                             _detailLabel(
                               '${l10n.get('jbDate')}: ${d.jbDate}',
                               colors,
+                              trailing: _weekdayLabel(d.jbDate, l10n) != null
+                                  ? _weekdayTag(
+                                      _weekdayLabel(d.jbDate, l10n)!,
+                                    )
+                                  : null,
                             ),
                           ],
                           if (d.startTime.isNotEmpty) ...[
@@ -633,7 +638,7 @@ class _OvertimeApplyEditPageState extends ConsumerState<OvertimeApplyEditPage> {
     return dt;
   }
 
-  String? _dayOfWeekLabel(String dateTimeStr, AppLocalizations l10n) {
+  String? _weekdayLabel(String dateTimeStr, AppLocalizations l10n) {
     final dt = DateTime.tryParse(dateTimeStr);
     if (dt == null) return null;
     switch (dt.weekday) {
@@ -671,22 +676,32 @@ class _OvertimeApplyEditPageState extends ConsumerState<OvertimeApplyEditPage> {
     }
   }
 
-  Widget _detailLabel(String text, AppColorsExtension colors) {
+  Widget _detailLabel(String text, AppColorsExtension colors, {Widget? trailing}) {
     return Padding(
       padding: const EdgeInsets.only(top: 2),
-      child: Text(
-        text,
-        maxLines: 2,
-        overflow: TextOverflow.ellipsis,
-        style: TextStyle(
-          fontSize: AppFontSizes.caption,
-          color: colors.textSecondary,
-        ),
+      child: Row(
+        children: [
+          Flexible(
+            child: Text(
+              text,
+              maxLines: 2,
+              overflow: TextOverflow.ellipsis,
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textSecondary,
+              ),
+            ),
+          ),
+          if (trailing != null) ...[
+            const SizedBox(width: 6),
+            trailing,
+          ],
+        ],
       ),
     );
   }
 
-  Widget _dayOfWeekTag(String label) {
+  Widget _weekdayTag(String label) {
     final tdTheme = TDTheme.of(context);
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 6),

+ 4 - 2
lib/features/overtime/widgets/overtime_apply_detail_dialog.dart

@@ -330,7 +330,9 @@ class _OvertimeApplyDetailDialogState extends State<OvertimeApplyDetailDialog> {
       );
       return;
     }
-    if (_startTime.compareTo(_endTime) >= 0) {
+    final st = DateTime.tryParse(_startTime);
+    final et = DateTime.tryParse(_endTime);
+    if (st == null || et == null || et.isBefore(st) || et.isAtSameMomentAs(st)) {
       TDToast.showText(_l10n.get('endTimeMustLater'), context: context);
       return;
     }
@@ -413,7 +415,7 @@ class _OvertimeApplyDetailDialogState extends State<OvertimeApplyDetailDialog> {
                           _buildStartTimePicker(colors),
                           if (_startTime.isNotEmpty &&
                               _endTime.isNotEmpty &&
-                              _startTime.compareTo(_endTime) >= 0)
+                              () { final st = DateTime.tryParse(_startTime); final et = DateTime.tryParse(_endTime); return st != null && et != null && !et.isAfter(st); }())
                             Padding(
                               padding: const EdgeInsets.only(top: 8),
                               child: _buildTimeErrorHint(),

+ 30 - 2
lib/features/overtime/widgets/overtime_apply_detail_view_dialog.dart

@@ -93,6 +93,10 @@ class OvertimeApplyDetailViewDialog extends StatelessWidget {
                           l10n.get('jbDate'),
                           _formatDate(d.jbDate!),
                           colors,
+                          trailing: _weekdayTag(
+                            context,
+                            _weekdayLabel(d.jbDate!),
+                          ),
                         ),
                       if (d.startTime != null)
                         _row(
@@ -150,6 +154,7 @@ class OvertimeApplyDetailViewDialog extends StatelessWidget {
     String value,
     AppColorsExtension colors, {
     Color? valueColor,
+    Widget? trailing,
   }) {
     return Padding(
       padding: const EdgeInsets.only(bottom: 12),
@@ -163,7 +168,7 @@ class OvertimeApplyDetailViewDialog extends StatelessWidget {
               style: TextStyle(fontSize: 14, color: colors.textSecondary),
             ),
           ),
-          Expanded(
+          Flexible(
             child: Text(
               value,
               style: TextStyle(
@@ -172,6 +177,10 @@ class OvertimeApplyDetailViewDialog extends StatelessWidget {
               ),
             ),
           ),
+          if (trailing != null) ...[
+            const SizedBox(width: 6),
+            trailing,
+          ],
         ],
       ),
     );
@@ -271,7 +280,7 @@ class OvertimeApplyDetailViewDialog extends StatelessWidget {
     }
   }
 
-  String _dayOfWeekFromDate(DateTime dt) {
+  String _weekdayLabel(DateTime dt) {
     switch (dt.weekday) {
       case 1:
         return l10n.get('monday');
@@ -291,4 +300,23 @@ class OvertimeApplyDetailViewDialog extends StatelessWidget {
         return '';
     }
   }
+
+  Widget _weekdayTag(BuildContext ctx, String label) {
+    final tdTheme = TDTheme.of(ctx);
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 6),
+      decoration: BoxDecoration(
+        color: tdTheme.brandColor1,
+        borderRadius: BorderRadius.circular(4),
+      ),
+      child: Text(
+        label,
+        style: TextStyle(
+          fontSize: 11,
+          fontWeight: FontWeight.w500,
+          color: tdTheme.brandColor7,
+        ),
+      ),
+    );
+  }
 }

+ 71 - 62
lib/features/vehicle/vehicle_apply_create_page.dart

@@ -22,6 +22,7 @@ import '../../shared/widgets/searchable_picker_sheet.dart';
 import '../../shared/widgets/location_picker.dart';
 import '../../shared/helpers/audit_flow_helper.dart';
 import 'vehicle_apply_api.dart';
+import 'vehicle_apply_model.dart';
 
 class VehicleApplyCreatePage extends ConsumerStatefulWidget {
   const VehicleApplyCreatePage({super.key});
@@ -116,6 +117,7 @@ class _VehicleApplyCreatePageState
       builder: (ctx) => TDAlertDialog(
         title: l10n.get('draftFound'),
         content: l10n.get('draftRestorePrompt'),
+        buttonStyle: TDDialogButtonStyle.text,
         leftBtn: TDDialogButtonOptions(
           title: l10n.get('discard'),
           titleColor: colors.textSecondary,
@@ -140,47 +142,48 @@ class _VehicleApplyCreatePageState
   }
 
   void _applyDraftData(Map<String, dynamic> data) {
-    _reasonController.text = data['reason'] as String? ?? '';
-    _originAdr = data['originAdr'] as String? ?? '';
-    _originLat = (data['originLat'] as num?)?.toDouble();
-    _originLng = (data['originLng'] as num?)?.toDouble();
-    _destAdr = data['destAdr'] as String? ?? '';
-    _destLat = (data['destLat'] as num?)?.toDouble();
-    _destLng = (data['destLng'] as num?)?.toDouble();
-    final startStr = data['startTime'] as String?;
-    _startTime = startStr != null ? DateTime.tryParse(startStr) : null;
-    final endStr = data['endTime'] as String?;
-    _endTime = endStr != null ? DateTime.tryParse(endStr) : null;
-    _licensePlate = data['licensePlate'] as String? ?? '';
-    _vehicleType = data['vehicleType'] as String? ?? '';
-    _brand = data['brand'] as String? ?? '';
-    _odometerBegin = data['odometerBegin'] as int? ?? 0;
-    _seats = data['seats'] as int? ?? 0;
-    _driverName = data['driverName'] as String? ?? '';
-    _selectedDeptId = data['selectedDeptId'] as String? ?? '';
-    _selectedDeptName = data['selectedDeptName'] as String? ?? '';
-    _selectedApplicantId = data['selectedApplicantId'] as String? ?? '';
-    _selectedApplicantName = data['selectedApplicantName'] as String? ?? '';
-    // 恢复同行人(先清空自动添加的,避免重复)
-    _passengers.clear();
-    final pCount = data['passengerCount'] as int? ?? 0;
-    final pName = data['passengerName'] as String? ?? '';
-    if (pCount > 0 && pName.isNotEmpty) {
-      final parts = pName.split(';');
-      for (final part in parts) {
-        if (part.isEmpty) continue;
-        final idx = part.indexOf('/');
-        if (idx > 0) {
-          _passengers.add(
-            EmployeeItem(
-              salNo: part.substring(0, idx),
-              name: part.substring(idx + 1),
-            ),
-          );
-        } else {
-          _passengers.add(EmployeeItem(salNo: part, name: part));
+    try {
+      final model = VehicleApplyModel.fromJson(data);
+      _reasonController.text = model.reason;
+      _originAdr = model.originAdr;
+      _originLat = model.originLatitude;
+      _originLng = model.originLongitude;
+      _destAdr = model.destAdr;
+      _destLat = model.destLatitude;
+      _destLng = model.destLongitude;
+      _startTime = model.startTime;
+      _endTime = model.endTime;
+      _licensePlate = model.licensePlate;
+      _vehicleType = model.vehicleType;
+      _brand = model.brand;
+      _odometerBegin = model.odometerBegin;
+      _seats = model.seats;
+      _driverName = model.driverName;
+      _selectedDeptId = model.dep;
+      _selectedDeptName = model.deptName;
+      _selectedApplicantId = model.salNo;
+      _selectedApplicantName = model.applicantName;
+      _passengers.clear();
+      final pName = model.passengerName;
+      if (model.passengerCount > 0 && pName.isNotEmpty) {
+        final parts = pName.split(';');
+        for (final part in parts) {
+          if (part.isEmpty) continue;
+          final idx = part.indexOf('/');
+          if (idx > 0) {
+            _passengers.add(
+              EmployeeItem(
+                salNo: part.substring(0, idx),
+                name: part.substring(idx + 1),
+              ),
+            );
+          } else {
+            _passengers.add(EmployeeItem(salNo: part, name: part));
+          }
         }
       }
+    } catch (_) {
+      // 旧格式草稿兼容
     }
   }
 
@@ -821,29 +824,35 @@ class _VehicleApplyCreatePageState
   // ═══ 草稿 ═══
   Future<void> _saveDraftToStorage() async {
     if (!_hasUnsaved()) return;
-    await DraftStorage.save(_draftKey, {
-      'reason': _reasonController.text,
-      'originAdr': _originAdr,
-      'originLat': _originLat,
-      'originLng': _originLng,
-      'destAdr': _destAdr,
-      'destLat': _destLat,
-      'destLng': _destLng,
-      'startTime': _startTime?.toIso8601String(),
-      'endTime': _endTime?.toIso8601String(),
-      'licensePlate': _licensePlate,
-      'vehicleType': _vehicleType,
-      'brand': _brand,
-      'odometerBegin': _odometerBegin,
-      'seats': _seats,
-      'driverName': _driverName,
-      'passengerCount': _passengers.length,
-      'passengerName': _passengers.map((p) => '${p.salNo}/${p.name}').join(';'),
-      'selectedDeptId': _selectedDeptId,
-      'selectedDeptName': _selectedDeptName,
-      'selectedApplicantId': _selectedApplicantId,
-      'selectedApplicantName': _selectedApplicantName,
-    });
+    final now = DateTime.now();
+    final model = VehicleApplyModel(
+      ycNo: '',
+      ycDd: now,
+      salNo: _selectedApplicantId,
+      applicantName: _selectedApplicantName,
+      dep: _selectedDeptId,
+      deptName: _selectedDeptName,
+      reason: _reasonController.text,
+      originAdr: _originAdr,
+      originLatitude: _originLat,
+      originLongitude: _originLng,
+      destAdr: _destAdr,
+      destLatitude: _destLat,
+      destLongitude: _destLng,
+      passengerCount: _passengers.length,
+      passengerName: _passengers.map((p) => '${p.salNo}/${p.name}').join(';'),
+      licensePlate: _licensePlate,
+      vehicleType: _vehicleType,
+      brand: _brand,
+      odometerBegin: _odometerBegin,
+      seats: _seats,
+      driverName: _driverName,
+      startTime: _startTime,
+      endTime: _endTime,
+      usr: HostAppChannel.usr,
+      recordDd: now,
+    );
+    await DraftStorage.save(_draftKey, model.toJson());
   }
 
   void _doPop() {