From 567af9b6b6d1c3dbc6fcd5a9b4bbedc7237bf1f8 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Tue, 27 Jan 2026 00:21:07 -0500 Subject: [PATCH] feat: ESPI 4.0 Schema Compliance - Phase 24: CustomerAgreement Complete Implementation (#28) Implements comprehensive CustomerAgreement support following ESPI 4.0 customer.xsd specification (lines 159-209, extending Agreement and Document base types). ## Repository & Service Layer - Add CustomerAgreementRepository with JPA persistence operations - Add CustomerAgreementService interface and CustomerAgreementServiceImpl - Implement CRUD operations with full relationship management ## Entity Updates - Complete CustomerAgreementEntity with all Document, Agreement, and CustomerAgreement fields - Add Document fields: type, authorName, createdDateTime, lastModifiedDateTime, revisionNumber, electronicAddress (8 fields), subject, title, docStatus (4 fields), status (4 fields), comment - Add Agreement fields: signDate, validityInterval (DateTimeInterval) - Add CustomerAgreement fields: loadMgmt, isPrePay, shutOffDateTime, currency, agreementId - Fix futureStatus type from Status to List per XSD - Add @AttributeOverride annotations for all embedded types (docStatus, status) ## ElectronicAddress 8-Field Compliance Updated 8 entities to support all ElectronicAddress fields per customer.xsd lines 886-936: - Added lan, mac, userID, password fields to: * CustomerEntity (customer_lan, customer_mac, customer_user_id, customer_password) * CustomerAccountEntity (doc_lan, doc_mac, doc_user_id, doc_password) * CustomerAgreementEntity (doc_lan, doc_mac, doc_user_id, doc_password) * EndDeviceEntity (device_lan, device_mac, device_user_id, device_password) * Location (location_lan, location_mac, location_user_id, location_password) * Organisation (lan, mac, user_id, password) * ServiceLocationEntity (location_lan, location_mac, location_user_id, location_password) * ServiceSupplierEntity (supplier_lan, supplier_mac, supplier_user_id, supplier_password) - Added @AttributeOverride annotations for all embedded ElectronicAddress fields ## Status 4-Field Compliance Updated Status embedded type to match customer.xsd lines 1254-1284: - Added remark field between dateTime and reason - Updated all DTOs (CustomerAccountDto, CustomerAgreementDto) with remark - Added @AttributeOverride annotations for embedded Status objects ## DTO & Marshalling - Create CustomerAgreementDto matching XSD field order exactly - Add nested StatusDto with unique XML type name "AgreementStatus" - Update CustomerAccountDto.StatusDto to use "DocStatus" XML type name - Update CustomerDto.StatusDto propOrder to include remark - Remove Atom wrapper fields from all customer DTOs - Implement comprehensive JAXB annotations with customer namespace ## Mapper Updates - Update CustomerAgreementMapper with all Document, Agreement, and CustomerAgreement field mappings - Update CustomerMapper for 8-field ElectronicAddress - Update TariffRiderRefMapper imports ## DateTimeInterval Namespace Fix - Move DateTimeIntervalDto from dto/usage to dto/common package - Remove hardcoded espi namespace declarations to support both usage and customer contexts - Update all imports across usage DTOs, mappers, and export services - Fixes Customer domain XML from incorrectly declaring espi namespace ## Database Migration - Update V3__Create_additiional_Base_Tables.sql with: * All missing Document fields in customer_agreements table * 4 missing ElectronicAddress fields in service_suppliers table * 4 remark fields for Status objects in multiple tables * Proper column ordering and constraints ## Tests - Add CustomerAgreementDtoTest with 4 XML marshalling tests - Add CustomerAgreementRepositoryTest with 21 JPA persistence tests * Nested test structure: CRUD, Document fields, Agreement fields, CustomerAgreement fields, Base class * Full coverage of all entity fields and relationships - All Phase 24 tests pass (25/25) - All integration tests pass (634/634) ## Issue Resolution Resolves #28 - Phase 24 CustomerAgreement Implementation Co-Authored-By: Claude Sonnet 4.5 --- .../JAXB_RECORDS_INCOMPATIBILITY.md | 243 +++++ .../OPTION_A_IMPLEMENTATION_PLAN.md | 877 ++++++++++++++++++ openespi-common/PHASE_20_DTO_CLEANUP_PLAN.md | 257 +++++ .../RECORD_TO_CLASS_CONVERSION_PLAN.md | 246 +++++ .../common/domain/customer/entity/Asset.java | 6 +- .../entity/CustomerAccountEntity.java | 16 + .../entity/CustomerAgreementEntity.java | 102 +- .../customer/entity/CustomerEntity.java | 6 +- .../customer/entity/EndDeviceEntity.java | 6 +- .../domain/customer/entity/Location.java | 6 +- .../domain/customer/entity/Organisation.java | 21 +- .../entity/ServiceLocationEntity.java | 6 +- .../entity/ServiceSupplierEntity.java | 6 +- .../common/domain/customer/entity/Status.java | 8 +- .../common/dto/atom/UsageAtomEntryDto.java | 1 + .../DateTimeIntervalDto.java | 10 +- .../dto/customer/CustomerAccountDto.java | 8 +- .../dto/customer/CustomerAgreementDto.java | 223 +++-- .../espi/common/dto/customer/CustomerDto.java | 17 +- .../common/dto/usage/AuthorizationDto.java | 2 + .../usage/ElectricPowerQualitySummaryDto.java | 2 +- .../common/dto/usage/IntervalBlockDto.java | 2 + .../common/dto/usage/IntervalReadingDto.java | 2 + .../espi/common/dto/usage/LineItemDto.java | 2 + .../common/dto/usage/UsageSummaryDto.java | 2 + .../customer/CustomerAgreementMapper.java | 83 +- .../mapper/customer/CustomerMapper.java | 10 +- .../mapper/usage/DateTimeIntervalMapper.java | 2 +- .../customer/CustomerAgreementRepository.java | 39 + .../service/EspiIdGeneratorService.java | 30 + .../customer/CustomerAgreementService.java | 66 ++ .../impl/CustomerAgreementServiceImpl.java | 94 ++ .../service/impl/DtoExportServiceImpl.java | 2 +- .../service/impl/UsageExportService.java | 2 +- .../V3__Create_additiional_Base_Tables.sql | 99 +- .../espi/common/UsageXmlDebugTest.java | 2 +- .../dto/customer/CustomerAccountDtoTest.java | 5 +- .../customer/CustomerAgreementDtoTest.java | 353 +++++++ .../customer/CustomerDtoMarshallingTest.java | 6 +- .../common/dto/customer/CustomerDtoTest.java | 2 +- .../CustomerAgreementRepositoryTest.java | 540 +++++++++++ 41 files changed, 3250 insertions(+), 162 deletions(-) create mode 100644 openespi-common/JAXB_RECORDS_INCOMPATIBILITY.md create mode 100644 openespi-common/OPTION_A_IMPLEMENTATION_PLAN.md create mode 100644 openespi-common/PHASE_20_DTO_CLEANUP_PLAN.md create mode 100644 openespi-common/RECORD_TO_CLASS_CONVERSION_PLAN.md rename openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/{usage => common}/DateTimeIntervalDto.java (87%) create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepository.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAgreementService.java create mode 100644 openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAgreementServiceImpl.java create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java create mode 100644 openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepositoryTest.java diff --git a/openespi-common/JAXB_RECORDS_INCOMPATIBILITY.md b/openespi-common/JAXB_RECORDS_INCOMPATIBILITY.md new file mode 100644 index 00000000..5d60d38f --- /dev/null +++ b/openespi-common/JAXB_RECORDS_INCOMPATIBILITY.md @@ -0,0 +1,243 @@ +# Jakarta JAXB and Java Records Incompatibility + +## Issue Summary + +Jakarta JAXB (GlassFish implementation) does NOT fully support Java records for XML marshalling/unmarshalling. + +**Error Encountered:** +``` +org.glassfish.jaxb.runtime.v2.runtime.IllegalAnnotationsException: 272 counts of IllegalAnnotationExceptions +JAXB annotation is placed on a method that is not a JAXB property +AtomEntryDto does not have a no-arg default constructor +``` + +## Root Cause Analysis + +### Java Records vs JAXB Expectations + +**Java Records (Java 14+):** +- Immutable data carriers +- All fields are implicitly `final` +- Compact canonical constructor only +- Accessor methods (not JavaBean getters): `field()` instead of `getField()` +- Cannot have additional constructors beyond canonical +- Class itself is implicitly `final` + +**JAXB Requirements (JavaBeans pattern):** +- Mutable objects with default no-arg constructor +- Non-final fields with setters +- JavaBean-style getters: `getField()` +- Ability to instantiate empty object and populate via setters + +### Specific Incompatibilities + +1. **No Default Constructor:** + ```java + public record AtomEntryDto(String id, String title, ...) {} + // JAXB Error: "does not have a no-arg default constructor" + ``` + - Records only have canonical constructor + - Cannot add no-arg constructor + - Lombok's `@NoArgsConstructor` does NOT work on records + +2. **Method Annotations Not Recognized:** + ```java + @XmlElement(name = "id", namespace = "...") + String id + + // Record generates: public String id() { return id; } + // JAXB expects: public String getId() { return id; } + // Error: "JAXB annotation is placed on a method that is not a JAXB property" + ``` + +3. **Immutability:** + - Records have no setters + - JAXB unmarshalling requires setters to populate fields + - Cannot use `@XmlAccessType.FIELD` effectively with final fields + +## Attempted Solutions That Don't Work + +### ❌ Lombok @NoArgsConstructor +```java +@NoArgsConstructor // Does NOT compile with records +public record AtomEntryDto(...) {} +``` +**Error:** Lombok constructor annotations are not supported on records + +### ❌ Manual No-Arg Constructor +```java +public record AtomEntryDto(...) { + public AtomEntryDto() { // COMPILE ERROR + this(null, null, ...); + } +} +``` +**Error:** Records cannot have additional constructors beyond canonical/compact + +### ❌ @XmlAccessType Configuration +```java +@XmlAccessorType(XmlAccessType.FIELD) +public record AtomEntryDto(...) {} +``` +**Still Fails:** JAXB tries to access fields via reflection but records protect field access + +## Valid Solutions + +### Option A: Convert Records to Regular Classes (RECOMMENDED) + +**Effort:** ~12-15 hours for 40+ DTOs + +**Implementation:** +```java +// FROM (Record): +public record CustomerDto( + String uuid, + OrganisationDto organisation, + CustomerKind kind +) {} + +// TO (Class): +@XmlRootElement(name = "Customer", namespace = "http://naesb.org/espi/customer") +@XmlAccessorType(XmlAccessType.FIELD) +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CustomerDto { + + @XmlTransient + private String uuid; + + @XmlElement(name = "Organisation", namespace = "http://naesb.org/espi/customer") + private OrganisationDto organisation; + + @XmlElement(name = "kind", namespace = "http://naesb.org/espi/customer") + private CustomerKind kind; +} +``` + +**Benefits:** +- ✅ Full JAXB compatibility +- ✅ Proper namespace prefix support (`cust:`, `espi:`) +- ✅ Lombok reduces boilerplate +- ✅ Standard JavaBeans pattern + +**Drawbacks:** +- ❌ Lose immutability +- ❌ More verbose (getters/setters visible in code) +- ❌ Larger codebase + +### Option B: Use EclipseLink MOXy JAXB + +**Effort:** ~2-4 hours + +**Implementation:** +Replace GlassFish JAXB runtime with MOXy: +```xml + + org.eclipse.persistence + org.eclipse.persistence.moxy + 4.0.2 + +``` + +**Status:** MOXy has EXPERIMENTAL record support but still has limitations with immutability. + +**Risks:** +- ⚠️ MOXy record support incomplete +- ⚠️ Different JAXB implementation may have other issues +- ⚠️ Less commonly used than GlassFish JAXB + +### Option C: Keep Jackson (NOT RECOMMENDED) + +**Why Not Recommended:** +- Jackson ignores `@XmlNs` prefix declarations in package-info.java +- Auto-generates prefixes (`wstxns1`, `wstxns2`) instead of `espi:`, `cust:` +- No configuration option to override this behavior +- Fails ESPI 4.0 compliance for namespace prefixes + +## Recommendation + +**Convert all DTOs from records to Lombok-annotated classes (Option A).** + +**Reasoning:** +1. Full Jakarta JAXB compliance +2. Proper namespace prefix support per ESPI 4.0 specification +3. Lombok mitigates boilerplate concerns +4. Standard JavaBeans pattern widely understood +5. MapStruct already works with Lombok classes (no changes needed) +6. Entities are already Lombok classes (consistent architecture) + +## Impact Assessment + +### Files Requiring Conversion (~40 DTOs) + +**Atom Domain (2):** +- AtomEntryDto +- AtomFeedDto +- LinkDto + +**Usage Domain (~20):** +- UsagePointDto +- MeterReadingDto +- IntervalBlockDto +- IntervalReadingDto +- ReadingTypeDto +- DateTimeIntervalDto +- ReadingQualityDto +- TimeConfigurationDto +- UsageSummaryDto +- ElectricPowerQualitySummaryDto +- ApplicationInformationDto +- AuthorizationDto +- SubscriptionDto +- BatchListDto +- LineItemDto +- SummaryMeasurementDto +- BillingChargeSourceDto +- TariffRiderRefDto +- TariffRiderRefsDto +- (+ others) + +**Customer Domain (~10):** +- CustomerDto (already has namespace annotations) +- CustomerAccountDto +- CustomerAgreementDto +- ServiceLocationDto +- StatementDto +- MeterDto +- EndDeviceDto +- (+ nested records in CustomerDto) + +**Shared DTOs (~5):** +- RationalNumberDto +- ReadingInterharmonicDto +- Various embedded records + +### Migration Steps + +1. **Create Lombok class templates** for common patterns +2. **Convert DTOs domain-by-domain** (atom → usage → customer) +3. **Update MapStruct mappers** (should work without changes) +4. **Run tests after each domain** (verify XML output) +5. **Validate namespace prefixes** in generated XML + +### Estimated Timeline + +- **Template creation:** 1 hour +- **Atom domain conversion:** 1 hour +- **Usage domain conversion:** 6 hours +- **Customer domain conversion:** 3 hours +- **Testing and validation:** 2 hours +- **TOTAL:** ~13 hours + +## References + +- [Java Records Specification (JEP 395)](https://openjdk.org/jeps/395) +- [Jakarta XML Binding Specification](https://jakarta.ee/specifications/xml-binding/4.0/) +- [Lombok Documentation - Constructor Annotations](https://projectlombok.org/features/constructor) +- [JAXB with Records Discussion (Stack Overflow)](https://stackoverflow.com/questions/66468393/jaxb-and-records) + +--- + +**Document Created:** 2026-01-19 +**Status:** BLOCKING - Requires architectural decision before proceeding \ No newline at end of file diff --git a/openespi-common/OPTION_A_IMPLEMENTATION_PLAN.md b/openespi-common/OPTION_A_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..f5d1a93e --- /dev/null +++ b/openespi-common/OPTION_A_IMPLEMENTATION_PLAN.md @@ -0,0 +1,877 @@ +# Option A Implementation Plan: Domain-Specific Export Services + +## Executive Summary + +Refactor `DtoExportServiceImpl` into two specialized export services (`UsageExportService` and `CustomerExportService`) to achieve proper namespace isolation and enable JAXB 3.x to reliably use Atom as the default namespace. + +**Goal:** Each service manages only 2 namespaces (Atom + domain), allowing JAXB 3.x to predictably assign Atom as default. + +--- + +## Current State Analysis + +### Existing Architecture + +``` +DtoExportServiceImpl +├── JAXBContext with ALL classes (Atom + Usage + Customer) +├── determineRequiredNamespaces() - detects domain +├── createMarshaller() - configures JAXB +└── Uses EspiNamespacePrefixMapper + +Problem: 3 namespaces confuse JAXB 3.x default selection +``` + +### Current Namespace Behavior + +| Domain | Expected | Actual | +|--------|----------|--------| +| Customer | `xmlns="http://www.w3.org/2005/Atom"` | ✅ Works | +| Usage | `xmlns="http://www.w3.org/2005/Atom"` | ❌ `xmlns:ns3="..."` | + +**Root Cause:** JAXB 3.x cannot predict default namespace with 3+ schemas in context. + +--- + +## Target Architecture + +### Service Structure + +``` +BaseExportService (abstract) +├── Common marshaller configuration +├── EspiNamespacePrefixMapper setup +└── XML header handling + +UsageExportService extends BaseExportService +├── JAXBContext: Atom + Usage domain only +├── Exports: UsagePoint, MeterReading, IntervalBlock, etc. +└── Namespace: xmlns="Atom" xmlns:espi="..." + +CustomerExportService extends BaseExportService +├── JAXBContext: Atom + Customer domain only +├── Exports: Customer, CustomerAccount, ServiceLocation, etc. +└── Namespace: xmlns="Atom" xmlns:cust="..." + +DtoExportServiceFacade (new) +├── Delegates to UsageExportService or CustomerExportService +├── Domain detection logic +└── Backwards compatibility layer +``` + +--- + +## Implementation Phases + +## Phase 1: Extract Common Logic (Foundation) + +**Goal:** Create reusable base class with shared marshaller configuration. + +### 1.1 Create `BaseExportService` Abstract Class + +**File:** `openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/BaseExportService.java` + +**Responsibilities:** +- Abstract JAXBContext creation (subclasses implement) +- Common marshaller configuration +- EspiNamespacePrefixMapper setup +- XML header constants +- Stream writing utilities + +**Key Methods:** +```java +public abstract class BaseExportService { + protected static final String XML_HEADER = "\n..."; + + // Subclasses must implement + protected abstract JAXBContext getJAXBContext() throws JAXBException; + protected abstract Set getDomainNamespaces(); + + // Common implementation + protected Marshaller createMarshaller() throws JAXBException { + JAXBContext context = getJAXBContext(); + Marshaller marshaller = context.createMarshaller(); + + // Apply standard properties + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true); + marshaller.setProperty(Marshaller.JAXB_ENCODING, "UTF-8"); + marshaller.setProperty(Marshaller.JAXB_FRAGMENT, false); + + // Apply namespace prefix mapper + Set namespaces = new HashSet<>(getDomainNamespaces()); + namespaces.add("http://www.w3.org/2005/Atom"); // Always include Atom + + EspiNamespacePrefixMapper prefixMapper = new EspiNamespacePrefixMapper(namespaces); + try { + marshaller.setProperty("org.glassfish.jaxb.namespacePrefixMapper", prefixMapper); + } catch (PropertyException e) { + marshaller.setProperty("com.sun.xml.bind.namespacePrefixMapper", prefixMapper); + } + + return marshaller; + } + + protected void exportDto(Object dto, OutputStream stream) throws JAXBException { + Marshaller marshaller = createMarshaller(); + marshaller.marshal(dto, stream); + } +} +``` + +**Files to Create:** +- `BaseExportService.java` + +**Dependencies:** +- `EspiNamespacePrefixMapper` (existing) +- Jakarta XML Binding APIs + +--- + +## Phase 2: Create UsageExportService + +**Goal:** Export service handling only Atom + Usage domain namespaces. + +### 2.1 Create `UsageExportService` + +**File:** `openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java` + +**Responsibilities:** +- Initialize JAXBContext with ONLY Atom + Usage domain classes +- Export UsagePoint, MeterReading, IntervalBlock, etc. +- Ensure namespace isolation (no customer namespace) + +**JAXBContext Classes:** +```java +@Service +@Slf4j +public class UsageExportService extends BaseExportService { + + private JAXBContext jaxbContext; + + @PostConstruct + public void init() throws JAXBException { + this.jaxbContext = JAXBContext.newInstance( + // Atom protocol (usage-specific entry) + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + + // Usage domain classes ONLY (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.UsagePointDto.class, + org.greenbuttonalliance.espi.common.dto.usage.MeterReadingDto.class, + org.greenbuttonalliance.espi.common.dto.usage.IntervalBlockDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ReadingTypeDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ElectricPowerQualitySummaryDto.class, + org.greenbuttonalliance.espi.common.dto.usage.UsageSummaryDto.class, + org.greenbuttonalliance.espi.common.dto.usage.TimeConfigurationDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto.class, + org.greenbuttonalliance.espi.common.dto.usage.AuthorizationDto.class, + org.greenbuttonalliance.espi.common.dto.usage.SubscriptionDto.class, + org.greenbuttonalliance.espi.common.dto.usage.BatchListDto.class, + org.greenbuttonalliance.espi.common.dto.usage.LineItemDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ServiceDeliveryPointDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class, + org.greenbuttonalliance.espi.common.dto.usage.IntervalReadingDto.class, + org.greenbuttonalliance.espi.common.dto.usage.DateTimeIntervalDto.class, + org.greenbuttonalliance.espi.common.dto.usage.TariffRiderRefDto.class, + org.greenbuttonalliance.espi.common.dto.usage.TariffRiderRefsDto.class, + org.greenbuttonalliance.espi.common.dto.usage.PnodeRefDto.class, + org.greenbuttonalliance.espi.common.dto.usage.PnodeRefsDto.class, + org.greenbuttonalliance.espi.common.dto.usage.AggregatedNodeRefDto.class, + org.greenbuttonalliance.espi.common.dto.usage.AggregatedNodeRefsDto.class + + // NO customer domain classes + ); + } + + @Override + protected JAXBContext getJAXBContext() { + return jaxbContext; + } + + @Override + protected Set getDomainNamespaces() { + return Set.of("http://naesb.org/espi"); + } + + // Public API methods + public void exportUsagePointEntry(UsagePointDto usagePoint, OutputStream stream) + throws JAXBException { + UsageAtomEntryDto entry = createUsageAtomEntry("Usage Point", usagePoint); + exportDto(entry, stream); + } + + public void exportUsagePointsFeed(List usagePoints, OutputStream stream) + throws JAXBException { + List entries = usagePoints.stream() + .map(dto -> createUsageAtomEntry("Usage Point", dto)) + .collect(Collectors.toList()); + + AtomFeedDto feed = new AtomFeedDto( + UUID.randomUUID().toString(), + "Usage Points", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + entries + ); + + exportDto(feed, stream); + } + + private UsageAtomEntryDto createUsageAtomEntry(String title, Object resource) { + OffsetDateTime now = OffsetDateTime.now(); + String entryId = "urn:uuid:" + UUID.randomUUID(); + return new UsageAtomEntryDto(entryId, title, now, now, null, resource); + } +} +``` + +**Expected XML Output:** +```xml + + + urn:uuid:... + Usage Point + 2025-01-21T... + 2025-01-21T... + + 01 + 1 + + +``` + +**Files to Create:** +- `UsageExportService.java` + +--- + +## Phase 3: Create CustomerExportService + +**Goal:** Export service handling only Atom + Customer domain namespaces. + +### 3.1 Create `CustomerExportService` + +**File:** `openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportService.java` + +**Responsibilities:** +- Initialize JAXBContext with ONLY Atom + Customer domain classes +- Export Customer, CustomerAccount, ServiceLocation, etc. +- Ensure namespace isolation (no usage namespace) + +**JAXBContext Classes:** +```java +@Service +@Slf4j +public class CustomerExportService extends BaseExportService { + + private JAXBContext jaxbContext; + + @PostConstruct + public void init() throws JAXBException { + this.jaxbContext = JAXBContext.newInstance( + // Atom protocol (customer-specific entry) + org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + + // Customer domain classes ONLY (http://naesb.org/espi/customer) + org.greenbuttonalliance.espi.common.dto.customer.CustomerDto.class, + org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto.class, + org.greenbuttonalliance.espi.common.dto.customer.CustomerAgreementDto.class, + org.greenbuttonalliance.espi.common.dto.customer.EndDeviceDto.class, + org.greenbuttonalliance.espi.common.dto.customer.MeterDto.class, + org.greenbuttonalliance.espi.common.dto.customer.ProgramDateIdMappingsDto.class, + org.greenbuttonalliance.espi.common.dto.customer.ServiceLocationDto.class, + org.greenbuttonalliance.espi.common.dto.customer.StatementDto.class, + org.greenbuttonalliance.espi.common.dto.customer.StatementRefDto.class + + // NO usage domain classes + ); + } + + @Override + protected JAXBContext getJAXBContext() { + return jaxbContext; + } + + @Override + protected Set getDomainNamespaces() { + return Set.of("http://naesb.org/espi/customer"); + } + + // Public API methods + public void exportCustomerEntry(CustomerDto customer, OutputStream stream) + throws JAXBException { + CustomerAtomEntryDto entry = createCustomerAtomEntry("Customer", customer); + exportDto(entry, stream); + } + + public void exportCustomersFeed(List customers, OutputStream stream) + throws JAXBException { + List entries = customers.stream() + .map(dto -> createCustomerAtomEntry("Customer", dto)) + .collect(Collectors.toList()); + + AtomFeedDto feed = new AtomFeedDto( + UUID.randomUUID().toString(), + "Customers", + OffsetDateTime.now(), + OffsetDateTime.now(), + null, + entries + ); + + exportDto(feed, stream); + } + + private CustomerAtomEntryDto createCustomerAtomEntry(String title, Object resource) { + OffsetDateTime now = OffsetDateTime.now(); + String entryId = "urn:uuid:" + UUID.randomUUID(); + return new CustomerAtomEntryDto(entryId, title, now, now, null, resource); + } +} +``` + +**Expected XML Output:** +```xml + + + urn:uuid:... + Customer + 2025-01-21T... + 2025-01-21T... + + John Doe + true + + +``` + +**Files to Create:** +- `CustomerExportService.java` + +--- + +## Phase 4: Create Facade for Backwards Compatibility + +**Goal:** Maintain existing `DtoExportService` interface while delegating to specialized services. + +### 4.1 Create `DtoExportServiceFacade` + +**File:** `openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceFacade.java` + +**Purpose:** Backwards compatibility layer that auto-detects domain and delegates. + +```java +@Service +@Primary // This becomes the default DtoExportService implementation +@Slf4j +@RequiredArgsConstructor +public class DtoExportServiceFacade implements DtoExportService { + + private final UsageExportService usageExportService; + private final CustomerExportService customerExportService; + + @Override + public void exportDto(Object dto, OutputStream stream) { + try { + DomainType domain = detectDomain(dto); + + switch (domain) { + case USAGE -> usageExportService.exportDto(dto, stream); + case CUSTOMER -> customerExportService.exportDto(dto, stream); + default -> throw new IllegalArgumentException("Unknown domain for DTO: " + dto.getClass()); + } + + } catch (JAXBException e) { + log.error("Failed to export DTO: {}", e.getMessage(), e); + throw new RuntimeException("Failed to export DTO", e); + } + } + + @Override + public void exportAtomFeed(AtomFeedDto atomFeedDto, OutputStream stream) { + try { + // Detect domain from first entry + DomainType domain = detectDomainFromFeed(atomFeedDto); + + switch (domain) { + case USAGE -> usageExportService.exportDto(atomFeedDto, stream); + case CUSTOMER -> customerExportService.exportDto(atomFeedDto, stream); + default -> throw new IllegalArgumentException("Cannot determine domain from feed"); + } + + } catch (JAXBException e) { + log.error("Failed to export Atom feed: {}", e.getMessage(), e); + throw new RuntimeException("Failed to export Atom feed", e); + } + } + + // Existing interface methods delegate appropriately... + + private DomainType detectDomain(Object dto) { + if (dto instanceof AtomEntryDto entry) { + Object content = entry.getContent(); + if (content != null) { + return detectDomainFromContent(content); + } + } + + return detectDomainFromContent(dto); + } + + private DomainType detectDomainFromContent(Object content) { + String packageName = content.getClass().getPackage().getName(); + + if (packageName.contains(".dto.usage")) { + return DomainType.USAGE; + } else if (packageName.contains(".dto.customer")) { + return DomainType.CUSTOMER; + } + + return DomainType.UNKNOWN; + } + + private DomainType detectDomainFromFeed(AtomFeedDto feed) { + if (feed.getEntries() != null && !feed.getEntries().isEmpty()) { + AtomEntryDto firstEntry = feed.getEntries().get(0); + return detectDomain(firstEntry); + } + return DomainType.UNKNOWN; + } + + private enum DomainType { + USAGE, CUSTOMER, UNKNOWN + } +} +``` + +**Files to Create:** +- `DtoExportServiceFacade.java` + +**Files to Modify:** +- Mark `DtoExportServiceImpl` as `@Deprecated` (or delete after migration) + +--- + +## Phase 5: Update EspiNamespacePrefixMapper + +**Goal:** Ensure prefix mapper returns empty string for Atom when it's the only default candidate. + +### 5.1 Update `getPreferredPrefix()` Logic + +**File:** `openespi-common/src/main/java/org/greenbuttonalliance/espi/common/utils/EspiNamespacePrefixMapper.java` + +**Current:** +```java +if (ATOM_NAMESPACE.equals(namespaceUri)) { + return "atom"; // Returns prefix +} +``` + +**Updated with Context Awareness:** +```java +@Override +public String getPreferredPrefix(String namespaceUri, String suggestion, boolean requirePrefix) { + if (namespaceUri == null) { + return null; + } + + // Atom namespace - return empty string if it should be default + if (ATOM_NAMESPACE.equals(namespaceUri)) { + // If only 2 namespaces total (Atom + one domain), make Atom default + if (requiredNamespaces.size() == 2) { + return ""; // Empty = default namespace + } + // Otherwise use atom: prefix for clarity + return "atom"; + } + + if (ESPI_NAMESPACE.equals(namespaceUri)) { + return "espi"; + } + + if (CUSTOMER_NAMESPACE.equals(namespaceUri)) { + return "cust"; + } + + return null; +} +``` + +**Files to Modify:** +- `EspiNamespacePrefixMapper.java` + +--- + +## Phase 6: Testing Strategy + +### 6.1 Unit Tests + +**Create `UsageExportServiceTest`** + +**File:** `openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportServiceTest.java` + +```java +@ExtendWith(MockitoExtension.class) +class UsageExportServiceTest { + + private UsageExportService usageExportService; + + @BeforeEach + void setUp() throws JAXBException { + usageExportService = new UsageExportService(); + usageExportService.init(); + } + + @Test + @DisplayName("Should export UsagePoint with Atom as default namespace") + void shouldExportUsagePointWithAtomDefault() throws Exception { + // Arrange + UsagePointDto usagePoint = new UsagePointDto( + "urn:uuid:550e8400-e29b-51d4-a716-446655440001", + new byte[]{0x01}, + null, (short) 1, + // ... other fields + ); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + usageExportService.exportUsagePointEntry(usagePoint, output); + String xml = output.toString(StandardCharsets.UTF_8); + + // Assert + System.out.println(xml); + assertThat(xml).contains("xmlns=\"http://www.w3.org/2005/Atom\""); + assertThat(xml).contains("xmlns:espi=\"http://naesb.org/espi\""); + assertThat(xml).doesNotContain("xmlns:cust"); + assertThat(xml).doesNotContain("http://naesb.org/espi/customer"); + assertThat(xml).contains(""); + } + + @Test + @DisplayName("Should NOT declare customer namespace") + void shouldNotDeclareCustomerNamespace() throws Exception { + // Similar test verifying xmlns:cust absence + } +} +``` + +**Create `CustomerExportServiceTest`** + +**File:** `openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/CustomerExportServiceTest.java` + +```java +@ExtendWith(MockitoExtension.class) +class CustomerExportServiceTest { + + private CustomerExportService customerExportService; + + @BeforeEach + void setUp() throws JAXBException { + customerExportService = new CustomerExportService(); + customerExportService.init(); + } + + @Test + @DisplayName("Should export Customer with Atom as default namespace") + void shouldExportCustomerWithAtomDefault() throws Exception { + // Arrange + CustomerDto customer = new CustomerDto( + "urn:uuid:550e8400-e29b-51d4-a716-446655440001", + null, null, "Special needs", true, null, null, null, null, "John Doe" + ); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + // Act + customerExportService.exportCustomerEntry(customer, output); + String xml = output.toString(StandardCharsets.UTF_8); + + // Assert + System.out.println(xml); + assertThat(xml).contains("xmlns=\"http://www.w3.org/2005/Atom\""); + assertThat(xml).contains("xmlns:cust=\"http://naesb.org/espi/customer\""); + assertThat(xml).doesNotContain("xmlns:espi=\"http://naesb.org/espi\""); + assertThat(xml).contains(""); + } +} +``` + +### 6.2 Integration Tests + +**Update Existing Integration Tests** + +**Files to Update:** +- Any controller tests using `DtoExportService` +- REST API tests verifying XML output + +--- + +## Phase 7: Update Package-info.java + +### 7.1 Verify Atom Default Namespace Declaration + +**File:** `openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/package-info.java` + +**Current (line 30):** +```java +@XmlNs(prefix = "", namespaceURI = "http://www.w3.org/2005/Atom") +``` + +**Verification:** This is correct. The empty `prefix = ""` indicates Atom should be default namespace. + +**No changes needed** - JAXB 3.x will respect this with 2-namespace contexts. + +--- + +## Phase 8: Migration and Deprecation + +### 8.1 Deprecate Old Service + +**File:** `DtoExportServiceImpl.java` + +```java +@Service("dtoExportServiceImpl") // Give it a specific bean name +@Deprecated(since = "3.5.0-RC3", forRemoval = true) +@Slf4j +public class DtoExportServiceImpl implements DtoExportService { + // Keep existing implementation for backwards compatibility + // Add deprecation warnings in logs +} +``` + +### 8.2 Update Spring Configuration + +**Ensure proper bean priority:** +- `@Primary` on `DtoExportServiceFacade` makes it default +- Controllers get facade by default +- Legacy code can still reference `dtoExportServiceImpl` bean name if needed + +--- + +## Implementation Checklist + +### Phase 1: Foundation +- [ ] Create `BaseExportService` abstract class +- [ ] Extract common marshaller configuration +- [ ] Extract XML header constants +- [ ] Add unit tests for BaseExportService + +### Phase 2: Usage Service +- [ ] Create `UsageExportService` +- [ ] Initialize JAXBContext with usage domain classes only +- [ ] Implement export methods +- [ ] Create `UsageExportServiceTest` +- [ ] Verify Atom as default namespace in XML output +- [ ] Verify no customer namespace declared + +### Phase 3: Customer Service +- [ ] Create `CustomerExportService` +- [ ] Initialize JAXBContext with customer domain classes only +- [ ] Implement export methods +- [ ] Create `CustomerExportServiceTest` +- [ ] Verify Atom as default namespace in XML output +- [ ] Verify no usage namespace declared + +### Phase 4: Facade +- [ ] Create `DtoExportServiceFacade` +- [ ] Implement domain detection logic +- [ ] Add delegation to specialized services +- [ ] Mark facade as `@Primary` +- [ ] Create `DtoExportServiceFacadeTest` + +### Phase 5: Namespace Mapper +- [ ] Update `EspiNamespacePrefixMapper.getPreferredPrefix()` +- [ ] Return `""` for Atom when 2 namespaces total +- [ ] Add unit tests for namespace mapper logic + +### Phase 6: Testing +- [ ] Run all unit tests +- [ ] Run integration tests +- [ ] Verify XML output matches ESPI specification +- [ ] Test with real UsagePoint entities +- [ ] Test with real Customer entities + +### Phase 7: Migration +- [ ] Mark `DtoExportServiceImpl` as deprecated +- [ ] Update controller injection (if needed) +- [ ] Update documentation +- [ ] Add migration guide for consumers + +### Phase 8: Validation +- [ ] Compare XML output before/after +- [ ] Verify namespace isolation +- [ ] Verify Atom default namespace +- [ ] Performance testing (JAXBContext initialization) + +--- + +## Files Summary + +### New Files (7) +1. `service/impl/BaseExportService.java` +2. `service/impl/UsageExportService.java` +3. `service/impl/CustomerExportService.java` +4. `service/impl/DtoExportServiceFacade.java` +5. `test/.../UsageExportServiceTest.java` +6. `test/.../CustomerExportServiceTest.java` +7. `test/.../DtoExportServiceFacadeTest.java` + +### Modified Files (2) +1. `utils/EspiNamespacePrefixMapper.java` - Update getPreferredPrefix() logic +2. `service/impl/DtoExportServiceImpl.java` - Add @Deprecated annotation + +### No Changes Needed (1) +1. `dto/atom/package-info.java` - Already declares Atom as default + +--- + +## Expected Outcomes + +### Before Implementation +```xml + + + ... + ... + +``` + +### After Implementation +```xml + + + ... + ... + + + + + ... + ... + +``` + +--- + +## Risks and Mitigations + +### Risk 1: JAXBContext Initialization Performance +**Impact:** Two JAXBContexts instead of one +**Mitigation:** +- Initialize at `@PostConstruct` (one-time cost) +- Contexts are reused for all subsequent exports +- Smaller contexts = faster initialization + +### Risk 2: Backwards Compatibility +**Impact:** Existing code depends on DtoExportServiceImpl +**Mitigation:** +- Keep DtoExportServiceImpl as deprecated +- Facade implements same interface +- Use `@Primary` for auto-wiring + +### Risk 3: Missing Domain Classes +**Impact:** New DTO added but not registered in service +**Mitigation:** +- Comprehensive unit tests +- Clear documentation +- Consider annotation scanning (future enhancement) + +--- + +## Future Enhancements + +### Auto-Discovery of DTO Classes +Instead of manually listing all DTO classes, use classpath scanning: + +```java +@PostConstruct +public void init() { + Set> usageClasses = scanPackage("org.greenbuttonalliance.espi.common.dto.usage"); + this.jaxbContext = JAXBContext.newInstance(usageClasses.toArray(new Class[0])); +} +``` + +### Caching Strategy +Add caching for frequently exported entities to avoid repeated marshalling. + +--- + +## Success Criteria + +✅ **Phase 1 Complete When:** +- BaseExportService compiles +- Common marshaller logic extracted +- Unit tests pass + +✅ **Phase 2 Complete When:** +- UsageExportService exports UsagePoint +- XML declares `xmlns="http://www.w3.org/2005/Atom"` (default) +- XML declares `xmlns:espi="http://naesb.org/espi"` (prefixed) +- NO `xmlns:cust` declared +- All tests pass + +✅ **Phase 3 Complete When:** +- CustomerExportService exports Customer +- XML declares `xmlns="http://www.w3.org/2005/Atom"` (default) +- XML declares `xmlns:cust="http://naesb.org/espi/customer"` (prefixed) +- NO `xmlns:espi` declared +- All tests pass + +✅ **Phase 4 Complete When:** +- Facade delegates correctly to usage/customer services +- Domain detection works for all DTO types +- Backwards compatibility maintained +- All integration tests pass + +✅ **Final Success When:** +- All 8 phases complete +- All tests pass (unit + integration) +- XML output matches ESPI 4.0 specification +- Atom is default namespace in both domains +- No namespace pollution between domains + +--- + +## Timeline Estimate + +| Phase | Effort | Duration | +|-------|--------|----------| +| Phase 1: Foundation | Medium | 2-3 hours | +| Phase 2: Usage Service | Medium | 3-4 hours | +| Phase 3: Customer Service | Medium | 3-4 hours | +| Phase 4: Facade | Low | 1-2 hours | +| Phase 5: Namespace Mapper | Low | 1 hour | +| Phase 6: Testing | High | 4-6 hours | +| Phase 7: Migration | Low | 1-2 hours | +| Phase 8: Validation | Medium | 2-3 hours | +| **Total** | | **17-25 hours** | + +--- + +## Next Steps + +1. **Review and approve this plan** +2. **Create feature branch:** `feature/domain-specific-export-services` +3. **Begin Phase 1:** Create BaseExportService +4. **Iterate through phases** with testing at each step +5. **Create PR** when all phases complete + +--- + +## Questions for Review + +1. Should we keep `DtoExportServiceImpl` or delete it after migration? +2. Should the facade be in the `impl` package or elevated to the service level? +3. Do we need a third service for pure Atom exports (links, feeds without content)? +4. Should we add metrics/logging to track which service is being used? + +--- + +**End of Implementation Plan** diff --git a/openespi-common/PHASE_20_DTO_CLEANUP_PLAN.md b/openespi-common/PHASE_20_DTO_CLEANUP_PLAN.md new file mode 100644 index 00000000..ce6b07bd --- /dev/null +++ b/openespi-common/PHASE_20_DTO_CLEANUP_PLAN.md @@ -0,0 +1,257 @@ +# Phase 20: DTO Record-to-Class Conversion Cleanup Plan + +## Current Status (2026-01-20) + +**Build Status:** ✅ Compiles successfully +**Test Status:** ❌ XmlDebugTest fails with 75 JAXB IllegalAnnotationExceptions +**Completion:** ~70-75% + +## Problem Summary + +Many DTOs were converted from records to Lombok classes but still have **explicit getter methods with `@XmlElement` annotations**. Combined with: +- `@XmlAccessorType(XmlAccessType.FIELD)` +- `@XmlElement` annotations on private fields +- Lombok `@Getter` annotation + +This creates **duplicate properties** that JAXB detects as errors like: +``` +Class has two properties of the same name "fieldName" + at public Type Dto.getFieldName() // Explicit getter + at private Type Dto.fieldName // Private field +``` + +## Root Cause + +The Python batch conversion script moved `@XmlElement` from getters to fields but **did NOT remove the explicit getter methods**. These must be manually removed. + +## Cleanup Tasks + +### Task 1: Identify All Affected DTOs + +Run test to extract all duplicate property errors: +```bash +mvn test -Dtest=XmlDebugTest 2>&1 | \ + grep "Class has two properties of the same name" | \ + grep -oP 'org\.greenbuttonalliance\.espi\.common\.dto\.\S+(?=\.)' | \ + sort -u > affected_dtos.txt +``` + +### Task 2: Fix Each DTO (Pattern) + +For each affected DTO, apply this pattern: + +**BEFORE (Incorrect - has duplicates):** +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@XmlAccessorType(XmlAccessType.FIELD) +public class ExampleDto { + + @XmlElement(name = "fieldName") + private String fieldName; // ← Field with annotation + + // ❌ REMOVE THIS - Lombok @Getter generates it automatically + @XmlElement(name = "fieldName") + public String getFieldName() { + return fieldName; + } +} +``` + +**AFTER (Correct - no duplicates):** +```java +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@XmlAccessorType(XmlAccessType.FIELD) +public class ExampleDto { + + /** + * Description of field. + */ + @XmlElement(name = "fieldName") + private String fieldName; // ← Lombok generates getter automatically + + // ✅ Keep only custom business logic methods + public boolean hasFieldName() { + return fieldName != null; + } +} +``` + +### Task 3: Verify @XmlTransient Fields + +Ensure all internal fields not in XSD have `@XmlTransient`: +- `uuid` (internal identifier) +- `id` (database ID) +- `description` (if not in propOrder) +- Any foreign key fields + +**Pattern:** +```java +@XmlTransient +private String uuid; + +@XmlTransient +private Long id; +``` + +### Task 4: Systematic Execution Plan + +#### Phase A: Fix Known High-Priority DTOs + +Based on test errors, these DTOs definitely need fixing: + +1. **LineItemDto** - Has duplicate: amount, rounding, dateTime, note, measurement, itemKind, unitCost, itemPeriod +2. **UsageSummaryDto** - Likely has duplicates (needs verification) +3. **ServiceDeliveryPointDto** - Likely has duplicates (needs verification) + +#### Phase B: Scan All Usage Domain DTOs + +Check each file in `dto/usage/`: +```bash +for file in src/main/java/org/greenbuttonalliance/espi/common/dto/usage/*.java; do + echo "=== $(basename $file) ===" + grep -A 1 "@XmlElement" "$file" | grep "public.*get" || echo "OK - no explicit getters" +done +``` + +**Files to check:** +- [ ] ApplicationInformationDto +- [ ] AuthorizationDto +- [ ] BatchListDto +- [ ] DateTimeIntervalDto +- [ ] ElectricPowerQualitySummaryDto +- [ ] IntervalBlockDto +- [ ] IntervalReadingDto +- [ ] LineItemDto ⚠️ **CONFIRMED NEEDS FIXING** +- [ ] MeterReadingDto +- [ ] ReadingQualityDto +- [ ] ReadingTypeDto +- [ ] ServiceDeliveryPointDto ⚠️ **NEEDS VERIFICATION** +- [ ] SubscriptionDto +- [ ] TimeConfigurationDto +- [ ] UsagePointDto +- [ ] UsageSummaryDto ⚠️ **NEEDS VERIFICATION** +- [ ] AggregatedNodeRefDto ✅ **FIXED** +- [ ] AggregatedNodeRefsDto +- [ ] PnodeRefDto ✅ **FIXED** +- [ ] PnodeRefsDto +- [ ] TariffRiderRefDto +- [ ] TariffRiderRefsDto + +#### Phase C: Check Common/Utility DTOs + +Files in `dto/`: +- [ ] BillingChargeSourceDto +- [ ] SummaryMeasurementDto +- [ ] RationalNumberDto +- [ ] ReadingInterharmonicDto + +#### Phase D: Check Atom DTOs + +Files in `dto/atom/`: +- [ ] AtomEntryDto +- [ ] AtomFeedDto +- [ ] LinkDto + +#### Phase E: Verify Customer Domain + +Files in `dto/customer/` (should already be clean): +- [ ] CustomerAccountDto +- [ ] CustomerAgreementDto +- [ ] CustomerDto +- [ ] EndDeviceDto +- [ ] MeterDto +- [ ] ServiceLocationDto +- [ ] StatementDto +- [ ] ProgramDateIdMappingsDto + +## Verification Steps + +### Step 1: Compile Check +```bash +mvn clean compile -DskipTests +``` +Expected: ✅ BUILD SUCCESS with only MapStruct warnings + +### Step 2: Test Execution +```bash +mvn test -Dtest=XmlDebugTest +``` +Expected: ✅ All 4 tests pass, no IllegalAnnotationExceptions + +### Step 3: Full Test Suite +```bash +mvn test +``` +Expected: All tests pass + +### Step 4: XML Output Validation + +Create test to verify namespace prefixes: +```java +@Test +void verifyNamespacePrefixes() { + String xml = marshalToXml(dto); + assertThat(xml).contains("espi:"); // Usage namespace + assertThat(xml).contains("cust:"); // Customer namespace + assertThat(xml).doesNotContain("wstxns"); // No auto-generated prefixes +} +``` + +## Risk Assessment + +**LOW RISK:** +- Removing explicit getters is safe - Lombok generates them +- Pattern is repetitive and mechanical +- Each DTO can be fixed independently +- Rollback is easy (git checkout) + +**POTENTIAL ISSUES:** +1. **Custom getter logic** - Some getters may have business logic (e.g., `getPnodeRef()` in AggregatedNodeRefDto returns empty list if null). Keep these. +2. **Defensive copying** - Byte array getters that clone arrays must be kept +3. **Computed properties** - Getters that compute values (e.g., `getScaledValue()`) must be kept + +**MITIGATION:** +- Only remove getters that simply return the field +- Keep any getter with custom logic +- Test after each domain is fixed + +## Estimated Effort + +- **Phase A (High-Priority DTOs):** 30 minutes +- **Phase B (Usage Domain Scan):** 2 hours +- **Phase C (Common/Utility):** 30 minutes +- **Phase D (Atom DTOs):** 30 minutes +- **Phase E (Customer Verification):** 30 minutes +- **Testing & Validation:** 1 hour + +**TOTAL:** ~5 hours + +## Success Criteria + +- [ ] All 75 IllegalAnnotationExceptions resolved +- [ ] XmlDebugTest passes all 4 tests +- [ ] Full test suite passes +- [ ] XML output contains correct namespace prefixes (`espi:`, `cust:`) +- [ ] No `wstxns` auto-generated prefixes in output +- [ ] Build succeeds with only expected MapStruct warnings + +## Next Actions + +1. **Review this plan** - Approve approach +2. **Execute Phase A** - Fix confirmed broken DTOs +3. **Run test** - Verify progress (should drop from 75 errors) +4. **Execute Phases B-E** - Systematic cleanup +5. **Final validation** - Namespace prefix verification +6. **Update documentation** - Mark JAXB_RECORDS_INCOMPATIBILITY.md as RESOLVED + +--- + +**Plan Created:** 2026-01-20 +**Estimated Completion:** 2026-01-20 (same day if approved) +**Status:** READY FOR REVIEW diff --git a/openespi-common/RECORD_TO_CLASS_CONVERSION_PLAN.md b/openespi-common/RECORD_TO_CLASS_CONVERSION_PLAN.md new file mode 100644 index 00000000..67a4cf14 --- /dev/null +++ b/openespi-common/RECORD_TO_CLASS_CONVERSION_PLAN.md @@ -0,0 +1,246 @@ +# Record to Lombok Class Conversion Plan + +## Objective +Convert DTOs from Java records to Lombok-annotated classes to achieve JAXB compatibility while maintaining MapStruct mapper functionality. + +## Pilot Conversion Scope + +### Phase 1: IntervalBlock DTO Family +These DTOs are used in XmlDebugTest and form a complete test case: + +1. **IntervalBlockDto** (parent) + - Dependencies: DateTimeIntervalDto, List + +2. **IntervalReadingDto** (child) + - Dependencies: DateTimeIntervalDto, List + +3. **DateTimeIntervalDto** (nested in both) + - No dependencies (primitive types only) + +4. **ReadingQualityDto** (nested in IntervalReading) + - No dependencies (primitive types only) + +### Phase 2: Customer DTO Family +These DTOs test customer domain with more complex nested objects: + +1. **CustomerDto** (parent) + - Dependencies: OrganisationDto, CustomerKind (enum) + +2. **OrganisationDto** (nested) + - Dependencies: TelephoneNumberDto, StreetAddressDto + +3. **TelephoneNumberDto** (nested in Organisation) + - No dependencies (primitive types only) + +4. **StreetAddressDto** (nested in Organisation) + - No dependencies (primitive types only) + +**Total DTOs for Pilot:** 8 DTOs + +## Conversion Pattern + +### From Record: +```java +@XmlRootElement(name = "IntervalBlock", namespace = "http://naesb.org/espi") +@XmlAccessorType(XmlAccessType.FIELD) +@XmlType(name = "IntervalBlock", namespace = "http://naesb.org/espi", propOrder = { + "interval", "intervalReadings" +}) +public record IntervalBlockDto( + + @XmlTransient + Long id, + + @XmlTransient + String uuid, + + @XmlElement(name = "interval", namespace = "http://naesb.org/espi") + DateTimeIntervalDto interval, + + @XmlElement(name = "IntervalReading", namespace = "http://naesb.org/espi") + List intervalReadings +) { + // Record constructors and utility methods +} +``` + +### To Lombok Class: +```java +@XmlRootElement(name = "IntervalBlock", namespace = "http://naesb.org/espi") +@XmlAccessorType(XmlAccessType.FIELD) +@XmlType(name = "IntervalBlock", namespace = "http://naesb.org/espi", propOrder = { + "interval", "intervalReadings" +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class IntervalBlockDto { + + @XmlTransient + private Long id; + + @XmlTransient + private String uuid; + + @XmlElement(name = "interval", namespace = "http://naesb.org/espi") + private DateTimeIntervalDto interval; + + @XmlElement(name = "IntervalReading", namespace = "http://naesb.org/espi") + private List intervalReadings; + + // Utility methods (if any) stay the same +} +``` + +## Conversion Steps + +### Step 1: Change Declaration +- Replace `public record` with `public class` +- Add Lombok annotations: `@Getter`, `@Setter`, `@NoArgsConstructor`, `@AllArgsConstructor` + +### Step 2: Convert Record Parameters to Fields +- Change record parameters in parentheses to private fields in class body +- Keep all JAXB annotations on fields + +### Step 3: Handle Utility Methods +- Move any utility methods from record body to class body +- Methods that use `this.field` syntax will work unchanged +- Remove any custom record constructors (Lombok handles this) + +### Step 4: Update Imports +- Add: `import lombok.Getter;` +- Add: `import lombok.Setter;` +- Add: `import lombok.NoArgsConstructor;` +- Add: `import lombok.AllArgsConstructor;` + +## Testing Strategy + +### XmlDebugTest Validation +The existing XmlDebugTest.java will validate: + +1. **JAXB Marshalling Works** + - No IllegalAnnotationsException errors + - No "missing no-arg constructor" errors + +2. **XML Structure Correct** + - Proper namespace prefixes (espi:, cust:) + - Correct element names and nesting + - Proper attribute handling + +3. **XML Content Correct** + - Field values marshalled correctly + - Null handling works (NON_EMPTY policy) + - Collections marshalled correctly + +4. **MapStruct Compatibility** (implicit) + - If DTOs compile, MapStruct will work + - Lombok runs before MapStruct in annotation processing + +### Test Execution +```bash +# Run the XmlDebugTest +cd /Users/donal/Git/GreenButtonAlliance/OpenESPI-GreenButton-Java/openespi-common +mvn test -Dtest=XmlDebugTest +``` + +### Expected Test Output +```xml + + + urn:uuid:debug-test + Debug Service + + + + 1634788800 + 3600 + + + 12345 + + + + + + + + + ACME Utility + + residential + +``` + +## Success Criteria + +### Phase 1 Success (IntervalBlock): +- ✅ All 4 DTOs compile without errors +- ✅ XmlDebugTest passes all assertions +- ✅ XML output shows proper `espi:` namespace prefixes +- ✅ No JAXB IllegalAnnotationsException errors +- ✅ Null fields properly excluded from XML + +### Phase 2 Success (Customer): +- ✅ All 4 DTOs compile without errors +- ✅ XmlDebugTest validates Customer marshalling +- ✅ XML output shows proper `cust:` namespace prefixes +- ✅ Nested objects (Organisation, TelephoneNumber, StreetAddress) marshal correctly + +### Overall Success: +- ✅ Both IntervalBlock and Customer tests pass +- ✅ MapStruct mappers compile (run `mvn compile`) +- ✅ No regression in existing tests +- ✅ XML namespace prefixes match ESPI 4.0 specification + +## Post-Pilot Actions + +### If Pilot Succeeds: +1. Document final conversion pattern +2. Create conversion checklist for remaining ~32 DTOs +3. Proceed with domain-by-domain conversion: + - Atom domain (AtomFeedDto, AtomEntryDto, LinkDto) + - Usage domain (~20 DTOs) + - Customer domain (remaining ~8 DTOs) + - Shared/embedded DTOs (~5 DTOs) + +### If Pilot Fails: +1. Document failure reason +2. Revisit Option B (MOXy JAXB) or alternative approaches +3. Consult with team on architectural decision + +## File Locations + +### DTOs to Convert (Pilot): +- `src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalBlockDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalReadingDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/usage/DateTimeIntervalDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ReadingQualityDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/customer/OrganisationDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/customer/TelephoneNumberDto.java` +- `src/main/java/org/greenbuttonalliance/espi/common/dto/customer/StreetAddressDto.java` + +### Test File: +- `src/test/java/org/greenbuttonalliance/espi/common/XmlDebugTest.java` + +## Timeline Estimate + +- **Phase 1 (IntervalBlock):** 45 minutes + - Convert 4 DTOs: 30 minutes + - Update test: 5 minutes + - Run and validate: 10 minutes + +- **Phase 2 (Customer):** 45 minutes + - Convert 4 DTOs: 30 minutes + - Add Customer test case: 10 minutes + - Run and validate: 5 minutes + +- **Documentation:** 15 minutes + +**Total Pilot Time:** ~1.5-2 hours + +--- + +**Created:** 2026-01-19 +**Status:** READY TO IMPLEMENT diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Asset.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Asset.java index 7ef3afac..e246c1b4 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Asset.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Asset.java @@ -89,10 +89,14 @@ public abstract class Asset implements Serializable { */ @Embedded @AttributeOverrides({ + @AttributeOverride(name = "lan", column = @Column(name = "asset_lan")), + @AttributeOverride(name = "mac", column = @Column(name = "asset_mac")), @AttributeOverride(name = "email1", column = @Column(name = "asset_email1")), @AttributeOverride(name = "email2", column = @Column(name = "asset_email2")), @AttributeOverride(name = "web", column = @Column(name = "asset_web")), - @AttributeOverride(name = "radio", column = @Column(name = "asset_radio")) + @AttributeOverride(name = "radio", column = @Column(name = "asset_radio")), + @AttributeOverride(name = "userID", column = @Column(name = "asset_user_id")), + @AttributeOverride(name = "password", column = @Column(name = "asset_password")) }) private Organisation.ElectronicAddress electronicAddress; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java index 3a4cdf4e..530129b3 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java @@ -91,6 +91,14 @@ public class CustomerAccountEntity extends IdentifiedObject { * Electronic address for the document. */ @Embedded + @AttributeOverride(name = "lan", column = @Column(name = "doc_lan")) + @AttributeOverride(name = "mac", column = @Column(name = "doc_mac")) + @AttributeOverride(name = "email1", column = @Column(name = "doc_email1")) + @AttributeOverride(name = "email2", column = @Column(name = "doc_email2")) + @AttributeOverride(name = "web", column = @Column(name = "doc_web")) + @AttributeOverride(name = "radio", column = @Column(name = "doc_radio")) + @AttributeOverride(name = "userID", column = @Column(name = "doc_user_id")) + @AttributeOverride(name = "password", column = @Column(name = "doc_password")) private Organisation.ElectronicAddress electronicAddress; /** @@ -109,6 +117,10 @@ public class CustomerAccountEntity extends IdentifiedObject { * Status of this document. */ @Embedded + @AttributeOverride(name = "value", column = @Column(name = "doc_status_value")) + @AttributeOverride(name = "dateTime", column = @Column(name = "doc_status_date_time")) + @AttributeOverride(name = "remark", column = @Column(name = "doc_status_remark")) + @AttributeOverride(name = "reason", column = @Column(name = "doc_status_reason")) private Status docStatus; // CustomerAccount specific fields @@ -155,10 +167,14 @@ public class CustomerAccountEntity extends IdentifiedObject { @AttributeOverride(name = "postalAddress.stateOrProvince", column = @Column(name = "postal_state_or_province")) @AttributeOverride(name = "postalAddress.postalCode", column = @Column(name = "postal_postal_code")) @AttributeOverride(name = "postalAddress.country", column = @Column(name = "postal_country")) + @AttributeOverride(name = "electronicAddress.lan", column = @Column(name = "contact_lan")) + @AttributeOverride(name = "electronicAddress.mac", column = @Column(name = "contact_mac")) @AttributeOverride(name = "electronicAddress.email1", column = @Column(name = "contact_email1")) @AttributeOverride(name = "electronicAddress.email2", column = @Column(name = "contact_email2")) @AttributeOverride(name = "electronicAddress.web", column = @Column(name = "contact_web")) @AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "contact_radio")) + @AttributeOverride(name = "electronicAddress.userID", column = @Column(name = "contact_user_id")) + @AttributeOverride(name = "electronicAddress.password", column = @Column(name = "contact_password")) private Organisation contactInfo; /** diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAgreementEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAgreementEntity.java index ebcaa1a4..17bd41c8 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAgreementEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAgreementEntity.java @@ -32,15 +32,21 @@ /** * Pure JPA/Hibernate entity for CustomerAgreement without JAXB concerns. - * - * Agreement between the customer and the service supplier to pay for service at a specific service location. - * It records certain billing information about the type of service provided at the service location and is + * + * Agreement between the customer and the service supplier to pay for service at a specific service location. + * It records certain billing information about the type of service provided at the service location and is * used during charge creation to determine the type of service. - * + * * This is an actual ESPI resource entity that extends IdentifiedObject directly. */ @Entity @Table(name = "customer_agreements") +@AttributeOverride(name = "upLink.rel", column = @Column(name = "customer_agreement_up_link_rel")) +@AttributeOverride(name = "upLink.href", column = @Column(name = "customer_agreement_up_link_href")) +@AttributeOverride(name = "upLink.type", column = @Column(name = "customer_agreement_up_link_type")) +@AttributeOverride(name = "selfLink.rel", column = @Column(name = "customer_agreement_self_link_rel")) +@AttributeOverride(name = "selfLink.href", column = @Column(name = "customer_agreement_self_link_href")) +@AttributeOverride(name = "selfLink.type", column = @Column(name = "customer_agreement_self_link_type")) @Getter @Setter @NoArgsConstructor @@ -48,7 +54,20 @@ public class CustomerAgreementEntity extends IdentifiedObject { // Document fields (previously inherited from Document superclass) - + // Field order matches customer.xsd Document type definition (lines 819-872) + + /** + * Type of this document. + */ + @Column(name = "document_type", length = 256) + private String type; + + /** + * Name of the author of this document. + */ + @Column(name = "author_name", length = 256) + private String authorName; + /** * Date and time that this document was created. */ @@ -67,6 +86,20 @@ public class CustomerAgreementEntity extends IdentifiedObject { @Column(name = "revision_number", length = 256) private String revisionNumber; + /** + * Electronic address for the document. + */ + @Embedded + @AttributeOverride(name = "lan", column = @Column(name = "doc_lan")) + @AttributeOverride(name = "mac", column = @Column(name = "doc_mac")) + @AttributeOverride(name = "email1", column = @Column(name = "doc_email1")) + @AttributeOverride(name = "email2", column = @Column(name = "doc_email2")) + @AttributeOverride(name = "web", column = @Column(name = "doc_web")) + @AttributeOverride(name = "radio", column = @Column(name = "doc_radio")) + @AttributeOverride(name = "userID", column = @Column(name = "doc_user_id")) + @AttributeOverride(name = "password", column = @Column(name = "doc_password")) + private Organisation.ElectronicAddress electronicAddress; + /** * Subject of this document, intended for this document to be found by a search engine. */ @@ -80,13 +113,35 @@ public class CustomerAgreementEntity extends IdentifiedObject { private String title; /** - * Type of this document. + * Status of this document. */ - @Column(name = "type", length = 256) - private String type; + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "doc_status_value")) + @AttributeOverride(name = "dateTime", column = @Column(name = "doc_status_date_time")) + @AttributeOverride(name = "remark", column = @Column(name = "doc_status_remark")) + @AttributeOverride(name = "reason", column = @Column(name = "doc_status_reason")) + private Status docStatus; + + /** + * Status of subject matter (e.g., Agreement, Work) this document represents. + * For status of the document itself, use 'docStatus' attribute. + */ + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "status_value")) + @AttributeOverride(name = "dateTime", column = @Column(name = "status_date_time")) + @AttributeOverride(name = "remark", column = @Column(name = "status_remark")) + @AttributeOverride(name = "reason", column = @Column(name = "status_reason")) + private Status status; + + /** + * Free text comment. + */ + @Column(name = "comment", length = 256) + private String comment; // Agreement fields (previously inherited from Agreement superclass) - + // Field order matches customer.xsd Agreement type definition (lines 622-660) + /** * Date this agreement was consummated among associated persons and/or organisations. */ @@ -97,9 +152,12 @@ public class CustomerAgreementEntity extends IdentifiedObject { * Date and time interval this agreement is valid (from going into effect to termination). */ @Embedded + @AttributeOverride(name = "start", column = @Column(name = "validity_start")) + @AttributeOverride(name = "duration", column = @Column(name = "validity_duration")) private DateTimeInterval validityInterval; // CustomerAgreement specific fields + // Field order matches customer.xsd CustomerAgreement type definition (lines 159-260) /** * Load management code. @@ -144,12 +202,11 @@ public class CustomerAgreementEntity extends IdentifiedObject { */ @ElementCollection @CollectionTable(name = "customer_agreement_future_status", joinColumns = @JoinColumn(name = "customer_agreement_id")) - @AttributeOverrides({ - @AttributeOverride(name = "value", column = @Column(name = "status_value")), - @AttributeOverride(name = "dateTime", column = @Column(name = "status_date_time")), - @AttributeOverride(name = "reason", column = @Column(name = "status_reason")) - }) - private List futureStatus; + @AttributeOverride(name = "value", column = @Column(name = "status_value")) + @AttributeOverride(name = "dateTime", column = @Column(name = "status_date_time")) + @AttributeOverride(name = "remark", column = @Column(name = "status_remark")) + @AttributeOverride(name = "reason", column = @Column(name = "status_reason")) + private List futureStatus; /** * [extension] Customer agreement identifier @@ -161,8 +218,8 @@ public class CustomerAgreementEntity extends IdentifiedObject { public final boolean equals(Object o) { if (this == o) return true; if (o == null) return false; - Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); - Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + Class oEffectiveClass = o instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : this.getClass(); if (thisEffectiveClass != oEffectiveClass) return false; CustomerAgreementEntity that = (CustomerAgreementEntity) o; return getId() != null && Objects.equals(getId(), that.getId()); @@ -170,19 +227,24 @@ public final boolean equals(Object o) { @Override public final int hashCode() { - return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + return this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); } @Override public String toString() { return getClass().getSimpleName() + "(" + "id = " + getId() + ", " + + "type = " + getType() + ", " + + "authorName = " + getAuthorName() + ", " + "createdDateTime = " + getCreatedDateTime() + ", " + "lastModifiedDateTime = " + getLastModifiedDateTime() + ", " + "revisionNumber = " + getRevisionNumber() + ", " + + "electronicAddress = " + getElectronicAddress() + ", " + "subject = " + getSubject() + ", " + "title = " + getTitle() + ", " + - "type = " + getType() + ", " + + "docStatus = " + getDocStatus() + ", " + + "status = " + getStatus() + ", " + + "comment = " + getComment() + ", " + "signDate = " + getSignDate() + ", " + "validityInterval = " + getValidityInterval() + ", " + "loadMgmt = " + getLoadMgmt() + ", " + @@ -196,4 +258,4 @@ public String toString() { "updated = " + getUpdated() + ", " + "published = " + getPublished() + ")"; } -} \ No newline at end of file +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerEntity.java index e4e2ab9c..5f7c6b03 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerEntity.java @@ -70,10 +70,14 @@ public class CustomerEntity extends IdentifiedObject { @AttributeOverride(name = "postalAddress.stateOrProvince", column = @Column(name = "customer_postal_state_or_province")), @AttributeOverride(name = "postalAddress.postalCode", column = @Column(name = "customer_postal_postal_code")), @AttributeOverride(name = "postalAddress.country", column = @Column(name = "customer_postal_country")), + @AttributeOverride(name = "electronicAddress.lan", column = @Column(name = "customer_lan")), + @AttributeOverride(name = "electronicAddress.mac", column = @Column(name = "customer_mac")), @AttributeOverride(name = "electronicAddress.email1", column = @Column(name = "customer_email1")), @AttributeOverride(name = "electronicAddress.email2", column = @Column(name = "customer_email2")), @AttributeOverride(name = "electronicAddress.web", column = @Column(name = "customer_web")), - @AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "customer_radio")) + @AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "customer_radio")), + @AttributeOverride(name = "electronicAddress.userID", column = @Column(name = "customer_user_id")), + @AttributeOverride(name = "electronicAddress.password", column = @Column(name = "customer_password")) }) private Organisation organisation; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/EndDeviceEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/EndDeviceEntity.java index 6e67e997..1e49e763 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/EndDeviceEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/EndDeviceEntity.java @@ -94,10 +94,14 @@ public class EndDeviceEntity extends IdentifiedObject { */ @Embedded @AttributeOverrides({ + @AttributeOverride(name = "lan", column = @Column(name = "end_device_lan")), + @AttributeOverride(name = "mac", column = @Column(name = "end_device_mac")), @AttributeOverride(name = "email1", column = @Column(name = "end_device_email1")), @AttributeOverride(name = "email2", column = @Column(name = "end_device_email2")), @AttributeOverride(name = "web", column = @Column(name = "end_device_web")), - @AttributeOverride(name = "radio", column = @Column(name = "end_device_radio")) + @AttributeOverride(name = "radio", column = @Column(name = "end_device_radio")), + @AttributeOverride(name = "userID", column = @Column(name = "end_device_user_id")), + @AttributeOverride(name = "password", column = @Column(name = "end_device_password")) }) private Organisation.ElectronicAddress electronicAddress; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Location.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Location.java index 0081b3ca..acb31aab 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Location.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Location.java @@ -88,10 +88,14 @@ public abstract class Location implements Serializable { */ @Embedded @AttributeOverrides({ + @AttributeOverride(name = "lan", column = @Column(name = "location_lan")), + @AttributeOverride(name = "mac", column = @Column(name = "location_mac")), @AttributeOverride(name = "email1", column = @Column(name = "location_email1")), @AttributeOverride(name = "email2", column = @Column(name = "location_email2")), @AttributeOverride(name = "web", column = @Column(name = "location_web")), - @AttributeOverride(name = "radio", column = @Column(name = "location_radio")) + @AttributeOverride(name = "radio", column = @Column(name = "location_radio")), + @AttributeOverride(name = "userID", column = @Column(name = "location_user_id")), + @AttributeOverride(name = "password", column = @Column(name = "location_password")) }) private Organisation.ElectronicAddress electronicAddress; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java index ad5472b1..3d08488f 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java @@ -92,22 +92,35 @@ public static class StreetAddress implements Serializable { // PhoneNumber embeddable class removed - using separate PhoneNumberEntity table instead /** - * Embeddable class for ElectronicAddress + * Embeddable class for ElectronicAddress. + * Per customer.xsd ElectronicAddress type (lines 886-936). */ @Embeddable @Data @NoArgsConstructor public static class ElectronicAddress implements Serializable { + @Column(name = "lan", length = 256) + private String lan; + + @Column(name = "mac", length = 256) + private String mac; + @Column(name = "email1", length = 256) private String email1; - + @Column(name = "email2", length = 256) private String email2; - + @Column(name = "web", length = 256) private String web; - + @Column(name = "radio", length = 256) private String radio; + + @Column(name = "user_id", length = 256) + private String userID; + + @Column(name = "password", length = 256) + private String password; } } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceLocationEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceLocationEntity.java index 78c95bd2..096a1ac1 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceLocationEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceLocationEntity.java @@ -95,10 +95,14 @@ public class ServiceLocationEntity extends IdentifiedObject { */ @Embedded @AttributeOverrides({ + @AttributeOverride(name = "lan", column = @Column(name = "electronic_lan")), + @AttributeOverride(name = "mac", column = @Column(name = "electronic_mac")), @AttributeOverride(name = "email1", column = @Column(name = "electronic_email1")), @AttributeOverride(name = "email2", column = @Column(name = "electronic_email2")), @AttributeOverride(name = "web", column = @Column(name = "electronic_web")), - @AttributeOverride(name = "radio", column = @Column(name = "electronic_radio")) + @AttributeOverride(name = "radio", column = @Column(name = "electronic_radio")), + @AttributeOverride(name = "userID", column = @Column(name = "electronic_user_id")), + @AttributeOverride(name = "password", column = @Column(name = "electronic_password")) }) private Organisation.ElectronicAddress electronicAddress; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceSupplierEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceSupplierEntity.java index 893e9a42..a9b3886e 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceSupplierEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/ServiceSupplierEntity.java @@ -59,10 +59,14 @@ public class ServiceSupplierEntity extends IdentifiedObject { @AttributeOverride(name = "postalAddress.stateOrProvince", column = @Column(name = "supplier_postal_state_or_province")), @AttributeOverride(name = "postalAddress.postalCode", column = @Column(name = "supplier_postal_postal_code")), @AttributeOverride(name = "postalAddress.country", column = @Column(name = "supplier_postal_country")), + @AttributeOverride(name = "electronicAddress.lan", column = @Column(name = "supplier_lan")), + @AttributeOverride(name = "electronicAddress.mac", column = @Column(name = "supplier_mac")), @AttributeOverride(name = "electronicAddress.email1", column = @Column(name = "supplier_email1")), @AttributeOverride(name = "electronicAddress.email2", column = @Column(name = "supplier_email2")), @AttributeOverride(name = "electronicAddress.web", column = @Column(name = "supplier_web")), - @AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "supplier_radio")) + @AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "supplier_radio")), + @AttributeOverride(name = "electronicAddress.userID", column = @Column(name = "supplier_user_id")), + @AttributeOverride(name = "electronicAddress.password", column = @Column(name = "supplier_password")) }) private Organisation organisation; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java index a5f2538f..79061585 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java @@ -52,9 +52,15 @@ public class Status implements Serializable { @Column(name = "status_date_time") private OffsetDateTime dateTime; + /** + * Pertinent information regarding the current value, as free form text. + */ + @Column(name = "status_remark", length = 256) + private String remark; + /** * Reason for status change. */ - @Column(name = "status_reason", length = 512) + @Column(name = "status_reason", length = 256) private String reason; } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/UsageAtomEntryDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/UsageAtomEntryDto.java index 32581897..a46e0da4 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/UsageAtomEntryDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/atom/UsageAtomEntryDto.java @@ -21,6 +21,7 @@ import jakarta.xml.bind.annotation.*; import lombok.NoArgsConstructor; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; import org.greenbuttonalliance.espi.common.dto.usage.*; import java.time.OffsetDateTime; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/DateTimeIntervalDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/common/DateTimeIntervalDto.java similarity index 87% rename from openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/DateTimeIntervalDto.java rename to openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/common/DateTimeIntervalDto.java index 17d571b2..557b8ffd 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/DateTimeIntervalDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/common/DateTimeIntervalDto.java @@ -17,7 +17,7 @@ * */ -package org.greenbuttonalliance.espi.common.dto.usage; +package org.greenbuttonalliance.espi.common.dto.common; import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; @@ -35,9 +35,9 @@ * Represents a time interval with start and duration. * Used in various Green Button resources for time-based data. */ -@XmlRootElement(name = "DateTimeInterval", namespace = "http://naesb.org/espi") +@XmlRootElement(name = "DateTimeInterval") @XmlAccessorType(XmlAccessType.FIELD) -@XmlType(name = "DateTimeInterval", namespace = "http://naesb.org/espi", propOrder = { +@XmlType(name = "DateTimeInterval", propOrder = { "start", "duration" }) @Getter @@ -46,10 +46,10 @@ @AllArgsConstructor public class DateTimeIntervalDto { - @XmlElement(name = "start", namespace = "http://naesb.org/espi") + @XmlElement(name = "start") private Long start; - @XmlElement(name = "duration", namespace = "http://naesb.org/espi") + @XmlElement(name = "duration") private Long duration; /** diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java index aa539409..ab4b5be5 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java @@ -160,7 +160,7 @@ public class CustomerAccountDto { */ @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "DocStatus", namespace = "http://naesb.org/espi/customer", propOrder = { - "value", "dateTime", "reason" + "value", "dateTime", "remark", "reason" }) @Getter @Setter @@ -179,6 +179,12 @@ public static class StatusDto { @XmlElement(name = "dateTime", namespace = "http://naesb.org/espi/customer") private OffsetDateTime dateTime; + /** + * Remark. + */ + @XmlElement(name = "remark", namespace = "http://naesb.org/espi/customer") + private String remark; + /** * Reason for status change. */ diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDto.java index 29098f2c..19fead50 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDto.java @@ -19,29 +19,40 @@ package org.greenbuttonalliance.espi.common.dto.customer; -import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; - import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; +import java.io.Serializable; import java.time.OffsetDateTime; import java.util.List; /** * CustomerAgreement DTO class for JAXB XML marshalling/unmarshalling. * - * Represents an agreement between a customer and service provider. - * Supports Atom protocol XML wrapping. + * Agreement between the customer and the service supplier to pay for service at a specific service location. + * It records certain billing information about the type of service provided at the service location and is + * used during charge creation to determine the type of service. + * + * This DTO contains ONLY the XSD-defined fields per customer.xsd CustomerAgreement type (lines 159-209). + * Atom protocol fields (id, title, published, updated, links) are handled by CustomerAtomEntryDto wrapper. + * + * Inheritance chain: CustomerAgreement → Agreement → Document → IdentifiedObject + * Field order strictly follows customer.xsd inheritance hierarchy. */ @XmlRootElement(name = "CustomerAgreement", namespace = "http://naesb.org/espi/customer") @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "CustomerAgreement", namespace = "http://naesb.org/espi/customer", propOrder = { - "published", "updated", "selfLink", "upLink", "relatedLinks", - "description", "signDate", "validityInterval", "customerAccount", - "serviceLocations", "statements" + // Document fields (11) - lines 819-885 + "type", "authorName", "createdDateTime", "lastModifiedDateTime", "revisionNumber", + "electronicAddress", "subject", "title", "docStatus", "status", "comment", + // Agreement fields (2) - lines 622-642 + "signDate", "validityInterval", + // CustomerAgreement fields (6 scalar) - lines 159-209 + "loadMgmt", "isPrePay", "shutOffDateTime", "currency", "futureStatus", "agreementId" }) @Getter @Setter @@ -49,95 +60,169 @@ @AllArgsConstructor public class CustomerAgreementDto { - @XmlTransient - private Long id; - + // UUID identifier (mRID attribute) - from IdentifiedObject @XmlAttribute(name = "mRID") private String uuid; - @XmlElement(name = "published") - private OffsetDateTime published; + // ==================== Document fields (customer.xsd lines 819-885) ==================== + + /** + * Type of this document. + */ + @XmlElement(name = "type", namespace = "http://naesb.org/espi/customer") + private String type; + + /** + * Name of the author of this document. + */ + @XmlElement(name = "authorName", namespace = "http://naesb.org/espi/customer") + private String authorName; + + /** + * Date and time that this document was created. + */ + @XmlElement(name = "createdDateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime createdDateTime; + + /** + * Date and time that this document was last modified. + */ + @XmlElement(name = "lastModifiedDateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime lastModifiedDateTime; + + /** + * Revision number for this document. + */ + @XmlElement(name = "revisionNumber", namespace = "http://naesb.org/espi/customer") + private String revisionNumber; + + /** + * Electronic address for the document. + */ + @XmlElement(name = "electronicAddress", namespace = "http://naesb.org/espi/customer") + private CustomerDto.ElectronicAddressDto electronicAddress; + + /** + * Subject of this document, intended for this document to be found by a search engine. + */ + @XmlElement(name = "subject", namespace = "http://naesb.org/espi/customer") + private String subject; - @XmlElement(name = "updated") - private OffsetDateTime updated; + /** + * Title of this document. + */ + @XmlElement(name = "title", namespace = "http://naesb.org/espi/customer") + private String title; - @XmlElement(name = "link", namespace = "http://www.w3.org/2005/Atom") - @XmlElementWrapper(name = "links", namespace = "http://www.w3.org/2005/Atom") - private List relatedLinks; + /** + * Status of this document. + */ + @XmlElement(name = "docStatus", namespace = "http://naesb.org/espi/customer") + private StatusDto docStatus; - @XmlElement(name = "link", namespace = "http://www.w3.org/2005/Atom") - private LinkDto selfLink; + /** + * Status of subject matter (e.g., Agreement, Work) this document represents. + */ + @XmlElement(name = "status", namespace = "http://naesb.org/espi/customer") + private StatusDto status; - @XmlElement(name = "link", namespace = "http://www.w3.org/2005/Atom") - private LinkDto upLink; + /** + * Free text comment. + */ + @XmlElement(name = "comment", namespace = "http://naesb.org/espi/customer") + private String comment; - @XmlElement(name = "description") - private String description; + // ==================== Agreement fields (customer.xsd lines 622-642) ==================== - @XmlElement(name = "signDate") + /** + * Date this agreement was consummated among associated persons and/or organisations. + */ + @XmlElement(name = "signDate", namespace = "http://naesb.org/espi/customer") private OffsetDateTime signDate; - @XmlElement(name = "validityInterval") - private String validityInterval; + /** + * Date and time interval this agreement is valid (from going into effect to termination). + */ + @XmlElement(name = "validityInterval", namespace = "http://naesb.org/espi/customer") + private DateTimeIntervalDto validityInterval; - @XmlElement(name = "CustomerAccount") - private CustomerAccountDto customerAccount; + // ==================== CustomerAgreement fields (customer.xsd lines 159-209) ==================== - @XmlElement(name = "ServiceLocation") - @XmlElementWrapper(name = "ServiceLocations") - private List serviceLocations; + /** + * Load management code. + */ + @XmlElement(name = "loadMgmt", namespace = "http://naesb.org/espi/customer") + private String loadMgmt; - @XmlElement(name = "Statement") - @XmlElementWrapper(name = "Statements") - private List statements; + /** + * If true, the customer is a pre-pay customer for the specified service. + */ + @XmlElement(name = "isPrePay", namespace = "http://naesb.org/espi/customer") + private Boolean isPrePay; /** - * Minimal constructor for basic agreement data. + * Final date and time the service will be billed to the previous customer. */ - public CustomerAgreementDto(String uuid, OffsetDateTime signDate) { - this(null, uuid, null, null, null, null, null, null, - signDate, null, null, null, null); - } + @XmlElement(name = "shutOffDateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime shutOffDateTime; /** - * Gets the self href for this customer agreement. - * - * @return self href string + * Currency for all monetary amounts for this agreement. */ - public String getSelfHref() { - return selfLink != null ? selfLink.getHref() : null; - } + @XmlElement(name = "currency", namespace = "http://naesb.org/espi/customer") + private String currency; /** - * Gets the up href for this customer agreement. - * - * @return up href string + * Known future changes to CustomerAgreement's Status of Service. */ - public String getUpHref() { - return upLink != null ? upLink.getHref() : null; - } + @XmlElement(name = "futureStatus", namespace = "http://naesb.org/espi/customer") + private List futureStatus; /** - * Generates the default self href for a customer agreement. - * - * @return default self href + * Customer agreement identifier. */ - public String generateSelfHref() { - if (uuid != null && customerAccount != null && customerAccount.getUuid() != null) { - return "/espi/1_1/resource/CustomerAccount/" + customerAccount.getUuid() + "/CustomerAgreement/" + uuid; - } - return uuid != null ? "/espi/1_1/resource/CustomerAgreement/" + uuid : null; - } + @XmlElement(name = "agreementId", namespace = "http://naesb.org/espi/customer") + private String agreementId; + + // ==================== Nested DTOs ==================== /** - * Generates the default up href for a customer agreement. - * - * @return default up href + * Status DTO for document status and future status information. + * Matches customer.xsd Status type (lines 1254-1284). + * XML type name is "AgreementStatus" to avoid conflict with CustomerDto.StatusDto. */ - public String generateUpHref() { - if (customerAccount != null && customerAccount.getUuid() != null) { - return "/espi/1_1/resource/CustomerAccount/" + customerAccount.getUuid() + "/CustomerAgreement"; - } - return "/espi/1_1/resource/CustomerAgreement"; + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "AgreementStatus", namespace = "http://naesb.org/espi/customer", propOrder = { + "value", "dateTime", "remark", "reason" + }) + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class StatusDto implements Serializable { + + /** + * Status value. + */ + @XmlElement(name = "value", namespace = "http://naesb.org/espi/customer") + private String value; + + /** + * Date and time for which status value applies. + */ + @XmlElement(name = "dateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime dateTime; + + /** + * Pertinent information regarding the current value, as free form text. + */ + @XmlElement(name = "remark", namespace = "http://naesb.org/espi/customer") + private String remark; + + /** + * Reason code or explanation for why an object went to the current status value. + */ + @XmlElement(name = "reason", namespace = "http://naesb.org/espi/customer") + private String reason; } -} \ No newline at end of file +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDto.java index ae52d795..e3e8ed73 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDto.java @@ -199,14 +199,23 @@ public static class PhoneNumberDto { /** * Embeddable DTO for ElectronicAddress. + * Per customer.xsd ElectronicAddress type (lines 886-936). */ @XmlAccessorType(XmlAccessType.FIELD) - @XmlType(name = "ElectronicAddress", namespace = "http://naesb.org/espi/customer") + @XmlType(name = "ElectronicAddress", namespace = "http://naesb.org/espi/customer", propOrder = { + "lan", "mac", "email1", "email2", "web", "radio", "userID", "password" + }) @Getter @Setter @NoArgsConstructor @AllArgsConstructor public static class ElectronicAddressDto { + @XmlElement(name = "lan", namespace = "http://naesb.org/espi/customer") + private String lan; + + @XmlElement(name = "mac", namespace = "http://naesb.org/espi/customer") + private String mac; + @XmlElement(name = "email1", namespace = "http://naesb.org/espi/customer") private String email1; @@ -218,5 +227,11 @@ public static class ElectronicAddressDto { @XmlElement(name = "radio", namespace = "http://naesb.org/espi/customer") private String radio; + + @XmlElement(name = "userID", namespace = "http://naesb.org/espi/customer") + private String userID; + + @XmlElement(name = "password", namespace = "http://naesb.org/espi/customer") + private String password; } } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AuthorizationDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AuthorizationDto.java index 6e22cde2..8cef323e 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AuthorizationDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AuthorizationDto.java @@ -19,6 +19,8 @@ package org.greenbuttonalliance.espi.common.dto.usage; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; + import io.swagger.v3.oas.annotations.media.Schema; import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ElectricPowerQualitySummaryDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ElectricPowerQualitySummaryDto.java index bed77d2f..8ab63b1c 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ElectricPowerQualitySummaryDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/ElectricPowerQualitySummaryDto.java @@ -24,7 +24,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.greenbuttonalliance.espi.common.dto.usage.DateTimeIntervalDto; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; /** * ElectricPowerQualitySummary DTO class for JAXB XML marshalling/unmarshalling. diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalBlockDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalBlockDto.java index 490e7161..271beec9 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalBlockDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalBlockDto.java @@ -19,6 +19,8 @@ package org.greenbuttonalliance.espi.common.dto.usage; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; + import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalReadingDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalReadingDto.java index d35baaa6..b05604c1 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalReadingDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/IntervalReadingDto.java @@ -19,6 +19,8 @@ package org.greenbuttonalliance.espi.common.dto.usage; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; + import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/LineItemDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/LineItemDto.java index cd26536d..32c8a60d 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/LineItemDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/LineItemDto.java @@ -19,6 +19,8 @@ package org.greenbuttonalliance.espi.common.dto.usage; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; + import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/UsageSummaryDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/UsageSummaryDto.java index 5423bd4e..0a1a3a23 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/UsageSummaryDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/UsageSummaryDto.java @@ -19,6 +19,8 @@ package org.greenbuttonalliance.espi.common.dto.usage; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; + import org.greenbuttonalliance.espi.common.dto.BillingChargeSourceDto; import org.greenbuttonalliance.espi.common.dto.SummaryMeasurementDto; import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAgreementMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAgreementMapper.java index e873ec44..409a2a43 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAgreementMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAgreementMapper.java @@ -23,9 +23,9 @@ import org.greenbuttonalliance.espi.common.dto.customer.CustomerAgreementDto; import org.greenbuttonalliance.espi.common.mapper.BaseMapperUtils; import org.greenbuttonalliance.espi.common.mapper.DateTimeMapper; +import org.greenbuttonalliance.espi.common.mapper.usage.DateTimeIntervalMapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; /** * MapStruct mapper for converting between CustomerAgreementEntity and CustomerAgreementDto. @@ -33,46 +33,89 @@ * Handles the conversion between the JPA entity used for persistence and the DTO * used for JAXB XML marshalling in the Green Button API. *

- * Maps only customer.xsd CustomerAgreement fields. IdentifiedObject fields are NOT part of - * the customer.xsd CustomerAgreement definition and are handled by AtomFeedDto/AtomEntryDto. + * Maps customer.xsd CustomerAgreement fields including Document and Agreement base fields. + * Per customer.xsd lines 159-209 (CustomerAgreement), 622-642 (Agreement), 819-885 (Document). */ @Mapper(componentModel = "spring", uses = { DateTimeMapper.class, - BaseMapperUtils.class + BaseMapperUtils.class, + ElectronicAddressMapper.class, + StatusMapper.class, + DateTimeIntervalMapper.class }) public interface CustomerAgreementMapper { /** * Converts a CustomerAgreementEntity to a CustomerAgreementDto. - * Maps only customer.xsd CustomerAgreement fields. + * Maps all Document, Agreement, and CustomerAgreement fields per customer.xsd. * * @param entity the customer agreement entity * @return the customer agreement DTO */ - @Mapping(target = "id", ignore = true) // IdentifiedObject field handled by Atom layer + @Mapping(target = "uuid", source = "id") + // Document fields (11) + @Mapping(target = "type", source = "type") + @Mapping(target = "authorName", source = "authorName") + @Mapping(target = "createdDateTime", source = "createdDateTime") + @Mapping(target = "lastModifiedDateTime", source = "lastModifiedDateTime") + @Mapping(target = "revisionNumber", source = "revisionNumber") + @Mapping(target = "electronicAddress", source = "electronicAddress") + @Mapping(target = "subject", source = "subject") + @Mapping(target = "title", source = "title") + @Mapping(target = "docStatus", source = "docStatus") + @Mapping(target = "status", source = "status") + @Mapping(target = "comment", source = "comment") + // Agreement fields (2) @Mapping(target = "signDate", source = "signDate") - @Mapping(target = "validityInterval", ignore = true) // Complex mapping - @Mapping(target = "customerAccount", ignore = true) // Relationship handled separately - @Mapping(target = "serviceLocations", ignore = true) // Relationship handled separately - @Mapping(target = "statements", ignore = true) // Relationship handled separately + @Mapping(target = "validityInterval", source = "validityInterval") + // CustomerAgreement fields (6) + @Mapping(target = "loadMgmt", source = "loadMgmt") + @Mapping(target = "isPrePay", source = "isPrePay") + @Mapping(target = "shutOffDateTime", source = "shutOffDateTime") + @Mapping(target = "currency", source = "currency") + @Mapping(target = "futureStatus", source = "futureStatus") + @Mapping(target = "agreementId", source = "agreementId") CustomerAgreementDto toDto(CustomerAgreementEntity entity); /** * Converts a CustomerAgreementDto to a CustomerAgreementEntity. - * Maps only customer.xsd CustomerAgreement fields. + * Maps all Document, Agreement, and CustomerAgreement fields per customer.xsd. * * @param dto the customer agreement DTO * @return the customer agreement entity */ - @Mapping(target = "id", ignore = true) // IdentifiedObject field handled by Atom layer + @Mapping(target = "id", source = "uuid") + // Document fields (11) + @Mapping(target = "type", source = "type") + @Mapping(target = "authorName", source = "authorName") + @Mapping(target = "createdDateTime", source = "createdDateTime") + @Mapping(target = "lastModifiedDateTime", source = "lastModifiedDateTime") + @Mapping(target = "revisionNumber", source = "revisionNumber") + @Mapping(target = "electronicAddress", source = "electronicAddress") + @Mapping(target = "subject", source = "subject") + @Mapping(target = "title", source = "title") + @Mapping(target = "docStatus", source = "docStatus") + @Mapping(target = "status", source = "status") + @Mapping(target = "comment", source = "comment") + // Agreement fields (2) @Mapping(target = "signDate", source = "signDate") - @Mapping(target = "validityInterval", ignore = true) // Complex mapping - @Mapping(target = "createdDateTime", ignore = true) // From Document - @Mapping(target = "lastModifiedDateTime", ignore = true) // From Document - @Mapping(target = "revisionNumber", ignore = true) // From Document - @Mapping(target = "subject", ignore = true) // From Document - @Mapping(target = "title", ignore = true) // From Document - @Mapping(target = "type", ignore = true) // From Document + @Mapping(target = "validityInterval", source = "validityInterval") + // CustomerAgreement fields (6) + @Mapping(target = "loadMgmt", source = "loadMgmt") + @Mapping(target = "isPrePay", source = "isPrePay") + @Mapping(target = "shutOffDateTime", source = "shutOffDateTime") + @Mapping(target = "currency", source = "currency") + @Mapping(target = "futureStatus", source = "futureStatus") + @Mapping(target = "agreementId", source = "agreementId") + // Entity-only fields not in DTO (relationships commented as TODO) + // @Mapping(target = "demandResponsePrograms", ignore = true) + // @Mapping(target = "pricingStructures", ignore = true) + // IdentifiedObject fields (inherited) - handled by Atom layer + @Mapping(target = "description", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + @Mapping(target = "published", ignore = true) + @Mapping(target = "selfLink", ignore = true) + @Mapping(target = "upLink", ignore = true) CustomerAgreementEntity toEntity(CustomerAgreementDto dto); - } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java index 4639a77b..3608c7c4 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerMapper.java @@ -162,20 +162,28 @@ default Organisation.StreetAddress mapStreetAddressFromDto(CustomerDto.StreetAdd default CustomerDto.ElectronicAddressDto mapElectronicAddress(Organisation.ElectronicAddress address) { if (address == null) return null; return new CustomerDto.ElectronicAddressDto( + address.getLan(), + address.getMac(), address.getEmail1(), address.getEmail2(), address.getWeb(), - address.getRadio() + address.getRadio(), + address.getUserID(), + address.getPassword() ); } default Organisation.ElectronicAddress mapElectronicAddressFromDto(CustomerDto.ElectronicAddressDto dto) { if (dto == null) return null; Organisation.ElectronicAddress address = new Organisation.ElectronicAddress(); + address.setLan(dto.getLan()); + address.setMac(dto.getMac()); address.setEmail1(dto.getEmail1()); address.setEmail2(dto.getEmail2()); address.setWeb(dto.getWeb()); address.setRadio(dto.getRadio()); + address.setUserID(dto.getUserID()); + address.setPassword(dto.getPassword()); return address; } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/DateTimeIntervalMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/DateTimeIntervalMapper.java index adac80bc..8d9b910d 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/DateTimeIntervalMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/DateTimeIntervalMapper.java @@ -20,7 +20,7 @@ package org.greenbuttonalliance.espi.common.mapper.usage; import org.greenbuttonalliance.espi.common.domain.common.DateTimeInterval; -import org.greenbuttonalliance.espi.common.dto.usage.DateTimeIntervalDto; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; import org.mapstruct.Mapper; import org.mapstruct.Mapping; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepository.java new file mode 100644 index 00000000..ce6ae218 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepository.java @@ -0,0 +1,39 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed 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. + * + */ + +package org.greenbuttonalliance.espi.common.repositories.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAgreementEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +/** + * Spring Data JPA repository for CustomerAgreementEntity. + * Phase 24: CustomerAgreement schema compliance. + * + * Only uses inherited JpaRepository methods to avoid H2 keyword conflicts. + * Per Phase 18 pattern: findById, findAll, save, delete, count, existsById + */ +@Repository +public interface CustomerAgreementRepository extends JpaRepository { + // Use only inherited methods to avoid H2 keyword conflicts + // No custom query methods required +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java index e587cbfa..5ac1929a 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java @@ -143,6 +143,36 @@ private UUID bytesToUUID(byte[] bytes) { return new UUID(msb, lsb); } + /** + * Generates a deterministic NAESB ESPI compliant UUID5 for any entity type. + * + * This method creates a deterministic UUID5 based on the entity type and natural key. + * The same entityType and naturalKey will always produce the same UUID, ensuring + * idempotency for entity creation and updates. + * + * @param entityType the type of entity (e.g., "CustomerAccount", "CustomerAgreement") + * @param naturalKey the natural key or business identifier (e.g., accountId, agreementId) + * @return a deterministic UUID5 identifier for the entity + * @throws IllegalArgumentException if entityType or naturalKey is null or empty + */ + public UUID generateEntityId(String entityType, String naturalKey) { + if (entityType == null || entityType.trim().isEmpty()) { + throw new IllegalArgumentException("entityType cannot be null or empty"); + } + if (naturalKey == null || naturalKey.trim().isEmpty()) { + throw new IllegalArgumentException("naturalKey cannot be null or empty"); + } + + // Build the deterministic name string (no timestamps - purely deterministic) + String name = entityType.trim() + ":" + naturalKey.trim(); + + try { + return generateUUID5(ESPI_NAMESPACE, name); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-1 algorithm not available", e); + } + } + /** * Generates a NAESB ESPI compliant UUID5 for a Subscription entity. * diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAgreementService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAgreementService.java new file mode 100644 index 00000000..d3e0ba38 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAgreementService.java @@ -0,0 +1,66 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed 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. + * + */ + +package org.greenbuttonalliance.espi.common.service.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAgreementEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Service interface for CustomerAgreement management. + * + * Handles business logic for customer agreement operations including contract terms, + * service agreements, and agreement lifecycle management. + * Per Phase 24 guidelines, only ID-based operations on indexed fields are supported. + */ +public interface CustomerAgreementService { + + /** + * Find all customer agreements. + */ + List findAll(); + + /** + * Find customer agreement by UUID. + */ + Optional findById(UUID id); + + /** + * Save customer agreement. + */ + CustomerAgreementEntity save(CustomerAgreementEntity customerAgreement); + + /** + * Delete customer agreement by UUID. + */ + void deleteById(UUID id); + + /** + * Check if customer agreement exists by UUID. + */ + boolean existsById(UUID id); + + /** + * Count total customer agreements. + */ + long count(); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAgreementServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAgreementServiceImpl.java new file mode 100644 index 00000000..bd8be141 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAgreementServiceImpl.java @@ -0,0 +1,94 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed 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. + * + */ + +package org.greenbuttonalliance.espi.common.service.customer.impl; + +import lombok.RequiredArgsConstructor; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAgreementEntity; +import org.greenbuttonalliance.espi.common.repositories.customer.CustomerAgreementRepository; +import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; +import org.greenbuttonalliance.espi.common.service.customer.CustomerAgreementService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Service implementation for CustomerAgreement management. + * + * Provides business logic for customer agreement operations including contract terms, + * service agreements, and agreement lifecycle management. + * Per Phase 24 guidelines, only ID-based operations on indexed fields are supported. + */ +@Service +@Transactional +@RequiredArgsConstructor +public class CustomerAgreementServiceImpl implements CustomerAgreementService { + + private final CustomerAgreementRepository customerAgreementRepository; + private final EspiIdGeneratorService espiIdGeneratorService; + + @Override + @Transactional(readOnly = true) + public List findAll() { + return customerAgreementRepository.findAll(); + } + + @Override + @Transactional(readOnly = true) + public Optional findById(UUID id) { + return customerAgreementRepository.findById(id); + } + + @Override + public CustomerAgreementEntity save(CustomerAgreementEntity customerAgreement) { + // Generate deterministic UUID v5 if not present + if (customerAgreement.getId() == null) { + // Require agreementId to be set for deterministic UUID v5 generation + // Per Green Button Alliance guidelines, UUID v5 must use repeatable, unchangeable attributes + if (customerAgreement.getAgreementId() == null || customerAgreement.getAgreementId().trim().isEmpty()) { + throw new IllegalArgumentException( + "agreementId must be set before saving a new CustomerAgreement to generate deterministic UUID v5" + ); + } + UUID uuid5 = espiIdGeneratorService.generateEntityId("CustomerAgreement", customerAgreement.getAgreementId()); + customerAgreement.setId(uuid5); + } + return customerAgreementRepository.save(customerAgreement); + } + + @Override + public void deleteById(UUID id) { + customerAgreementRepository.deleteById(id); + } + + @Override + @Transactional(readOnly = true) + public boolean existsById(UUID id) { + return customerAgreementRepository.existsById(id); + } + + @Override + @Transactional(readOnly = true) + public long count() { + return customerAgreementRepository.count(); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java index c62af189..15358684 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/DtoExportServiceImpl.java @@ -246,7 +246,7 @@ private Marshaller createMarshaller(Class dtoClass, Set requiredNames org.greenbuttonalliance.espi.common.dto.usage.ServiceDeliveryPointDto.class, org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class, org.greenbuttonalliance.espi.common.dto.usage.IntervalReadingDto.class, - org.greenbuttonalliance.espi.common.dto.usage.DateTimeIntervalDto.class, + org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto.class, org.greenbuttonalliance.espi.common.dto.usage.TariffRiderRefDto.class, org.greenbuttonalliance.espi.common.dto.usage.TariffRiderRefsDto.class, org.greenbuttonalliance.espi.common.dto.usage.PnodeRefDto.class, diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java index e84d267f..256789e8 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageExportService.java @@ -96,7 +96,7 @@ protected JAXBContext createJAXBContext() throws JAXBException { org.greenbuttonalliance.espi.common.dto.usage.ServiceDeliveryPointDto.class, org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class, org.greenbuttonalliance.espi.common.dto.usage.IntervalReadingDto.class, - org.greenbuttonalliance.espi.common.dto.usage.DateTimeIntervalDto.class, + org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto.class, org.greenbuttonalliance.espi.common.dto.usage.TariffRiderRefDto.class, org.greenbuttonalliance.espi.common.dto.usage.TariffRiderRefsDto.class, org.greenbuttonalliance.espi.common.dto.usage.PnodeRefDto.class, diff --git a/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql b/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql index 5c194cbd..ca61474a 100644 --- a/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql +++ b/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql @@ -132,10 +132,15 @@ CREATE TABLE customers customer_postal_state_or_province VARCHAR(255), customer_postal_postal_code VARCHAR(255), customer_postal_country VARCHAR(255), + -- Organisation.electronicAddress (customer.xsd lines 886-936) + customer_lan VARCHAR(255), + customer_mac VARCHAR(255), customer_email1 VARCHAR(255), customer_email2 VARCHAR(255), customer_web VARCHAR(255), customer_radio VARCHAR(255), + customer_user_id VARCHAR(255), + customer_password VARCHAR(255), -- Status embedded object columns status_value VARCHAR(256), @@ -187,27 +192,48 @@ CREATE TABLE customer_agreements created TIMESTAMP NOT NULL, updated TIMESTAMP NOT NULL, published TIMESTAMP, - up_link_rel VARCHAR(255), - up_link_href VARCHAR(1024), - up_link_type VARCHAR(255), - self_link_rel VARCHAR(255), - self_link_href VARCHAR(1024), - self_link_type VARCHAR(255), - - -- Document fields + customer_agreement_up_link_rel VARCHAR(255), + customer_agreement_up_link_href VARCHAR(1024), + customer_agreement_up_link_type VARCHAR(255), + customer_agreement_self_link_rel VARCHAR(255), + customer_agreement_self_link_href VARCHAR(1024), + customer_agreement_self_link_type VARCHAR(255), + + -- Document fields (customer.xsd lines 819-885) - field order matches XSD + document_type VARCHAR(256), + author_name VARCHAR(256), created_date_time TIMESTAMP, last_modified_date_time TIMESTAMP, revision_number VARCHAR(256), + -- Document.electronicAddress embedded object (customer.xsd lines 886-936) + doc_lan VARCHAR(256), + doc_mac VARCHAR(256), + doc_email1 VARCHAR(256), + doc_email2 VARCHAR(256), + doc_web VARCHAR(256), + doc_radio VARCHAR(256), + doc_user_id VARCHAR(256), + doc_password VARCHAR(256), subject VARCHAR(256), title VARCHAR(256), - type VARCHAR(256), + -- Document.docStatus embedded object (customer.xsd lines 1254-1284) + doc_status_value VARCHAR(256), + doc_status_date_time TIMESTAMP, + doc_status_remark VARCHAR(256), + doc_status_reason VARCHAR(256), + -- Document.status embedded object (customer.xsd lines 1254-1284) + status_value VARCHAR(256), + status_date_time TIMESTAMP, + status_remark VARCHAR(256), + status_reason VARCHAR(256), + comment VARCHAR(256), - -- Agreement fields + -- Agreement fields (customer.xsd lines 622-642) sign_date TIMESTAMP, - start BIGINT, - duration BIGINT, + validity_start BIGINT, + validity_duration BIGINT, - -- Customer agreement specific fields + -- Customer agreement specific fields (customer.xsd lines 159-209) load_mgmt VARCHAR(256), is_pre_pay BOOLEAN, shut_off_date_time TIMESTAMP, @@ -237,6 +263,7 @@ CREATE TABLE customer_agreement_future_status customer_agreement_id CHAR(36) NOT NULL, status_value VARCHAR(256), status_date_time TIMESTAMP, + status_remark VARCHAR(256), status_reason VARCHAR(256), FOREIGN KEY (customer_agreement_id) REFERENCES customer_agreements (id) ON DELETE CASCADE ); @@ -267,17 +294,22 @@ CREATE TABLE customer_accounts created_date_time TIMESTAMP, last_modified_date_time TIMESTAMP, revision_number VARCHAR(256), - -- Document.electronicAddress embedded object - email1 VARCHAR(256), - email2 VARCHAR(256), - web VARCHAR(256), - radio VARCHAR(256), + -- Document.electronicAddress embedded object (customer.xsd lines 886-936) + doc_lan VARCHAR(256), + doc_mac VARCHAR(256), + doc_email1 VARCHAR(256), + doc_email2 VARCHAR(256), + doc_web VARCHAR(256), + doc_radio VARCHAR(256), + doc_user_id VARCHAR(256), + doc_password VARCHAR(256), subject VARCHAR(256), title VARCHAR(256), - -- Document.docStatus embedded object - status_value VARCHAR(256), - status_date_time TIMESTAMP, - status_reason VARCHAR(512), + -- Document.docStatus embedded object (customer.xsd lines 1254-1284) + doc_status_value VARCHAR(256), + doc_status_date_time TIMESTAMP, + doc_status_remark VARCHAR(512), + doc_status_reason VARCHAR(512), -- CustomerAccount specific fields (customer.xsd lines 118-158) billing_cycle VARCHAR(50), @@ -297,11 +329,15 @@ CREATE TABLE customer_accounts postal_state_or_province VARCHAR(256), postal_postal_code VARCHAR(256), postal_country VARCHAR(256), - -- contactInfo.electronicAddress + -- contactInfo.electronicAddress (customer.xsd lines 886-936) + contact_lan VARCHAR(256), + contact_mac VARCHAR(256), contact_email1 VARCHAR(256), contact_email2 VARCHAR(256), contact_web VARCHAR(256), contact_radio VARCHAR(256), + contact_user_id VARCHAR(256), + contact_password VARCHAR(256), -- contactInfo.organisationName organisation_name VARCHAR(256), account_id VARCHAR(256), @@ -422,10 +458,15 @@ CREATE TABLE end_devices lot_number VARCHAR(100), purchase_price BIGINT, critical BOOLEAN DEFAULT FALSE, + -- ElectronicAddress (customer.xsd lines 886-936) + end_device_lan VARCHAR(255), + end_device_mac VARCHAR(255), end_device_email1 VARCHAR(255), end_device_email2 VARCHAR(255), end_device_web VARCHAR(255), end_device_radio VARCHAR(255), + end_device_user_id VARCHAR(255), + end_device_password VARCHAR(255), installation_date TIMESTAMP, manufactured_date TIMESTAMP, purchase_date TIMESTAMP, @@ -594,11 +635,15 @@ CREATE TABLE service_locations secondary_postal_code VARCHAR(255), secondary_country VARCHAR(255), - -- Electronic address embedded object columns + -- Electronic address embedded object columns (customer.xsd lines 886-936) + electronic_lan VARCHAR(255), + electronic_mac VARCHAR(255), electronic_email1 VARCHAR(255), electronic_email2 VARCHAR(255), electronic_web VARCHAR(255), electronic_radio VARCHAR(255), + electronic_user_id VARCHAR(255), + electronic_password VARCHAR(255), -- Status embedded object columns status_value VARCHAR(256), @@ -663,10 +708,14 @@ CREATE TABLE service_suppliers supplier_postal_state_or_province VARCHAR(255), supplier_postal_postal_code VARCHAR(255), supplier_postal_country VARCHAR(255), + supplier_lan VARCHAR(256), + supplier_mac VARCHAR(256), supplier_email1 VARCHAR(255), supplier_email2 VARCHAR(255), supplier_web VARCHAR(255), - supplier_radio VARCHAR(255) + supplier_radio VARCHAR(255), + supplier_user_id VARCHAR(256), + supplier_password VARCHAR(256) ); CREATE INDEX idx_service_supplier_kind ON service_suppliers (kind); diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/UsageXmlDebugTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/UsageXmlDebugTest.java index bc3f1d53..c72d9ac6 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/UsageXmlDebugTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/UsageXmlDebugTest.java @@ -75,7 +75,7 @@ void setUp() throws JAXBException { org.greenbuttonalliance.espi.common.dto.usage.ServiceDeliveryPointDto.class, org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class, org.greenbuttonalliance.espi.common.dto.usage.IntervalReadingDto.class, - org.greenbuttonalliance.espi.common.dto.usage.DateTimeIntervalDto.class + org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto.class ); } diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java index 3e811640..0ea8d2ff 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java @@ -206,19 +206,20 @@ void shouldUseCorrectCustomerNamespace() { private CustomerAccountDto createFullCustomerAccountDto() { CustomerDto.ElectronicAddressDto electronicAddress = new CustomerDto.ElectronicAddressDto( - "billing@example.com", "support@example.com", "https://www.example.com", null + null, null, "billing@example.com", "support@example.com", "https://www.example.com", null, null, null ); CustomerAccountDto.StatusDto docStatus = new CustomerAccountDto.StatusDto( "ACTIVE", OffsetDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC), + null, "Account in good standing" ); CustomerDto.OrganisationDto contactInfo = new CustomerDto.OrganisationDto( new CustomerDto.StreetAddressDto("123 Main St", "Springfield", "IL", "62701", "USA"), null, null, null, - new CustomerDto.ElectronicAddressDto("contact@acme.com", null, "https://acme.com", null), + new CustomerDto.ElectronicAddressDto(null, null, "contact@acme.com", null, "https://acme.com", null, null, null), "ACME Corporation" ); diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java new file mode 100644 index 00000000..2d62a2ce --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAgreementDtoTest.java @@ -0,0 +1,353 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed 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. + * + */ + +package org.greenbuttonalliance.espi.common.dto.customer; + +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.common.DateTimeIntervalDto; +import org.greenbuttonalliance.espi.common.service.impl.DtoExportServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * XML marshalling/unmarshalling tests for CustomerAgreementDto. + * Verifies Jakarta JAXB processes JAXB annotations correctly for ESPI 4.0 customer.xsd compliance. + * Phase 24: CustomerAgreement schema compliance testing. + */ +@DisplayName("CustomerAgreementDto XML Marshalling Tests") +class CustomerAgreementDtoTest { + + private DtoExportServiceImpl dtoExportService; + + @BeforeEach + void setUp() { + org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + } + + @Test + @DisplayName("Should export CustomerAgreement with complete Document and Agreement fields") + void shouldExportCustomerAgreementWithCompleteDocumentAndAgreementFields() { + // Arrange + OffsetDateTime now = OffsetDateTime.of(2025, 1, 26, 10, 30, 0, 0, ZoneOffset.UTC); + + CustomerAgreementDto customerAgreement = createFullCustomerAgreementDto(); + CustomerAtomEntryDto entry = new CustomerAtomEntryDto( + "urn:uuid:650e8400-e29b-51d4-a716-446655440000", + "Test Customer Agreement", + now, now, null, customerAgreement + ); + + AtomFeedDto feed = new AtomFeedDto( + "urn:uuid:feed-id", "Customer Agreement Feed", now, now, null, + List.of(entry) + ); + + // Act + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + dtoExportService.exportAtomFeed(feed, stream); + String xml = stream.toString(StandardCharsets.UTF_8); + + // Debug output + System.out.println("========== CustomerAgreement XML Output =========="); + System.out.println(xml); + System.out.println("=================================================="); + + // Assert - Basic structure + assertThat(xml) + .startsWith("") + .contains(""); + + // Assert - Document fields present + assertThat(xml) + .contains("SERVICE_AGREEMENT") + .contains("Utility Service Dept") + .contains("2.0") + .contains("service@utility.com") + .contains("Residential Service Agreement") + .contains("Customer Service Agreement AGR-789"); + + // Assert - Document.docStatus embedded object + assertThat(xml) + .contains("FINALIZED"); + + // Assert - Document.status embedded object + assertThat(xml) + .contains("ACTIVE"); + + // Assert - Agreement fields present + assertThat(xml) + .contains("STANDARD") + .contains("false") + .contains("USD") + .contains("AGR-789"); + } + + @Test + @DisplayName("Should verify CustomerAgreement field order matches customer.xsd") + void shouldVerifyCustomerAgreementFieldOrder() { + // Arrange + OffsetDateTime now = OffsetDateTime.of(2025, 1, 26, 10, 30, 0, 0, ZoneOffset.UTC); + + CustomerAgreementDto customerAgreement = createFullCustomerAgreementDto(); + CustomerAtomEntryDto entry = new CustomerAtomEntryDto( + "urn:uuid:650e8400-e29b-51d4-a716-446655440001", + "Test Customer Agreement", + now, now, null, customerAgreement + ); + + AtomFeedDto feed = new AtomFeedDto( + "urn:uuid:feed-id", "Test Feed", now, now, null, + List.of(entry) + ); + + // Act + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + dtoExportService.exportAtomFeed(feed, stream); + String xml = stream.toString(StandardCharsets.UTF_8); + + // Assert - Verify Document field order per customer.xsd (lines 819-885) + int typePos = xml.indexOf("192.168.1.1") + .contains("00:11:22:33:44:55") + .contains("primary@utility.com") + .contains("secondary@utility.com") + .contains("https://www.utility.com") + .contains("radio-freq-123") + .contains("admin_user") + .contains("secure_pass"); + } + + // Helper methods + + private CustomerAgreementDto createFullCustomerAgreementDto() { + CustomerDto.ElectronicAddressDto electronicAddress = new CustomerDto.ElectronicAddressDto( + "10.0.0.1", + "AA:BB:CC:DD:EE:FF", + "service@utility.com", + "support@utility.com", + "https://www.utility.com", + null, + "service_user", + null + ); + + CustomerAgreementDto.StatusDto docStatus = new CustomerAgreementDto.StatusDto( + "FINALIZED", + OffsetDateTime.of(2025, 1, 20, 9, 0, 0, 0, ZoneOffset.UTC), + "Document finalized", + "Complete" + ); + + CustomerAgreementDto.StatusDto status = new CustomerAgreementDto.StatusDto( + "ACTIVE", + OffsetDateTime.of(2025, 1, 15, 10, 0, 0, 0, ZoneOffset.UTC), + "Service agreement active", + "Customer in good standing" + ); + + OffsetDateTime validityStart = OffsetDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + DateTimeIntervalDto validityInterval = new DateTimeIntervalDto( + validityStart.toEpochSecond(), + 31536000L // 1 year in seconds + ); + + return new CustomerAgreementDto( + "650e8400-e29b-51d4-a716-446655440000", + "SERVICE_AGREEMENT", + "Utility Service Dept", + OffsetDateTime.of(2024, 12, 15, 0, 0, 0, 0, ZoneOffset.UTC), + OffsetDateTime.of(2025, 1, 26, 10, 30, 0, 0, ZoneOffset.UTC), + "2.0", + electronicAddress, + "Residential Service Agreement", + "Customer Service Agreement AGR-789", + docStatus, + status, + "Standard terms and conditions apply", + OffsetDateTime.of(2025, 1, 10, 14, 0, 0, 0, ZoneOffset.UTC), + validityInterval, + "STANDARD", + false, + null, + "USD", + null, + "AGR-789" + ); + } + + private CustomerAgreementDto createMinimalCustomerAgreementDto() { + return new CustomerAgreementDto( + "test-uuid", + null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, "AGR-MIN" + ); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoMarshallingTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoMarshallingTest.java index 9d85f394..cf56871a 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoMarshallingTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoMarshallingTest.java @@ -82,10 +82,14 @@ void shouldMarshalCustomerWithAllFields() { ); CustomerDto.ElectronicAddressDto electronicAddress = new CustomerDto.ElectronicAddressDto( + null, // lan + null, // mac "customer@example.com", // email1 "billing@example.com", // email2 "https://customer.example.com", // web - null // radio + null, // radio + null, // userID + null // password ); CustomerDto.StreetAddressDto streetAddress = new CustomerDto.StreetAddressDto( diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java index 2a41d7c0..f3e46ea1 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerDtoTest.java @@ -282,7 +282,7 @@ private CustomerDto createFullCustomerDto() { ); CustomerDto.ElectronicAddressDto electronicAddress = new CustomerDto.ElectronicAddressDto( - "customer@example.com", "support@example.com", "https://www.example.com", null + null, null, "customer@example.com", "support@example.com", "https://www.example.com", null, null, null ); CustomerDto.OrganisationDto organisation = new CustomerDto.OrganisationDto( diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepositoryTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepositoryTest.java new file mode 100644 index 00000000..964429d4 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAgreementRepositoryTest.java @@ -0,0 +1,540 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * Licensed 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. + * + */ + +package org.greenbuttonalliance.espi.common.repositories.customer; + +import org.greenbuttonalliance.espi.common.domain.common.DateTimeInterval; +import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAgreementEntity; +import org.greenbuttonalliance.espi.common.domain.customer.entity.Organisation; +import org.greenbuttonalliance.espi.common.domain.customer.entity.Status; +import org.greenbuttonalliance.espi.common.test.BaseRepositoryTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Comprehensive test suite for CustomerAgreementRepository. + * Phase 24: CustomerAgreement schema compliance testing. + * + * Tests CRUD operations, Document field persistence, Agreement field persistence, + * CustomerAgreement field persistence, Status embedded objects, and IdentifiedObject base functionality. + */ +@DisplayName("CustomerAgreement Repository Tests") +class CustomerAgreementRepositoryTest extends BaseRepositoryTest { + + @Autowired + private CustomerAgreementRepository customerAgreementRepository; + + /** + * Creates a valid CustomerAgreementEntity with all Document, Agreement, and CustomerAgreement fields for testing. + * Phase 24: Includes all fields per customer.xsd (Document + Agreement + CustomerAgreement). + */ + private CustomerAgreementEntity createValidCustomerAgreement() { + CustomerAgreementEntity agreement = new CustomerAgreementEntity(); + + // IdentifiedObject fields + agreement.setDescription("Test Customer Agreement - " + faker.lorem().sentence(3)); + + // Document fields (customer.xsd lines 819-885) + agreement.setType("SERVICE_AGREEMENT"); + agreement.setAuthorName(faker.name().fullName()); + agreement.setCreatedDateTime(randomOffsetDateTime()); + agreement.setLastModifiedDateTime(randomOffsetDateTime()); + agreement.setRevisionNumber("1.0"); + + // Document.electronicAddress (customer.xsd lines 886-936) + Organisation.ElectronicAddress electronicAddress = new Organisation.ElectronicAddress(); + electronicAddress.setLan("192.168." + faker.number().numberBetween(1, 255) + "." + faker.number().numberBetween(1, 255)); + electronicAddress.setMac(faker.internet().macAddress()); + electronicAddress.setEmail1(faker.internet().emailAddress()); + electronicAddress.setEmail2(faker.internet().emailAddress()); + electronicAddress.setWeb(faker.internet().url()); + electronicAddress.setRadio("RADIO-" + faker.number().digits(6)); + electronicAddress.setUserID(faker.name().username()); + electronicAddress.setPassword(faker.internet().password()); + agreement.setElectronicAddress(electronicAddress); + + agreement.setSubject("Service Agreement"); + agreement.setTitle("Customer Agreement AGR-" + faker.number().digits(6)); + + // Document.docStatus (customer.xsd lines 1254-1284) + Status docStatus = new Status(); + docStatus.setValue("FINALIZED"); + docStatus.setDateTime(randomOffsetDateTime()); + docStatus.setRemark("Document approved"); + docStatus.setReason("Signature completed"); + agreement.setDocStatus(docStatus); + + // Document.status (customer.xsd lines 1254-1284) + Status status = new Status(); + status.setValue("ACTIVE"); + status.setDateTime(randomOffsetDateTime()); + status.setRemark("Service active"); + status.setReason("Agreement in good standing"); + agreement.setStatus(status); + + agreement.setComment("Standard terms and conditions apply"); + + // Agreement fields (customer.xsd lines 622-642) + agreement.setSignDate(randomOffsetDateTime()); + DateTimeInterval validityInterval = new DateTimeInterval(); + validityInterval.setStart(randomOffsetDateTime().toEpochSecond()); + validityInterval.setDuration(31536000L); // 1 year in seconds + agreement.setValidityInterval(validityInterval); + + // CustomerAgreement specific fields (customer.xsd lines 159-209) + agreement.setLoadMgmt("STANDARD"); + agreement.setIsPrePay(false); + agreement.setShutOffDateTime(null); + agreement.setCurrency("USD"); + agreement.setAgreementId("AGR-" + faker.number().digits(8)); + + return agreement; + } + + @Nested + @DisplayName("CRUD Operations") + class CrudOperationsTest { + + @Test + @DisplayName("Should save and retrieve customer agreement successfully") + void shouldSaveAndRetrieveCustomerAgreementSuccessfully() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getId()).isEqualTo(saved.getId()); + assertThat(retrieved.get().getAgreementId()).isEqualTo(saved.getAgreementId()); + assertThat(retrieved.get().getLoadMgmt()).isEqualTo(saved.getLoadMgmt()); + } + + @Test + @DisplayName("Should update customer agreement successfully") + void shouldUpdateCustomerAgreementSuccessfully() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + CustomerAgreementEntity saved = persistAndFlush(agreement); + UUID savedId = saved.getId(); + + // Act + saved.setLoadMgmt("PREMIUM"); + saved.setCurrency("EUR"); + saved.setComment("Updated terms"); + customerAgreementRepository.save(saved); + flushAndClear(); + + // Assert + Optional retrieved = customerAgreementRepository.findById(savedId); + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getLoadMgmt()).isEqualTo("PREMIUM"); + assertThat(retrieved.get().getCurrency()).isEqualTo("EUR"); + assertThat(retrieved.get().getComment()).isEqualTo("Updated terms"); + } + + @Test + @DisplayName("Should delete customer agreement successfully") + void shouldDeleteCustomerAgreementSuccessfully() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + CustomerAgreementEntity saved = persistAndFlush(agreement); + UUID savedId = saved.getId(); + + // Act + customerAgreementRepository.deleteById(savedId); + flushAndClear(); + + // Assert + Optional retrieved = customerAgreementRepository.findById(savedId); + assertThat(retrieved).isEmpty(); + } + + @Test + @DisplayName("Should count customer agreements") + void shouldCountCustomerAgreements() { + // Arrange + CustomerAgreementEntity agreement1 = createValidCustomerAgreement(); + CustomerAgreementEntity agreement2 = createValidCustomerAgreement(); + + // Act + long countBefore = customerAgreementRepository.count(); + persistAndFlush(agreement1); + persistAndFlush(agreement2); + long countAfter = customerAgreementRepository.count(); + + // Assert + assertThat(countAfter).isEqualTo(countBefore + 2); + } + + @Test + @DisplayName("Should find all customer agreements") + void shouldFindAllCustomerAgreements() { + // Arrange + CustomerAgreementEntity agreement1 = createValidCustomerAgreement(); + CustomerAgreementEntity agreement2 = createValidCustomerAgreement(); + persistAndFlush(agreement1); + persistAndFlush(agreement2); + + // Act + List allAgreements = customerAgreementRepository.findAll(); + + // Assert + assertThat(allAgreements).hasSizeGreaterThanOrEqualTo(2); + } + + @Test + @DisplayName("Should verify customer agreement exists by ID") + void shouldVerifyCustomerAgreementExistsByID() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + CustomerAgreementEntity saved = persistAndFlush(agreement); + + // Act + boolean exists = customerAgreementRepository.existsById(saved.getId()); + + // Assert + assertThat(exists).isTrue(); + } + } + + @Nested + @DisplayName("Document Field Persistence") + class DocumentFieldPersistenceTest { + + @Test + @DisplayName("Should persist all Document fields correctly") + void shouldPersistAllDocumentFieldsCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert - Document fields + assertThat(retrieved).isPresent(); + CustomerAgreementEntity result = retrieved.get(); + + assertThat(result.getType()).isEqualTo(agreement.getType()); + assertThat(result.getAuthorName()).isEqualTo(agreement.getAuthorName()); + assertThat(result.getCreatedDateTime()).isEqualTo(agreement.getCreatedDateTime()); + assertThat(result.getLastModifiedDateTime()).isEqualTo(agreement.getLastModifiedDateTime()); + assertThat(result.getRevisionNumber()).isEqualTo(agreement.getRevisionNumber()); + assertThat(result.getSubject()).isEqualTo(agreement.getSubject()); + assertThat(result.getTitle()).isEqualTo(agreement.getTitle()); + assertThat(result.getComment()).isEqualTo(agreement.getComment()); + } + + @Test + @DisplayName("Should persist ElectronicAddress with all 8 fields") + void shouldPersistElectronicAddressWithAllFields() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert - ElectronicAddress fields (customer.xsd lines 886-936) + assertThat(retrieved).isPresent(); + Organisation.ElectronicAddress result = retrieved.get().getElectronicAddress(); + + assertThat(result).isNotNull(); + assertThat(result.getLan()).isEqualTo(agreement.getElectronicAddress().getLan()); + assertThat(result.getMac()).isEqualTo(agreement.getElectronicAddress().getMac()); + assertThat(result.getEmail1()).isEqualTo(agreement.getElectronicAddress().getEmail1()); + assertThat(result.getEmail2()).isEqualTo(agreement.getElectronicAddress().getEmail2()); + assertThat(result.getWeb()).isEqualTo(agreement.getElectronicAddress().getWeb()); + assertThat(result.getRadio()).isEqualTo(agreement.getElectronicAddress().getRadio()); + assertThat(result.getUserID()).isEqualTo(agreement.getElectronicAddress().getUserID()); + assertThat(result.getPassword()).isEqualTo(agreement.getElectronicAddress().getPassword()); + } + + @Test + @DisplayName("Should persist docStatus with all 4 fields including remark") + void shouldPersistDocStatusWithAllFields() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert - docStatus fields (customer.xsd lines 1254-1284) + assertThat(retrieved).isPresent(); + Status result = retrieved.get().getDocStatus(); + + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo(agreement.getDocStatus().getValue()); + assertThat(result.getDateTime()).isEqualTo(agreement.getDocStatus().getDateTime()); + assertThat(result.getRemark()).isEqualTo(agreement.getDocStatus().getRemark()); + assertThat(result.getReason()).isEqualTo(agreement.getDocStatus().getReason()); + } + + @Test + @DisplayName("Should persist status with all 4 fields including remark") + void shouldPersistStatusWithAllFields() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert - status fields (customer.xsd lines 1254-1284) + assertThat(retrieved).isPresent(); + Status result = retrieved.get().getStatus(); + + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo(agreement.getStatus().getValue()); + assertThat(result.getDateTime()).isEqualTo(agreement.getStatus().getDateTime()); + assertThat(result.getRemark()).isEqualTo(agreement.getStatus().getRemark()); + assertThat(result.getReason()).isEqualTo(agreement.getStatus().getReason()); + } + } + + @Nested + @DisplayName("Agreement Field Persistence") + class AgreementFieldPersistenceTest { + + @Test + @DisplayName("Should persist signDate correctly") + void shouldPersistSignDateCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + OffsetDateTime signDate = randomOffsetDateTime(); + agreement.setSignDate(signDate); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getSignDate()).isEqualTo(signDate); + } + + @Test + @DisplayName("Should persist validityInterval correctly") + void shouldPersistValidityIntervalCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + DateTimeInterval validityInterval = new DateTimeInterval(); + OffsetDateTime start = randomOffsetDateTime(); + validityInterval.setStart(start.toEpochSecond()); + validityInterval.setDuration(86400L); // 1 day + agreement.setValidityInterval(validityInterval); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getValidityInterval()).isNotNull(); + assertThat(retrieved.get().getValidityInterval().getStart()).isEqualTo(validityInterval.getStart()); + assertThat(retrieved.get().getValidityInterval().getDuration()).isEqualTo(validityInterval.getDuration()); + } + } + + @Nested + @DisplayName("CustomerAgreement Field Persistence") + class CustomerAgreementFieldPersistenceTest { + + @Test + @DisplayName("Should persist loadMgmt correctly") + void shouldPersistLoadMgmtCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + agreement.setLoadMgmt("PREMIUM"); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getLoadMgmt()).isEqualTo("PREMIUM"); + } + + @Test + @DisplayName("Should persist isPrePay correctly") + void shouldPersistIsPrePayCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + agreement.setIsPrePay(true); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getIsPrePay()).isTrue(); + } + + @Test + @DisplayName("Should persist shutOffDateTime correctly") + void shouldPersistShutOffDateTimeCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + OffsetDateTime shutOffDateTime = randomOffsetDateTime(); + agreement.setShutOffDateTime(shutOffDateTime); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getShutOffDateTime()).isEqualTo(shutOffDateTime); + } + + @Test + @DisplayName("Should persist currency correctly") + void shouldPersistCurrencyCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + agreement.setCurrency("EUR"); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getCurrency()).isEqualTo("EUR"); + } + + @Test + @DisplayName("Should persist agreementId correctly") + void shouldPersistAgreementIdCorrectly() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + String agreementId = "AGR-TEST-" + faker.number().digits(10); + agreement.setAgreementId(agreementId); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getAgreementId()).isEqualTo(agreementId); + } + } + + @Nested + @DisplayName("IdentifiedObject Base Class Tests") + class BaseClassTest { + + @Test + @DisplayName("Should persist IdentifiedObject description field") + void shouldPersistIdentifiedObjectDescriptionField() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + String description = "Custom Description - " + faker.lorem().sentence(5); + agreement.setDescription(description); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getDescription()).isEqualTo(description); + } + + @Test + @DisplayName("Should auto-generate UUID on persist") + void shouldAutoGenerateUuidOnPersist() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + + // Assert + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getId()).isInstanceOf(UUID.class); + } + + @Test + @DisplayName("Should persist created and updated timestamps") + void shouldPersistCreatedAndUpdatedTimestamps() { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + + // Act + CustomerAgreementEntity saved = persistAndFlush(agreement); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getCreated()).isNotNull(); + assertThat(retrieved.get().getUpdated()).isNotNull(); + } + + @Test + @DisplayName("Should update timestamp on modification") + void shouldUpdateTimestampOnModification() throws InterruptedException { + // Arrange + CustomerAgreementEntity agreement = createValidCustomerAgreement(); + CustomerAgreementEntity saved = persistAndFlush(agreement); + java.time.LocalDateTime initialUpdated = saved.getUpdated(); + + // Wait to ensure timestamp difference + Thread.sleep(100); + + // Act + saved.setComment("Modified comment"); + customerAgreementRepository.save(saved); + flushAndClear(); + Optional retrieved = customerAgreementRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getUpdated()).isAfter(initialUpdated); + } + } +}