Конфигурация «Договор» за 3 шага

Создаём полноценную CRM-модель на JMatrixPlatform: атрибуты, тип, жизненный цикл. Всё — на Java, с готовыми примерами.

1. Атрибуты договора

Определяем юридически значимые поля. Каждый атрибут наследуется от JAttribute. Для "вид договора" используем getRanges().
@JModelPart
public final class ATRContractDate extends JAttribute {
    public static final ATRContractDate ATTRIBUTE = new ATRContractDate();
    
    @Override
    public JDataType getDataType() { 
        return new JDataType.JString();
    }
    
    @Override
    public String getDescription() {
        return "Дата заключения договора";
    }
}

@JModelPart
public final class ATRAmount extends JAttribute {
    public static final ATRAmount ATTRIBUTE = new ATRAmount();
    
    @Override
    public JDataType getDataType() {
        return new JDataType.JReal();
    }
    
    @Override
    public String getDescription() {
        return "Сумма договора";
    }
}

@JModelPart
public final class ATRDeadline extends JAttribute {
    public static final ATRDeadline ATTRIBUTE = new ATRDeadline();
    
    @Override
    public JDataType getDataType() {
        return new JDataType.JTimestamp();
    }
    
    @Override
    public String getDescription() {
        return "Срок исполнения обязательств";
    }
}

@JModelPart
public final class ATRContractType extends JAttribute {
    public static final ATRContractType ATTRIBUTE = new ATRContractType();
    
    @Override
    public JDataType getDataType() {
        return new JDataType.JString();
    }
    
    @Override
    public List<Range> getRanges() {
        return List.of(
            new Range(JQ.EQ, new JString("Купля-продажа")),
            new Range(JQ.EQ, new JString("Услуги")),
            new Range(JQ.EQ, new JString("Подряд"))
        );
    }
    
    @Override
    public String getDescription() {
        return "Вид договора";
    }
}
Обратите внимание: Базовые свойства, такие как code (номер договора), title (предмет договора) и description, уже присутствуют у каждого объекта. Их не нужно создавать отдельно. Справочно: также у каждого объекта базово доступны свойства release (version), shortCode, fullCode, fullTitle, originated, modified и json.

2. Тип «Договор»

Тип объединяет атрибуты в единую сущность.
@JModelPart
public final class ATPContract extends JType {
    public static final ATPContract TYPE = new ATPContract();

    @Override
    public Set<JAttribute> getAttributes() {
        return Set.of(
            ATRContractDate.ATTRIBUTE,
            ATRAmount.ATTRIBUTE,
            ATRDeadline.ATTRIBUTE,
            ATRContractType.ATTRIBUTE
        );
    }
    
    @Override
    public String getDescription() {
        return "Тип данных «Договор» для управления договорной базой";
    }

    @Override
    public void modifyCheck(JContext ctx, UUID oid, JTriggerChanges changes) {
        // любая Java-логика: проверки, валидации и т.д.
    }

    //modifyAction, deleteCheck, deleteAction и т.д.
}

3. Жизненный цикл со статусами

Статусы определяют этапы жизни договора. На каждом статусе настраиваются права для ролей (менеджер, бухгалтер, публичная роль) и допустимые форматы файлов.
@JModelPart
public final class ALCContractLifecycle extends JPolicy {
    public static final ALCContractLifecycle POLICY = new ALCContractLifecycle();

    @Override
    public Set<JType> getTypes() {
        return Set.of(ATPContract.TYPE);
    }

    @Override
    public List<JState> getStates() {
        return List.of(
            Draft.STATE,
            Signing.STATE,
            Signed.STATE
        );
    }
    
    @Override
    public Set<JFileType> getFileTypes() {
        return Set.of(
            PDF.FILE_TYPE,//pdf скан
            JGeneric.FILE_TYPE//любой редактируемый формат, например docx
        );
    }

    // Статус 1: Черновик
    @JModelPart
    public static final class Draft extends JState {
        public static final Draft STATE = new Draft();
        
        @Override
        public Set<RoleAccess> getAccess() {
            return Set.of(
                new RoleAccess(ARLContractManager.ROLE, AccessType.ALL),
                new RoleAccess(JOwner.ROLE, AccessType.ALL)
            );
        }
    }

    // Статус 2: На подписи
    @JModelPart
    public static final class Signing extends JState {
        public static final Signing STATE = new Signing();
        
