package jnpf.dmsBusinessOccupancy; import org.mybatis.spring.SqlSessionTemplate; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.HashSet; /** * Shared admission conditions for NEW competing lifecycle applications against an existing file. * No native form CRUD, no HTTP controller and no automatic runtime registration. * First creation, trusted chain continuation, expiry, errata and usage tasks use their own contracts. */ public final class BusinessFileEligibilityService { public enum Operation { REVISION, REVOCATION, INVALIDATION, EXTENSION, REVIEW_EXTENSION } public enum Reason { FILE_NOT_FOUND, AMBIGUOUS_FILE_NUMBER, FILE_NOT_EFFECTIVE, VERSION_CHANGED, INCONSISTENT_CURRENT_VERSION, PENDING_VERSION, REVOCATION_IN_PROGRESS_OR_SUCCEEDED, REVOCATION_STATE_UNKNOWN, FILE_OCCUPIED, NUMBER_BUSY } /** tenantId comes from authenticated server context; IDs are resource/business-version IDs. */ public record Query(String tenantId, String fileNumber, String fileId, String businessVersionId, Operation operation) { public Query { valid(tenantId, 50); valid(fileNumber, 200); valid(fileId, 50); valid(businessVersionId, 50); Objects.requireNonNull(operation, "operation"); } private static void valid(String value, int max) { if (value == null || value.isBlank() || !value.equals(value.strip()) || value.indexOf('\0') >= 0 || value.codePointCount(0, value.length()) > max) throw new IllegalArgumentException("Invalid eligibility query identity"); } } /** Machine codes only; business display text remains online-configurable. */ public record Decision(List reasons, String fileId, String businessVersionId, String centerVersionId) { public Decision { reasons = List.copyOf(reasons); } public boolean allowed() { return reasons.isEmpty(); } } private final BusinessOccupancyTransactions transactions; private final BusinessFileOccupancyCoordinator coordinator; public BusinessFileEligibilityService(SqlSessionTemplate sessions) { transactions = new BusinessOccupancyTransactions(sessions); coordinator = new BusinessFileOccupancyCoordinator(sessions); } /** Read-only hint. It neither acquires occupancy nor promises a later submission will succeed. */ public Decision query(Query query) { Objects.requireNonNull(query); return evaluate(query, transactions.reader().eligibilityFacts(query.tenantId(), query.fileNumber())); } /** Single-file form of the batch recheck. No occupancy or application is written here. */ public Decision recheckForSubmission(Query query) { return recheckForSubmission(List.of(Objects.requireNonNull(query))).get(0); } /** * Lock the entire number set in the coordinator's stable order, then reread all facts in the * caller's verified transaction, BEFORE document/resource locks. Caller must require ALL * decisions allowed, acquire occupancy and persist natively in this same transaction. * No own-chain exclusion is accepted from the caller. Continuation is a separate trusted path. */ public List recheckForSubmission(List queries) { List batch = List.copyOf(queries); if (batch.isEmpty()) throw new IllegalArgumentException("Empty eligibility batch"); String tenantId = batch.get(0).tenantId(); var numbers = new HashSet(); for (Query query : batch) { if (!tenantId.equals(query.tenantId()) || !numbers.add(query.fileNumber())) throw new IllegalArgumentException("Batch must contain unique numbers in one tenant"); } if (!coordinator.tryLockNumbers(tenantId, numbers)) return batch.stream().map(q -> new Decision(List.of(Reason.NUMBER_BUSY), null, null, null)).toList(); return transactions.execute(mapper -> batch.stream().map(query -> evaluate(query, mapper.eligibilityFacts(query.tenantId(), query.fileNumber()))).toList()); } static Decision evaluate(Query query, BusinessFileEligibilityFacts facts) { Objects.requireNonNull(facts, "Missing database facts"); List reasons = new ArrayList<>(); if (facts.getFileCount() == 0) reasons.add(Reason.FILE_NOT_FOUND); else if (facts.getFileCount() != 1) reasons.add(Reason.AMBIGUOUS_FILE_NUMBER); else { if (!facts.isEffective()) reasons.add(Reason.FILE_NOT_EFFECTIVE); if (!Objects.equals(query.fileId(), facts.getFileId()) || !Objects.equals(query.businessVersionId(), facts.getBusinessVersionId())) reasons.add(Reason.VERSION_CHANGED); if (facts.getEffectiveVersionCount() != 1) reasons.add(Reason.INCONSISTENT_CURRENT_VERSION); if (facts.isPendingVersion()) reasons.add(Reason.PENDING_VERSION); if (query.operation() == Operation.REVISION) { if (facts.isRevocationBlocking()) reasons.add(Reason.REVOCATION_IN_PROGRESS_OR_SUCCEEDED); if (facts.isRevocationUnknown()) reasons.add(Reason.REVOCATION_STATE_UNKNOWN); } } if (facts.isOccupied()) reasons.add(Reason.FILE_OCCUPIED); // Do not choose a winner or expose an arbitrary target when a number is ambiguous. return new Decision(reasons, facts.getFileCount() == 1 ? facts.getFileId() : null, facts.getFileCount() == 1 ? facts.getBusinessVersionId() : null, facts.getFileCount() == 1 ? facts.getCenterVersionId() : null); } }