From 037f153273bcf93c4085170bcd6335be29a4d3fb Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 22 Jul 2026 17:58:02 +0300 Subject: [PATCH] feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint --- .../models/consultant_profile_model.dart | 74 ++- .../models/consultation_request_model.dart | 52 +- .../models/sbp_bank_model.dart | 17 + .../register_consultant_cubit.dart | 162 +++++- .../register_consultant_state.dart | 3 + .../certificates_and_diplomas_screen.dart | 14 +- .../presentation/pages/last_step_screen.dart | 68 ++- .../pages/passport_data_screen.dart | 527 ++++++++++++++++++ .../pages/registration_as_screen.dart | 50 +- .../stepper_become_consultant_screen.dart | 441 ++++++++------- .../repositories/consultant_repository.dart | 13 +- .../services/consultant_service.dart | 14 +- .../models/consultant_profile_model_test.dart | 40 +- .../consultation_request_model_test.dart | 41 ++ .../bloc/register_consultant_cubit_test.dart | 84 ++- .../pages/last_step_screen_test.dart | 21 +- .../pages/passport_data_screen_test.dart | 59 ++ .../pages/registration_as_screen_test.dart | 32 +- ...r_become_consultant_screen_smoke_test.dart | 2 +- .../consultant_repository_test.dart | 13 + .../consultant_profile_request.json | 24 +- .../consultant/consultation_request.json | 26 +- test/fixtures/consultant/sbp_banks.json | 16 + 23 files changed, 1514 insertions(+), 279 deletions(-) create mode 100644 lib/features/consultant/become_consultant/models/sbp_bank_model.dart create mode 100644 lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart create mode 100644 test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart create mode 100644 test/fixtures/consultant/sbp_banks.json diff --git a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart index 535b58fd..dd9e01f5 100644 --- a/lib/features/consultant/become_consultant/models/consultant_profile_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_profile_model.dart @@ -6,24 +6,80 @@ part 'consultant_profile_model.g.dart'; @freezed abstract class ConsultantProfileModel with _$ConsultantProfileModel { + const ConsultantProfileModel._(); + const factory ConsultantProfileModel({ - @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, - @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, - @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, - @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, + @JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, + @JsonKey(name: 'cert_photos', includeIfNull: false) List? photos, + @JsonKey(name: 'completed_courses', includeIfNull: false) List? certificates, + @JsonKey(name: 'legal_type', includeIfNull: false) String? legalType, + @JsonKey(name: 'last_name', includeIfNull: false) String? lastName, + @JsonKey(name: 'first_name', includeIfNull: false) String? firstName, + @JsonKey(name: 'middle_name', includeIfNull: false) String? middleName, + @JsonKey(name: 'inn', includeIfNull: false) String? inn, + @JsonKey(name: 'snils', includeIfNull: false) String? snils, + @JsonKey(name: 'passport_series', includeIfNull: false) String? passportSeries, + @JsonKey(name: 'passport_number', includeIfNull: false) String? passportNumber, + @JsonKey(name: 'passport_issue_date', includeIfNull: false) String? passportIssueDate, + @JsonKey(name: 'passport_issuer', includeIfNull: false) String? passportIssuer, + @JsonKey(name: 'passport_issuer_code', includeIfNull: false) String? passportIssuerCode, + @JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate, + @JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace, + @JsonKey(name: 'registration_address', includeIfNull: false) String? registrationAddress, + @JsonKey(name: 'residence_address', includeIfNull: false) String? residenceAddress, + @JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship, + @JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident', includeIfNull: false) bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id', includeIfNull: false) String? sbpBankMemberId, + @JsonKey(name: 'agreement_accepted', includeIfNull: false) bool? agreementAccepted, + @JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false) bool? frdoAgreementAccepted, }) = _ConsultantProfileModel; factory ConsultantProfileModel.fromJson(Map json) => _$ConsultantProfileModelFromJson(json); + + bool get hasValidPassportData { + final last = lastName ?? ''; + final first = firstName ?? ''; + final innValue = inn ?? ''; + final snilsValue = snils ?? ''; + final series = passportSeries ?? ''; + final number = passportNumber ?? ''; + final issueDate = passportIssueDate ?? ''; + final issuer = passportIssuer ?? ''; + final issuerCode = passportIssuerCode ?? ''; + final birth = birthDate ?? ''; + final birthPlaceValue = birthPlace ?? ''; + final registration = registrationAddress ?? ''; + final residence = residenceAddress ?? ''; + final citizenshipValue = citizenship ?? ''; + + return last.trim().isNotEmpty && + first.trim().isNotEmpty && + RegExp(r'^\d{12}$').hasMatch(innValue) && + RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) && + RegExp(r'^\d{4}$').hasMatch(series) && + RegExp(r'^\d{6}$').hasMatch(number) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) && + issuer.trim().isNotEmpty && + RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) && + birthPlaceValue.trim().isNotEmpty && + registration.trim().isNotEmpty && + residence.trim().isNotEmpty && + RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue); + } } @freezed abstract class SocialLinksModel with _$SocialLinksModel { const factory SocialLinksModel({ - @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, - @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, - @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, - @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, - @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, + @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, + @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, + @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, + @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, + @JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, }) = _SocialLinksModel; factory SocialLinksModel.fromJson(Map json) => _$SocialLinksModelFromJson(json); diff --git a/lib/features/consultant/become_consultant/models/consultation_request_model.dart b/lib/features/consultant/become_consultant/models/consultation_request_model.dart index 13cc0e77..051f517f 100644 --- a/lib/features/consultant/become_consultant/models/consultation_request_model.dart +++ b/lib/features/consultant/become_consultant/models/consultation_request_model.dart @@ -12,11 +12,61 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel { @JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'cert_photos') required List photos, @JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, + @JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity, }) = _ConsultationRequestModel; factory ConsultationRequestModel.fromJson(Map json) => _$ConsultationRequestModelFromJson(json); } +@freezed +abstract class LegalEntityModel with _$LegalEntityModel { + const LegalEntityModel._(); + + const factory LegalEntityModel({ + required int id, + required String type, + String? inn, + String? snils, + String? state, + @JsonKey(name: 'alfa_scoring_status') String? alfaScoringStatus, + @JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId, + @JsonKey(name: 'last_name') String? lastName, + @JsonKey(name: 'first_name') String? firstName, + @JsonKey(name: 'middle_name') String? middleName, + @JsonKey(name: 'passport_series') String? passportSeries, + @JsonKey(name: 'passport_number') String? passportNumber, + @JsonKey(name: 'passport_issue_date') String? passportIssueDate, + @JsonKey(name: 'passport_issuer') String? passportIssuer, + @JsonKey(name: 'passport_issuer_code') String? passportIssuerCode, + @JsonKey(name: 'birth_date') String? birthDate, + @JsonKey(name: 'birth_place') String? birthPlace, + @JsonKey(name: 'registration_address') String? registrationAddress, + @JsonKey(name: 'residence_address') String? residenceAddress, + String? citizenship, + @JsonKey(name: 'tax_resident_us') bool? taxResidentUs, + @JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident, + @JsonKey(name: 'sbp_phone') String? sbpPhone, + @JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId, + }) = _LegalEntityModel; + + factory LegalEntityModel.fromJson(Map json) => _$LegalEntityModelFromJson(json); + + /// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`. + static String? normalizeApiDate(String? value) { + if (value == null || value.isEmpty) { + return null; + } + if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) { + return value.substring(0, 10); + } + return value; + } + + String? get normalizedPassportIssueDate => normalizeApiDate(passportIssueDate); + + String? get normalizedBirthDate => normalizeApiDate(birthDate); +} + @freezed abstract class CertPhotosModel with _$CertPhotosModel { const factory CertPhotosModel({ @@ -29,7 +79,7 @@ abstract class CertPhotosModel with _$CertPhotosModel { enum ModerationStatus { approved, created, - canceled + canceled, } @freezed diff --git a/lib/features/consultant/become_consultant/models/sbp_bank_model.dart b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart new file mode 100644 index 00000000..c7bf7976 --- /dev/null +++ b/lib/features/consultant/become_consultant/models/sbp_bank_model.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sbp_bank_model.freezed.dart'; +part 'sbp_bank_model.g.dart'; + +@freezed +abstract class SbpBankModel with _$SbpBankModel { + const factory SbpBankModel({ + @JsonKey(name: 'bank_member_id') required String bankMemberId, + @JsonKey(name: 'bank_name') required String bankName, + @JsonKey(name: 'short_name') String? shortName, + @JsonKey(name: 'logo_url') String? logoUrl, + String? bic, + }) = _SbpBankModel; + + factory SbpBankModel.fromJson(Map json) => _$SbpBankModelFromJson(json); +} diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 0410ae04..9cae8fa5 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -2,6 +2,8 @@ import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,21 +13,60 @@ class RegisterConsultantCubit extends Cubit { final ConsultantRepository consultantRepository; bool _update = false; + static const String individualPersonLegalType = 'individualPerson'; + RegisterConsultantCubit({required this.consultantRepository}) : super( const RegisterConsultantState( - consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true, legalType: individualPersonLegalType), loading: true, acceptOffer: false, + acceptFrdoAgreement: false, isUpdating: false, ), ); + Future> _loadSbpBanks() async { + try { + return await consultantRepository.getSbpBanks(); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString()); + return const []; + } + } + + List searchSbpBanks(String query) { + final normalized = query.trim().toLowerCase(); + if (normalized.isEmpty) { + return state.sbpBanks; + } + return state.sbpBanks.where((bank) { + final name = bank.bankName.toLowerCase(); + final shortName = (bank.shortName ?? '').toLowerCase(); + final bic = (bank.bic ?? '').toLowerCase(); + return name.contains(normalized) || shortName.contains(normalized) || bic.contains(normalized); + }).toList(); + } + + SbpBankModel? findSbpBankById(String? bankMemberId) { + if (bankMemberId == null || bankMemberId.isEmpty) { + return null; + } + for (final bank in state.sbpBanks) { + if (bank.bankMemberId == bankMemberId) { + return bank; + } + } + return null; + } + Future getMyRequestStatus(String slug) async { try { _update = true; final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); final certPhotos = await consultantRepository.getConsultantCertPhotos(); + final sbpBanks = await _loadSbpBanks(); List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { final course = courseMap.values.first; @@ -37,6 +78,8 @@ class RegisterConsultantCubit extends Cubit { return (link != null && link.isNotEmpty) ? link : null; } + final legal = myRequestStatus.legalEntity; + emit( RegisterConsultantState( consultant: ConsultantProfileModel( @@ -49,11 +92,33 @@ class RegisterConsultantCubit extends Cubit { personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), ), hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + legalType: legal?.type ?? individualPersonLegalType, + lastName: _emptyToNull(legal?.lastName), + firstName: _emptyToNull(legal?.firstName), + middleName: _emptyToNull(legal?.middleName), + inn: _emptyToNull(legal?.inn), + snils: _emptyToNull(legal?.snils), + passportSeries: _emptyToNull(legal?.passportSeries), + passportNumber: _emptyToNull(legal?.passportNumber), + passportIssueDate: legal?.normalizedPassportIssueDate, + passportIssuer: _emptyToNull(legal?.passportIssuer), + passportIssuerCode: _emptyToNull(legal?.passportIssuerCode), + birthDate: legal?.normalizedBirthDate, + birthPlace: _emptyToNull(legal?.birthPlace), + registrationAddress: _emptyToNull(legal?.registrationAddress), + residenceAddress: _emptyToNull(legal?.residenceAddress), + citizenship: _emptyToNull(legal?.citizenship) ?? 'RU', + taxResidentUs: legal?.taxResidentUs ?? false, + foreignTaxResident: legal?.foreignTaxResident ?? false, + sbpPhone: _emptyToNull(legal?.sbpPhone), + sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId), ), acceptOffer: false, + acceptFrdoAgreement: false, loading: false, requestModel: myRequestStatus, certPhotos: certPhotos, + sbpBanks: sbpBanks, isUpdating: _update, error: null, ), @@ -65,10 +130,31 @@ class RegisterConsultantCubit extends Cubit { } } + String? _emptyToNull(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + return value.trim(); + } + Future disableLoading() async { try { final certPhotos = await consultantRepository.getConsultantCertPhotos(); - emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null)); + final sbpBanks = await _loadSbpBanks(); + emit( + state.copyWith( + loading: false, + certPhotos: certPhotos, + sbpBanks: sbpBanks, + error: null, + consultant: state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship ?? 'RU', + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + ), + ), + ); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); @@ -86,13 +172,79 @@ class RegisterConsultantCubit extends Cubit { } } - void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { - emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); + void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool? acceptOffer, bool? acceptFrdoAgreement]) { + emit( + state.copyWith( + consultant: consultant, + acceptOffer: acceptOffer ?? state.acceptOffer, + acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, + loading: false, + error: null, + ), + ); + } + + void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) { + emit(state.copyWith(acceptOffer: acceptOffer ?? state.acceptOffer, acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement, error: null)); + } + + bool get hasAcceptedRequiredAgreements { + if (_update) { + return state.acceptOffer; + } + return state.acceptOffer && state.acceptFrdoAgreement; + } + + ConsultantProfileModel buildSubmitPayload() { + final withLegalType = state.consultant.copyWith( + legalType: individualPersonLegalType, + citizenship: state.consultant.citizenship ?? 'RU', + taxResidentUs: state.consultant.taxResidentUs ?? false, + foreignTaxResident: state.consultant.foreignTaxResident ?? false, + certificates: state.consultant.certificates + ?.map((course) => CertificatesModel(title: course.title, date: LegalEntityModel.normalizeApiDate(course.date) ?? course.date)) + .toList(), + ); + + if (_update) { + return ConsultantProfileModel( + hasMedicalEducation: withLegalType.hasMedicalEducation, + socialLinks: withLegalType.socialLinks, + photos: withLegalType.photos, + certificates: withLegalType.certificates, + legalType: withLegalType.legalType, + lastName: withLegalType.lastName, + firstName: withLegalType.firstName, + middleName: withLegalType.middleName, + inn: withLegalType.inn, + snils: withLegalType.snils, + passportSeries: withLegalType.passportSeries, + passportNumber: withLegalType.passportNumber, + passportIssueDate: withLegalType.passportIssueDate, + passportIssuer: withLegalType.passportIssuer, + passportIssuerCode: withLegalType.passportIssuerCode, + birthDate: withLegalType.birthDate, + birthPlace: withLegalType.birthPlace, + registrationAddress: withLegalType.registrationAddress, + residenceAddress: withLegalType.residenceAddress, + citizenship: withLegalType.citizenship, + taxResidentUs: withLegalType.taxResidentUs, + foreignTaxResident: withLegalType.foreignTaxResident, + sbpPhone: withLegalType.sbpPhone, + sbpBankMemberId: withLegalType.sbpBankMemberId, + ); + } + + return withLegalType.copyWith(agreementAccepted: state.acceptOffer, frdoAgreementAccepted: state.acceptFrdoAgreement); } Future registerConsultant() async { emit(state.copyWith(error: null)); try { + if (!state.consultant.hasValidPassportData) { + emit(state.copyWith(error: 'Заполните паспортные данные')); + return; + } if (state.newAddedPhotos.isNotEmpty) { await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); } @@ -101,7 +253,7 @@ class RegisterConsultantCubit extends Cubit { await consultantRepository.deletePhoto(id); } } - await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); + await consultantRepository.registerConsultant(consultant: buildSubmitPayload(), update: _update); } catch (e, st) { await reportFrom(e, st, this); log(e.toString()); diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index e9635eb6..a39a1313 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'register_consultant_state.freezed.dart'; @@ -12,10 +13,12 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { const factory RegisterConsultantState({ required ConsultantProfileModel consultant, required bool acceptOffer, + @Default(false) bool acceptFrdoAgreement, required bool loading, required bool isUpdating, ConsultationRequestModel? requestModel, @Default([]) List certPhotos, + @Default([]) List sbpBanks, @Default([]) List newAddedPhotos, @Default([]) List deletedPhotoIds, String? error, diff --git a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart index b2b26c0e..afff462e 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; class CertificatesAndDiplomasScreen extends StatefulWidget { const CertificatesAndDiplomasScreen({super.key}); @@ -231,10 +232,15 @@ class _CertificatesAndDiplomasScreenState extends State().updateConsultantProfileModel( context.read().state.consultant.copyWith( - certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist( - 0, - diplomasList.length - 1, - ), + certificates: diplomasList + .map( + (e) => CertificatesModel( + title: e.textEditingController.text, + date: e.date == null ? '' : DateFormat('yyyy-MM-dd').format(e.date!), + ), + ) + .toList() + .sublist(0, diplomasList.length - 1), ), ); } diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index 734387f1..7fd501a6 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -15,10 +16,28 @@ class LastStepScreen extends StatefulWidget { } class _LastStepScreenState extends State { - bool checkboxState = false; + static const _frdoUrl = + 'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/'; + + late final TapGestureRecognizer _frdoTapRecognizer; + + @override + void initState() { + super.initState(); + _frdoTapRecognizer = TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse(_frdoUrl)); + } + + @override + void dispose() { + _frdoTapRecognizer.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final cubit = context.watch(); + final state = cubit.state; + return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 80, top: 16), child: Column( @@ -31,29 +50,56 @@ class _LastStepScreenState extends State { ), const SizedBox(height: 16), CheckboxListTile( - value: checkboxState, + value: state.acceptOffer, title: Text( hidePlans ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' - : 'Я ознакомлен и принимаю договор публичной офёрты', - style: const TextStyle(fontSize: 18, height: 1), + : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности', + style: const TextStyle(fontSize: 18, height: 1.2), ), - onChanged: (_) { - setState(() { - checkboxState = !checkboxState; - }); - context.read().updateConsultantProfileModel(context.read().state.consultant, checkboxState); + onChanged: (value) { + cubit.updateAgreements(acceptOffer: value ?? false); }, activeColor: appTheme.blueA400, contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, ), + if (!state.isUpdating) ...[ + CheckboxListTile( + value: state.acceptFrdoAgreement, + title: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 18, height: 1.2, color: Color(0xFF323F4B)), + children: [ + const TextSpan(text: 'Обязательное требование: Один из моих документов об образовании есть в реестре '), + TextSpan( + text: 'ФРДО', + style: TextStyle( + color: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: _frdoTapRecognizer, + ), + ], + ), + ), + onChanged: (value) { + cubit.updateAgreements(acceptFrdoAgreement: value ?? false); + }, + activeColor: appTheme.blueA400, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + controlAffinity: ListTileControlAffinity.leading, + ), + ], Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( - onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf')), + onPressed: () async => await launchUrl( + Uri.parse(hidePlans ? '${sl().baseUrl}/docs/privacy.pdf' : '${sl().baseUrl}/oferta_bs.pdf'), + ), child: Text( - hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', + hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор оферты', style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ), ), diff --git a/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart new file mode 100644 index 00000000..f3eb774b --- /dev/null +++ b/lib/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart @@ -0,0 +1,527 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; + +class PassportDataScreen extends StatefulWidget { + const PassportDataScreen({super.key}); + + @override + State createState() => _PassportDataScreenState(); +} + +class _PassportDataScreenState extends State { + late final TextEditingController lastNameController; + late final TextEditingController firstNameController; + late final TextEditingController middleNameController; + late final TextEditingController innController; + late final TextEditingController snilsController; + late final TextEditingController seriesController; + late final TextEditingController numberController; + late final TextEditingController issueDateController; + late final TextEditingController issuerController; + late final TextEditingController issuerCodeController; + late final TextEditingController birthDateController; + late final TextEditingController birthPlaceController; + late final TextEditingController citizenshipController; + late final TextEditingController registrationAddressController; + late final TextEditingController residenceAddressController; + late final TextEditingController sbpPhoneController; + late final TextEditingController sbpBankController; + final sbpBankSuggestionsController = SuggestionsController(); + + bool taxResidentUs = false; + bool foreignTaxResident = false; + String? selectedSbpBankMemberId; + String? selectedSbpBankName; + + final snilsMask = MaskTextInputFormatter( + mask: '###-###-### ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskIssue = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final dateMaskBirth = MaskTextInputFormatter( + mask: '##.##.####', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final issuerCodeMask = MaskTextInputFormatter( + mask: '###-###', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + final phoneMask = MaskTextInputFormatter( + mask: '+7 (###) ### ## ##', + filter: {'#': RegExp(r'[0-9]')}, + type: MaskAutoCompletionType.lazy, + ); + + @override + void initState() { + super.initState(); + final consultant = context.read().state.consultant; + lastNameController = TextEditingController(text: consultant.lastName ?? ''); + firstNameController = TextEditingController( + text: consultant.firstName ?? '', + ); + middleNameController = TextEditingController( + text: consultant.middleName ?? '', + ); + innController = TextEditingController(text: consultant.inn ?? ''); + snilsController = TextEditingController(text: consultant.snils ?? ''); + seriesController = TextEditingController( + text: consultant.passportSeries ?? '', + ); + numberController = TextEditingController( + text: consultant.passportNumber ?? '', + ); + issueDateController = TextEditingController( + text: _apiDateToUi(consultant.passportIssueDate), + ); + issuerController = TextEditingController( + text: consultant.passportIssuer ?? '', + ); + issuerCodeController = TextEditingController( + text: consultant.passportIssuerCode ?? '', + ); + birthDateController = TextEditingController( + text: _apiDateToUi(consultant.birthDate), + ); + birthPlaceController = TextEditingController( + text: consultant.birthPlace ?? '', + ); + citizenshipController = TextEditingController( + text: consultant.citizenship ?? 'RU', + ); + registrationAddressController = TextEditingController( + text: consultant.registrationAddress ?? '', + ); + residenceAddressController = TextEditingController( + text: consultant.residenceAddress ?? '', + ); + sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? ''); + selectedSbpBankMemberId = consultant.sbpBankMemberId; + final selectedBank = context + .read() + .findSbpBankById(consultant.sbpBankMemberId); + selectedSbpBankName = selectedBank?.bankName; + sbpBankController = TextEditingController(text: selectedSbpBankName ?? ''); + taxResidentUs = consultant.taxResidentUs ?? false; + foreignTaxResident = consultant.foreignTaxResident ?? false; + } + + void _hydrateSbpBankNameIfNeeded() { + if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) { + return; + } + if (sbpBankController.text.trim().isNotEmpty) { + return; + } + final selectedBank = context + .read() + .findSbpBankById(selectedSbpBankMemberId); + if (selectedBank == null) { + return; + } + selectedSbpBankName = selectedBank.bankName; + sbpBankController.text = selectedBank.bankName; + } + + @override + void dispose() { + lastNameController.dispose(); + firstNameController.dispose(); + middleNameController.dispose(); + innController.dispose(); + snilsController.dispose(); + seriesController.dispose(); + numberController.dispose(); + issueDateController.dispose(); + issuerController.dispose(); + issuerCodeController.dispose(); + birthDateController.dispose(); + birthPlaceController.dispose(); + citizenshipController.dispose(); + registrationAddressController.dispose(); + residenceAddressController.dispose(); + sbpPhoneController.dispose(); + sbpBankController.dispose(); + sbpBankSuggestionsController.dispose(); + super.dispose(); + } + + String _apiDateToUi(String? apiDate) { + if (apiDate == null || apiDate.isEmpty) { + return ''; + } + final parts = apiDate.split('-'); + if (parts.length != 3) { + return apiDate; + } + return '${parts[2]}.${parts[1]}.${parts[0]}'; + } + + String? _uiDateToApi(String uiDate) { + final digits = uiDate.replaceAll(RegExp(r'\D'), ''); + if (digits.length != 8) { + return null; + } + return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}'; + } + + String? _uiPhoneToApi(String uiPhone) { + final digits = uiPhone.replaceAll(RegExp(r'\D'), ''); + if (digits.isEmpty) { + return null; + } + if (digits.length == 11 && digits.startsWith('7')) { + return '+$digits'; + } + if (digits.length == 10) { + return '+7$digits'; + } + return uiPhone.trim().isEmpty ? null : uiPhone.trim(); + } + + void _syncToCubit() { + final cubit = context.read(); + final current = cubit.state.consultant; + cubit.updateConsultantProfileModel( + current.copyWith( + legalType: RegisterConsultantCubit.individualPersonLegalType, + lastName: lastNameController.text.trim().isEmpty + ? null + : lastNameController.text.trim(), + firstName: firstNameController.text.trim().isEmpty + ? null + : firstNameController.text.trim(), + middleName: middleNameController.text.trim().isEmpty + ? null + : middleNameController.text.trim(), + inn: innController.text.trim().isEmpty + ? null + : innController.text.trim(), + snils: snilsController.text.trim().isEmpty + ? null + : snilsController.text.trim(), + passportSeries: seriesController.text.trim().isEmpty + ? null + : seriesController.text.trim(), + passportNumber: numberController.text.trim().isEmpty + ? null + : numberController.text.trim(), + passportIssueDate: _uiDateToApi(issueDateController.text), + passportIssuer: issuerController.text.trim().isEmpty + ? null + : issuerController.text.trim(), + passportIssuerCode: issuerCodeController.text.trim().isEmpty + ? null + : issuerCodeController.text.trim(), + birthDate: _uiDateToApi(birthDateController.text), + birthPlace: birthPlaceController.text.trim().isEmpty + ? null + : birthPlaceController.text.trim(), + citizenship: citizenshipController.text.trim().isEmpty + ? 'RU' + : citizenshipController.text.trim().toUpperCase(), + registrationAddress: registrationAddressController.text.trim().isEmpty + ? null + : registrationAddressController.text.trim(), + residenceAddress: residenceAddressController.text.trim().isEmpty + ? null + : residenceAddressController.text.trim(), + taxResidentUs: taxResidentUs, + foreignTaxResident: foreignTaxResident, + sbpPhone: _uiPhoneToApi(sbpPhoneController.text), + sbpBankMemberId: selectedSbpBankMemberId, + ), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + } + + void _selectSbpBank(SbpBankModel bank) { + setState(() { + selectedSbpBankMemberId = bank.bankMemberId; + selectedSbpBankName = bank.bankName; + sbpBankController.text = bank.bankName; + }); + _syncToCubit(); + } + + void _clearSbpBankIfNeeded(String value) { + if (selectedSbpBankMemberId != null && value != selectedSbpBankName) { + setState(() { + selectedSbpBankMemberId = null; + selectedSbpBankName = null; + }); + _syncToCubit(); + } + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks, + listener: (context, state) => _hydrateSbpBankNameIfNeeded(), + child: SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: 80, + right: 16, + left: 16, + top: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Паспортные данные', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + _sectionTitle('Персональные данные (как в паспорте)'), + CustomTextFormField( + hintText: 'Фамилия', + controller: lastNameController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Имя', + controller: firstNameController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Отчество', + controller: middleNameController, + onChanged: (_) => _syncToCubit(), + ), + _sectionTitle('Идентификация'), + CustomTextFormField( + hintText: 'ИНН (12 цифр)', + controller: innController, + keyboardType: TextInputType.number, + maxLength: 12, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'СНИЛС', + controller: snilsController, + keyboardType: TextInputType.number, + inputFormatters: [snilsMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Дата рождения (ДД.ММ.ГГГГ)', + controller: birthDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskBirth], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Место рождения', + controller: birthPlaceController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Гражданство (ISO, например RU)', + controller: citizenshipController, + maxLength: 2, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Адрес регистрации', + controller: registrationAddressController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Адрес проживания', + controller: residenceAddressController, + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: taxResidentUs, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: const Text( + 'Я являюсь налоговым резидентом США', + style: TextStyle(fontSize: 16, height: 1.2), + ), + onChanged: (value) { + setState(() => taxResidentUs = value ?? false); + _syncToCubit(); + }, + ), + CheckboxListTile( + value: foreignTaxResident, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + activeColor: appTheme.blueA400, + title: const Text( + 'Я являюсь иностранным налоговым резидентом', + style: TextStyle(fontSize: 16, height: 1.2), + ), + onChanged: (value) { + setState(() => foreignTaxResident = value ?? false); + _syncToCubit(); + }, + ), + _sectionTitle('Паспорт'), + Row( + children: [ + Expanded( + child: CustomTextFormField( + hintText: 'Серия', + controller: seriesController, + keyboardType: TextInputType.number, + maxLength: 4, + onChanged: (_) => _syncToCubit(), + ), + ), + const SizedBox(width: 8), + Expanded( + child: CustomTextFormField( + hintText: 'Номер', + controller: numberController, + keyboardType: TextInputType.number, + maxLength: 6, + onChanged: (_) => _syncToCubit(), + ), + ), + ], + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Дата выдачи (ДД.ММ.ГГГГ)', + controller: issueDateController, + keyboardType: TextInputType.number, + inputFormatters: [dateMaskIssue], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Код подразделения', + controller: issuerCodeController, + keyboardType: TextInputType.number, + inputFormatters: [issuerCodeMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + CustomTextFormField( + hintText: 'Кем выдан', + controller: issuerController, + onChanged: (_) => _syncToCubit(), + ), + _sectionTitle('Реквизиты СБП'), + CustomTextFormField( + hintText: 'Телефон для выплат через СБП', + controller: sbpPhoneController, + keyboardType: TextInputType.phone, + inputFormatters: [phoneMask], + onChanged: (_) => _syncToCubit(), + ), + const SizedBox(height: 12), + TypeAheadField( + debounceDuration: const Duration(milliseconds: 250), + controller: sbpBankController, + suggestionsController: sbpBankSuggestionsController, + builder: (context, controller, focusNode) => CustomTextFormField( + controller: controller, + focusNode: focusNode, + hintText: 'Банк получатель (поиск)', + onChanged: (value) { + _clearSbpBankIfNeeded(value); + }, + ), + suggestionsCallback: (query) async { + return context.read().searchSbpBanks( + query, + ); + }, + itemBuilder: (context, bank) { + return ListTile( + dense: true, + title: Text( + bank.bankName, + style: const TextStyle(fontSize: 16, color: Colors.black), + ), + subtitle: bank.shortName == null || bank.shortName!.isEmpty + ? null + : Text( + bank.shortName!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ); + }, + decorationBuilder: (context, child) => Material( + type: MaterialType.card, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: child, + ), + emptyBuilder: (context) => const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Ничего не найдено', + style: TextStyle(color: Colors.black54), + ), + ), + onSelected: _selectSbpBank, + ), + const SizedBox(height: 16), + const Divider(height: 0.4, thickness: 0.4, color: Colors.black26), + const SizedBox(height: 16), + Text( + 'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.', + style: TextStyle( + fontSize: 14, + height: 1.2, + color: appTheme.blueA400, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index b8fed20b..a754de27 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class RegistrationAsScreen extends StatefulWidget { const RegistrationAsScreen({super.key}); @@ -11,7 +13,23 @@ class RegistrationAsScreen extends StatefulWidget { enum RegistrationAsType { physical, selfBusy, iP, ooo } class _RegistrationAsScreenState extends State { - RegistrationAsType registrationAsType = RegistrationAsType.physical; + static const RegistrationAsType registrationAsType = RegistrationAsType.physical; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final cubit = context.read(); + cubit.updateConsultantProfileModel( + cubit.state.consultant.copyWith(legalType: RegisterConsultantCubit.individualPersonLegalType), + cubit.state.acceptOffer, + cubit.state.acceptFrdoAgreement, + ); + }); + } @override Widget build(BuildContext context) { @@ -36,11 +54,7 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: (_) {}, ), const Expanded( child: Padding( @@ -58,16 +72,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], @@ -80,16 +90,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], @@ -102,16 +108,12 @@ class _RegistrationAsScreenState extends State { groupValue: registrationAsType, activeColor: appTheme.blueA400, // ignore: deprecated_member_use - onChanged: (value) { - setState(() { - registrationAsType = value!; - }); - }, + onChanged: null, ), const Expanded( child: Padding( padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)), + child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1, color: Colors.black38)), ), ), ], diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index a27e5c67..69f88361 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; @@ -28,7 +29,7 @@ class StepperBecomeConsultantScreen extends StatefulWidget { } class _StepperBecomeConsultantScreenState extends State { - double progressValue = 1 / 6; + double progressValue = 0; int step = 1; final pageController = PageController(); late final RegisterConsultantCubit registrationConsultantCubit; @@ -44,6 +45,20 @@ class _StepperBecomeConsultantScreenState extends State _pages() { + return const [ + CertificatesAndDiplomasScreen(), + PhotoOrScansScreen(), + AdditionalSettingsScreen(), + SocialNetworksScreen(), + RegistrationAsScreen(), + PassportDataScreen(), + LastStepScreen(), + ]; + } + @override Widget build(BuildContext context) { return BlocProvider( @@ -53,7 +68,7 @@ class _StepperBecomeConsultantScreenState extends State AlertDialog( - title: Text(state.requestModel!.canceledModeration!.title), - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ - Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), - ], + ), + if (widget.isCanceled == true) + InkWell( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(state.requestModel!.canceledModeration!.title), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ + Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], - ), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Ink( - height: 48, - width: MediaQuery.of(context).size.width * 0.6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), - child: const Center( - child: Text( - 'Комментарий', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), + ], ), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + height: 48, + width: MediaQuery.of(context).size.width * 0.6, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), + child: const Center( + child: Text( + 'Комментарий', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), ), ), ), - Container( - height: 48, - width: widget.isCanceled == true - ? MediaQuery.of(context).size.width * 0.2 - : MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - backgroundColor: isLoading - ? Colors.grey - : widget.isCanceled == true - ? const Color(0xFFEAF2FD) - : appTheme.blueA400, - onPressed: isLoading - ? null - : () async { - if (step == 6) { - if (widget.isCanceled == true) { - return; - } - if (registrationConsultantCubit.state.acceptOffer) { - setState(() { - isLoading = true; - }); - try { - await registrationConsultantCubit.registerConsultant(); - if (!context.mounted) return; - if (registrationConsultantCubit.state.error != null) { - return; - } - await showModalBottomSheet( - useRootNavigator: true, - showDragHandle: true, - context: context, - builder: (context) => Container( - padding: const EdgeInsets.all(16), - width: MediaQuery.of(context).size.width, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Спасибо!\nВаши данные отправлены на модерацию', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), - const SizedBox(height: 16), - const Text( - 'В течении трех рабочих дней Вам придет уведомление о смене статуса', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - InkWell( - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pop(); - }, - child: Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), - child: const Center( - child: Text( - 'Закрыть', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), - ), + ), + Container( + height: 48, + width: widget.isCanceled == true + ? MediaQuery.of(context).size.width * 0.2 + : MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + backgroundColor: isLoading + ? Colors.grey + : widget.isCanceled == true + ? const Color(0xFFEAF2FD) + : appTheme.blueA400, + onPressed: isLoading + ? null + : () async { + if (step == totalSteps) { + if (widget.isCanceled == true) { + return; + } + if (!state.consultant.hasValidPassportData) { + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + content: const Text('Заполните паспортные данные'), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + return; + } + if (registrationConsultantCubit.hasAcceptedRequiredAgreements) { + setState(() { + isLoading = true; + }); + try { + await registrationConsultantCubit.registerConsultant(); + if (!context.mounted) return; + if (registrationConsultantCubit.state.error != null) { + return; + } + await showModalBottomSheet( + useRootNavigator: true, + showDragHandle: true, + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(16), + width: MediaQuery.of(context).size.width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Спасибо!\nВаши данные отправлены на модерацию', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), + const SizedBox(height: 16), + const Text( + 'В течении трех рабочих дней Вам придет уведомление о смене статуса', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + InkWell( + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pop(); + }, + child: Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), + child: const Center( + child: Text( + 'Закрыть', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), ), ), ), - ], - ), - ), - ); - } catch (e) { - if (!context.mounted) return; - showDialog( - context: context, - builder: (context) => AlertDialog( - actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: Text(e.toString()), - actions: [ - TextButton( - child: const Text('Ок'), - onPressed: () => Navigator.of(context).pop(), ), ], ), - ); - } finally { - setState(() { - isLoading = false; - }); - } - } else { - showDialog( + ), + ); + } catch (e) { + if (!context.mounted) return; + showDialog( context: context, builder: (context) => AlertDialog( actionsPadding: const EdgeInsets.symmetric(horizontal: 4), - content: const Text('Примите договор публичной офёрты'), + content: Text(e.toString()), actions: [ TextButton( child: const Text('Ок'), - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: () => Navigator.of(context).pop(), ), ], ), ); + } finally { + setState(() { + isLoading = false; + }); } } else { - FocusManager.instance.primaryFocus?.unfocus(); - setState(() { - pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); - }); + showDialog( + context: context, + builder: (context) => AlertDialog( + actionsPadding: const EdgeInsets.symmetric(horizontal: 4), + content: Text( + state.isUpdating + ? 'Примите договор публичной оферты' + : 'Примите оферту и согласие ФРДО', + ), + actions: [ + TextButton( + child: const Text('Ок'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ); } - }, - label: isLoading - ? const CircularProgressIndicator() - : widget.isCanceled == true - ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) - : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), - ), + } else { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() { + pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + }); + } + }, + label: isLoading + ? const CircularProgressIndicator() + : widget.isCanceled == true + ? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) + : Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), ), - centerTitle: true, - title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), - actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween(begin: 1 / 6, end: progressValue), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), - ), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), + ), + centerTitle: true, + title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), + actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из $totalSteps'))], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween(begin: 1 / totalSteps, end: progressValue), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(appTheme.blueA400), ), ), ), ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / 6; - }); - }, - children: const [ - CertificatesAndDiplomasScreen(), - PhotoOrScansScreen(), - AdditionalSettingsScreen(), - SocialNetworksScreen(), - RegistrationAsScreen(), - LastStepScreen(), - ], - ), - ); + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / totalSteps; + }); + }, + children: _pages(), + ), + ); }, ), ); diff --git a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart index c0e1c57a..b8d55b5a 100644 --- a/lib/features/consultant/become_consultant/repositories/consultant_repository.dart +++ b/lib/features/consultant/become_consultant/repositories/consultant_repository.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:http_parser/http_parser.dart'; @@ -17,22 +18,24 @@ class ConsultantRepository { } else { return await consultantService.consultantRegistration(consultantModel: consultant); } - } + /// Профиль консультанта (свой или чужой) — публичные данные без паспорта. Future getConsultantRequestStatus(String slug) async { return await consultantService.getConsultantRequestStatus(slug: slug); - } + /// Своя заявка — education + legal/passport для регистрации и update. Future getMyRequestStatus(String slug) async { return await consultantService.getMyRequestStatus(slug: slug); + } + Future> getSbpBanks() async { + return await consultantService.getSbpBanks(); } Future> getConsultantCertPhotos() async { return await consultantService.getConsultantCertPhotos(); - } Future> storeConsultantCertPhotos({required List files}) async { @@ -49,21 +52,17 @@ class ConsultantRepository { } return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); - } Future hideConsultantCertPhoto(int id) async { await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); - } Future unHideConsultantCertPhoto(int id) async { await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); - } Future deletePhoto(int id) async { await consultantService.deletePhoto(id: id); - } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index 9a0757ad..6482c667 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:retrofit/retrofit.dart'; part 'consultant_service.g.dart'; @@ -10,22 +11,29 @@ part 'consultant_service.g.dart'; abstract class ConsultantService { factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; - @POST('/api/consultant_request/registration') + @POST('/api/v2/consultant_request/registration') Future consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); @POST('/api/consultant_request/photos/upload') @MultiPart() Future> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List? photos}); + /// Публичный статус консультанта для просмотра профиля. + /// Без паспортных данных: `/api/profile/consultant/{slug}/status`. @GET('/api/profile/consultant/{slug}/status') Future getConsultantRequestStatus({@Path() required String slug}); - @GET('/api/consultant_request/{slug}/status') + /// Свой статус заявки для редактирования/модерации. + /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`. + @GET('/api/v2/consultant_request/{slug}/status') Future getMyRequestStatus({@Path() required String slug}); - @PUT('/api/consultant_request/update') + @PUT('/api/v2/consultant_request/update') Future consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); + @GET('/api/finances/sbp_banks') + Future> getSbpBanks(); + @GET('/api/v2/consultant_request/cert_photos') Future> getConsultantCertPhotos(); diff --git a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart index 3b008a3f..fb88c9c5 100644 --- a/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultant_profile_model_test.dart @@ -106,7 +106,17 @@ void main() { test('toJson serializes all fields correctly', () { const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user'); const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01'); - const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]); + const model = ConsultantProfileModel( + hasMedicalEducation: true, + socialLinks: socialLinks, + photos: ['photo1.jpg'], + certificates: [certificates], + legalType: 'individualPerson', + inn: '123456789012', + snils: '123-456-789 00', + agreementAccepted: true, + frdoAgreementAccepted: true, + ); final json = model.toJson(); @@ -114,6 +124,34 @@ void main() { expect(json['social_links'], isNotNull); expect(json['cert_photos'], ['photo1.jpg']); expect(json['completed_courses'], isNotNull); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['snils'], '123-456-789 00'); + expect(json['agreement_accepted'], isTrue); + expect(json['frdo_agreement_accepted'], isTrue); + }); + + test('hasValidPassportData validates required individualPerson fields', () { + const valid = ConsultantProfileModel( + lastName: 'Иванов', + firstName: 'Иван', + inn: '123456789012', + snils: '123-456-789 00', + passportSeries: '1234', + passportNumber: '567890', + passportIssueDate: '2020-01-15', + passportIssuer: 'ОВД', + passportIssuerCode: '772-053', + birthDate: '1990-05-20', + birthPlace: 'Москва', + registrationAddress: 'Адрес 1', + residenceAddress: 'Адрес 2', + citizenship: 'RU', + ); + + expect(valid.hasValidPassportData, isTrue); + expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse); + expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse); }); }); } diff --git a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart index 7dd05d98..f11423e4 100644 --- a/test/features/consultant/become_consultant/models/consultation_request_model_test.dart +++ b/test/features/consultant/become_consultant/models/consultation_request_model_test.dart @@ -122,6 +122,47 @@ void main() { expect(model.canceledModeration!.status, ModerationStatus.canceled); }); + test('fromJson parses legal_entity_resource', () { + final json = { + 'rating_place': 5, + 'education_resource': {'request_state': 'approved'}, + 'cert_photos': [], + 'legal_entity_resource': { + 'id': 1, + 'type': 'individualPerson', + 'inn': '123456789012', + 'snils': '123-456-789 00', + 'state': 'created', + 'alfa_scoring_status': 'pending', + 'alfa_scoring_request_id': 'abc', + 'passport_series': '1234', + 'passport_number': '567890', + 'passport_issue_date': '2020-01-15T00:00:00.000000Z', + 'passport_issuer': 'ОВД', + 'passport_issuer_code': '772-053', + 'birth_date': '1990-05-20', + 'birth_place': 'Москва', + 'registration_address': 'Адрес 1', + 'residence_address': 'Адрес 2', + }, + }; + + final model = ConsultationRequestModel.fromJson(json); + + expect(model.legalEntity, isNotNull); + expect(model.legalEntity!.type, 'individualPerson'); + expect(model.legalEntity!.inn, '123456789012'); + expect(model.legalEntity!.passportSeries, '1234'); + expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15'); + expect(model.legalEntity!.alfaScoringStatus, 'pending'); + }); + + test('normalizeApiDate strips time from DateTime.toString format', () { + expect(LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'), '2023-09-22'); + expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22'); + expect(LegalEntityModel.normalizeApiDate(null), isNull); + }); + test('toJson serializes ConsultationRequestModel correctly', () { const educationModel = EducationModel(requestState: ModerationStatus.approved); const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')]; diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index a721050b..9eeeb304 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,7 @@ void main() { late RegisterConsultantCubit cubit; late ConsultationRequestModel requestModel; late List certPhotosFromFixture; + late List sbpBanksFromFixture; late ConsultantProfileModel consultantProfile; setUp(() { @@ -32,7 +34,11 @@ void main() { certPhotosFromFixture = FixtureReader.readAsList( 'consultant/consultant_cert_photos.json', ).map((item) => ConsultantCertPhotoModel.fromJson(item as Map)).toList(); + sbpBanksFromFixture = FixtureReader.readAsList( + 'consultant/sbp_banks.json', + ).map((item) => SbpBankModel.fromJson(item as Map)).toList(); consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json')); + when(() => repository.getSbpBanks()).thenAnswer((_) async => sbpBanksFromFixture); }); tearDown(() async { @@ -61,11 +67,19 @@ void main() { expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); + expect(state.consultant.legalType, 'individualPerson'); + expect(state.consultant.inn, '123456789012'); + expect(state.consultant.passportSeries, '1234'); + expect(state.consultant.passportIssueDate, '2020-01-15'); + expect(state.consultant.birthPlace, 'г. Москва'); // All cert photos from backend are kept in state (visible + hidden). expect(state.certPhotos, hasLength(3)); expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); + expect(state.sbpBanks, hasLength(2)); + expect(state.sbpBanks.first.bankName, 'Сбербанк'); verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); + verify(() => repository.getSbpBanks()).called(1); }); test('disableLoading stops spinner and loads cert photos', () async { @@ -76,7 +90,28 @@ void main() { expect(cubit.state.loading, isFalse); // disableLoading now keeps all photos from repository. expect(cubit.state.certPhotos, hasLength(3)); + expect(cubit.state.sbpBanks, hasLength(2)); verify(() => repository.getConsultantCertPhotos()).called(1); + verify(() => repository.getSbpBanks()).called(1); + }); + + test('searchSbpBanks filters banks by name', () async { + when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); + + final results = cubit.searchSbpBanks('сбер'); + + expect(results, hasLength(1)); + expect(results.first.bankMemberId, '100000000111'); + }); + + test('findSbpBankById returns matching bank', () async { + when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); + await cubit.disableLoading(); + + final bank = cubit.findSbpBankById('100000000004'); + + expect(bank?.bankName, 'Т-Банк'); }); test('updateConsultantProfileModel replaces consultant data and offer flag', () { @@ -95,12 +130,24 @@ void main() { ), ).thenAnswer((_) async {}); + cubit.updateConsultantProfileModel(consultantProfile); + cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1); + final captured = verify( + () => repository.registerConsultant( + consultant: captureAny(named: 'consultant'), + update: false, + ), + ).captured.single as ConsultantProfileModel; + + expect(captured.legalType, 'individualPerson'); + expect(captured.agreementAccepted, isTrue); + expect(captured.frdoAgreementAccepted, isTrue); + expect(captured.inn, isNotNull); }); - test('registerConsultant sends update=true after getMyRequestStatus call', () async { + test('registerConsultant sends legal payload with update=true', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when( @@ -113,7 +160,38 @@ void main() { await cubit.getMyRequestStatus('my-consultant-slug'); await cubit.registerConsultant(); - verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1); + final captured = verify( + () => repository.registerConsultant( + consultant: captureAny(named: 'consultant'), + update: true, + ), + ).captured.single as ConsultantProfileModel; + + final json = captured.toJson(); + expect(json['legal_type'], 'individualPerson'); + expect(json['inn'], '123456789012'); + expect(json['passport_series'], '1234'); + expect(json.containsKey('agreement_accepted'), isFalse); + expect(json.containsKey('frdo_agreement_accepted'), isFalse); + }); + + test('registerConsultant blocks submit without passport data', () async { + when( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ).thenAnswer((_) async {}); + + await cubit.registerConsultant(); + + expect(cubit.state.error, 'Заполните паспортные данные'); + verifyNever( + () => repository.registerConsultant( + consultant: any(named: 'consultant'), + update: any(named: 'update'), + ), + ); }); test('removeCertPhoto marks cert as deleted locally', () async { diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index cff449e8..c95b21c1 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -36,7 +36,7 @@ void main() { ); } - testWidgets('renders last step screen', (tester) async { + testWidgets('renders last step screen with both agreements for new registration', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel( hasMedicalEducation: null, @@ -49,13 +49,30 @@ void main() { when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); when(cubit.close()).thenAnswer((_) async => Future.value()); - when(cubit.updateConsultantProfileModel(any, any)).thenReturn(null); await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); await tester.pump(); expect(find.byType(LastStepScreen), findsOneWidget); expect(find.text('Последний шаг'), findsOneWidget); + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.textContaining('ФРДО'), findsOneWidget); + }); + + testWidgets('hides FRDO checkbox in update mode', (tester) async { + final cubit = MockRegisterConsultantCubit(); + const consultant = ConsultantProfileModel(); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await tester.pumpWidget(wrap(const LastStepScreen(), cubit)); + await tester.pump(); + + expect(find.byType(CheckboxListTile), findsOneWidget); + expect(find.textContaining('ФРДО'), findsNothing); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart new file mode 100644 index 00000000..3344e719 --- /dev/null +++ b/test/features/consultant/become_consultant/presentation/pages/passport_data_screen_test.dart @@ -0,0 +1,59 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} + +void main() { + group('PassportDataScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + + Widget wrap(Widget child) { + return MaterialApp( + theme: ThemeHelper().themeData(), + home: Scaffold( + body: BlocProvider.value(value: cubit, child: child), + ), + ); + } + + testWidgets('renders passport data fields', (tester) async { + await tester.pumpWidget(wrap(const PassportDataScreen())); + await tester.pump(); + + expect(find.byType(PassportDataScreen), findsOneWidget); + expect(find.text('Паспортные данные'), findsOneWidget); + expect(find.text('Фамилия'), findsOneWidget); + expect(find.text('Имя'), findsOneWidget); + expect(find.text('ИНН (12 цифр)'), findsOneWidget); + expect(find.text('СНИЛС'), findsOneWidget); + expect(find.text('Гражданство (ISO, например RU)'), findsOneWidget); + expect(find.text('Телефон для выплат через СБП'), findsOneWidget); + expect(find.text('Банк получатель (поиск)'), findsOneWidget); + expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget); + }); + }); +} diff --git a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart index df170daa..d4818dd8 100644 --- a/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/registration_as_screen_test.dart @@ -1,24 +1,54 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockConsultantRepository extends Mock implements ConsultantRepository {} void main() { group('RegistrationAsScreen smoke', () { + late RegisterConsultantCubit cubit; + + setUp(() { + cubit = RegisterConsultantCubit(consultantRepository: MockConsultantRepository()); + cubit.emit( + const RegisterConsultantState( + consultant: ConsultantProfileModel(socialLinks: SocialLinksModel()), + acceptOffer: false, + loading: false, + isUpdating: false, + ), + ); + }); + + tearDown(() async { + await cubit.close(); + }); + Widget wrap(Widget child) { return MaterialApp( theme: ThemeHelper().themeData(), - home: Scaffold(body: child), + home: Scaffold( + body: BlocProvider.value(value: cubit, child: child), + ), ); } testWidgets('renders registration as screen', (tester) async { await tester.pumpWidget(wrap(const RegistrationAsScreen())); await tester.pump(); + await tester.pump(); expect(find.byType(RegistrationAsScreen), findsOneWidget); expect(find.text('Регистрация в качестве'), findsOneWidget); expect(find.text('Физического лица'), findsOneWidget); + expect(cubit.state.consultant.legalType, 'individualPerson'); }); }); } diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 34c2db42..0f742c33 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -86,7 +86,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('шаг 1 из 6'), findsOneWidget); + expect(find.text('шаг 1 из 7'), findsOneWidget); }); testWidgets('displays progress bar', (tester) async { diff --git a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart index c695cc5b..468e638c 100644 --- a/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart +++ b/test/features/consultant/become_consultant/repositories/consultant_repository_test.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -88,6 +89,18 @@ void main() { verify(() => mockService.getConsultantCertPhotos()).called(1); }); + test('getSbpBanks returns banks from service', () async { + final banks = FixtureReader.readAsList('consultant/sbp_banks.json') + .map((item) => SbpBankModel.fromJson(item as Map)) + .toList(); + when(() => mockService.getSbpBanks()).thenAnswer((_) async => banks); + + final result = await repository.getSbpBanks(); + + expect(result, banks); + verify(() => mockService.getSbpBanks()).called(1); + }); + test('storeConsultantCertPhotos returns empty list when no files provided', () async { final result = await repository.storeConsultantCertPhotos(files: []); diff --git a/test/fixtures/consultant/consultant_profile_request.json b/test/fixtures/consultant/consultant_profile_request.json index fc05c731..270d4a20 100644 --- a/test/fixtures/consultant/consultant_profile_request.json +++ b/test/fixtures/consultant/consultant_profile_request.json @@ -20,6 +20,26 @@ "title": "Психология питания", "date": "2023-06-05" } - ] + ], + "legal_type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567", + "agreement_accepted": true, + "frdo_agreement_accepted": true } - diff --git a/test/fixtures/consultant/consultation_request.json b/test/fixtures/consultant/consultation_request.json index f4065e15..bf7ae187 100644 --- a/test/fixtures/consultant/consultation_request.json +++ b/test/fixtures/consultant/consultation_request.json @@ -23,6 +23,31 @@ "personal_site_link": "https://consultant.eatvio.app", "request_state": "approved" }, + "legal_entity_resource": { + "id": 1, + "type": "individualPerson", + "last_name": "Иванов", + "first_name": "Иван", + "middle_name": "Иванович", + "inn": "123456789012", + "snils": "123-456-789 00", + "state": "created", + "alfa_scoring_status": "pending", + "alfa_scoring_request_id": "abc-123", + "passport_series": "1234", + "passport_number": "567890", + "passport_issue_date": "2020-01-15", + "passport_issuer": "ОВД Пресненского района г. Москвы", + "passport_issuer_code": "772-053", + "birth_date": "1990-05-20", + "birth_place": "г. Москва", + "registration_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "residence_address": "г. Москва, ул. Тверская, д. 1, кв. 1", + "citizenship": "RU", + "tax_resident_us": false, + "foreign_tax_resident": false, + "sbp_phone": "+79991234567" + }, "cert_photos": [ { "url": "https://cdn.eatvio.app/certificates/visible.jpg" @@ -40,4 +65,3 @@ ] } } - diff --git a/test/fixtures/consultant/sbp_banks.json b/test/fixtures/consultant/sbp_banks.json new file mode 100644 index 00000000..b725de52 --- /dev/null +++ b/test/fixtures/consultant/sbp_banks.json @@ -0,0 +1,16 @@ +[ + { + "bank_member_id": "100000000111", + "bank_name": "Сбербанк", + "short_name": "Сбер", + "logo_url": "https://example.com/sber.png", + "bic": "044525225" + }, + { + "bank_member_id": "100000000004", + "bank_name": "Т-Банк", + "short_name": "ТБанк", + "logo_url": "https://example.com/tbank.png", + "bic": "044525974" + } +]