        @Override
        public Set<RoleAccess> getAccess() {
            return Set.of(
                new RoleAccess(ARLContractManager.ROLE, AccessType.READ, AccessType.PROMOTE),
                new RoleAccess(JOwner.ROLE, AccessType.READ),
                new RoleAccess(ARLAccountant.ROLE, AccessType.READ)
            );
        }
    }

    // Статус 3: Подписан
    @JModelPart
    public static final class Signed extends JState {
        public static final Signed STATE = new Signed();
        
        @Override
        public Set<RoleAccess> getAccess() {
            return Set.of(
                new RoleAccess(ARLContractManager.ROLE, AccessType.READ, AccessType.ARCHIVE),
                new RoleAccess(JPublic.ROLE, AccessType.READ),
                new RoleAccess(ARLAccountant.ROLE, AccessType.READ)
            );
        }
    }
}
Роли в примере: ARLContractManager (менеджер по договорам), ARLAccountant (бухгалтер), JOwner (создатель договора), JPublic (все остальные).

Пример. Создание договора через API

После конфигурации модели данных можно сразу управлять объектами с помощью REST API (сразу доступен CRUD в полном объёме). Номер договора указывается в стандартном поле code.
POST /objects/
{
  "type": "ATPContract",
  "policy": "ALCContractLifecycle",
  "code": "Д-2026-001",
  "title": "Лицензионный договор на ПО",
  "attributeValues": {
    "ATRContractDate": {
      "value": "2026-04-01T00:00:00Z"
    },
    "ATRAmount": {
      "value": 1250000.00
    },
    "ATRDeadline": {
      "value": "2026-12-31T00:00:00Z"
    },
    "ATRContractType": {
      "value": "Услуги"
    }
  }
}

Результат. Таблица договоров

Аналогично платформа предоставляет и конфигурацию UI для просмотра и полного управления объектами. Пример таблицы:
Номер (code) Предмет (title) Дата заключения Сумма Вид Статус
Д-2026-001 Лицензионный договор на ПО 01.04.2026 1 250 000 Услуги Черновик
Д-2026-002 Поставка серверного оборудования 15.03.2026 4 800 000 Купля-продажа На подписи
Д-2025-189 Консалтинг 20.12.2025 960 000 Услуги Подписан
Итог: За ~30 минут конфигурации создана полноценная модель данных с бизнес-логикой и UI. Изменение статуса автоматически переключает права доступа.
Если вдруг забыли, сколько кода придётся писать на Spring Boot...
Ниже — только базовая реализация CRUD для договора. Здесь нет статусно-ролевого доступа, аудита, файлов, версионности, связей между объектами, кастомизируемого UI и универсального поиска по любым атрибутам. Это лишь верхушка айсберга того, что предстоит написать вручную.

Entity

@Entity
@Table(name = "contract")
public class ContractEntity {
    @Id
    @GeneratedValue(generator = "UUID")
    @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
    private UUID id;
    
    @Column(nullable = false, unique = true)
    private String code; // номер договора
    
    @Column(nullable = false)
    private String title; // предмет договора
    
    @Column(nullable = false)
    private LocalDate contractDate;
    
    @Column(nullable = false, precision = 15, scale = 2)
    private BigDecimal amount;
    
    @Column(nullable = false)
    private LocalDateTime deadline;
    
    @Column(nullable = false)
    private String contractType; // Купля-продажа / Услуги / Подряд
    
    @Column(nullable = false)
    private String status; // DRAFT, SIGNING, SIGNED
    
    // Аудит
    @Column(nullable = false)
    private UUID createdBy;
    
    @Column(nullable = false)
    private Instant createdAt;
    
    private UUID modifiedBy;
    private Instant modifiedAt;
    
    // геттеры, сеттеры, конструкторы...
}

Репозиторий

@Repository
public interface ContractRepository extends JpaRepository<ContractEntity, UUID> {
    
    // Базовые методы поиска
    Optional<ContractEntity> findByCode(String code);
    List<ContractEntity> findByStatus(String status);
    Page<ContractEntity> findByStatusIn(List<String> statuses, Pageable pageable);
    
    // Поиск по датам
    List<ContractEntity> findByContractDateBetween(LocalDate start, LocalDate end);
    List<ContractEntity> findByDeadlineBefore(LocalDateTime date);
    List<ContractEntity> findByDeadlineAfter(LocalDateTime date);
    
