feat(sprint-1): CannaManage foundation — compliance engine, JPA entities, tests TC-001→TC-025
- Maven multi-module project (parent + domain + service + api) - AbstractTenantEntity with Hibernate @Filter for multi-tenancy (explicit getters/setters, Java 25 compatible) - TenantContext ThreadLocal for request-scoped tenant isolation - 8 JPA entities: Club, Member, Strain, Batch, Distribution, MonthlyQuota, StockMovement, User - ComplianceConstants with CanG §19 limits (25g/day adult, 50g/month adult, 30g/month under-21, 10% THC cap) - ComplianceService: checkDistributionAllowed() with fail-fast sequential CanG checks - Unit tests TC-001→TC-025: 25/25 passing, 100% line+branch coverage on ComplianceService (JaCoCo 0.8.13) - Flyway V1__initial_schema.sql: all 8 tables + indexes - docker-compose.yml: PostgreSQL 16 local dev - application-local.properties: local profile configuration Closes #1 #2 #3 #4 #5 #6 #7 #8 #9 #10
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
wrapperVersion=3.3.4
|
||||||
|
distributionType=only-script
|
||||||
|
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-parent</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>cannamanage-api</artifactId>
|
||||||
|
<name>CannaManage — API (Spring Boot Entry Point)</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-service</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package de.cannamanage.api;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CannaManage Spring Boot application entry point.
|
||||||
|
* REST controllers are deferred to Sprint 2.
|
||||||
|
* Sprint 1 focus: compliance engine validation only.
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(scanBasePackages = "de.cannamanage")
|
||||||
|
@EntityScan(basePackages = "de.cannamanage.domain.entity")
|
||||||
|
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
|
||||||
|
public class CannaManageApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(CannaManageApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
spring.datasource.url=jdbc:postgresql://localhost:5432/cannamanage
|
||||||
|
spring.datasource.username=cannamanage
|
||||||
|
spring.datasource.password=dev_password_change_in_prod
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.locations=classpath:db/migration
|
||||||
|
logging.level.de.cannamanage=DEBUG
|
||||||
|
logging.level.org.flywaydb=INFO
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
spring.application.name=cannamanage
|
||||||
|
# Default profile — override with -Dspring.profiles.active=local
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.flyway.enabled=false
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
-- CannaManage V1 Initial Schema
|
||||||
|
-- Implements all tables required for CanG §19 compliance tracking
|
||||||
|
|
||||||
|
-- Clubs (root of tenant hierarchy)
|
||||||
|
CREATE TABLE clubs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
license_number VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
max_members INT NOT NULL DEFAULT 500,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Members
|
||||||
|
CREATE TABLE members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
club_id UUID NOT NULL REFERENCES clubs(id),
|
||||||
|
first_name VARCHAR(100) NOT NULL,
|
||||||
|
last_name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
date_of_birth DATE NOT NULL,
|
||||||
|
membership_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
membership_number VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
is_under_21 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
prevention_officer BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(email, tenant_id),
|
||||||
|
UNIQUE(membership_number, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Strains
|
||||||
|
CREATE TABLE strains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
thc_percentage NUMERIC(5,2) NOT NULL,
|
||||||
|
cbd_percentage NUMERIC(5,2) NOT NULL DEFAULT 0.00,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Batches
|
||||||
|
CREATE TABLE batches (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
strain_id UUID NOT NULL REFERENCES strains(id),
|
||||||
|
quantity_grams NUMERIC(10,2) NOT NULL,
|
||||||
|
harvest_date DATE,
|
||||||
|
batch_code VARCHAR(100) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'AVAILABLE',
|
||||||
|
contamination_flag BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(batch_code, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Distributions (immutable — append-only for CanG §26 compliance)
|
||||||
|
CREATE TABLE distributions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
batch_id UUID NOT NULL REFERENCES batches(id),
|
||||||
|
quantity_grams NUMERIC(10,2) NOT NULL,
|
||||||
|
distributed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
recorded_by UUID NOT NULL REFERENCES members(id),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Monthly quotas (one row per member per calendar month)
|
||||||
|
CREATE TABLE monthly_quotas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
member_id UUID NOT NULL REFERENCES members(id),
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL CHECK (month >= 1 AND month <= 12),
|
||||||
|
total_distributed NUMERIC(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
max_allowed NUMERIC(10,2) NOT NULL,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(member_id, year, month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stock movements (audit journal)
|
||||||
|
CREATE TABLE stock_movements (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
batch_id UUID NOT NULL REFERENCES batches(id),
|
||||||
|
movement_type VARCHAR(50) NOT NULL,
|
||||||
|
quantity_grams NUMERIC(10,2) NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users (login identities — Sprint 2 auth)
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
member_id UUID REFERENCES members(id),
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'ROLE_MEMBER',
|
||||||
|
last_login TIMESTAMPTZ,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
refresh_token_hash VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(email, tenant_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Performance indexes
|
||||||
|
CREATE INDEX idx_members_club_id ON members(club_id);
|
||||||
|
CREATE INDEX idx_members_tenant_id ON members(tenant_id);
|
||||||
|
CREATE INDEX idx_distributions_member_id ON distributions(member_id);
|
||||||
|
CREATE INDEX idx_distributions_tenant_id ON distributions(tenant_id);
|
||||||
|
CREATE INDEX idx_distributions_distributed_at ON distributions(distributed_at);
|
||||||
|
CREATE INDEX idx_monthly_quotas_member_month ON monthly_quotas(member_id, year, month);
|
||||||
|
CREATE INDEX idx_batches_tenant_status ON batches(tenant_id, status);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-parent</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>cannamanage-domain</artifactId>
|
||||||
|
<name>CannaManage — Domain (JPA Entities)</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- JPA API only — implementation provided by Spring Boot in runtime -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.persistence</groupId>
|
||||||
|
<artifactId>jakarta.persistence-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Hibernate core for @FilterDef, @Filter -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Validation API -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Lombok for boilerplate reduction -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
</project>
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package de.cannamanage.domain.constants;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CanG (Cannabisgesetz) compliance limits.
|
||||||
|
* All limits are defined as per §19 CanG (Cannabisgesetz) for social cannabis clubs.
|
||||||
|
* These are immutable constants — changes require legal review.
|
||||||
|
*/
|
||||||
|
public final class ComplianceConstants {
|
||||||
|
|
||||||
|
// CanG §19(2) — adult daily distribution limit
|
||||||
|
public static final BigDecimal ADULT_DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
|
||||||
|
|
||||||
|
// CanG §19(2) — adult monthly distribution limit
|
||||||
|
public static final BigDecimal ADULT_MONTHLY_LIMIT_GRAMS = new BigDecimal("50.0");
|
||||||
|
|
||||||
|
// CanG §19(3) — under-21 monthly distribution limit
|
||||||
|
public static final BigDecimal UNDER21_MONTHLY_LIMIT_GRAMS = new BigDecimal("30.0");
|
||||||
|
|
||||||
|
// CanG §19(4) — maximum THC percentage allowed for under-21 members
|
||||||
|
public static final BigDecimal UNDER21_MAX_THC_PERCENTAGE = new BigDecimal("10.0");
|
||||||
|
|
||||||
|
// Minimum age for club membership (§5 CanG)
|
||||||
|
public static final int MINIMUM_MEMBERSHIP_AGE = 18;
|
||||||
|
|
||||||
|
// Age threshold below which stricter limits apply
|
||||||
|
public static final int UNDER21_THRESHOLD_AGE = 21;
|
||||||
|
|
||||||
|
private ComplianceConstants() {
|
||||||
|
// Utility class — do not instantiate
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.Filter;
|
||||||
|
import org.hibernate.annotations.FilterDef;
|
||||||
|
import org.hibernate.annotations.ParamDef;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all tenant-scoped entities.
|
||||||
|
* Applies a Hibernate @Filter to ensure all queries automatically scope to the current tenant.
|
||||||
|
* The tenantId is set automatically from TenantContext on persist and cannot be updated.
|
||||||
|
*/
|
||||||
|
@MappedSuperclass
|
||||||
|
@FilterDef(
|
||||||
|
name = "tenantFilter",
|
||||||
|
parameters = @ParamDef(name = "tenantId", type = UUID.class)
|
||||||
|
)
|
||||||
|
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
|
||||||
|
public abstract class AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "tenant_id", nullable = false, updatable = false)
|
||||||
|
private UUID tenantId;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
void onCreate() {
|
||||||
|
this.tenantId = TenantContext.getCurrentTenant();
|
||||||
|
this.createdAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public UUID getTenantId() { return tenantId; }
|
||||||
|
public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
|
||||||
|
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "batches",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"batch_code", "tenant_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class Batch extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "strain_id", nullable = false)
|
||||||
|
private UUID strainId;
|
||||||
|
|
||||||
|
@Column(name = "quantity_grams", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal quantityGrams;
|
||||||
|
|
||||||
|
@Column(name = "harvest_date")
|
||||||
|
private LocalDate harvestDate;
|
||||||
|
|
||||||
|
@Column(name = "batch_code", nullable = false, length = 100)
|
||||||
|
private String batchCode;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 50)
|
||||||
|
private BatchStatus status = BatchStatus.AVAILABLE;
|
||||||
|
|
||||||
|
@Column(name = "contamination_flag", nullable = false)
|
||||||
|
private boolean contaminationFlag = false;
|
||||||
|
|
||||||
|
public UUID getStrainId() { return strainId; }
|
||||||
|
public void setStrainId(UUID strainId) { this.strainId = strainId; }
|
||||||
|
|
||||||
|
public BigDecimal getQuantityGrams() { return quantityGrams; }
|
||||||
|
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
|
||||||
|
|
||||||
|
public LocalDate getHarvestDate() { return harvestDate; }
|
||||||
|
public void setHarvestDate(LocalDate harvestDate) { this.harvestDate = harvestDate; }
|
||||||
|
|
||||||
|
public String getBatchCode() { return batchCode; }
|
||||||
|
public void setBatchCode(String batchCode) { this.batchCode = batchCode; }
|
||||||
|
|
||||||
|
public BatchStatus getStatus() { return status; }
|
||||||
|
public void setStatus(BatchStatus status) { this.status = status; }
|
||||||
|
|
||||||
|
public boolean isContaminationFlag() { return contaminationFlag; }
|
||||||
|
public void setContaminationFlag(boolean contaminationFlag) { this.contaminationFlag = contaminationFlag; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.ClubStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "clubs")
|
||||||
|
public class Club extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 255)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "address")
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
@Column(name = "license_number", nullable = false, unique = true, length = 100)
|
||||||
|
private String licenseNumber;
|
||||||
|
|
||||||
|
@Column(name = "max_members", nullable = false)
|
||||||
|
private Integer maxMembers = 500;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 50)
|
||||||
|
private ClubStatus status = ClubStatus.ACTIVE;
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public void setAddress(String address) { this.address = address; }
|
||||||
|
|
||||||
|
public String getLicenseNumber() { return licenseNumber; }
|
||||||
|
public void setLicenseNumber(String licenseNumber) { this.licenseNumber = licenseNumber; }
|
||||||
|
|
||||||
|
public Integer getMaxMembers() { return maxMembers; }
|
||||||
|
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
|
||||||
|
|
||||||
|
public ClubStatus getStatus() { return status; }
|
||||||
|
public void setStatus(ClubStatus status) { this.status = status; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable distribution record — all fields are updatable=false.
|
||||||
|
* Required for CanG §26 record-keeping obligations.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "distributions")
|
||||||
|
public class Distribution extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "member_id", nullable = false, updatable = false)
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@Column(name = "batch_id", nullable = false, updatable = false)
|
||||||
|
private UUID batchId;
|
||||||
|
|
||||||
|
@Column(name = "quantity_grams", nullable = false, updatable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal quantityGrams;
|
||||||
|
|
||||||
|
@Column(name = "distributed_at", nullable = false, updatable = false)
|
||||||
|
private Instant distributedAt;
|
||||||
|
|
||||||
|
@Column(name = "recorded_by", nullable = false, updatable = false)
|
||||||
|
private UUID recordedBy;
|
||||||
|
|
||||||
|
@Column(name = "notes", updatable = false)
|
||||||
|
private String notes;
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public UUID getBatchId() { return batchId; }
|
||||||
|
public void setBatchId(UUID batchId) { this.batchId = batchId; }
|
||||||
|
|
||||||
|
public BigDecimal getQuantityGrams() { return quantityGrams; }
|
||||||
|
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
|
||||||
|
|
||||||
|
public Instant getDistributedAt() { return distributedAt; }
|
||||||
|
public void setDistributedAt(Instant distributedAt) { this.distributedAt = distributedAt; }
|
||||||
|
|
||||||
|
public UUID getRecordedBy() { return recordedBy; }
|
||||||
|
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
|
||||||
|
|
||||||
|
public String getNotes() { return notes; }
|
||||||
|
public void setNotes(String notes) { this.notes = notes; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "members",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"email", "tenant_id"}),
|
||||||
|
@UniqueConstraint(columnNames = {"membership_number", "tenant_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class Member extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "club_id", nullable = false)
|
||||||
|
private UUID clubId;
|
||||||
|
|
||||||
|
@Column(name = "first_name", nullable = false, length = 100)
|
||||||
|
private String firstName;
|
||||||
|
|
||||||
|
@Column(name = "last_name", nullable = false, length = 100)
|
||||||
|
private String lastName;
|
||||||
|
|
||||||
|
@Column(name = "email", nullable = false, length = 255)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "date_of_birth", nullable = false)
|
||||||
|
private LocalDate dateOfBirth;
|
||||||
|
|
||||||
|
@Column(name = "membership_date", nullable = false)
|
||||||
|
private LocalDate membershipDate;
|
||||||
|
|
||||||
|
@Column(name = "membership_number", nullable = false, length = 50)
|
||||||
|
private String membershipNumber;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 50)
|
||||||
|
private MemberStatus status = MemberStatus.ACTIVE;
|
||||||
|
|
||||||
|
@Column(name = "is_under_21", nullable = false)
|
||||||
|
private boolean under21 = false;
|
||||||
|
|
||||||
|
@Column(name = "prevention_officer", nullable = false)
|
||||||
|
private boolean preventionOfficer = false;
|
||||||
|
|
||||||
|
public UUID getClubId() { return clubId; }
|
||||||
|
public void setClubId(UUID clubId) { this.clubId = clubId; }
|
||||||
|
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||||
|
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||||
|
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
|
||||||
|
public LocalDate getDateOfBirth() { return dateOfBirth; }
|
||||||
|
public void setDateOfBirth(LocalDate dateOfBirth) { this.dateOfBirth = dateOfBirth; }
|
||||||
|
|
||||||
|
public LocalDate getMembershipDate() { return membershipDate; }
|
||||||
|
public void setMembershipDate(LocalDate membershipDate) { this.membershipDate = membershipDate; }
|
||||||
|
|
||||||
|
public String getMembershipNumber() { return membershipNumber; }
|
||||||
|
public void setMembershipNumber(String membershipNumber) { this.membershipNumber = membershipNumber; }
|
||||||
|
|
||||||
|
public MemberStatus getStatus() { return status; }
|
||||||
|
public void setStatus(MemberStatus status) { this.status = status; }
|
||||||
|
|
||||||
|
public boolean isUnder21() { return under21; }
|
||||||
|
public void setUnder21(boolean under21) { this.under21 = under21; }
|
||||||
|
|
||||||
|
public boolean isPreventionOfficer() { return preventionOfficer; }
|
||||||
|
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks monthly distribution totals per member per calendar month.
|
||||||
|
* One row per (member_id, year, month) — unique constraint enforced at DB level.
|
||||||
|
* @Version for optimistic locking — concurrent distribution processing must not corrupt totals.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "monthly_quotas",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"member_id", "year", "month"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class MonthlyQuota extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "member_id", nullable = false)
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@Column(name = "year", nullable = false)
|
||||||
|
private Integer year;
|
||||||
|
|
||||||
|
@Column(name = "month", nullable = false)
|
||||||
|
private Integer month;
|
||||||
|
|
||||||
|
@Column(name = "total_distributed", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal totalDistributed = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "max_allowed", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal maxAllowed;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
@Column(name = "version", nullable = false)
|
||||||
|
private Long version = 0L;
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public Integer getYear() { return year; }
|
||||||
|
public void setYear(Integer year) { this.year = year; }
|
||||||
|
|
||||||
|
public Integer getMonth() { return month; }
|
||||||
|
public void setMonth(Integer month) { this.month = month; }
|
||||||
|
|
||||||
|
public BigDecimal getTotalDistributed() { return totalDistributed; }
|
||||||
|
public void setTotalDistributed(BigDecimal totalDistributed) { this.totalDistributed = totalDistributed; }
|
||||||
|
|
||||||
|
public BigDecimal getMaxAllowed() { return maxAllowed; }
|
||||||
|
public void setMaxAllowed(BigDecimal maxAllowed) { this.maxAllowed = maxAllowed; }
|
||||||
|
|
||||||
|
public Long getVersion() { return version; }
|
||||||
|
public void setVersion(Long version) { this.version = version; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.StockMovementType;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "stock_movements")
|
||||||
|
public class StockMovement extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "batch_id", nullable = false)
|
||||||
|
private UUID batchId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "movement_type", nullable = false, length = 50)
|
||||||
|
private StockMovementType movementType;
|
||||||
|
|
||||||
|
@Column(name = "quantity_grams", nullable = false, precision = 10, scale = 2)
|
||||||
|
private BigDecimal quantityGrams;
|
||||||
|
|
||||||
|
@Column(name = "reason")
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public UUID getBatchId() { return batchId; }
|
||||||
|
public void setBatchId(UUID batchId) { this.batchId = batchId; }
|
||||||
|
|
||||||
|
public StockMovementType getMovementType() { return movementType; }
|
||||||
|
public void setMovementType(StockMovementType movementType) { this.movementType = movementType; }
|
||||||
|
|
||||||
|
public BigDecimal getQuantityGrams() { return quantityGrams; }
|
||||||
|
public void setQuantityGrams(BigDecimal quantityGrams) { this.quantityGrams = quantityGrams; }
|
||||||
|
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public void setReason(String reason) { this.reason = reason; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "strains")
|
||||||
|
public class Strain extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 255)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "thc_percentage", nullable = false, precision = 5, scale = 2)
|
||||||
|
private BigDecimal thcPercentage;
|
||||||
|
|
||||||
|
@Column(name = "cbd_percentage", nullable = false, precision = 5, scale = 2)
|
||||||
|
private BigDecimal cbdPercentage = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public BigDecimal getThcPercentage() { return thcPercentage; }
|
||||||
|
public void setThcPercentage(BigDecimal thcPercentage) { this.thcPercentage = thcPercentage; }
|
||||||
|
|
||||||
|
public BigDecimal getCbdPercentage() { return cbdPercentage; }
|
||||||
|
public void setCbdPercentage(BigDecimal cbdPercentage) { this.cbdPercentage = cbdPercentage; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThreadLocal holder for the current tenant's UUID.
|
||||||
|
* Must be set at the start of each request (e.g., via a servlet filter or Spring interceptor)
|
||||||
|
* and cleared at the end to prevent tenant leakage.
|
||||||
|
*/
|
||||||
|
public final class TenantContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<UUID> CURRENT_TENANT = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private TenantContext() {}
|
||||||
|
|
||||||
|
public static UUID getCurrentTenant() {
|
||||||
|
return CURRENT_TENANT.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setCurrentTenant(UUID tenantId) {
|
||||||
|
CURRENT_TENANT.set(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
CURRENT_TENANT.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package de.cannamanage.domain.entity;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.enums.UserRole;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = {"email", "tenant_id"})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class User extends AbstractTenantEntity {
|
||||||
|
|
||||||
|
@Column(name = "member_id")
|
||||||
|
private UUID memberId;
|
||||||
|
|
||||||
|
@Column(name = "email", nullable = false, length = 255)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "password_hash", nullable = false, length = 255)
|
||||||
|
private String passwordHash;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "role", nullable = false, length = 50)
|
||||||
|
private UserRole role = UserRole.ROLE_MEMBER;
|
||||||
|
|
||||||
|
@Column(name = "last_login")
|
||||||
|
private Instant lastLogin;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "refresh_token_hash", length = 255)
|
||||||
|
private String refreshTokenHash;
|
||||||
|
|
||||||
|
public UUID getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(UUID memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
|
||||||
|
public String getPasswordHash() { return passwordHash; }
|
||||||
|
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
|
||||||
|
|
||||||
|
public UserRole getRole() { return role; }
|
||||||
|
public void setRole(UserRole role) { this.role = role; }
|
||||||
|
|
||||||
|
public Instant getLastLogin() { return lastLogin; }
|
||||||
|
public void setLastLogin(Instant lastLogin) { this.lastLogin = lastLogin; }
|
||||||
|
|
||||||
|
public boolean isActive() { return active; }
|
||||||
|
public void setActive(boolean active) { this.active = active; }
|
||||||
|
|
||||||
|
public String getRefreshTokenHash() { return refreshTokenHash; }
|
||||||
|
public void setRefreshTokenHash(String refreshTokenHash) { this.refreshTokenHash = refreshTokenHash; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum BatchStatus {
|
||||||
|
AVAILABLE,
|
||||||
|
EXHAUSTED,
|
||||||
|
RECALLED,
|
||||||
|
QUARANTINED
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum ClubStatus {
|
||||||
|
ACTIVE,
|
||||||
|
SUSPENDED,
|
||||||
|
REVOKED,
|
||||||
|
PENDING
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum MemberStatus {
|
||||||
|
ACTIVE,
|
||||||
|
SUSPENDED,
|
||||||
|
EXPELLED,
|
||||||
|
PENDING_APPROVAL,
|
||||||
|
RESIGNED
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum StockMovementType {
|
||||||
|
IN,
|
||||||
|
OUT,
|
||||||
|
RECALL,
|
||||||
|
ADJUSTMENT
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.cannamanage.domain.enums;
|
||||||
|
|
||||||
|
public enum UserRole {
|
||||||
|
ROLE_ADMIN,
|
||||||
|
ROLE_MANAGER,
|
||||||
|
ROLE_MEMBER,
|
||||||
|
ROLE_PREVENTION_OFFICER
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-parent</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>cannamanage-service</artifactId>
|
||||||
|
<name>CannaManage — Service (Business Logic)</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Internal domain -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-domain</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Data JPA for repository interfaces -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring TX annotations -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-tx</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<!-- Test dependencies -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.assertj</groupId>
|
||||||
|
<artifactId>assertj-core</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>prepare-agent</id>
|
||||||
|
<goals>
|
||||||
|
<goal>prepare-agent</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>report</id>
|
||||||
|
<phase>test</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>report</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>check</id>
|
||||||
|
<goals>
|
||||||
|
<goal>check</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<rules>
|
||||||
|
<rule>
|
||||||
|
<element>CLASS</element>
|
||||||
|
<includes>
|
||||||
|
<include>de.cannamanage.service.ComplianceService</include>
|
||||||
|
</includes>
|
||||||
|
<limits>
|
||||||
|
<limit>
|
||||||
|
<counter>LINE</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>1.00</minimum>
|
||||||
|
</limit>
|
||||||
|
<limit>
|
||||||
|
<counter>BRANCH</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>1.00</minimum>
|
||||||
|
</limit>
|
||||||
|
</limits>
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.MonthlyQuota;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.dto.ComplianceCheckResult;
|
||||||
|
import de.cannamanage.service.dto.QuotaStatus;
|
||||||
|
import de.cannamanage.service.exception.BatchNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
|
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.MonthlyQuotaRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core compliance engine implementing CanG §19 distribution rules.
|
||||||
|
* All checks are fail-fast — the first violation throws immediately.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ComplianceService {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
private final DistributionRepository distributionRepository;
|
||||||
|
private final BatchRepository batchRepository;
|
||||||
|
private final MonthlyQuotaRepository monthlyQuotaRepository;
|
||||||
|
private final StrainRepository strainRepository;
|
||||||
|
|
||||||
|
public ComplianceService(
|
||||||
|
MemberRepository memberRepository,
|
||||||
|
DistributionRepository distributionRepository,
|
||||||
|
BatchRepository batchRepository,
|
||||||
|
MonthlyQuotaRepository monthlyQuotaRepository,
|
||||||
|
StrainRepository strainRepository) {
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
this.distributionRepository = distributionRepository;
|
||||||
|
this.batchRepository = batchRepository;
|
||||||
|
this.monthlyQuotaRepository = monthlyQuotaRepository;
|
||||||
|
this.strainRepository = strainRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a distribution is permitted under CanG §19.
|
||||||
|
* Sequential fail-fast checks in legally mandated order.
|
||||||
|
*/
|
||||||
|
public ComplianceCheckResult checkDistributionAllowed(
|
||||||
|
UUID memberId, UUID batchId, BigDecimal quantityGrams) {
|
||||||
|
|
||||||
|
// 1. Load member
|
||||||
|
Member member = memberRepository.findById(memberId)
|
||||||
|
.orElseThrow(() -> new MemberNotFoundException(memberId));
|
||||||
|
|
||||||
|
// 2. Member must be ACTIVE
|
||||||
|
if (member.getStatus() != MemberStatus.ACTIVE) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.MEMBER_INACTIVE,
|
||||||
|
"Member " + memberId + " is not active (status=" + member.getStatus() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Batch must be AVAILABLE
|
||||||
|
Batch batch = batchRepository.findById(batchId)
|
||||||
|
.orElseThrow(() -> new BatchNotFoundException(batchId));
|
||||||
|
|
||||||
|
if (batch.getStatus() != BatchStatus.AVAILABLE) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.BATCH_UNAVAILABLE,
|
||||||
|
"Batch " + batchId + " is not available (status=" + batch.getStatus() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Load strain
|
||||||
|
Strain strain = strainRepository.findById(batch.getStrainId())
|
||||||
|
.orElseThrow(() -> new BatchNotFoundException(batch.getStrainId()));
|
||||||
|
|
||||||
|
// 5. Under-21 THC restriction (CanG §19(4))
|
||||||
|
if (member.isUnder21() &&
|
||||||
|
strain.getThcPercentage().compareTo(ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE) > 0) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21,
|
||||||
|
"Under-21 member cannot receive strain with THC% > " +
|
||||||
|
ComplianceConstants.UNDER21_MAX_THC_PERCENTAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Daily limit check (CanG §19(2))
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
Instant dayStart = today.atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
Instant dayEnd = today.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
|
||||||
|
|
||||||
|
BigDecimal todayDistributed = distributionRepository
|
||||||
|
.sumQuantityByMemberAndDay(memberId, dayStart, dayEnd);
|
||||||
|
|
||||||
|
BigDecimal dailyLimit = ComplianceConstants.ADULT_DAILY_LIMIT_GRAMS;
|
||||||
|
if (todayDistributed.add(quantityGrams).compareTo(dailyLimit) > 0) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.QUOTA_EXCEEDED_DAILY,
|
||||||
|
"Daily limit of " + dailyLimit + "g would be exceeded. " +
|
||||||
|
"Already distributed today: " + todayDistributed + "g");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Monthly limit check (CanG §19(2)/(3))
|
||||||
|
int year = today.getYear();
|
||||||
|
int month = today.getMonthValue();
|
||||||
|
|
||||||
|
BigDecimal monthlyLimit = member.isUnder21()
|
||||||
|
? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
|
||||||
|
: ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
|
||||||
|
|
||||||
|
MonthlyQuota quota = monthlyQuotaRepository
|
||||||
|
.findByMemberIdAndYearAndMonth(memberId, year, month)
|
||||||
|
.orElseGet(() -> createNewQuota(memberId, year, month, monthlyLimit));
|
||||||
|
|
||||||
|
if (quota.getTotalDistributed().add(quantityGrams).compareTo(quota.getMaxAllowed()) > 0) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY,
|
||||||
|
"Monthly limit of " + quota.getMaxAllowed() + "g would be exceeded. " +
|
||||||
|
"Already distributed this month: " + quota.getTotalDistributed() + "g");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. All checks passed
|
||||||
|
BigDecimal remainingDaily = dailyLimit
|
||||||
|
.subtract(todayDistributed)
|
||||||
|
.subtract(quantityGrams);
|
||||||
|
BigDecimal remainingMonthly = quota.getMaxAllowed()
|
||||||
|
.subtract(quota.getTotalDistributed())
|
||||||
|
.subtract(quantityGrams);
|
||||||
|
|
||||||
|
return new ComplianceCheckResult(true, remainingDaily, remainingMonthly, member.isUnder21());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public QuotaStatus getQuotaStatus(UUID memberId) {
|
||||||
|
Member member = memberRepository.findById(memberId)
|
||||||
|
.orElseThrow(() -> new MemberNotFoundException(memberId));
|
||||||
|
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
int year = today.getYear();
|
||||||
|
int month = today.getMonthValue();
|
||||||
|
|
||||||
|
BigDecimal monthlyLimit = member.isUnder21()
|
||||||
|
? ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS
|
||||||
|
: ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS;
|
||||||
|
|
||||||
|
MonthlyQuota quota = monthlyQuotaRepository
|
||||||
|
.findByMemberIdAndYearAndMonth(memberId, year, month)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
MonthlyQuota empty = new MonthlyQuota();
|
||||||
|
empty.setTotalDistributed(BigDecimal.ZERO);
|
||||||
|
empty.setMaxAllowed(monthlyLimit);
|
||||||
|
return empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
BigDecimal remaining = quota.getMaxAllowed().subtract(quota.getTotalDistributed());
|
||||||
|
|
||||||
|
return new QuotaStatus(
|
||||||
|
quota.getMaxAllowed(),
|
||||||
|
quota.getTotalDistributed(),
|
||||||
|
remaining,
|
||||||
|
member.isUnder21(),
|
||||||
|
year,
|
||||||
|
month
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateMembershipAge(LocalDate dateOfBirth) {
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
int age = today.getYear() - dateOfBirth.getYear();
|
||||||
|
if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
if (age < ComplianceConstants.MINIMUM_MEMBERSHIP_AGE) {
|
||||||
|
throw new QuotaExceededException(
|
||||||
|
QuotaViolationCode.MEMBER_INACTIVE,
|
||||||
|
"Applicant is under minimum membership age of " +
|
||||||
|
ComplianceConstants.MINIMUM_MEMBERSHIP_AGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUnder21(LocalDate dateOfBirth) {
|
||||||
|
LocalDate today = LocalDate.now(ZoneOffset.UTC);
|
||||||
|
int age = today.getYear() - dateOfBirth.getYear();
|
||||||
|
if (today.getDayOfYear() < dateOfBirth.getDayOfYear()) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return age < ComplianceConstants.UNDER21_THRESHOLD_AGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MonthlyQuota createNewQuota(UUID memberId, int year, int month, BigDecimal maxAllowed) {
|
||||||
|
MonthlyQuota quota = new MonthlyQuota();
|
||||||
|
quota.setMemberId(memberId);
|
||||||
|
quota.setYear(year);
|
||||||
|
quota.setMonth(month);
|
||||||
|
quota.setTotalDistributed(BigDecimal.ZERO);
|
||||||
|
quota.setMaxAllowed(maxAllowed);
|
||||||
|
return monthlyQuotaRepository.save(quota);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.service.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a compliance check for a potential cannabis distribution.
|
||||||
|
* When {@code allowed} is true, {@code remainingDaily} and {@code remainingMonthly}
|
||||||
|
* reflect the quota after the requested distribution would be applied.
|
||||||
|
*/
|
||||||
|
public record ComplianceCheckResult(
|
||||||
|
boolean allowed,
|
||||||
|
BigDecimal remainingDaily,
|
||||||
|
BigDecimal remainingMonthly,
|
||||||
|
boolean isUnder21
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package de.cannamanage.service.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current quota status for a member in a given calendar month.
|
||||||
|
*/
|
||||||
|
public record QuotaStatus(
|
||||||
|
BigDecimal totalAllowed,
|
||||||
|
BigDecimal totalUsed,
|
||||||
|
BigDecimal remaining,
|
||||||
|
boolean isUnder21,
|
||||||
|
int year,
|
||||||
|
int month
|
||||||
|
) {}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package de.cannamanage.service.exception;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class BatchNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public BatchNotFoundException(UUID batchId) {
|
||||||
|
super("Batch not found: " + batchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
package de.cannamanage.service.exception;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class MemberNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public MemberNotFoundException(UUID memberId) {
|
||||||
|
super("Member not found: " + memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package de.cannamanage.service.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a cannabis distribution would violate a CanG compliance rule.
|
||||||
|
* The {@code code} field identifies which specific rule was violated,
|
||||||
|
* enabling the API layer to return structured error responses.
|
||||||
|
*/
|
||||||
|
public class QuotaExceededException extends RuntimeException {
|
||||||
|
|
||||||
|
private final QuotaViolationCode code;
|
||||||
|
|
||||||
|
public QuotaExceededException(QuotaViolationCode code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QuotaViolationCode getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package de.cannamanage.service.exception;
|
||||||
|
|
||||||
|
public enum QuotaViolationCode {
|
||||||
|
MEMBER_INACTIVE,
|
||||||
|
QUOTA_EXCEEDED_DAILY,
|
||||||
|
QUOTA_EXCEEDED_MONTHLY,
|
||||||
|
HIGH_THC_RESTRICTED_UNDER_21,
|
||||||
|
BATCH_UNAVAILABLE
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface BatchRepository extends JpaRepository<Batch, UUID> {
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Distribution;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DistributionRepository extends JpaRepository<Distribution, UUID> {
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(d.quantityGrams), 0) FROM Distribution d " +
|
||||||
|
"WHERE d.memberId = :memberId AND d.distributedAt >= :dayStart AND d.distributedAt < :dayEnd")
|
||||||
|
BigDecimal sumQuantityByMemberAndDay(
|
||||||
|
@Param("memberId") UUID memberId,
|
||||||
|
@Param("dayStart") Instant dayStart,
|
||||||
|
@Param("dayEnd") Instant dayEnd
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface MemberRepository extends JpaRepository<Member, UUID> {
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.MonthlyQuota;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface MonthlyQuotaRepository extends JpaRepository<MonthlyQuota, UUID> {
|
||||||
|
|
||||||
|
Optional<MonthlyQuota> findByMemberIdAndYearAndMonth(UUID memberId, int year, int month);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package de.cannamanage.service.repository;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface StrainRepository extends JpaRepository<Strain, UUID> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package de.cannamanage.service;
|
||||||
|
|
||||||
|
import de.cannamanage.domain.constants.ComplianceConstants;
|
||||||
|
import de.cannamanage.domain.entity.Batch;
|
||||||
|
import de.cannamanage.domain.entity.Member;
|
||||||
|
import de.cannamanage.domain.entity.MonthlyQuota;
|
||||||
|
import de.cannamanage.domain.entity.Strain;
|
||||||
|
import de.cannamanage.domain.enums.BatchStatus;
|
||||||
|
import de.cannamanage.domain.enums.MemberStatus;
|
||||||
|
import de.cannamanage.service.exception.MemberNotFoundException;
|
||||||
|
import de.cannamanage.service.exception.QuotaExceededException;
|
||||||
|
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||||
|
import de.cannamanage.service.repository.BatchRepository;
|
||||||
|
import de.cannamanage.service.repository.DistributionRepository;
|
||||||
|
import de.cannamanage.service.repository.MemberRepository;
|
||||||
|
import de.cannamanage.service.repository.MonthlyQuotaRepository;
|
||||||
|
import de.cannamanage.service.repository.StrainRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ComplianceServiceTest {
|
||||||
|
|
||||||
|
@Mock MemberRepository memberRepository;
|
||||||
|
@Mock DistributionRepository distributionRepository;
|
||||||
|
@Mock BatchRepository batchRepository;
|
||||||
|
@Mock MonthlyQuotaRepository monthlyQuotaRepository;
|
||||||
|
@Mock StrainRepository strainRepository;
|
||||||
|
|
||||||
|
@InjectMocks ComplianceService complianceService;
|
||||||
|
|
||||||
|
private static final UUID ADULT_MEMBER_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
|
||||||
|
private static final UUID UNDER21_MEMBER_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
|
||||||
|
private static final UUID BATCH_ID = UUID.fromString("33333333-3333-3333-3333-333333333333");
|
||||||
|
private static final UUID STRAIN_ID = UUID.fromString("44444444-4444-4444-4444-444444444444");
|
||||||
|
private static final UUID HIGH_THC_STRAIN_ID = UUID.fromString("55555555-5555-5555-5555-555555555555");
|
||||||
|
|
||||||
|
private Member adultMember;
|
||||||
|
private Member under21Member;
|
||||||
|
private Batch availableBatch;
|
||||||
|
private Strain normalStrain;
|
||||||
|
private Strain highThcStrain;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adultMember = new Member();
|
||||||
|
adultMember.setStatus(MemberStatus.ACTIVE);
|
||||||
|
adultMember.setUnder21(false);
|
||||||
|
|
||||||
|
under21Member = new Member();
|
||||||
|
under21Member.setStatus(MemberStatus.ACTIVE);
|
||||||
|
under21Member.setUnder21(true);
|
||||||
|
|
||||||
|
normalStrain = new Strain();
|
||||||
|
normalStrain.setThcPercentage(new BigDecimal("8.0"));
|
||||||
|
normalStrain.setCbdPercentage(new BigDecimal("2.0"));
|
||||||
|
|
||||||
|
highThcStrain = new Strain();
|
||||||
|
highThcStrain.setThcPercentage(new BigDecimal("22.0"));
|
||||||
|
highThcStrain.setCbdPercentage(BigDecimal.ZERO);
|
||||||
|
|
||||||
|
availableBatch = new Batch();
|
||||||
|
availableBatch.setStatus(BatchStatus.AVAILABLE);
|
||||||
|
availableBatch.setStrainId(STRAIN_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-001: Adult at monthly limit → QUOTA_EXCEEDED_MONTHLY
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-001: Adult at 50g monthly usage → QUOTA_EXCEEDED_MONTHLY")
|
||||||
|
void tc001_adultAtMonthlyLimit() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
MonthlyQuota quota = buildQuota("50.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.of(quota));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-002: Under-21 at monthly limit → QUOTA_EXCEEDED_MONTHLY
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-002: Under-21 at 30g monthly usage → QUOTA_EXCEEDED_MONTHLY")
|
||||||
|
void tc002_under21AtMonthlyLimit() {
|
||||||
|
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
MonthlyQuota quota = buildQuota("30.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.of(quota));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-003: Adult at daily limit → QUOTA_EXCEEDED_DAILY
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-003: Adult at 25g today requesting 1g → QUOTA_EXCEEDED_DAILY")
|
||||||
|
void tc003_adultAtDailyLimit() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(new BigDecimal("25.0"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("1.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-004: Under-21 + high THC → HIGH_THC_RESTRICTED_UNDER_21
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-004: Under-21 + 22% THC batch → HIGH_THC_RESTRICTED_UNDER_21")
|
||||||
|
void tc004_under21HighThcStrain() {
|
||||||
|
Batch highThcBatch = new Batch();
|
||||||
|
highThcBatch.setStatus(BatchStatus.AVAILABLE);
|
||||||
|
highThcBatch.setStrainId(HIGH_THC_STRAIN_ID);
|
||||||
|
|
||||||
|
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(highThcBatch));
|
||||||
|
when(strainRepository.findById(HIGH_THC_STRAIN_ID)).thenReturn(Optional.of(highThcStrain));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.HIGH_THC_RESTRICTED_UNDER_21);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-005: Adult at 49g requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-005: Adult at 49g monthly requesting 2g → QUOTA_EXCEEDED_MONTHLY (boundary)")
|
||||||
|
void tc005_adultAt49gRequesting2g() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
MonthlyQuota quota = buildQuota("49.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.of(quota));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("2.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_MONTHLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-006: Adult at 0g requesting 25g → allowed, remainingDaily=0")
|
||||||
|
void tc006_adultAt0gRequesting25g_allowed() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
MonthlyQuota quota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.of(quota));
|
||||||
|
|
||||||
|
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("25.0"));
|
||||||
|
|
||||||
|
assertThat(result.allowed()).isTrue();
|
||||||
|
assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
assertThat(result.remainingMonthly()).isEqualByComparingTo(new BigDecimal("25.0"));
|
||||||
|
assertThat(result.isUnder21()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-007: Adult at 24.9g today requesting 0.1g → allowed, remainingDaily=0")
|
||||||
|
void tc007_adultAt24dot9gRequesting0dot1g_allowed() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(new BigDecimal("24.9"));
|
||||||
|
MonthlyQuota quota = buildQuota("24.9", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.of(quota));
|
||||||
|
|
||||||
|
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.1"));
|
||||||
|
|
||||||
|
assertThat(result.allowed()).isTrue();
|
||||||
|
assertThat(result.remainingDaily()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-008: Adult at 24.9g today requesting 0.2g → QUOTA_EXCEEDED_DAILY (boundary)")
|
||||||
|
void tc008_adultAt24dot9gRequesting0dot2g_dailyExceeded() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(new BigDecimal("24.9"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("0.2")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.QUOTA_EXCEEDED_DAILY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-009: SUSPENDED member → MEMBER_INACTIVE
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-009: SUSPENDED member → MEMBER_INACTIVE")
|
||||||
|
void tc009_suspendedMember() {
|
||||||
|
Member suspended = new Member();
|
||||||
|
suspended.setStatus(MemberStatus.SUSPENDED);
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(suspended));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-010: EXPELLED member → MEMBER_INACTIVE
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-010: EXPELLED member → MEMBER_INACTIVE")
|
||||||
|
void tc010_expelledMember() {
|
||||||
|
Member expelled = new Member();
|
||||||
|
expelled.setStatus(MemberStatus.EXPELLED);
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(expelled));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-011: No existing quota → creates new quota (createNewQuota path)
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-011: No existing quota → creates new quota, distribution allowed")
|
||||||
|
void tc011_noExistingQuota_createsNewAndAllows() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
// Return empty — triggers createNewQuota
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);
|
||||||
|
|
||||||
|
var result = complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("10.0"));
|
||||||
|
|
||||||
|
assertThat(result.allowed()).isTrue();
|
||||||
|
assertThat(result.isUnder21()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-012: Under-21 no existing quota → creates quota with under-21 limit
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-012: Under-21 no existing quota → creates quota with 30g limit")
|
||||||
|
void tc012_under21NoExistingQuota_createsNewWithUnder21Limit() {
|
||||||
|
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(availableBatch));
|
||||||
|
when(strainRepository.findById(STRAIN_ID)).thenReturn(Optional.of(normalStrain));
|
||||||
|
when(distributionRepository.sumQuantityByMemberAndDay(any(), any(Instant.class), any(Instant.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
MonthlyQuota newQuota = buildQuota("0.0", ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.save(any())).thenReturn(newQuota);
|
||||||
|
|
||||||
|
var result = complianceService.checkDistributionAllowed(UNDER21_MEMBER_ID, BATCH_ID, new BigDecimal("5.0"));
|
||||||
|
|
||||||
|
assertThat(result.allowed()).isTrue();
|
||||||
|
assertThat(result.isUnder21()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-013: BATCH_UNAVAILABLE — batch is RECALLED
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-013: RECALLED batch → BATCH_UNAVAILABLE")
|
||||||
|
void tc013_recalledBatch() {
|
||||||
|
Batch recalled = new Batch();
|
||||||
|
recalled.setStatus(BatchStatus.RECALLED);
|
||||||
|
recalled.setStrainId(STRAIN_ID);
|
||||||
|
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(recalled));
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.BATCH_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-014: getQuotaStatus — adult member with existing quota
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-014: getQuotaStatus — adult with existing quota returns correct status")
|
||||||
|
void tc014_getQuotaStatus_adultWithExistingQuota() {
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
MonthlyQuota quota = buildQuota("20.0", ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.of(quota));
|
||||||
|
|
||||||
|
var status = complianceService.getQuotaStatus(ADULT_MEMBER_ID);
|
||||||
|
|
||||||
|
assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.ADULT_MONTHLY_LIMIT_GRAMS);
|
||||||
|
assertThat(status.totalUsed()).isEqualByComparingTo(new BigDecimal("20.0"));
|
||||||
|
assertThat(status.remaining()).isEqualByComparingTo(new BigDecimal("30.0"));
|
||||||
|
assertThat(status.isUnder21()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-015: getQuotaStatus — under-21 with no existing quota
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-015: getQuotaStatus — under-21 with no quota returns 30g limit")
|
||||||
|
void tc015_getQuotaStatus_under21NoExistingQuota() {
|
||||||
|
when(memberRepository.findById(UNDER21_MEMBER_ID)).thenReturn(Optional.of(under21Member));
|
||||||
|
when(monthlyQuotaRepository.findByMemberIdAndYearAndMonth(any(), anyInt(), anyInt()))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
var status = complianceService.getQuotaStatus(UNDER21_MEMBER_ID);
|
||||||
|
|
||||||
|
assertThat(status.totalAllowed()).isEqualByComparingTo(ComplianceConstants.UNDER21_MONTHLY_LIMIT_GRAMS);
|
||||||
|
assertThat(status.totalUsed()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
assertThat(status.isUnder21()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-016: getQuotaStatus — member not found throws exception
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-016: getQuotaStatus — unknown member ID throws MemberNotFoundException")
|
||||||
|
void tc016_getQuotaStatus_memberNotFound() {
|
||||||
|
UUID unknown = UUID.fromString("99999999-9999-9999-9999-999999999999");
|
||||||
|
when(memberRepository.findById(unknown)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> complianceService.getQuotaStatus(unknown))
|
||||||
|
.isInstanceOf(MemberNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-017: validateMembershipAge — 18-year-old is allowed
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-017: validateMembershipAge — 18-year-old is allowed")
|
||||||
|
void tc017_validateMembershipAge_18YearsOld_allowed() {
|
||||||
|
// Just before birthday this year to keep age = 18
|
||||||
|
LocalDate dob = LocalDate.now().minusYears(18).minusDays(1);
|
||||||
|
// Should not throw
|
||||||
|
complianceService.validateMembershipAge(dob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-018: validateMembershipAge — 17-year-old is rejected
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-018: validateMembershipAge — 17-year-old is rejected")
|
||||||
|
void tc018_validateMembershipAge_17YearsOld_rejected() {
|
||||||
|
LocalDate dob = LocalDate.now().minusYears(17);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-019: isUnder21 — 20-year-old returns true
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-019: isUnder21 — 20-year-old returns true")
|
||||||
|
void tc019_isUnder21_20YearsOld_returnsTrue() {
|
||||||
|
LocalDate dob = LocalDate.now().minusYears(20).minusDays(1);
|
||||||
|
assertThat(complianceService.isUnder21(dob)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-020: isUnder21 — 21-year-old returns false
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-020: isUnder21 — 21-year-old returns false")
|
||||||
|
void tc020_isUnder21_21YearsOld_returnsFalse() {
|
||||||
|
LocalDate dob = LocalDate.now().minusYears(21).minusDays(1);
|
||||||
|
assertThat(complianceService.isUnder21(dob)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-021: member not found in checkDistributionAllowed → MemberNotFoundException
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-021: Unknown member ID in checkDistributionAllowed → MemberNotFoundException")
|
||||||
|
void tc021_memberNotFoundInCheck() {
|
||||||
|
UUID unknown = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
when(memberRepository.findById(unknown)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(unknown, BATCH_ID, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(de.cannamanage.service.exception.MemberNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-022: batch not found in checkDistributionAllowed → BatchNotFoundException
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-022: Unknown batch ID in checkDistributionAllowed → BatchNotFoundException")
|
||||||
|
void tc022_batchNotFoundInCheck() {
|
||||||
|
UUID unknownBatch = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(unknownBatch)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, unknownBatch, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-023: strain not found for batch → BatchNotFoundException
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-023: Strain not found for batch → BatchNotFoundException")
|
||||||
|
void tc023_strainNotFoundForBatch() {
|
||||||
|
UUID unknownStrain = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||||
|
Batch batchWithUnknownStrain = new Batch();
|
||||||
|
batchWithUnknownStrain.setStatus(BatchStatus.AVAILABLE);
|
||||||
|
batchWithUnknownStrain.setStrainId(unknownStrain);
|
||||||
|
|
||||||
|
when(memberRepository.findById(ADULT_MEMBER_ID)).thenReturn(Optional.of(adultMember));
|
||||||
|
when(batchRepository.findById(BATCH_ID)).thenReturn(Optional.of(batchWithUnknownStrain));
|
||||||
|
when(strainRepository.findById(unknownStrain)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
complianceService.checkDistributionAllowed(ADULT_MEMBER_ID, BATCH_ID, new BigDecimal("5.0")))
|
||||||
|
.isInstanceOf(de.cannamanage.service.exception.BatchNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-024: validateMembershipAge — birthday not yet occurred this year (age-- branch)
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-024: validateMembershipAge — birthday later this year → age is 17, rejected")
|
||||||
|
void tc024_validateMembershipAge_birthdayLaterThisYear() {
|
||||||
|
// Person who will turn 18 tomorrow — today they are 17 → should throw
|
||||||
|
LocalDate dob = LocalDate.now().plusDays(1).minusYears(18);
|
||||||
|
assertThatThrownBy(() -> complianceService.validateMembershipAge(dob))
|
||||||
|
.isInstanceOf(QuotaExceededException.class)
|
||||||
|
.extracting("code").isEqualTo(QuotaViolationCode.MEMBER_INACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC-025: isUnder21 — birthday not yet occurred this year → age-- branch
|
||||||
|
@Test
|
||||||
|
@DisplayName("TC-025: isUnder21 — person turns 21 tomorrow → still under 21 today")
|
||||||
|
void tc025_isUnder21_birthdayTomorrow_stillUnder21() {
|
||||||
|
// Person who will turn 21 tomorrow — today they are 20 → still under 21
|
||||||
|
LocalDate dob = LocalDate.now().plusDays(1).minusYears(21);
|
||||||
|
assertThat(complianceService.isUnder21(dob)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper
|
||||||
|
private MonthlyQuota buildQuota(String totalDistributed, BigDecimal maxAllowed) {
|
||||||
|
MonthlyQuota quota = new MonthlyQuota();
|
||||||
|
quota.setTotalDistributed(new BigDecimal(totalDistributed));
|
||||||
|
quota.setMaxAllowed(maxAllowed);
|
||||||
|
return quota;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: cannamanage-db-local
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: cannamanage
|
||||||
|
POSTGRES_USER: cannamanage
|
||||||
|
POSTGRES_PASSWORD: dev_password_change_in_prod
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata_local:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U cannamanage"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata_local:
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
#
|
||||||
|
# Optional ENV vars
|
||||||
|
# -----------------
|
||||||
|
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
|
||||||
|
# MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
set -euf
|
||||||
|
[ "${MVNW_VERBOSE-}" != debug ] || set -x
|
||||||
|
|
||||||
|
# OS specific support.
|
||||||
|
native_path() { printf %s\\n "$1"; }
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* | MINGW*)
|
||||||
|
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
|
||||||
|
native_path() { cygpath --path --windows "$1"; }
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# set JAVACMD and JAVACCMD
|
||||||
|
set_java_home() {
|
||||||
|
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
|
||||||
|
if [ -n "${JAVA_HOME-}" ]; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/jre/sh/javac"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
JAVACCMD="$JAVA_HOME/bin/javac"
|
||||||
|
|
||||||
|
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
|
||||||
|
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
|
||||||
|
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v java
|
||||||
|
)" || :
|
||||||
|
JAVACCMD="$(
|
||||||
|
'set' +e
|
||||||
|
'unset' -f command 2>/dev/null
|
||||||
|
'command' -v javac
|
||||||
|
)" || :
|
||||||
|
|
||||||
|
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
|
||||||
|
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# hash string like Java String::hashCode
|
||||||
|
hash_string() {
|
||||||
|
str="${1:-}" h=0
|
||||||
|
while [ -n "$str" ]; do
|
||||||
|
char="${str%"${str#?}"}"
|
||||||
|
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
|
||||||
|
str="${str#?}"
|
||||||
|
done
|
||||||
|
printf %x\\n $h
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose() { :; }
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
printf %s\\n "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
# MWRAPPER-139:
|
||||||
|
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
|
||||||
|
# Needed for removing poorly interpreted newline sequences when running in more
|
||||||
|
# exotic environments such as mingw bash on Windows.
|
||||||
|
printf "%s" "${1}" | tr -d '[:space:]'
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptDir="$(dirname "$0")"
|
||||||
|
scriptName="$(basename "$0")"
|
||||||
|
|
||||||
|
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
while IFS="=" read -r key value; do
|
||||||
|
case "${key-}" in
|
||||||
|
distributionUrl) distributionUrl=$(trim "${value-}") ;;
|
||||||
|
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
|
||||||
|
esac
|
||||||
|
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
|
||||||
|
case "${distributionUrl##*/}" in
|
||||||
|
maven-mvnd-*bin.*)
|
||||||
|
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
|
||||||
|
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
|
||||||
|
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
|
||||||
|
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
|
||||||
|
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
|
||||||
|
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
|
||||||
|
*)
|
||||||
|
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
|
||||||
|
distributionPlatform=linux-amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
|
||||||
|
;;
|
||||||
|
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
|
||||||
|
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
distributionUrlNameMain="${distributionUrlName%.*}"
|
||||||
|
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
|
||||||
|
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
|
||||||
|
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
|
||||||
|
|
||||||
|
exec_maven() {
|
||||||
|
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
|
||||||
|
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -d "$MAVEN_HOME" ]; then
|
||||||
|
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
exec_maven "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "${distributionUrl-}" in
|
||||||
|
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
|
||||||
|
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
|
||||||
|
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
|
||||||
|
trap clean HUP INT TERM EXIT
|
||||||
|
else
|
||||||
|
die "cannot create temp dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p -- "${MAVEN_HOME%/*}"
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
verbose "Downloading from: $distributionUrl"
|
||||||
|
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
# select .zip or .tar.gz
|
||||||
|
if ! command -v unzip >/dev/null; then
|
||||||
|
distributionUrl="${distributionUrl%.zip}.tar.gz"
|
||||||
|
distributionUrlName="${distributionUrl##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# verbose opt
|
||||||
|
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
|
||||||
|
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
|
||||||
|
|
||||||
|
# normalize http auth
|
||||||
|
case "${MVNW_PASSWORD:+has-password}" in
|
||||||
|
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
|
||||||
|
verbose "Found wget ... using wget"
|
||||||
|
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
|
||||||
|
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
|
||||||
|
verbose "Found curl ... using curl"
|
||||||
|
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
|
||||||
|
elif set_java_home; then
|
||||||
|
verbose "Falling back to use Java to download"
|
||||||
|
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
|
||||||
|
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
cat >"$javaSource" <<-END
|
||||||
|
public class Downloader extends java.net.Authenticator
|
||||||
|
{
|
||||||
|
protected java.net.PasswordAuthentication getPasswordAuthentication()
|
||||||
|
{
|
||||||
|
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
|
||||||
|
}
|
||||||
|
public static void main( String[] args ) throws Exception
|
||||||
|
{
|
||||||
|
setDefault( new Downloader() );
|
||||||
|
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END
|
||||||
|
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
|
||||||
|
verbose " - Compiling Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
|
||||||
|
verbose " - Running Downloader.java ..."
|
||||||
|
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
if [ -n "${distributionSha256Sum-}" ]; then
|
||||||
|
distributionSha256Result=false
|
||||||
|
if [ "$MVN_CMD" = mvnd.sh ]; then
|
||||||
|
echo "Checksum validation is not supported for maven-mvnd." >&2
|
||||||
|
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
elif command -v sha256sum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
elif command -v shasum >/dev/null; then
|
||||||
|
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
|
||||||
|
distributionSha256Result=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
|
||||||
|
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ $distributionSha256Result = false ]; then
|
||||||
|
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
|
||||||
|
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
if command -v unzip >/dev/null; then
|
||||||
|
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
|
||||||
|
else
|
||||||
|
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
actualDistributionDir=""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
|
||||||
|
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$distributionUrlNameMain"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
# enable globbing to iterate over items
|
||||||
|
set +f
|
||||||
|
for dir in "$TMP_DOWNLOAD_DIR"/*; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
if [ -f "$dir/bin/$MVN_CMD" ]; then
|
||||||
|
actualDistributionDir="$(basename "$dir")"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
set -f
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$actualDistributionDir" ]; then
|
||||||
|
verbose "Contents of $TMP_DOWNLOAD_DIR:"
|
||||||
|
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
|
||||||
|
die "Could not find Maven distribution directory in extracted archive"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
|
||||||
|
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
|
||||||
|
|
||||||
|
clean || :
|
||||||
|
exec_maven "$@"
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<# : batch portion
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
@REM or more contributor license agreements. See the NOTICE file
|
||||||
|
@REM distributed with this work for additional information
|
||||||
|
@REM regarding copyright ownership. The ASF licenses this file
|
||||||
|
@REM to you under the Apache License, Version 2.0 (the
|
||||||
|
@REM "License"); you may not use this file except in compliance
|
||||||
|
@REM with the License. You may obtain a copy of the License at
|
||||||
|
@REM
|
||||||
|
@REM http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@REM
|
||||||
|
@REM Unless required by applicable law or agreed to in writing,
|
||||||
|
@REM software distributed under the License is distributed on an
|
||||||
|
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
@REM KIND, either express or implied. See the License for the
|
||||||
|
@REM specific language governing permissions and limitations
|
||||||
|
@REM under the License.
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
@REM Apache Maven Wrapper startup batch script, version 3.3.4
|
||||||
|
@REM
|
||||||
|
@REM Optional ENV vars
|
||||||
|
@REM MVNW_REPOURL - repo url base for downloading maven distribution
|
||||||
|
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
|
||||||
|
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
|
||||||
|
@REM ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
|
||||||
|
@SET __MVNW_CMD__=
|
||||||
|
@SET __MVNW_ERROR__=
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
|
||||||
|
@SET PSModulePath=
|
||||||
|
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
|
||||||
|
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
|
||||||
|
)
|
||||||
|
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
|
||||||
|
@SET __MVNW_PSMODULEP_SAVE=
|
||||||
|
@SET __MVNW_ARG0_NAME__=
|
||||||
|
@SET MVNW_USERNAME=
|
||||||
|
@SET MVNW_PASSWORD=
|
||||||
|
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
|
||||||
|
@echo Cannot start maven from wrapper >&2 && exit /b 1
|
||||||
|
@GOTO :EOF
|
||||||
|
: end batch / begin powershell #>
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
if ($env:MVNW_VERBOSE -eq "true") {
|
||||||
|
$VerbosePreference = "Continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
|
||||||
|
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
|
||||||
|
if (!$distributionUrl) {
|
||||||
|
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
|
||||||
|
"maven-mvnd-*" {
|
||||||
|
$USE_MVND = $true
|
||||||
|
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
|
||||||
|
$MVN_CMD = "mvnd.cmd"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
$USE_MVND = $false
|
||||||
|
$MVN_CMD = $script -replace '^mvnw','mvn'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# apply MVNW_REPOURL and calculate MAVEN_HOME
|
||||||
|
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
if ($env:MVNW_REPOURL) {
|
||||||
|
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
|
||||||
|
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
|
||||||
|
}
|
||||||
|
$distributionUrlName = $distributionUrl -replace '^.*/',''
|
||||||
|
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
|
||||||
|
|
||||||
|
$MAVEN_M2_PATH = "$HOME/.m2"
|
||||||
|
if ($env:MAVEN_USER_HOME) {
|
||||||
|
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
|
||||||
|
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_WRAPPER_DISTS = $null
|
||||||
|
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
|
||||||
|
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
|
||||||
|
} else {
|
||||||
|
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
|
||||||
|
}
|
||||||
|
|
||||||
|
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
|
||||||
|
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
|
||||||
|
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
|
||||||
|
|
||||||
|
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
|
||||||
|
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
|
||||||
|
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
|
||||||
|
}
|
||||||
|
|
||||||
|
# prepare tmp dir
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
|
||||||
|
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
|
||||||
|
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
|
||||||
|
trap {
|
||||||
|
if ($TMP_DOWNLOAD_DIR.Exists) {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
|
||||||
|
|
||||||
|
# Download and Install Apache Maven
|
||||||
|
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
|
||||||
|
Write-Verbose "Downloading from: $distributionUrl"
|
||||||
|
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
|
||||||
|
|
||||||
|
$webclient = New-Object System.Net.WebClient
|
||||||
|
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
|
||||||
|
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
|
||||||
|
}
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
|
||||||
|
|
||||||
|
# If specified, validate the SHA-256 sum of the Maven distribution zip file
|
||||||
|
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
|
||||||
|
if ($distributionSha256Sum) {
|
||||||
|
if ($USE_MVND) {
|
||||||
|
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
|
||||||
|
}
|
||||||
|
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
|
||||||
|
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
|
||||||
|
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# unzip and move
|
||||||
|
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
|
||||||
|
|
||||||
|
# Find the actual extracted directory name (handles snapshots where filename != directory name)
|
||||||
|
$actualDistributionDir = ""
|
||||||
|
|
||||||
|
# First try the expected directory name (for regular distributions)
|
||||||
|
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
|
||||||
|
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
|
||||||
|
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
|
||||||
|
$actualDistributionDir = $distributionUrlNameMain
|
||||||
|
}
|
||||||
|
|
||||||
|
# If not found, search for any directory with the Maven executable (for snapshots)
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
|
||||||
|
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
|
||||||
|
if (Test-Path -Path $testPath -PathType Leaf) {
|
||||||
|
$actualDistributionDir = $_.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actualDistributionDir) {
|
||||||
|
Write-Error "Could not find Maven distribution directory in extracted archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
|
||||||
|
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
|
||||||
|
try {
|
||||||
|
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
|
||||||
|
} catch {
|
||||||
|
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
|
||||||
|
Write-Error "fail to move MAVEN_HOME"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
|
||||||
|
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.4</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-parent</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<packaging>pom</packaging>
|
||||||
|
<name>CannaManage — Parent POM</name>
|
||||||
|
<description>Cannabis Social Club SaaS — German CanG Compliance Platform</description>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>cannamanage-domain</module>
|
||||||
|
<module>cannamanage-service</module>
|
||||||
|
<module>cannamanage-api</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||||
|
<!-- Dependency versions -->
|
||||||
|
<hibernate.version>6.5.3.Final</hibernate.version>
|
||||||
|
<flyway.version>10.15.0</flyway.version>
|
||||||
|
<assertj.version>3.26.3</assertj.version>
|
||||||
|
<mockito.version>5.12.0</mockito.version>
|
||||||
|
<!-- JaCoCo -->
|
||||||
|
<jacoco.version>0.8.13</jacoco.version>
|
||||||
|
<jacoco.minimum.coverage>1.00</jacoco.minimum.coverage>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<!-- Internal modules -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-domain</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.cannamanage</groupId>
|
||||||
|
<artifactId>cannamanage-service</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<pluginManagement>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>${jacoco.version}</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</pluginManagement>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Reference in New Issue
Block a user