    // Поиск по сумме
    List<ContractEntity> findByAmountGreaterThanEqual(BigDecimal amount);
    List<ContractEntity> findByAmountBetween(BigDecimal min, BigDecimal max);
    
    // Поиск по типу договора
    List<ContractEntity> findByContractType(String contractType);
    Page<ContractEntity> findByContractTypeAndStatus(String contractType, String status, Pageable pageable);
    
    // Комбинированные поиски
    List<ContractEntity> findByCodeContainingAndStatus(String codePart, String status);
    Page<ContractEntity> findByTitleContainingIgnoreCase(String titlePart, Pageable pageable);
    
    // Поиск с несколькими условиями
    @Query("SELECT c FROM ContractEntity c WHERE " +
           "(:code IS NULL OR c.code LIKE %:code%) AND " +
           "(:contractType IS NULL OR c.contractType = :contractType) AND " +
           "(:status IS NULL OR c.status = :status) AND " +
           "(c.amount >= :minAmount) AND " +
           "(c.deadline >= :deadlineFrom)")
    Page<ContractEntity> searchContracts(@Param("code") String code,
                                         @Param("contractType") String contractType,
                                         @Param("status") String status,
                                         @Param("minAmount") BigDecimal minAmount,
                                         @Param("deadlineFrom") LocalDateTime deadlineFrom,
                                         Pageable pageable);
    
    // Статистика
    long countByStatus(String status);
    @Query("SELECT c.status, COUNT(c) FROM ContractEntity c GROUP BY c.status")
    List<Object[]> countByStatusGroup();
    
    // Обновление статуса (массовое)
    @Modifying
    @Query("UPDATE ContractEntity c SET c.status = :newStatus WHERE c.id IN :ids AND c.status = :oldStatus")
    int bulkUpdateStatus(@Param("ids") List<UUID> ids,
                         @Param("oldStatus") String oldStatus,
                         @Param("newStatus") String newStatus);
}

Сервис (фрагмент)

@Service
@Transactional
public class ContractService {
    private final ContractRepository repository;
    private final AuthorizationService authService;
    private final AuditService auditService;
    private final FileStorageService fileStorageService;
    
    public ContractEntity create(ContractCreateDto dto, UUID userId) {
        // Проверка прав
        if (!authService.canCreateContract(userId)) {
            throw new AccessDeniedException("Нет прав на создание договора");
        }
        
        ContractEntity entity = new ContractEntity();
        entity.setCode(dto.getCode());
        entity.setTitle(dto.getTitle());
        entity.setContractDate(dto.getContractDate());
        entity.setAmount(dto.getAmount());
        entity.setDeadline(dto.getDeadline());
        entity.setContractType(dto.getContractType());
        entity.setStatus("DRAFT");
        entity.setCreatedBy(userId);
        entity.setCreatedAt(Instant.now());
        
        ContractEntity saved = repository.save(entity);
        auditService.record("CREATE", saved.getId(), userId, null);
        return saved;
    }
    
    public Page<ContractDto> searchContracts(String code, String contractType, 
                                            String status, BigDecimal minAmount,
                                            LocalDateTime deadlineFrom, UUID userId, 
                                            Pageable pageable) {
        List<String> visibleStatuses = authService.getReadableStatuses(userId);
        // Логика поиска с учётом видимых статусов
        return repository.searchContracts(code, contractType, status, minAmount, deadlineFrom, pageable)
            .map(this::toDto);
    }

    public ContractEntity promoteStatus(UUID contractId, UUID userId) {
        ContractEntity contract = repository.findById(contractId)
            .orElseThrow(() -> new NotFoundException("Договор не найден"));
        
        String oldStatus = contract.getStatus();

        // Сложная логика проверки прав в зависимости от статуса
        if ("DRAFT".equals(contract.getStatus())) {
            if (!authService.canPromoteFromDraft(userId, contract)) {
                throw new AccessDeniedException("Нельзя перевести на подпись");
            }
            contract.setStatus("SIGNING");
        } else if ("SIGNING".equals(contract.getStatus())) {
            if (!authService.canPromoteFromSigning(userId, contract)) {
                throw new AccessDeniedException("Нельзя подписать договор");
            }
            contract.setStatus("SIGNED");
        } else {
            throw new IllegalStateException("Недопустимый переход статуса");
        }
        
        contract.setModifiedBy(userId);
        contract.setModifiedAt(Instant.now());
        
        auditService.record("PROMOTE", contractId, userId, 
            "Статус изменён с " + oldStatus + " на " + contract.getStatus());
        
        return repository.save(contract);
    }
    
    public Page<ContractDto> listVisibleContracts(UUID userId, Pageable pageable) {
        // Ручное вычисление видимых статусов на основе ролей пользователя
        List<String> visibleStatuses = authService.getReadableStatuses(userId);
        return repository.findByStatusIn(visibleStatuses, pageable)
            .map(this::toDto);
    }
    
    // Методы: update, delete, demote, getHistory, attachFile, getFiles...
}

Контроллер

@RestController
@RequestMapping("/api/contracts")
public class ContractController {
    @Autowired
    private ContractService contractService;
    
    @PostMapping
    public ResponseEntity<ContractDto> create(@Valid @RequestBody ContractCreateDto dto,
                                               @RequestAttribute UUID userId) {
        return ResponseEntity.ok(contractService.create(dto, userId));
    }
    
    @PostMapping("/{id}/promote")
    public ResponseEntity<ContractDto> promote(@PathVariable UUID id,
                                                @RequestAttribute UUID userId) {
        return ResponseEntity.ok(contractService.promoteStatus(id, userId));
    }
    
    @PostMapping("/{id}/demote")
    public ResponseEntity<ContractDto> demote(@PathVariable UUID id,
                                               @RequestAttribute UUID userId) {
        return ResponseEntity.ok(contractService.demoteStatus(id, userId));
    }
    
    @GetMapping
    public ResponseEntity<Page<ContractDto>> list(@RequestAttribute UUID userId,
                                                  Pageable pageable) {
        return ResponseEntity.ok(contractService.listVisibleContracts(userId, pageable));
    }
    
    @GetMapping("/search")
    public ResponseEntity<Page<ContractDto>> search(@RequestParam(required = false) String code,
                                                     @RequestParam(required = false) String contractType,
                                                     @RequestParam(required = false) String status,
                                                     @RequestParam(required = false) BigDecimal minAmount,
                                                     @RequestParam(required = false) LocalDateTime deadlineFrom,
                                                     @RequestAttribute UUID userId,
                                                     Pageable pageable) {
        return ResponseEntity.ok(contractService.searchContracts(code, contractType, 
            status, minAmount, deadlineFrom, userId, pageable));
    }
    
    @PostMapping("/{id}/files")
    public ResponseEntity<Void> uploadFile(@PathVariable UUID id,
                                           @RequestParam MultipartFile file,
                                           @RequestAttribute UUID userId) {
        contractService.attachFile(id, file, userId);
        return ResponseEntity.ok().build();
    }
    
    @GetMapping("/{id}/history")
    public ResponseEntity<List<AuditRecord>> getHistory(@PathVariable UUID id) {
        return ResponseEntity.ok(auditService.getHistory(id));
    }
    
    // И ещё десяток endpoint'ов: update, delete, getById, downloadFile, release version...
}

DTO, мапперы

public class ContractCreateDto {
    @NotBlank private String code;
    @NotBlank private String title;
    @NotNull private LocalDate contractDate;
    @NotNull @Positive private BigDecimal amount;
    @NotNull private LocalDateTime deadline;
    @NotBlank private String contractType;
    // геттеры, сеттеры
}

public class ContractDto {
    private UUID id;
    private String code;
    private String title;
    private LocalDate contractDate;
    private BigDecimal amount;
    private LocalDateTime deadline;
    private String contractType;
    private String status;
    private UUID createdBy;
    private Instant createdAt;
    private UUID modifiedBy;
    private Instant modifiedAt;
    // геттеры, сеттеры
}

// Маппер (MapStruct)
@Mapper(componentModel = "spring")
public interface ContractMapper {
    ContractEntity toEntity(ContractCreateDto dto);
    ContractDto toDto(ContractEntity entity);
    List<ContractDto> toDtoList(List<ContractEntity> entities);
}
Не забудьте, что после этой реализации у вас всё равно нет:
  • Полноценного аудита с историей каждого изменения атрибута
  • Работы с файлами (прикрепление, контроль доступа к файлам)
  • Связей между объектами (договор → контрагент → счета → акты)
  • Версионности объектов (release)
  • Кастомизируемого UI (нужен отдельный фронтенд на Angular/React/Vue)
  • Универсального поиска по любому атрибуту и связанным объектам
  • Статусно-ролевого доступа на уровне SQL
  • Встроенного жизненного цикла с promote/demote и историей статусов
← Вернуться на главную