diff --git a/README.md b/README.md index 26ef0a4..14785e2 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,67 @@ -# Ballistic Builder ( The Armory?) Backend -### Internal Engine for the Shadow System Armory? - -The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. - -It’s built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform. - ---- - -## What This Backend Does - -### **Merchant Feed Ingestion** -- Pulls AvantLink feeds (CSV or TSV) -- Automatically detects delimiters -- Normalizes raw merchant fields -- Creates or updates product records -- Upserts price and stock offers -- Tracks first-seen / last-seen timestamps -- Safely handles malformed or incomplete rows -- Ensures repeat imports never duplicate offers - -### **Category Mapping Engine** -- Identifies every unique raw category coming from each merchant feed -- Exposes *unmapped* categories in the admin UI -- Allows you to assign: - - Part Role - - Product Configuration (Stripped, Complete, Kit, etc.) -- Applies mappings automatically on future imports -- Respects manual overrides such as `platform_locked` - -### **Builder Support** -The frontend Builder depends on this backend for: - -- Loading parts grouped by role -- Offering compatible options -- Calculating build cost -- Comparing offers across merchants -- Providing product metadata, imagery, and offer data - -**Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection. - ---- - -## Tech Stack - -- **Spring Boot 3.x** -- **Java 17** -- **PostgreSQL** -- **Hibernate (JPA)** -- **HikariCP** -- **Apache Commons CSV** -- **Maven** -- **REST API** - ---- - -## Local Development - -### Requirements -- Java 17 or newer -- PostgreSQL running locally -- Port 8080 open (default backend port) - -### Run Development Server - -```bash +# Ballistic Builder ( The Armory?) Backend +### Internal Engine for the Shadow System Armory? + +The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. + +It’s built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform. + +--- + +## What This Backend Does + +### **Merchant Feed Ingestion** +- Pulls AvantLink feeds (CSV or TSV) +- Automatically detects delimiters +- Normalizes raw merchant fields +- Creates or updates product records +- Upserts price and stock offers +- Tracks first-seen / last-seen timestamps +- Safely handles malformed or incomplete rows +- Ensures repeat imports never duplicate offers + +### **Category Mapping Engine** +- Identifies every unique raw category coming from each merchant feed +- Exposes *unmapped* categories in the admin UI +- Allows you to assign: + - Part Role + - Product Configuration (Stripped, Complete, Kit, etc.) +- Applies mappings automatically on future imports +- Respects manual overrides such as `platform_locked` + +### **Builder Support** +The frontend Builder depends on this backend for: + +- Loading parts grouped by role +- Offering compatible options +- Calculating build cost +- Comparing offers across merchants +- Providing product metadata, imagery, and offer data + +**Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection. + +--- + +## Tech Stack + +- **Spring Boot 3.x** +- **Java 17** +- **PostgreSQL** +- **Hibernate (JPA)** +- **HikariCP** +- **Apache Commons CSV** +- **Maven** +- **REST API** + +--- + +## Local Development + +### Requirements +- Java 17 or newer +- PostgreSQL running locally +- Port 8080 open (default backend port) + +### Run Development Server + +```bash ./mvnw spring-boot:run \ No newline at end of file diff --git a/action1.yaml b/action1.yaml index d3f5dbb..4689143 100644 --- a/action1.yaml +++ b/action1.yaml @@ -1,39 +1,39 @@ -# File: .gitea/workflows/build-and-upload.yml -name: Build and Upload Artifact - -on: - push: - branches: - - main - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - # Step 1: Check out repository code - - name: Checkout code - uses: actions/checkout@v4 - - # Step 2: Set up Node.js (example for a JS project; adjust for your stack) - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - # Step 3: Install dependencies - - name: Install dependencies - run: npm ci - - # Step 4: Build project - - name: Build project - run: npm run build - - # Step 5: Upload build output as artifact - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: build-output - path: dist/ # Change to your build output directory - retention-days: 7 # Optional: how long to keep artifact +# File: .gitea/workflows/build-and-upload.yml +name: Build and Upload Artifact + +on: + push: + branches: + - main + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # Step 1: Check out repository code + - name: Checkout code + uses: actions/checkout@v4 + + # Step 2: Set up Node.js (example for a JS project; adjust for your stack) + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + # Step 3: Install dependencies + - name: Install dependencies + run: npm ci + + # Step 4: Build project + - name: Build project + run: npm run build + + # Step 5: Upload build output as artifact + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: build-output + path: dist/ # Change to your build output directory + retention-days: 7 # Optional: how long to keep artifact diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile new file mode 100644 index 0000000..38beb6c --- /dev/null +++ b/docker/backend/Dockerfile @@ -0,0 +1,34 @@ +# Stage 1: Build the application (The Build Stage) +# Use a Java SDK image with Maven pre-installed +FROM maven:3.9-jdk-17-slim AS build + +# Set the working directory inside the container +WORKDIR /app + +# Copy the Maven project files (pom.xml) first to leverage Docker layer caching +COPY pom.xml . + +# Copy the source code +COPY src ./src + +# Build the Spring Boot application, skipping tests to speed up the Docker build +# This creates the executable JAR file in the 'target' directory +RUN mvn clean package -DskipTests + +# Stage 2: Create the final lightweight image (The Runtime Stage) +# Use a smaller Java Runtime Environment (JRE) image for a smaller footprint +FROM openjdk:17-jre-slim + +# Set the working directory in the final image +WORKDIR /app + +# Copy the built JAR file from the 'build' stage into the final image +# The JAR file is typically named 'target/-.jar' +# You may need to adjust the name if you have a non-standard pom.xml +COPY --from=build /app/target/*.jar app.jar + +# Expose the default Spring Boot port +EXPOSE 8080 + +# Define the command to run the application +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker/bb-spring/Dockerfile b/docker/bb-spring/Dockerfile deleted file mode 100644 index 399820d..0000000 --- a/docker/bb-spring/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -# Stage 1: Build the application -FROM openjdk:17-jdk-slim as build -WORKDIR /app -COPY gradlew . -COPY settings.gradle . -COPY build.gradle . -COPY src ./src -# Adjust the build command for Maven: ./mvnw package -DskipTests -RUN ./gradlew bootJar - -# Stage 2: Create the final lightweight image -FROM openjdk:17-jre-slim -WORKDIR /app -# Get the built JAR from the build stage -COPY --from=build /app/build/libs/*.jar app.jar -EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3495250..7cb22e9 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,63 +1,63 @@ -version: '3.8' - -services: - # --- 1. Spring API Service (Backend) --- - spring-api: - build: - context: ./backend # Path to your Spring project's root folder - dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend - container_name: spring-api - ports: - - "8080:8080" # Map host port 8080 to container port 8080 - environment: - # These environment variables link the API to the database service defined below - - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase - - SPRING_DATASOURCE_USERNAME=myuser - - SPRING_DATASOURCE_PASSWORD=mypassword - depends_on: - - db - networks: - - app-network - - # --- 2. Next.js App Service (Frontend) --- - nextjs-app: - build: - context: ./frontend # Path to your Next.js project's root folder - dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend - container_name: nextjs-app - ports: - - "3000:3000" # Map host port 3000 to container port 3000 - environment: - # This variable is crucial: Next.js needs the URL for the Spring API - # Use the Docker internal service name 'spring-api' and its port 8080 - - NEXT_PUBLIC_API_URL=http://spring-api:8080 - # For local testing, you might need the host IP for Next.js to call back - # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 - depends_on: - - spring-api - networks: - - app-network - - # --- 3. PostgreSQL Database Service (Example Dependency) --- - db: - image: postgres:15-alpine # Lightweight and stable PostgreSQL image - container_name: postgres-db - environment: - - POSTGRES_DB=mydatabase - - POSTGRES_USER=myuser - - POSTGRES_PASSWORD=mypassword - volumes: - - postgres_data:/var/lib/postgresql/data # Persist the database data - ports: - - "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver) - networks: - - app-network - -# --- Docker Volume for Persistent Data --- -volumes: - postgres_data: - -# --- Docker Network for Inter-Container Communication --- -networks: - app-network: +version: '3.8' + +services: + # --- 1. Spring API Service (Backend) --- + spring-api: + build: + context: ./backend # Path to your Spring project's root folder + dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend + container_name: spring-api + ports: + - "8080:8080" # Map host port 8080 to container port 8080 + environment: + # These environment variables link the API to the database service defined below + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase + - SPRING_DATASOURCE_USERNAME=myuser + - SPRING_DATASOURCE_PASSWORD=mypassword + depends_on: + - db + networks: + - app-network + + # --- 2. Next.js App Service (Frontend) --- + nextjs-app: + build: + context: ./frontend # Path to your Next.js project's root folder + dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend + container_name: nextjs-app + ports: + - "3000:3000" # Map host port 3000 to container port 3000 + environment: + # This variable is crucial: Next.js needs the URL for the Spring API + # Use the Docker internal service name 'spring-api' and its port 8080 + - NEXT_PUBLIC_API_URL=http://spring-api:8080 + # For local testing, you might need the host IP for Next.js to call back + # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 + depends_on: + - spring-api + networks: + - app-network + + # --- 3. PostgreSQL Database Service (Example Dependency) --- + db: + image: postgres:15-alpine # Lightweight and stable PostgreSQL image + container_name: postgres-db + environment: + - POSTGRES_DB=mydatabase + - POSTGRES_USER=myuser + - POSTGRES_PASSWORD=mypassword + volumes: + - postgres_data:/var/lib/postgresql/data # Persist the database data + ports: + - "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver) + networks: + - app-network + +# --- Docker Volume for Persistent Data --- +volumes: + postgres_data: + +# --- Docker Network for Inter-Container Communication --- +networks: + app-network: driver: bridge \ No newline at end of file diff --git a/docker/ss_builder/Dockerfile b/docker/frontend/Dockerfile similarity index 96% rename from docker/ss_builder/Dockerfile rename to docker/frontend/Dockerfile index c9d0091..d03598a 100644 --- a/docker/ss_builder/Dockerfile +++ b/docker/frontend/Dockerfile @@ -1,22 +1,22 @@ -# Stage 1: Build the static assets -FROM node:20-alpine as builder -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm install -COPY . . -# Run the Next.js build command -RUN npm run build - -# Stage 2: Run the production application (Next.js server) -FROM node:20-alpine -WORKDIR /app -# Copy only the necessary files for running the app -COPY --from=builder /app/.next ./.next -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/public ./public -# Set environment variables -ENV NODE_ENV production -EXPOSE 3000 -# Run the Next.js production server +# Stage 1: Build the static assets +FROM node:20-alpine as builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm install +COPY . . +# Run the Next.js build command +RUN npm run build + +# Stage 2: Run the production application (Next.js server) +FROM node:20-alpine +WORKDIR /app +# Copy only the necessary files for running the app +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/public ./public +# Set environment variables +ENV NODE_ENV production +EXPOSE 3000 +# Run the Next.js production server CMD ["npm", "start"] \ No newline at end of file diff --git a/importLogic.md b/importLogic.md index d91feb7..863e7b6 100644 --- a/importLogic.md +++ b/importLogic.md @@ -1,213 +1,213 @@ -# Ballistic Import Pipeline -A high-level overview of how merchant data flows through the Spring ETL system. - ---- - -## Purpose - -This document explains how the Ballistic backend: - -1. Fetches merchant product feeds (CSV/TSV) -2. Normalizes raw data into structured entities -3. Updates products and offers in an idempotent way -4. Supports two sync modes: - - Full Import - - Offer-Only Sync - ---- - -# 1. High-Level Flow - -## ASCII Diagram - -``` - ┌──────────────────────────┐ - │ /admin/imports/{id} │ - │ (Full Import Trigger) │ - └─────────────┬────────────┘ - │ - ▼ - ┌──────────────────────────────┐ - │ importMerchantFeed(merchantId)│ - └─────────────┬────────────────┘ - │ - ▼ - ┌────────────────────────────────────────────────────────┐ - │ readFeedRowsForMerchant() │ - │ - auto-detect delimiter │ - │ - parse CSV/TSV → MerchantFeedRow objects │ - └─────────────────┬──────────────────────────────────────┘ - │ List - ▼ - ┌──────────────────────────────────────┐ - │ For each MerchantFeedRow row: │ - │ resolveBrand() │ - │ upsertProduct() │ - │ - find existing via brand+mpn/upc │ - │ - update fields (mapped partRole) │ - │ upsertOfferFromRow() │ - └──────────────────────────────────────┘ -``` - ---- - -# 2. Full Import Explained - -Triggered by: - -``` -POST /admin/imports/{merchantId} -``` - -### Step 1 — Load merchant -Using `merchantRepository.findById()`. - -### Step 2 — Parse feed rows -`readFeedRowsForMerchant()`: -- Auto-detects delimiter (`\t`, `,`, `;`) -- Validates required headers -- Parses each row into `MerchantFeedRow` - -### Step 3 — Process each row - -For each parsed row: - -#### a. resolveBrand() -- Finds or creates brand -- Defaults to “Aero Precision” if missing - -#### b. upsertProduct() -Dedupes by: - -1. Brand + MPN -2. Brand + UPC (currently SKU placeholder) - -If no match → create new product. - -Then applies: -- Name + slug -- Descriptions -- Images -- MPN/identifiers -- Platform inference -- Category mapping -- Part role inference - -#### c. upsertOfferFromRow() -Creates or updates a ProductOffer: -- Prices -- Stock -- Buy URL -- lastSeenAt -- firstSeenAt when newly created - -Idempotent — does not duplicate offers. - ---- - -# 3. Offer-Only Sync - -Triggered by: - -``` -POST /admin/imports/{merchantId}/offers-only -``` - -Does NOT: -- Create products -- Update product fields - -It only updates: -- price -- originalPrice -- inStock -- buyUrl -- lastSeenAt - -If the offer does not exist, it is skipped. - ---- - -# 4. Auto-Detecting CSV/TSV Parser - -The parser: - -- Attempts multiple delimiters -- Validates headers -- Handles malformed or short rows -- Never throws on missing columns -- Returns clean MerchantFeedRow objects - -Designed for messy merchant feeds. - ---- - -# 5. Entities Updated During Import - -### Product -- name -- slug -- short/long description -- main image -- mpn -- upc (future) -- platform -- rawCategoryKey -- partRole - -### ProductOffer -- merchant -- product -- avantlinkProductId (SKU placeholder) -- price -- originalPrice -- inStock -- buyUrl -- lastSeenAt -- firstSeenAt - -### Merchant -- lastFullImportAt -- lastOfferSyncAt - ---- - -# 6. Extension Points - -You can extend the import pipeline in these areas: - -- Add per-merchant column mapping -- Add true UPC parsing -- Support multi-platform parts -- Improve partRole inference -- Implement global deduplication across merchants - ---- - -# 7. Quick Reference: Main Methods - -| Method | Purpose | -|--------|---------| -| importMerchantFeed | Full product + offer import | -| readFeedRowsForMerchant | Detect delimiter + parse feed | -| resolveBrand | Normalize brand names | -| upsertProduct | Idempotent product write | -| updateProductFromRow | Apply product fields | -| upsertOfferFromRow | Idempotent offer write | -| syncOffersOnly | Offer-only sync | -| upsertOfferOnlyFromRow | Update existing offers | -| detectCsvFormat | Auto-detect delimiter | -| fetchFeedRows | Simpler parser for offers | - ---- - -# 8. Summary - -The Ballistic importer is: - -- Robust against bad data -- Idempotent and safe -- Flexible for multiple merchants -- Extensible for long-term scaling - +# Ballistic Import Pipeline +A high-level overview of how merchant data flows through the Spring ETL system. + +--- + +## Purpose + +This document explains how the Ballistic backend: + +1. Fetches merchant product feeds (CSV/TSV) +2. Normalizes raw data into structured entities +3. Updates products and offers in an idempotent way +4. Supports two sync modes: + - Full Import + - Offer-Only Sync + +--- + +# 1. High-Level Flow + +## ASCII Diagram + +``` + ┌──────────────────────────┐ + │ /admin/imports/{id} │ + │ (Full Import Trigger) │ + └─────────────┬────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ importMerchantFeed(merchantId)│ + └─────────────┬────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ readFeedRowsForMerchant() │ + │ - auto-detect delimiter │ + │ - parse CSV/TSV → MerchantFeedRow objects │ + └─────────────────┬──────────────────────────────────────┘ + │ List + ▼ + ┌──────────────────────────────────────┐ + │ For each MerchantFeedRow row: │ + │ resolveBrand() │ + │ upsertProduct() │ + │ - find existing via brand+mpn/upc │ + │ - update fields (mapped partRole) │ + │ upsertOfferFromRow() │ + └──────────────────────────────────────┘ +``` + +--- + +# 2. Full Import Explained + +Triggered by: + +``` +POST /admin/imports/{merchantId} +``` + +### Step 1 — Load merchant +Using `merchantRepository.findById()`. + +### Step 2 — Parse feed rows +`readFeedRowsForMerchant()`: +- Auto-detects delimiter (`\t`, `,`, `;`) +- Validates required headers +- Parses each row into `MerchantFeedRow` + +### Step 3 — Process each row + +For each parsed row: + +#### a. resolveBrand() +- Finds or creates brand +- Defaults to “Aero Precision” if missing + +#### b. upsertProduct() +Dedupes by: + +1. Brand + MPN +2. Brand + UPC (currently SKU placeholder) + +If no match → create new product. + +Then applies: +- Name + slug +- Descriptions +- Images +- MPN/identifiers +- Platform inference +- Category mapping +- Part role inference + +#### c. upsertOfferFromRow() +Creates or updates a ProductOffer: +- Prices +- Stock +- Buy URL +- lastSeenAt +- firstSeenAt when newly created + +Idempotent — does not duplicate offers. + +--- + +# 3. Offer-Only Sync + +Triggered by: + +``` +POST /admin/imports/{merchantId}/offers-only +``` + +Does NOT: +- Create products +- Update product fields + +It only updates: +- price +- originalPrice +- inStock +- buyUrl +- lastSeenAt + +If the offer does not exist, it is skipped. + +--- + +# 4. Auto-Detecting CSV/TSV Parser + +The parser: + +- Attempts multiple delimiters +- Validates headers +- Handles malformed or short rows +- Never throws on missing columns +- Returns clean MerchantFeedRow objects + +Designed for messy merchant feeds. + +--- + +# 5. Entities Updated During Import + +### Product +- name +- slug +- short/long description +- main image +- mpn +- upc (future) +- platform +- rawCategoryKey +- partRole + +### ProductOffer +- merchant +- product +- avantlinkProductId (SKU placeholder) +- price +- originalPrice +- inStock +- buyUrl +- lastSeenAt +- firstSeenAt + +### Merchant +- lastFullImportAt +- lastOfferSyncAt + +--- + +# 6. Extension Points + +You can extend the import pipeline in these areas: + +- Add per-merchant column mapping +- Add true UPC parsing +- Support multi-platform parts +- Improve partRole inference +- Implement global deduplication across merchants + +--- + +# 7. Quick Reference: Main Methods + +| Method | Purpose | +|--------|---------| +| importMerchantFeed | Full product + offer import | +| readFeedRowsForMerchant | Detect delimiter + parse feed | +| resolveBrand | Normalize brand names | +| upsertProduct | Idempotent product write | +| updateProductFromRow | Apply product fields | +| upsertOfferFromRow | Idempotent offer write | +| syncOffersOnly | Offer-only sync | +| upsertOfferOnlyFromRow | Update existing offers | +| detectCsvFormat | Auto-detect delimiter | +| fetchFeedRows | Simpler parser for offers | + +--- + +# 8. Summary + +The Ballistic importer is: + +- Robust against bad data +- Idempotent and safe +- Flexible for multiple merchants +- Extensible for long-term scaling + This pipeline powers the product catalog and offer data for the Ballistic ecosystem. \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java index e86d919..f48d7b3 100644 --- a/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java +++ b/src/main/java/group/goforward/ballistic/configuration/CacheConfig.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.configuration; - -import org.springframework.cache.CacheManager; -import org.springframework.cache.concurrent.ConcurrentMapCacheManager; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class CacheConfig { - - @Bean - public CacheManager cacheManager() { - // Simple in-memory cache for dev/local - return new ConcurrentMapCacheManager("gunbuilderProducts"); - } +package group.goforward.ballistic.configuration; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + // Simple in-memory cache for dev/local + return new ConcurrentMapCacheManager("gunbuilderProducts"); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/configuration/package-info.java b/src/main/java/group/goforward/ballistic/configuration/package-info.java index abe5a7c..34f56da 100644 --- a/src/main/java/group/goforward/ballistic/configuration/package-info.java +++ b/src/main/java/group/goforward/ballistic/configuration/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Configurations for the ballistic -Builder application. - * This package includes Configurations for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Don Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Configurations for the ballistic -Builder application. + * This package includes Configurations for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.configuration; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/Dockerfile b/src/main/java/group/goforward/ballistic/controllers/Dockerfile index 399820d..a4fba4f 100644 --- a/src/main/java/group/goforward/ballistic/controllers/Dockerfile +++ b/src/main/java/group/goforward/ballistic/controllers/Dockerfile @@ -1,17 +1,17 @@ -# Stage 1: Build the application -FROM openjdk:17-jdk-slim as build -WORKDIR /app -COPY gradlew . -COPY settings.gradle . -COPY build.gradle . -COPY src ./src -# Adjust the build command for Maven: ./mvnw package -DskipTests -RUN ./gradlew bootJar - -# Stage 2: Create the final lightweight image -FROM openjdk:17-jre-slim -WORKDIR /app -# Get the built JAR from the build stage -COPY --from=build /app/build/libs/*.jar app.jar -EXPOSE 8080 +# Stage 1: Build the application +FROM openjdk:17-jdk-slim as build +WORKDIR /app +COPY gradlew . +COPY settings.gradle . +COPY build.gradle . +COPY src ./src +# Adjust the build command for Maven: ./mvnw package -DskipTests +RUN ./gradlew bootJar + +# Stage 2: Create the final lightweight image +FROM openjdk:17-jre-slim +WORKDIR /app +# Get the built JAR from the build stage +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ImportController.java b/src/main/java/group/goforward/ballistic/controllers/ImportController.java index 996e460..d251b01 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ImportController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ImportController.java @@ -1,39 +1,39 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.services.MerchantFeedImportService; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/admin/imports") -@CrossOrigin(origins = "http://localhost:3000") -public class ImportController { - - private final MerchantFeedImportService merchantFeedImportService; - - public ImportController(MerchantFeedImportService merchantFeedImportService) { - this.merchantFeedImportService = merchantFeedImportService; - } - - /** - * Full product + offer import for a merchant. - * - * POST /admin/imports/{merchantId} - */ - @PostMapping("/{merchantId}") - public ResponseEntity importMerchant(@PathVariable Integer merchantId) { - merchantFeedImportService.importMerchantFeed(merchantId); - return ResponseEntity.noContent().build(); - } - - /** - * Offers-only sync (price/stock) for a merchant. - * - * POST /admin/imports/{merchantId}/offers-only - */ - @PostMapping("/{merchantId}/offers-only") - public ResponseEntity syncOffersOnly(@PathVariable Integer merchantId) { - merchantFeedImportService.syncOffersOnly(merchantId); - return ResponseEntity.noContent().build(); - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.services.MerchantFeedImportService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/imports") +@CrossOrigin(origins = "http://localhost:3000") +public class ImportController { + + private final MerchantFeedImportService merchantFeedImportService; + + public ImportController(MerchantFeedImportService merchantFeedImportService) { + this.merchantFeedImportService = merchantFeedImportService; + } + + /** + * Full product + offer import for a merchant. + * + * POST /admin/imports/{merchantId} + */ + @PostMapping("/{merchantId}") + public ResponseEntity importMerchant(@PathVariable Integer merchantId) { + merchantFeedImportService.importMerchantFeed(merchantId); + return ResponseEntity.noContent().build(); + } + + /** + * Offers-only sync (price/stock) for a merchant. + * + * POST /admin/imports/{merchantId}/offers-only + */ + @PostMapping("/{merchantId}/offers-only") + public ResponseEntity syncOffersOnly(@PathVariable Integer merchantId) { + merchantFeedImportService.syncOffersOnly(merchantId); + return ResponseEntity.noContent().build(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java index 511ea9b..7aeb3ce 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java @@ -1,63 +1,63 @@ -// MerchantAdminController.java -package group.goforward.ballistic.controllers; - -import org.springframework.web.bind.annotation.CrossOrigin; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.web.dto.MerchantAdminDto; -import org.springframework.web.bind.annotation.*; - -import java.time.OffsetDateTime; -import java.util.List; - -@RestController -@RequestMapping("/admin/merchants") -@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug -public class MerchantAdminController { - - private final MerchantRepository merchantRepository; - - public MerchantAdminController(MerchantRepository merchantRepository) { - this.merchantRepository = merchantRepository; - } - - @GetMapping - public List listMerchants() { - return merchantRepository.findAll().stream().map(this::toDto).toList(); - } - - @PutMapping("/{id}") - public MerchantAdminDto updateMerchant( - @PathVariable Integer id, - @RequestBody MerchantAdminDto payload - ) { - Merchant merchant = merchantRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Merchant not found")); - - merchant.setFeedUrl(payload.getFeedUrl()); - merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); - merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); - // don’t touch last* here; those are set by import jobs - - merchant = merchantRepository.save(merchant); - return toDto(merchant); - } - - private MerchantAdminDto toDto(Merchant m) { - MerchantAdminDto dto = new MerchantAdminDto(); - dto.setId(m.getId()); - dto.setName(m.getName()); - dto.setFeedUrl(m.getFeedUrl()); - dto.setOfferFeedUrl(m.getOfferFeedUrl()); - dto.setIsActive(m.getIsActive()); - dto.setLastFullImportAt(m.getLastFullImportAt()); - dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); - return dto; - } +// MerchantAdminController.java +package group.goforward.ballistic.controllers; + +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.web.dto.MerchantAdminDto; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; + +@RestController +@RequestMapping("/admin/merchants") +@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug +public class MerchantAdminController { + + private final MerchantRepository merchantRepository; + + public MerchantAdminController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMerchants() { + return merchantRepository.findAll().stream().map(this::toDto).toList(); + } + + @PutMapping("/{id}") + public MerchantAdminDto updateMerchant( + @PathVariable Integer id, + @RequestBody MerchantAdminDto payload + ) { + Merchant merchant = merchantRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + merchant.setFeedUrl(payload.getFeedUrl()); + merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); + merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); + // don’t touch last* here; those are set by import jobs + + merchant = merchantRepository.save(merchant); + return toDto(merchant); + } + + private MerchantAdminDto toDto(Merchant m) { + MerchantAdminDto dto = new MerchantAdminDto(); + dto.setId(m.getId()); + dto.setName(m.getName()); + dto.setFeedUrl(m.getFeedUrl()); + dto.setOfferFeedUrl(m.getOfferFeedUrl()); + dto.setIsActive(m.getIsActive()); + dto.setLastFullImportAt(m.getLastFullImportAt()); + dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); + return dto; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java index e2cc758..e801bb0 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantCategoryMappingController.java @@ -1,65 +1,65 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.MerchantCategoryMapping; -import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.services.MerchantCategoryMappingService; -import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; -import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; -import java.util.List; -import java.util.stream.Collectors; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/admin/merchant-category-mappings") -@CrossOrigin -public class MerchantCategoryMappingController { - - private final MerchantCategoryMappingService mappingService; - private final MerchantRepository merchantRepository; - - public MerchantCategoryMappingController( - MerchantCategoryMappingService mappingService, - MerchantRepository merchantRepository - ) { - this.mappingService = mappingService; - this.merchantRepository = merchantRepository; - } - - @GetMapping - public List listMappings( - @RequestParam("merchantId") Integer merchantId - ) { - List mappings = mappingService.findByMerchant(merchantId); - return mappings.stream() - .map(this::toDto) - .collect(Collectors.toList()); - } - - @PostMapping - public MerchantCategoryMappingDto upsertMapping( - @RequestBody UpsertMerchantCategoryMappingRequest request - ) { - Merchant merchant = merchantRepository - .findById(request.getMerchantId()) - .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); - - MerchantCategoryMapping mapping = mappingService.upsertMapping( - merchant, - request.getRawCategory(), - request.getMappedPartRole() - ); - - return toDto(mapping); - } - - private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { - MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); - dto.setId(mapping.getId()); - dto.setMerchantId(mapping.getMerchant().getId()); - dto.setMerchantName(mapping.getMerchant().getName()); - dto.setRawCategory(mapping.getRawCategory()); - dto.setMappedPartRole(mapping.getMappedPartRole()); - return dto; - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.services.MerchantCategoryMappingService; +import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; +import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/admin/merchant-category-mappings") +@CrossOrigin +public class MerchantCategoryMappingController { + + private final MerchantCategoryMappingService mappingService; + private final MerchantRepository merchantRepository; + + public MerchantCategoryMappingController( + MerchantCategoryMappingService mappingService, + MerchantRepository merchantRepository + ) { + this.mappingService = mappingService; + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMappings( + @RequestParam("merchantId") Integer merchantId + ) { + List mappings = mappingService.findByMerchant(merchantId); + return mappings.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @PostMapping + public MerchantCategoryMappingDto upsertMapping( + @RequestBody UpsertMerchantCategoryMappingRequest request + ) { + Merchant merchant = merchantRepository + .findById(request.getMerchantId()) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); + + MerchantCategoryMapping mapping = mappingService.upsertMapping( + merchant, + request.getRawCategory(), + request.getMappedPartRole() + ); + + return toDto(mapping); + } + + private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { + MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); + dto.setId(mapping.getId()); + dto.setMerchantId(mapping.getMerchant().getId()); + dto.setMerchantName(mapping.getMerchant().getName()); + dto.setRawCategory(mapping.getRawCategory()); + dto.setMappedPartRole(mapping.getMappedPartRole()); + return dto; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java index 5d43b71..cd632ab 100644 --- a/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantDebugController.java @@ -1,23 +1,23 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.repos.MerchantRepository; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@RestController -public class MerchantDebugController { - - private final MerchantRepository merchantRepository; - - public MerchantDebugController(MerchantRepository merchantRepository) { - this.merchantRepository = merchantRepository; - } - - @GetMapping("/admin/debug/merchants") - public List listMerchants() { - return merchantRepository.findAll(); - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +public class MerchantDebugController { + + private final MerchantRepository merchantRepository; + + public MerchantDebugController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping("/admin/debug/merchants") + public List listMerchants() { + return merchantRepository.findAll(); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/PingController.java b/src/main/java/group/goforward/ballistic/controllers/PingController.java index 40957c3..224fc94 100644 --- a/src/main/java/group/goforward/ballistic/controllers/PingController.java +++ b/src/main/java/group/goforward/ballistic/controllers/PingController.java @@ -1,13 +1,13 @@ -package group.goforward.ballistic.controllers; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class PingController { - - @GetMapping("/ping") - public String ping() { - return "pong"; - } +package group.goforward.ballistic.controllers; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class PingController { + + @GetMapping("/ping") + public String ping() { + return "pong"; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ProductController.java b/src/main/java/group/goforward/ballistic/controllers/ProductController.java index 61feb24..bca4876 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ProductController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ProductController.java @@ -1,137 +1,137 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.model.ProductOffer; -import group.goforward.ballistic.repos.ProductOfferRepository; -import group.goforward.ballistic.web.dto.ProductOfferDto; -import group.goforward.ballistic.repos.ProductRepository; -import group.goforward.ballistic.web.dto.ProductSummaryDto; -import group.goforward.ballistic.web.mapper.ProductMapper; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.web.bind.annotation.*; - -import java.math.BigDecimal; -import java.util.*; -import java.util.stream.Collectors; - -@RestController -@RequestMapping("/api/products") -@CrossOrigin -public class ProductController { - - private final ProductRepository productRepository; - private final ProductOfferRepository productOfferRepository; - - public ProductController( - ProductRepository productRepository, - ProductOfferRepository productOfferRepository - ) { - this.productRepository = productRepository; - this.productOfferRepository = productOfferRepository; - } - - @GetMapping("/gunbuilder") - @Cacheable( - value = "gunbuilderProducts", - key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" - ) - public List getGunbuilderProducts( - @RequestParam(defaultValue = "AR-15") String platform, - @RequestParam(required = false, name = "partRoles") List partRoles - ) { - long started = System.currentTimeMillis(); - System.out.println("getGunbuilderProducts: start, platform=" + platform + - ", partRoles=" + (partRoles == null ? "null" : partRoles)); - - // 1) Load products (with brand pre-fetched) - long tProductsStart = System.currentTimeMillis(); - List products; - if (partRoles == null || partRoles.isEmpty()) { - products = productRepository.findByPlatformWithBrand(platform); - } else { - products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); - } - long tProductsEnd = System.currentTimeMillis(); - System.out.println("getGunbuilderProducts: loaded products: " + - products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); - - if (products.isEmpty()) { - long took = System.currentTimeMillis() - started; - System.out.println("getGunbuilderProducts: 0 products in " + took + " ms"); - return List.of(); - } - - // 2) Load offers for these product IDs - long tOffersStart = System.currentTimeMillis(); - List productIds = products.stream() - .map(Product::getId) - .toList(); - - List allOffers = - productOfferRepository.findByProductIdIn(productIds); - long tOffersEnd = System.currentTimeMillis(); - System.out.println("getGunbuilderProducts: loaded offers: " + - allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); - - Map> offersByProductId = allOffers.stream() - .collect(Collectors.groupingBy(o -> o.getProduct().getId())); - - // 3) Map to DTOs with price and buyUrl - long tMapStart = System.currentTimeMillis(); - List result = products.stream() - .map(p -> { - List offersForProduct = - offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); - - ProductOffer bestOffer = pickBestOffer(offersForProduct); - - BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; - String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; - - return ProductMapper.toSummary(p, price, buyUrl); - }) - .toList(); - long tMapEnd = System.currentTimeMillis(); - long took = System.currentTimeMillis() - started; - - System.out.println("getGunbuilderProducts: mapping to DTOs took " + - (tMapEnd - tMapStart) + " ms"); - System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" + - "products=" + (tProductsEnd - tProductsStart) + " ms, " + - "offers=" + (tOffersEnd - tOffersStart) + " ms, " + - "map=" + (tMapEnd - tMapStart) + " ms)"); - - return result; - } - - @GetMapping("/{id}/offers") - public List getOffersForProduct(@PathVariable("id") Integer productId) { - List offers = productOfferRepository.findByProductId(productId); - - return offers.stream() - .map(offer -> { - ProductOfferDto dto = new ProductOfferDto(); - dto.setId(offer.getId().toString()); - dto.setMerchantName(offer.getMerchant().getName()); - dto.setPrice(offer.getEffectivePrice()); - dto.setOriginalPrice(offer.getOriginalPrice()); - dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); - dto.setBuyUrl(offer.getBuyUrl()); - dto.setLastUpdated(offer.getLastSeenAt()); - return dto; - }) - .toList(); - } - - private ProductOffer pickBestOffer(List offers) { - if (offers == null || offers.isEmpty()) { - return null; - } - - // Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway) - return offers.stream() - .filter(o -> o.getEffectivePrice() != null) - .min(Comparator.comparing(ProductOffer::getEffectivePrice)) - .orElse(null); - } +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.ProductOffer; +import group.goforward.ballistic.repos.ProductOfferRepository; +import group.goforward.ballistic.web.dto.ProductOfferDto; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.web.dto.ProductSummaryDto; +import group.goforward.ballistic.web.mapper.ProductMapper; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/products") +@CrossOrigin +public class ProductController { + + private final ProductRepository productRepository; + private final ProductOfferRepository productOfferRepository; + + public ProductController( + ProductRepository productRepository, + ProductOfferRepository productOfferRepository + ) { + this.productRepository = productRepository; + this.productOfferRepository = productOfferRepository; + } + + @GetMapping("/gunbuilder") + @Cacheable( + value = "gunbuilderProducts", + key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" + ) + public List getGunbuilderProducts( + @RequestParam(defaultValue = "AR-15") String platform, + @RequestParam(required = false, name = "partRoles") List partRoles + ) { + long started = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: start, platform=" + platform + + ", partRoles=" + (partRoles == null ? "null" : partRoles)); + + // 1) Load products (with brand pre-fetched) + long tProductsStart = System.currentTimeMillis(); + List products; + if (partRoles == null || partRoles.isEmpty()) { + products = productRepository.findByPlatformWithBrand(platform); + } else { + products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); + } + long tProductsEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded products: " + + products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); + + if (products.isEmpty()) { + long took = System.currentTimeMillis() - started; + System.out.println("getGunbuilderProducts: 0 products in " + took + " ms"); + return List.of(); + } + + // 2) Load offers for these product IDs + long tOffersStart = System.currentTimeMillis(); + List productIds = products.stream() + .map(Product::getId) + .toList(); + + List allOffers = + productOfferRepository.findByProductIdIn(productIds); + long tOffersEnd = System.currentTimeMillis(); + System.out.println("getGunbuilderProducts: loaded offers: " + + allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); + + Map> offersByProductId = allOffers.stream() + .collect(Collectors.groupingBy(o -> o.getProduct().getId())); + + // 3) Map to DTOs with price and buyUrl + long tMapStart = System.currentTimeMillis(); + List result = products.stream() + .map(p -> { + List offersForProduct = + offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); + + ProductOffer bestOffer = pickBestOffer(offersForProduct); + + BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; + String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; + + return ProductMapper.toSummary(p, price, buyUrl); + }) + .toList(); + long tMapEnd = System.currentTimeMillis(); + long took = System.currentTimeMillis() - started; + + System.out.println("getGunbuilderProducts: mapping to DTOs took " + + (tMapEnd - tMapStart) + " ms"); + System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" + + "products=" + (tProductsEnd - tProductsStart) + " ms, " + + "offers=" + (tOffersEnd - tOffersStart) + " ms, " + + "map=" + (tMapEnd - tMapStart) + " ms)"); + + return result; + } + + @GetMapping("/{id}/offers") + public List getOffersForProduct(@PathVariable("id") Integer productId) { + List offers = productOfferRepository.findByProductId(productId); + + return offers.stream() + .map(offer -> { + ProductOfferDto dto = new ProductOfferDto(); + dto.setId(offer.getId().toString()); + dto.setMerchantName(offer.getMerchant().getName()); + dto.setPrice(offer.getEffectivePrice()); + dto.setOriginalPrice(offer.getOriginalPrice()); + dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); + dto.setBuyUrl(offer.getBuyUrl()); + dto.setLastUpdated(offer.getLastSeenAt()); + return dto; + }) + .toList(); + } + + private ProductOffer pickBestOffer(List offers) { + if (offers == null || offers.isEmpty()) { + return null; + } + + // Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway) + return offers.stream() + .filter(o -> o.getEffectivePrice() != null) + .min(Comparator.comparing(ProductOffer::getEffectivePrice)) + .orElse(null); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/UserController.java b/src/main/java/group/goforward/ballistic/controllers/UserController.java index b28ecc3..a18fb56 100644 --- a/src/main/java/group/goforward/ballistic/controllers/UserController.java +++ b/src/main/java/group/goforward/ballistic/controllers/UserController.java @@ -1,50 +1,53 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.User; -import group.goforward.ballistic.repos.UserRepository; -import group.goforward.ballistic.services.UsersService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - - -@RestController -@RequestMapping() -public class UserController { - @Autowired - private UserRepository repo; - @Autowired - private UsersService usersService; - - @GetMapping("/api/getAllUsers") - public ResponseEntity> getAllUsers() { - List data = repo.findAll(); - return ResponseEntity.ok(data); - } - - - @GetMapping("/api/getAllUsersById/{id}") - public ResponseEntity getAllStatesById(@PathVariable Integer id) { - return repo.findById(id) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - @PostMapping("/api/addUser") - public ResponseEntity createUser(@RequestBody User item) { - User created = usersService.save(item); - return ResponseEntity.status(HttpStatus.CREATED).body(created); - } - - @DeleteMapping("/api/deleteUser/{id}") - public ResponseEntity deleteItem(@PathVariable Integer id) { - return usersService.findById(id) - .map(item -> { - usersService.deleteById(id); - return ResponseEntity.noContent().build(); - }) - .orElse(ResponseEntity.notFound().build()); - } -} +package group.goforward.ballistic.controllers; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.services.UsersService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping() +public class UserController { + private final UserRepository repo; + private final UsersService usersService; + + public UserController(UserRepository repo, UsersService usersService) { + this.repo = repo; + this.usersService = usersService; + } + + @GetMapping("/api/getAllUsers") + public ResponseEntity> getAllUsers() { + List data = repo.findAll(); + return ResponseEntity.ok(data); + } + + + @GetMapping("/api/getAllUsersById/{id}") + public ResponseEntity getAllStatesById(@PathVariable Integer id) { + return repo.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + @PostMapping("/api/addUser") + public ResponseEntity createUser(@RequestBody User item) { + User created = usersService.save(item); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @DeleteMapping("/api/deleteUser/{id}") + public ResponseEntity deleteItem(@PathVariable Integer id) { + return usersService.findById(id) + .map(item -> { + usersService.deleteById(id); + return ResponseEntity.noContent().build(); + }) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/src/main/java/group/goforward/ballistic/controllers/package-info.java b/src/main/java/group/goforward/ballistic/controllers/package-info.java index 7af4a39..c49339c 100644 --- a/src/main/java/group/goforward/ballistic/controllers/package-info.java +++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application. - * This package includes Controllers for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Don Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application. + * This package includes Controllers for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.controllers; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java index 0cc0c42..4ed5dab 100644 --- a/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java +++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java @@ -1,30 +1,30 @@ -package group.goforward.ballistic.imports; - -import java.math.BigDecimal; - -public record MerchantFeedRow( - String sku, - String manufacturerId, - String brandName, - String productName, - String longDescription, - String shortDescription, - String department, - String category, - String subCategory, - String thumbUrl, - String imageUrl, - String buyLink, - String keywords, - String reviews, - BigDecimal retailPrice, - BigDecimal salePrice, - String brandPageLink, - String brandLogoImage, - String productPageViewTracking, - String variantsXml, - String mediumImageUrl, - String productContentWidget, - String googleCategorization, - String itemBasedCommission +package group.goforward.ballistic.imports; + +import java.math.BigDecimal; + +public record MerchantFeedRow( + String sku, + String manufacturerId, + String brandName, + String productName, + String longDescription, + String shortDescription, + String department, + String category, + String subCategory, + String thumbUrl, + String imageUrl, + String buyLink, + String keywords, + String reviews, + BigDecimal retailPrice, + BigDecimal salePrice, + String brandPageLink, + String brandLogoImage, + String productPageViewTracking, + String variantsXml, + String mediumImageUrl, + String productContentWidget, + String googleCategorization, + String itemBasedCommission ) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java index 516053f..dfa6eb1 100644 --- a/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java +++ b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.imports.dto; - -import java.math.BigDecimal; - -public record MerchantFeedRow( - String brandName, - String productName, - String mpn, - String upc, - String avantlinkProductId, - String sku, - String categoryPath, - String buyUrl, - BigDecimal price, - BigDecimal originalPrice, - boolean inStock +package group.goforward.ballistic.imports.dto; + +import java.math.BigDecimal; + +public record MerchantFeedRow( + String brandName, + String productName, + String mpn, + String upc, + String avantlinkProductId, + String sku, + String categoryPath, + String buyUrl, + BigDecimal price, + BigDecimal originalPrice, + boolean inStock ) {} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java index 35c6703..586776b 100644 --- a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java +++ b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application. - * This package includes DTO for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Sean Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application. + * This package includes DTO for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Sean Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.imports.dto; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java index 9bb833c..3b2338a 100644 --- a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java +++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java @@ -1,105 +1,105 @@ -package group.goforward.ballistic.model; - -import jakarta.persistence.*; -import java.time.OffsetDateTime; - -import group.goforward.ballistic.model.ProductConfiguration; - -@Entity -@Table( - name = "merchant_category_mappings", - uniqueConstraints = @UniqueConstraint( - name = "uq_merchant_category", - columnNames = { "merchant_id", "raw_category" } - ) -) -public class MerchantCategoryMapping { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL - @Column(name = "id", nullable = false) - private Integer id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "merchant_id", nullable = false) - private Merchant merchant; - - @Column(name = "raw_category", nullable = false, length = 512) - private String rawCategory; - - @Column(name = "mapped_part_role", length = 128) - private String mappedPartRole; // e.g. "upper-receiver", "barrel" - - @Column(name = "mapped_configuration") - @Enumerated(EnumType.STRING) - private ProductConfiguration mappedConfiguration; - - @Column(name = "created_at", nullable = false) - private OffsetDateTime createdAt = OffsetDateTime.now(); - - @Column(name = "updated_at", nullable = false) - private OffsetDateTime updatedAt = OffsetDateTime.now(); - - @PreUpdate - public void onUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - - // getters & setters - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public Merchant getMerchant() { - return merchant; - } - - public void setMerchant(Merchant merchant) { - this.merchant = merchant; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - public String getMappedPartRole() { - return mappedPartRole; - } - - public void setMappedPartRole(String mappedPartRole) { - this.mappedPartRole = mappedPartRole; - } - - public ProductConfiguration getMappedConfiguration() { - return mappedConfiguration; - } - - public void setMappedConfiguration(ProductConfiguration mappedConfiguration) { - this.mappedConfiguration = mappedConfiguration; - } - - public OffsetDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(OffsetDateTime createdAt) { - this.createdAt = createdAt; - } - - public OffsetDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(OffsetDateTime updatedAt) { - this.updatedAt = updatedAt; - } +package group.goforward.ballistic.model; + +import jakarta.persistence.*; +import java.time.OffsetDateTime; + +import group.goforward.ballistic.model.ProductConfiguration; + +@Entity +@Table( + name = "merchant_category_mappings", + uniqueConstraints = @UniqueConstraint( + name = "uq_merchant_category", + columnNames = { "merchant_id", "raw_category" } + ) +) +public class MerchantCategoryMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL + @Column(name = "id", nullable = false) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Column(name = "raw_category", nullable = false, length = 512) + private String rawCategory; + + @Column(name = "mapped_part_role", length = 128) + private String mappedPartRole; // e.g. "upper-receiver", "barrel" + + @Column(name = "mapped_configuration") + @Enumerated(EnumType.STRING) + private ProductConfiguration mappedConfiguration; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt = OffsetDateTime.now(); + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt = OffsetDateTime.now(); + + @PreUpdate + public void onUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + + // getters & setters + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Merchant getMerchant() { + return merchant; + } + + public void setMerchant(Merchant merchant) { + this.merchant = merchant; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } + + public ProductConfiguration getMappedConfiguration() { + return mappedConfiguration; + } + + public void setMappedConfiguration(ProductConfiguration mappedConfiguration) { + this.mappedConfiguration = mappedConfiguration; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java index 7bda4e9..2c8c7d8 100644 --- a/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java +++ b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java @@ -1,10 +1,10 @@ -package group.goforward.ballistic.model; - -public enum ProductConfiguration { - STRIPPED, // bare receiver / component - ASSEMBLED, // built up but not fully complete - BARRELED, // upper + barrel + gas system, no BCG/CH - COMPLETE, // full assembly ready to run - KIT, // collection of parts (LPK, trigger kits, etc.) - OTHER // fallback / unknown +package group.goforward.ballistic.model; + +public enum ProductConfiguration { + STRIPPED, // bare receiver / component + ASSEMBLED, // built up but not fully complete + BARRELED, // upper + barrel + gas system, no BCG/CH + COMPLETE, // full assembly ready to run + KIT, // collection of parts (LPK, trigger kits, etc.) + OTHER // fallback / unknown } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/AccountRepository.java b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java index 1c8702f..dcf38ae 100644 --- a/src/main/java/group/goforward/ballistic/repos/AccountRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java @@ -1,9 +1,9 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Account; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.UUID; - -public interface AccountRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Account; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface AccountRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BrandRepository.java b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java index 4235a7f..58b63cc 100644 --- a/src/main/java/group/goforward/ballistic/repos/BrandRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java @@ -1,8 +1,8 @@ -package group.goforward.ballistic.repos; -import group.goforward.ballistic.model.Brand; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - -public interface BrandRepository extends JpaRepository { - Optional findByNameIgnoreCase(String name); +package group.goforward.ballistic.repos; +import group.goforward.ballistic.model.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface BrandRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java index 0856d01..bd9c97f 100644 --- a/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java @@ -1,12 +1,12 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.BuildsComponent; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface BuildItemRepository extends JpaRepository { - List findByBuildId(Integer buildId); - Optional findByUuid(UUID uuid); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.BuildsComponent; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BuildItemRepository extends JpaRepository { + List findByBuildId(Integer buildId); + Optional findByUuid(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/BuildRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java index 1270fe2..a849ed8 100644 --- a/src/main/java/group/goforward/ballistic/repos/BuildRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java @@ -1,10 +1,10 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Build; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; -import java.util.UUID; - -public interface BuildRepository extends JpaRepository { - Optional findByUuid(UUID uuid); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Build; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface BuildRepository extends JpaRepository { + Optional findByUuid(UUID uuid); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java index cbaa5ee..cea3afc 100644 --- a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java @@ -1,7 +1,7 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.AffiliateCategoryMap; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CategoryMappingRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.AffiliateCategoryMap; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CategoryMappingRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java index eafaeaf..90adae7 100644 --- a/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java @@ -1,7 +1,7 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.FeedImport; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FeedImportRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.FeedImport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FeedImportRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java index bddeed5..f26eca3 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.MerchantCategoryMapping; -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface MerchantCategoryMappingRepository - extends JpaRepository { - - Optional findByMerchantIdAndRawCategoryIgnoreCase( - Integer merchantId, - String rawCategory - ); - - List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.MerchantCategoryMapping; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MerchantCategoryMappingRepository + extends JpaRepository { + + Optional findByMerchantIdAndRawCategoryIgnoreCase( + Integer merchantId, + String rawCategory + ); + + List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java index 23baf5f..853687f 100644 --- a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java @@ -1,11 +1,11 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Merchant; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface MerchantRepository extends JpaRepository { - - Optional findByNameIgnoreCase(String name); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Merchant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MerchantRepository extends JpaRepository { + + Optional findByNameIgnoreCase(String name); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java index 32e41af..78e2c2e 100644 --- a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java @@ -1,9 +1,9 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.PartCategory; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - -public interface PartCategoryRepository extends JpaRepository { - Optional findBySlug(String slug); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PartCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface PartCategoryRepository extends JpaRepository { + Optional findBySlug(String slug); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java index ec87f45..2a8423b 100644 --- a/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java @@ -1,7 +1,7 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.PriceHistory; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PriceHistoryRepository extends JpaRepository { +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.PriceHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PriceHistoryRepository extends JpaRepository { } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java index caaa372..6178413 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java @@ -1,22 +1,22 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.ProductOffer; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface ProductOfferRepository extends JpaRepository { - - List findByProductId(Integer productId); - - // Used by the /api/products/gunbuilder endpoint - List findByProductIdIn(Collection productIds); - - // Unique offer lookup for importer upsert - Optional findByMerchantIdAndAvantlinkProductId( - Integer merchantId, - String avantlinkProductId - ); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.ProductOffer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductOfferRepository extends JpaRepository { + + List findByProductId(Integer productId); + + // Used by the /api/products/gunbuilder endpoint + List findByProductIdIn(Collection productIds); + + // Unique offer lookup for importer upsert + Optional findByMerchantIdAndAvantlinkProductId( + Integer merchantId, + String avantlinkProductId + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java index ff601f1..179f1a6 100644 --- a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java +++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java @@ -1,53 +1,53 @@ -package group.goforward.ballistic.repos; - -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.model.Brand; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.Optional; -import java.util.UUID; -import java.util.List; -import java.util.Collection; - -public interface ProductRepository extends JpaRepository { - - Optional findByUuid(UUID uuid); - - boolean existsBySlug(String slug); - - List findAllByBrandAndMpn(Brand brand, String mpn); - - List findAllByBrandAndUpc(Brand brand, String upc); - - // All products for a given platform (e.g. "AR-15") - List findByPlatform(String platform); - - // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) - List findByPlatformAndPartRoleIn(String platform, Collection partRoles); - - // ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.deletedAt IS NULL - """) - List findByPlatformWithBrand(@Param("platform") String platform); - - @Query(""" - SELECT p - FROM Product p - JOIN FETCH p.brand b - WHERE p.platform = :platform - AND p.partRole IN :partRoles - AND p.deletedAt IS NULL - """) - List findByPlatformAndPartRoleInWithBrand( - @Param("platform") String platform, - @Param("partRoles") Collection partRoles - ); +package group.goforward.ballistic.repos; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.model.Brand; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import java.util.UUID; +import java.util.List; +import java.util.Collection; + +public interface ProductRepository extends JpaRepository { + + Optional findByUuid(UUID uuid); + + boolean existsBySlug(String slug); + + List findAllByBrandAndMpn(Brand brand, String mpn); + + List findAllByBrandAndUpc(Brand brand, String upc); + + // All products for a given platform (e.g. "AR-15") + List findByPlatform(String platform); + + // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) + List findByPlatformAndPartRoleIn(String platform, Collection partRoles); + + // ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.deletedAt IS NULL + """) + List findByPlatformWithBrand(@Param("platform") String platform); + + @Query(""" + SELECT p + FROM Product p + JOIN FETCH p.brand b + WHERE p.platform = :platform + AND p.partRole IN :partRoles + AND p.deletedAt IS NULL + """) + List findByPlatformAndPartRoleInWithBrand( + @Param("platform") String platform, + @Param("partRoles") Collection partRoles + ); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/repos/package-info.java b/src/main/java/group/goforward/ballistic/repos/package-info.java index 570163b..c278bd2 100644 --- a/src/main/java/group/goforward/ballistic/repos/package-info.java +++ b/src/main/java/group/goforward/ballistic/repos/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Repository for the ballistic -Builder application. - * This package includes Repository for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Sean Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Repository for the ballistic -Builder application. + * This package includes Repository for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Sean Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.repos; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java index 06e808c..ec963df 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java @@ -1,96 +1,96 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.MerchantCategoryMapping; -import group.goforward.ballistic.model.ProductConfiguration; -import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; -import jakarta.transaction.Transactional; -import java.util.List; -import java.util.Optional; -import org.springframework.stereotype.Service; - -@Service -public class MerchantCategoryMappingService { - - private final MerchantCategoryMappingRepository mappingRepository; - - public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { - this.mappingRepository = mappingRepository; - } - - public List findByMerchant(Integer merchantId) { - return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); - } - - /** - * Resolve (or create) a mapping row for this merchant + raw category. - * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). - * - If it doesn't exist, creates a placeholder row with null mappings and returns it. - * - * The importer can then: - * - skip rows where mappedPartRole is still null - * - use mappedConfiguration if present - */ - @Transactional - public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { - if (rawCategory == null || rawCategory.isBlank()) { - return null; - } - - String trimmed = rawCategory.trim(); - - return mappingRepository - .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) - .orElseGet(() -> { - MerchantCategoryMapping mapping = new MerchantCategoryMapping(); - mapping.setMerchant(merchant); - mapping.setRawCategory(trimmed); - mapping.setMappedPartRole(null); - mapping.setMappedConfiguration(null); - return mappingRepository.save(mapping); - }); - } - - /** - * Upsert mapping (admin UI). - */ - @Transactional - public MerchantCategoryMapping upsertMapping( - Merchant merchant, - String rawCategory, - String mappedPartRole, - ProductConfiguration mappedConfiguration - ) { - String trimmed = rawCategory.trim(); - - MerchantCategoryMapping mapping = mappingRepository - .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) - .orElseGet(() -> { - MerchantCategoryMapping m = new MerchantCategoryMapping(); - m.setMerchant(merchant); - m.setRawCategory(trimmed); - return m; - }); - - mapping.setMappedPartRole( - (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() - ); - - mapping.setMappedConfiguration(mappedConfiguration); - - return mappingRepository.save(mapping); - } - /** - * Backwards-compatible overload for existing callers (e.g. controller) - * that don’t care about productConfiguration yet. - */ - @Transactional - public MerchantCategoryMapping upsertMapping( - Merchant merchant, - String rawCategory, - String mappedPartRole - ) { - // Delegate to the new method with `null` configuration - return upsertMapping(merchant, rawCategory, mappedPartRole, null); - } +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import group.goforward.ballistic.model.ProductConfiguration; +import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; +import jakarta.transaction.Transactional; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; + +@Service +public class MerchantCategoryMappingService { + + private final MerchantCategoryMappingRepository mappingRepository; + + public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { + this.mappingRepository = mappingRepository; + } + + public List findByMerchant(Integer merchantId) { + return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); + } + + /** + * Resolve (or create) a mapping row for this merchant + raw category. + * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). + * - If it doesn't exist, creates a placeholder row with null mappings and returns it. + * + * The importer can then: + * - skip rows where mappedPartRole is still null + * - use mappedConfiguration if present + */ + @Transactional + public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { + if (rawCategory == null || rawCategory.isBlank()) { + return null; + } + + String trimmed = rawCategory.trim(); + + return mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping mapping = new MerchantCategoryMapping(); + mapping.setMerchant(merchant); + mapping.setRawCategory(trimmed); + mapping.setMappedPartRole(null); + mapping.setMappedConfiguration(null); + return mappingRepository.save(mapping); + }); + } + + /** + * Upsert mapping (admin UI). + */ + @Transactional + public MerchantCategoryMapping upsertMapping( + Merchant merchant, + String rawCategory, + String mappedPartRole, + ProductConfiguration mappedConfiguration + ) { + String trimmed = rawCategory.trim(); + + MerchantCategoryMapping mapping = mappingRepository + .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) + .orElseGet(() -> { + MerchantCategoryMapping m = new MerchantCategoryMapping(); + m.setMerchant(merchant); + m.setRawCategory(trimmed); + return m; + }); + + mapping.setMappedPartRole( + (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() + ); + + mapping.setMappedConfiguration(mappedConfiguration); + + return mappingRepository.save(mapping); + } + /** + * Backwards-compatible overload for existing callers (e.g. controller) + * that don’t care about productConfiguration yet. + */ + @Transactional + public MerchantCategoryMapping upsertMapping( + Merchant merchant, + String rawCategory, + String mappedPartRole + ) { + // Delegate to the new method with `null` configuration + return upsertMapping(merchant, rawCategory, mappedPartRole, null); + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java index 5fea407..399c448 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java @@ -1,14 +1,14 @@ -package group.goforward.ballistic.services; - -public interface MerchantFeedImportService { - - /** - * Full product + offer import for a given merchant. - */ - void importMerchantFeed(Integer merchantId); - - /** - * Offers-only sync (price / stock) for a given merchant. - */ - void syncOffersOnly(Integer merchantId); +package group.goforward.ballistic.services; + +public interface MerchantFeedImportService { + + /** + * Full product + offer import for a given merchant. + */ + void importMerchantFeed(Integer merchantId); + + /** + * Offers-only sync (price / stock) for a given merchant. + */ + void syncOffersOnly(Integer merchantId); } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/PsaService.java b/src/main/java/group/goforward/ballistic/services/PsaService.java index 337d278..ecaa265 100644 --- a/src/main/java/group/goforward/ballistic/services/PsaService.java +++ b/src/main/java/group/goforward/ballistic/services/PsaService.java @@ -1,17 +1,17 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.Psa; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface PsaService { - List findAll(); - - Optional findById(UUID id); - - Psa save(Psa psa); - - void deleteById(UUID id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.Psa; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PsaService { + List findAll(); + + Optional findById(UUID id); + + Psa save(Psa psa); + + void deleteById(UUID id); +} diff --git a/src/main/java/group/goforward/ballistic/services/StatesService.java b/src/main/java/group/goforward/ballistic/services/StatesService.java index a8d74c1..e07d927 100644 --- a/src/main/java/group/goforward/ballistic/services/StatesService.java +++ b/src/main/java/group/goforward/ballistic/services/StatesService.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.State; - -import java.util.List; -import java.util.Optional; - -public interface StatesService { - - List findAll(); - - Optional findById(Integer id); - - State save(State item); - void deleteById(Integer id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.State; + +import java.util.List; +import java.util.Optional; + +public interface StatesService { + + List findAll(); + + Optional findById(Integer id); + + State save(State item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/services/UsersService.java b/src/main/java/group/goforward/ballistic/services/UsersService.java index 59ebe13..3717947 100644 --- a/src/main/java/group/goforward/ballistic/services/UsersService.java +++ b/src/main/java/group/goforward/ballistic/services/UsersService.java @@ -1,16 +1,16 @@ -package group.goforward.ballistic.services; - -import group.goforward.ballistic.model.User; - -import java.util.List; -import java.util.Optional; - -public interface UsersService { - - List findAll(); - - Optional findById(Integer id); - - User save(User item); - void deleteById(Integer id); -} +package group.goforward.ballistic.services; + +import group.goforward.ballistic.model.User; + +import java.util.List; +import java.util.Optional; + +public interface UsersService { + + List findAll(); + + Optional findById(Integer id); + + User save(User item); + void deleteById(Integer id); +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index c4440e0..c2357a4 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -1,660 +1,660 @@ -package group.goforward.ballistic.services.impl; - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.HashMap; -import java.io.Reader; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import org.springframework.cache.annotation.CacheEvict; - -import group.goforward.ballistic.imports.MerchantFeedRow; -import group.goforward.ballistic.services.MerchantFeedImportService; -import org.apache.commons.csv.CSVFormat; -import org.apache.commons.csv.CSVParser; -import org.apache.commons.csv.CSVRecord; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import group.goforward.ballistic.model.Brand; -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.repos.BrandRepository; -import group.goforward.ballistic.repos.MerchantRepository; -import group.goforward.ballistic.repos.ProductRepository; -import group.goforward.ballistic.services.MerchantCategoryMappingService; -import group.goforward.ballistic.model.MerchantCategoryMapping; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import group.goforward.ballistic.repos.ProductOfferRepository; -import group.goforward.ballistic.model.ProductOffer; - -import java.time.OffsetDateTime; - -@Service -@Transactional -public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { - private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); - - private final MerchantRepository merchantRepository; - private final BrandRepository brandRepository; - private final ProductRepository productRepository; - private final MerchantCategoryMappingService merchantCategoryMappingService; - private final ProductOfferRepository productOfferRepository; - - public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, - BrandRepository brandRepository, - ProductRepository productRepository, - MerchantCategoryMappingService merchantCategoryMappingService, - ProductOfferRepository productOfferRepository) { - this.merchantRepository = merchantRepository; - this.brandRepository = brandRepository; - this.productRepository = productRepository; - this.merchantCategoryMappingService = merchantCategoryMappingService; - this.productOfferRepository = productOfferRepository; - } - - @Override - @CacheEvict(value = "gunbuilderProducts", allEntries = true) - public void importMerchantFeed(Integer merchantId) { - log.info("Starting full import for merchantId={}", merchantId); - - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); - - // Read all rows from the merchant feed - List rows = readFeedRowsForMerchant(merchant); - log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); - - for (MerchantFeedRow row : rows) { - Brand brand = resolveBrand(row); - Product p = upsertProduct(merchant, brand, row); - log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}", - p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); - } - } - - // --------------------------------------------------------------------- - // Upsert logic - // --------------------------------------------------------------------- - - private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { - log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName()); - - String mpn = trimOrNull(row.manufacturerId()); - String upc = trimOrNull(row.sku()); // placeholder until real UPC field - - List candidates = Collections.emptyList(); - - if (mpn != null) { - candidates = productRepository.findAllByBrandAndMpn(brand, mpn); - } - if ((candidates == null || candidates.isEmpty()) && upc != null) { - candidates = productRepository.findAllByBrandAndUpc(brand, upc); - } - - Product p; - boolean isNew = (candidates == null || candidates.isEmpty()); - - if (isNew) { - p = new Product(); - p.setBrand(brand); - } else { - if (candidates.size() > 1) { - log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}", - brand.getName(), mpn, upc, candidates.get(0).getId()); - } - p = candidates.get(0); - } - - updateProductFromRow(p, merchant, row, isNew); - - // Save the product first - Product saved = productRepository.save(p); - - // Then upsert the offer for this row - upsertOfferFromRow(saved, merchant, row); - - return saved; - } - private List> fetchFeedRows(String feedUrl) { - log.info("Reading offer feed from {}", feedUrl); - - List> rows = new ArrayList<>(); - - try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) - ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) - : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); - CSVParser parser = CSVFormat.DEFAULT - .withFirstRecordAsHeader() - .withIgnoreSurroundingSpaces() - .withTrim() - .parse(reader)) { - - // capture header names from the CSV - List headers = new ArrayList<>(parser.getHeaderMap().keySet()); - - for (CSVRecord rec : parser) { - Map row = new HashMap<>(); - for (String header : headers) { - row.put(header, rec.get(header)); - } - rows.add(row); - } - } catch (Exception ex) { - throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); - } - - log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); - return rows; - } - - private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { - // ---------- NAME ---------- - String name = coalesce( - trimOrNull(row.productName()), - trimOrNull(row.shortDescription()), - trimOrNull(row.longDescription()), - trimOrNull(row.sku()) - ); - if (name == null) { - name = "Unknown Product"; - } - p.setName(name); - - // ---------- SLUG ---------- - if (isNew || p.getSlug() == null || p.getSlug().isBlank()) { - String baseForSlug = coalesce( - trimOrNull(name), - trimOrNull(row.sku()) - ); - if (baseForSlug == null) { - baseForSlug = "product-" + System.currentTimeMillis(); - } - - String slug = baseForSlug - .toLowerCase() - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("(^-|-$)", ""); - if (slug.isBlank()) { - slug = "product-" + System.currentTimeMillis(); - } - - String uniqueSlug = generateUniqueSlug(slug); - p.setSlug(uniqueSlug); - } - - // ---------- DESCRIPTIONS ---------- - p.setShortDescription(trimOrNull(row.shortDescription())); - p.setDescription(trimOrNull(row.longDescription())); - - // ---------- IMAGE ---------- - String mainImage = coalesce( - trimOrNull(row.imageUrl()), - trimOrNull(row.mediumImageUrl()), - trimOrNull(row.thumbUrl()) - ); - p.setMainImageUrl(mainImage); - - // ---------- IDENTIFIERS ---------- - String mpn = coalesce( - trimOrNull(row.manufacturerId()), - trimOrNull(row.sku()) - ); - p.setMpn(mpn); - - // UPC placeholder - p.setUpc(null); - - // ---------- PLATFORM ---------- - if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { - String platform = inferPlatform(row); - p.setPlatform(platform != null ? platform : "AR-15"); - } - - // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- - String rawCategoryKey = buildRawCategoryKey(row); - p.setRawCategoryKey(rawCategoryKey); - - // ---------- PART ROLE (via category mapping, with keyword fallback) ---------- - String partRole = null; - - if (rawCategoryKey != null) { - // Ask the mapping service for (or to create) a mapping row - MerchantCategoryMapping mapping = - merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); - - if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) { - partRole = mapping.getMappedPartRole().trim(); - } - } - - // Fallback: keyword-based inference if we still don't have a mapped partRole - if (partRole == null || partRole.isBlank()) { - partRole = inferPartRole(row); - } - - if (partRole == null || partRole.isBlank()) { - partRole = "unknown"; - } - - p.setPartRole(partRole); - } - private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) { - // For now, we’ll use SKU as the "avantlinkProductId" placeholder. - // If/when you have a real AvantLink product_id in the feed, switch to that. - String avantlinkProductId = trimOrNull(row.sku()); - if (avantlinkProductId == null) { - // If there's truly no SKU, bail out – we can't match this offer reliably. - log.debug("Skipping offer row with no SKU for product id={}", product.getId()); - return; - } - - // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id - ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) - .orElseGet(ProductOffer::new); - - // If this is a brand‑new offer, initialize key fields - if (offer.getId() == null) { - offer.setMerchant(merchant); - offer.setProduct(product); - offer.setAvantlinkProductId(avantlinkProductId); - offer.setFirstSeenAt(OffsetDateTime.now()); - } else { - // Make sure associations stay in sync if anything changed - offer.setMerchant(merchant); - offer.setProduct(product); - } - - // Identifiers - offer.setSku(trimOrNull(row.sku())); - // No real UPC in this feed yet – leave null for now - offer.setUpc(null); - - // Buy URL - offer.setBuyUrl(trimOrNull(row.buyLink())); - - // Prices from feed - BigDecimal retail = row.retailPrice(); // parsed as BigDecimal in readFeedRowsForMerchant - BigDecimal sale = row.salePrice(); - - BigDecimal effectivePrice; - BigDecimal originalPrice; - - // Prefer sale price if it exists and is less than or equal to retail - if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { - effectivePrice = sale; - originalPrice = (retail != null ? retail : sale); - } else { - // Otherwise fall back to retail or whatever is present - effectivePrice = (retail != null ? retail : sale); - originalPrice = (retail != null ? retail : sale); - } - - offer.setPrice(effectivePrice); - offer.setOriginalPrice(originalPrice); - - // Currency + stock - offer.setCurrency("USD"); - // We don't have a real stock flag in this CSV, so assume in-stock for now - offer.setInStock(Boolean.TRUE); - - // Update "last seen" on every import pass - offer.setLastSeenAt(OffsetDateTime.now()); - - productOfferRepository.save(offer); - } - - - // --------------------------------------------------------------------- - // Feed reading + brand resolution - // --------------------------------------------------------------------- - - /** - * Open a Reader for either an HTTP(S) URL or a local file path. - */ - private Reader openFeedReader(String feedUrl) throws java.io.IOException { - if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) { - return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8); - } else { - return java.nio.file.Files.newBufferedReader( - java.nio.file.Paths.get(feedUrl), - StandardCharsets.UTF_8 - ); - } - } - - /** - * Try a few common delimiters (tab, comma, semicolon) and pick the one - * that yields the expected AvantLink-style header set. - */ - private CSVFormat detectCsvFormat(String feedUrl) throws Exception { - char[] delimiters = new char[]{'\t', ',', ';'}; - java.util.List requiredHeaders = - java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); - - Exception lastException = null; - - for (char delimiter : delimiters) { - try (Reader reader = openFeedReader(feedUrl); - CSVParser parser = CSVFormat.DEFAULT.builder() - .setDelimiter(delimiter) - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build() - .parse(reader)) { - - Map headerMap = parser.getHeaderMap(); - if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { - log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl); - - return CSVFormat.DEFAULT.builder() - .setDelimiter(delimiter) - .setHeader() - .setSkipHeaderRecord(true) - .setIgnoreSurroundingSpaces(true) - .setTrim(true) - .build(); - } else if (headerMap != null) { - log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl); - } - } catch (Exception ex) { - lastException = ex; - log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); - } - } - - if (lastException != null) { - throw lastException; - } - throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl); - } - - private List readFeedRowsForMerchant(Merchant merchant) { - String rawFeedUrl = merchant.getFeedUrl(); - if (rawFeedUrl == null || rawFeedUrl.isBlank()) { - throw new IllegalStateException("Merchant " + merchant.getName() + " has no feed_url configured"); - } - - String feedUrl = rawFeedUrl.trim(); - log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl); - - List rows = new ArrayList<>(); - - try { - // Auto-detect delimiter (TSV/CSV/semicolon) based on header row - CSVFormat format = detectCsvFormat(feedUrl); - - try (Reader reader = openFeedReader(feedUrl); - CSVParser parser = new CSVParser(reader, format)) { - - log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet()); - - for (CSVRecord rec : parser) { - MerchantFeedRow row = new MerchantFeedRow( - getCsvValue(rec, "SKU"), - getCsvValue(rec, "Manufacturer Id"), - getCsvValue(rec, "Brand Name"), - getCsvValue(rec, "Product Name"), - getCsvValue(rec, "Long Description"), - getCsvValue(rec, "Short Description"), - getCsvValue(rec, "Department"), - getCsvValue(rec, "Category"), - getCsvValue(rec, "SubCategory"), - getCsvValue(rec, "Thumb URL"), - getCsvValue(rec, "Image URL"), - getCsvValue(rec, "Buy Link"), - getCsvValue(rec, "Keywords"), - getCsvValue(rec, "Reviews"), - parseBigDecimal(getCsvValue(rec, "Retail Price")), - parseBigDecimal(getCsvValue(rec, "Sale Price")), - getCsvValue(rec, "Brand Page Link"), - getCsvValue(rec, "Brand Logo Image"), - getCsvValue(rec, "Product Page View Tracking"), - null, - getCsvValue(rec, "Medium Image URL"), - getCsvValue(rec, "Product Content Widget"), - getCsvValue(rec, "Google Categorization"), - getCsvValue(rec, "Item Based Commission") - ); - - rows.add(row); - } - } - } catch (Exception ex) { - throw new RuntimeException("Failed to read feed for merchant " - + merchant.getName() + " from " + feedUrl, ex); - } - - log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName()); - return rows; - } - - private Brand resolveBrand(MerchantFeedRow row) { - String rawBrand = trimOrNull(row.brandName()); - final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision"; - - return brandRepository.findByNameIgnoreCase(brandName) - .orElseGet(() -> { - Brand b = new Brand(); - b.setName(brandName); - return brandRepository.save(b); - }); - } - - private String getCol(String[] cols, int index) { - return (index >= 0 && index < cols.length) ? cols[index] : null; - } - - private BigDecimal parseBigDecimal(String raw) { - if (raw == null) return null; - String trimmed = raw.trim(); - if (trimmed.isEmpty()) return null; - try { - return new BigDecimal(trimmed); - } catch (NumberFormatException ex) { - log.debug("Skipping invalid numeric value '{}'", raw); - return null; - } - } - - /** - * Safely get a column value by header name. If the record is "short" - * (fewer values than headers) or the header is missing, return null - * instead of throwing IllegalArgumentException. - */ - private String getCsvValue(CSVRecord rec, String header) { - if (rec == null || header == null) { - return null; - } - if (!rec.isMapped(header)) { - // Header not present at all - return null; - } - try { - return rec.get(header); - } catch (IllegalArgumentException ex) { - log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header); - return null; - } - } - - // --------------------------------------------------------------------- - // Misc helpers - // --------------------------------------------------------------------- - - private String trimOrNull(String value) { - if (value == null) return null; - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - private String coalesce(String... values) { - if (values == null) return null; - for (String v : values) { - if (v != null && !v.isBlank()) { - return v; - } - } - return null; - } - - private String generateUniqueSlug(String baseSlug) { - String candidate = baseSlug; - int suffix = 1; - while (productRepository.existsBySlug(candidate)) { - candidate = baseSlug + "-" + suffix; - suffix++; - } - return candidate; - } - - private String buildRawCategoryKey(MerchantFeedRow row) { - String dept = trimOrNull(row.department()); - String cat = trimOrNull(row.category()); - String sub = trimOrNull(row.subCategory()); - - java.util.List parts = new java.util.ArrayList<>(); - if (dept != null) parts.add(dept); - if (cat != null) parts.add(cat); - if (sub != null) parts.add(sub); - - if (parts.isEmpty()) { - return null; - } - - return String.join(" > ", parts); - } - - private String inferPlatform(MerchantFeedRow row) { - String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); - if (department == null) return null; - - String lower = department.toLowerCase(); - if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15"; - if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; - if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; - - return "AR-15"; - } - - private String inferPartRole(MerchantFeedRow row) { - String cat = coalesce( - trimOrNull(row.subCategory()), - trimOrNull(row.category()) - ); - if (cat == null) return null; - - String lower = cat.toLowerCase(); - - if (lower.contains("handguard") || lower.contains("rail")) { - return "handguard"; - } - if (lower.contains("barrel")) { - return "barrel"; - } - if (lower.contains("upper")) { - return "upper-receiver"; - } - if (lower.contains("lower")) { - return "lower-receiver"; - } - if (lower.contains("magazine") || lower.contains("mag")) { - return "magazine"; - } - if (lower.contains("stock") || lower.contains("buttstock")) { - return "stock"; - } - if (lower.contains("grip")) { - return "grip"; - } - - return "unknown"; - } - @CacheEvict(value = "gunbuilderProducts", allEntries = true) - public void syncOffersOnly(Integer merchantId) { - log.info("Starting offers-only sync for merchantId={}", merchantId); - Merchant merchant = merchantRepository.findById(merchantId) - .orElseThrow(() -> new RuntimeException("Merchant not found")); - - if (Boolean.FALSE.equals(merchant.getIsActive())) { - return; - } - - String feedUrl = merchant.getOfferFeedUrl() != null - ? merchant.getOfferFeedUrl() - : merchant.getFeedUrl(); - - if (feedUrl == null) { - throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); - } - - List> rows = fetchFeedRows(feedUrl); - - for (Map row : rows) { - upsertOfferOnlyFromRow(merchant, row); - } - - merchant.setLastOfferSyncAt(OffsetDateTime.now()); - merchantRepository.save(merchant); - log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); - } - - private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { - // For the offer-only sync, we key offers by the same identifier we used when creating them. - // In the current AvantLink-style feed, that is the SKU column. - String avantlinkProductId = trimOrNull(row.get("SKU")); - if (avantlinkProductId == null || avantlinkProductId.isBlank()) { - return; - } - - // Find existing offer - ProductOffer offer = productOfferRepository - .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) - .orElse(null); - - if (offer == null) { - // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. - return; - } - - // Parse price fields (column names match the main product feed) - BigDecimal price = parseBigDecimal(row.get("Sale Price")); - BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); - - // Update only *offer* fields – do not touch Product - offer.setPrice(price); - offer.setOriginalPrice(originalPrice); - offer.setInStock(parseInStock(row)); - - // Prefer a fresh Buy Link from the feed if present, otherwise keep existing - String newBuyUrl = trimOrNull(row.get("Buy Link")); - offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); - - offer.setLastSeenAt(OffsetDateTime.now()); - - productOfferRepository.save(offer); - } - private Boolean parseInStock(Map row) { - String inStock = trimOrNull(row.get("In Stock")); - if (inStock == null) return Boolean.FALSE; - - String lower = inStock.toLowerCase(); - if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { - return Boolean.TRUE; - } - if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { - return Boolean.FALSE; - } - - return Boolean.FALSE; - } +package group.goforward.ballistic.services.impl; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.io.Reader; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import org.springframework.cache.annotation.CacheEvict; + +import group.goforward.ballistic.imports.MerchantFeedRow; +import group.goforward.ballistic.services.MerchantFeedImportService; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVParser; +import org.apache.commons.csv.CSVRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import group.goforward.ballistic.model.Brand; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.repos.BrandRepository; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.repos.ProductRepository; +import group.goforward.ballistic.services.MerchantCategoryMappingService; +import group.goforward.ballistic.model.MerchantCategoryMapping; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import group.goforward.ballistic.repos.ProductOfferRepository; +import group.goforward.ballistic.model.ProductOffer; + +import java.time.OffsetDateTime; + +@Service +@Transactional +public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { + private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class); + + private final MerchantRepository merchantRepository; + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + private final MerchantCategoryMappingService merchantCategoryMappingService; + private final ProductOfferRepository productOfferRepository; + + public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository, + BrandRepository brandRepository, + ProductRepository productRepository, + MerchantCategoryMappingService merchantCategoryMappingService, + ProductOfferRepository productOfferRepository) { + this.merchantRepository = merchantRepository; + this.brandRepository = brandRepository; + this.productRepository = productRepository; + this.merchantCategoryMappingService = merchantCategoryMappingService; + this.productOfferRepository = productOfferRepository; + } + + @Override + @CacheEvict(value = "gunbuilderProducts", allEntries = true) + public void importMerchantFeed(Integer merchantId) { + log.info("Starting full import for merchantId={}", merchantId); + + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); + + // Read all rows from the merchant feed + List rows = readFeedRowsForMerchant(merchant); + log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName()); + + for (MerchantFeedRow row : rows) { + Brand brand = resolveBrand(row); + Product p = upsertProduct(merchant, brand, row); + log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}", + p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName()); + } + } + + // --------------------------------------------------------------------- + // Upsert logic + // --------------------------------------------------------------------- + + private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { + log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName()); + + String mpn = trimOrNull(row.manufacturerId()); + String upc = trimOrNull(row.sku()); // placeholder until real UPC field + + List candidates = Collections.emptyList(); + + if (mpn != null) { + candidates = productRepository.findAllByBrandAndMpn(brand, mpn); + } + if ((candidates == null || candidates.isEmpty()) && upc != null) { + candidates = productRepository.findAllByBrandAndUpc(brand, upc); + } + + Product p; + boolean isNew = (candidates == null || candidates.isEmpty()); + + if (isNew) { + p = new Product(); + p.setBrand(brand); + } else { + if (candidates.size() > 1) { + log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}", + brand.getName(), mpn, upc, candidates.get(0).getId()); + } + p = candidates.get(0); + } + + updateProductFromRow(p, merchant, row, isNew); + + // Save the product first + Product saved = productRepository.save(p); + + // Then upsert the offer for this row + upsertOfferFromRow(saved, merchant, row); + + return saved; + } + private List> fetchFeedRows(String feedUrl) { + log.info("Reading offer feed from {}", feedUrl); + + List> rows = new ArrayList<>(); + + try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) + ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) + : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + // capture header names from the CSV + List headers = new ArrayList<>(parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + Map row = new HashMap<>(); + for (String header : headers) { + row.put(header, rec.get(header)); + } + rows.add(row); + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); + } + + log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl); + return rows; + } + + private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { + // ---------- NAME ---------- + String name = coalesce( + trimOrNull(row.productName()), + trimOrNull(row.shortDescription()), + trimOrNull(row.longDescription()), + trimOrNull(row.sku()) + ); + if (name == null) { + name = "Unknown Product"; + } + p.setName(name); + + // ---------- SLUG ---------- + if (isNew || p.getSlug() == null || p.getSlug().isBlank()) { + String baseForSlug = coalesce( + trimOrNull(name), + trimOrNull(row.sku()) + ); + if (baseForSlug == null) { + baseForSlug = "product-" + System.currentTimeMillis(); + } + + String slug = baseForSlug + .toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-|-$)", ""); + if (slug.isBlank()) { + slug = "product-" + System.currentTimeMillis(); + } + + String uniqueSlug = generateUniqueSlug(slug); + p.setSlug(uniqueSlug); + } + + // ---------- DESCRIPTIONS ---------- + p.setShortDescription(trimOrNull(row.shortDescription())); + p.setDescription(trimOrNull(row.longDescription())); + + // ---------- IMAGE ---------- + String mainImage = coalesce( + trimOrNull(row.imageUrl()), + trimOrNull(row.mediumImageUrl()), + trimOrNull(row.thumbUrl()) + ); + p.setMainImageUrl(mainImage); + + // ---------- IDENTIFIERS ---------- + String mpn = coalesce( + trimOrNull(row.manufacturerId()), + trimOrNull(row.sku()) + ); + p.setMpn(mpn); + + // UPC placeholder + p.setUpc(null); + + // ---------- PLATFORM ---------- + if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) { + String platform = inferPlatform(row); + p.setPlatform(platform != null ? platform : "AR-15"); + } + + // ---------- RAW CATEGORY KEY (for debugging / analytics) ---------- + String rawCategoryKey = buildRawCategoryKey(row); + p.setRawCategoryKey(rawCategoryKey); + + // ---------- PART ROLE (via category mapping, with keyword fallback) ---------- + String partRole = null; + + if (rawCategoryKey != null) { + // Ask the mapping service for (or to create) a mapping row + MerchantCategoryMapping mapping = + merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey); + + if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) { + partRole = mapping.getMappedPartRole().trim(); + } + } + + // Fallback: keyword-based inference if we still don't have a mapped partRole + if (partRole == null || partRole.isBlank()) { + partRole = inferPartRole(row); + } + + if (partRole == null || partRole.isBlank()) { + partRole = "unknown"; + } + + p.setPartRole(partRole); + } + private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) { + // For now, we’ll use SKU as the "avantlinkProductId" placeholder. + // If/when you have a real AvantLink product_id in the feed, switch to that. + String avantlinkProductId = trimOrNull(row.sku()); + if (avantlinkProductId == null) { + // If there's truly no SKU, bail out – we can't match this offer reliably. + log.debug("Skipping offer row with no SKU for product id={}", product.getId()); + return; + } + + // Idempotent upsert: look for an existing offer for this merchant + AvantLink product id + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElseGet(ProductOffer::new); + + // If this is a brand‑new offer, initialize key fields + if (offer.getId() == null) { + offer.setMerchant(merchant); + offer.setProduct(product); + offer.setAvantlinkProductId(avantlinkProductId); + offer.setFirstSeenAt(OffsetDateTime.now()); + } else { + // Make sure associations stay in sync if anything changed + offer.setMerchant(merchant); + offer.setProduct(product); + } + + // Identifiers + offer.setSku(trimOrNull(row.sku())); + // No real UPC in this feed yet – leave null for now + offer.setUpc(null); + + // Buy URL + offer.setBuyUrl(trimOrNull(row.buyLink())); + + // Prices from feed + BigDecimal retail = row.retailPrice(); // parsed as BigDecimal in readFeedRowsForMerchant + BigDecimal sale = row.salePrice(); + + BigDecimal effectivePrice; + BigDecimal originalPrice; + + // Prefer sale price if it exists and is less than or equal to retail + if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) { + effectivePrice = sale; + originalPrice = (retail != null ? retail : sale); + } else { + // Otherwise fall back to retail or whatever is present + effectivePrice = (retail != null ? retail : sale); + originalPrice = (retail != null ? retail : sale); + } + + offer.setPrice(effectivePrice); + offer.setOriginalPrice(originalPrice); + + // Currency + stock + offer.setCurrency("USD"); + // We don't have a real stock flag in this CSV, so assume in-stock for now + offer.setInStock(Boolean.TRUE); + + // Update "last seen" on every import pass + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + + + // --------------------------------------------------------------------- + // Feed reading + brand resolution + // --------------------------------------------------------------------- + + /** + * Open a Reader for either an HTTP(S) URL or a local file path. + */ + private Reader openFeedReader(String feedUrl) throws java.io.IOException { + if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) { + return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8); + } else { + return java.nio.file.Files.newBufferedReader( + java.nio.file.Paths.get(feedUrl), + StandardCharsets.UTF_8 + ); + } + } + + /** + * Try a few common delimiters (tab, comma, semicolon) and pick the one + * that yields the expected AvantLink-style header set. + */ + private CSVFormat detectCsvFormat(String feedUrl) throws Exception { + char[] delimiters = new char[]{'\t', ',', ';'}; + java.util.List requiredHeaders = + java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name"); + + Exception lastException = null; + + for (char delimiter : delimiters) { + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build() + .parse(reader)) { + + Map headerMap = parser.getHeaderMap(); + if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { + log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl); + + return CSVFormat.DEFAULT.builder() + .setDelimiter(delimiter) + .setHeader() + .setSkipHeaderRecord(true) + .setIgnoreSurroundingSpaces(true) + .setTrim(true) + .build(); + } else if (headerMap != null) { + log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl); + } + } catch (Exception ex) { + lastException = ex; + log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage()); + } + } + + if (lastException != null) { + throw lastException; + } + throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl); + } + + private List readFeedRowsForMerchant(Merchant merchant) { + String rawFeedUrl = merchant.getFeedUrl(); + if (rawFeedUrl == null || rawFeedUrl.isBlank()) { + throw new IllegalStateException("Merchant " + merchant.getName() + " has no feed_url configured"); + } + + String feedUrl = rawFeedUrl.trim(); + log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl); + + List rows = new ArrayList<>(); + + try { + // Auto-detect delimiter (TSV/CSV/semicolon) based on header row + CSVFormat format = detectCsvFormat(feedUrl); + + try (Reader reader = openFeedReader(feedUrl); + CSVParser parser = new CSVParser(reader, format)) { + + log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + MerchantFeedRow row = new MerchantFeedRow( + getCsvValue(rec, "SKU"), + getCsvValue(rec, "Manufacturer Id"), + getCsvValue(rec, "Brand Name"), + getCsvValue(rec, "Product Name"), + getCsvValue(rec, "Long Description"), + getCsvValue(rec, "Short Description"), + getCsvValue(rec, "Department"), + getCsvValue(rec, "Category"), + getCsvValue(rec, "SubCategory"), + getCsvValue(rec, "Thumb URL"), + getCsvValue(rec, "Image URL"), + getCsvValue(rec, "Buy Link"), + getCsvValue(rec, "Keywords"), + getCsvValue(rec, "Reviews"), + parseBigDecimal(getCsvValue(rec, "Retail Price")), + parseBigDecimal(getCsvValue(rec, "Sale Price")), + getCsvValue(rec, "Brand Page Link"), + getCsvValue(rec, "Brand Logo Image"), + getCsvValue(rec, "Product Page View Tracking"), + null, + getCsvValue(rec, "Medium Image URL"), + getCsvValue(rec, "Product Content Widget"), + getCsvValue(rec, "Google Categorization"), + getCsvValue(rec, "Item Based Commission") + ); + + rows.add(row); + } + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read feed for merchant " + + merchant.getName() + " from " + feedUrl, ex); + } + + log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName()); + return rows; + } + + private Brand resolveBrand(MerchantFeedRow row) { + String rawBrand = trimOrNull(row.brandName()); + final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision"; + + return brandRepository.findByNameIgnoreCase(brandName) + .orElseGet(() -> { + Brand b = new Brand(); + b.setName(brandName); + return brandRepository.save(b); + }); + } + + private String getCol(String[] cols, int index) { + return (index >= 0 && index < cols.length) ? cols[index] : null; + } + + private BigDecimal parseBigDecimal(String raw) { + if (raw == null) return null; + String trimmed = raw.trim(); + if (trimmed.isEmpty()) return null; + try { + return new BigDecimal(trimmed); + } catch (NumberFormatException ex) { + log.debug("Skipping invalid numeric value '{}'", raw); + return null; + } + } + + /** + * Safely get a column value by header name. If the record is "short" + * (fewer values than headers) or the header is missing, return null + * instead of throwing IllegalArgumentException. + */ + private String getCsvValue(CSVRecord rec, String header) { + if (rec == null || header == null) { + return null; + } + if (!rec.isMapped(header)) { + // Header not present at all + return null; + } + try { + return rec.get(header); + } catch (IllegalArgumentException ex) { + log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header); + return null; + } + } + + // --------------------------------------------------------------------- + // Misc helpers + // --------------------------------------------------------------------- + + private String trimOrNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String coalesce(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) { + return v; + } + } + return null; + } + + private String generateUniqueSlug(String baseSlug) { + String candidate = baseSlug; + int suffix = 1; + while (productRepository.existsBySlug(candidate)) { + candidate = baseSlug + "-" + suffix; + suffix++; + } + return candidate; + } + + private String buildRawCategoryKey(MerchantFeedRow row) { + String dept = trimOrNull(row.department()); + String cat = trimOrNull(row.category()); + String sub = trimOrNull(row.subCategory()); + + java.util.List parts = new java.util.ArrayList<>(); + if (dept != null) parts.add(dept); + if (cat != null) parts.add(cat); + if (sub != null) parts.add(sub); + + if (parts.isEmpty()) { + return null; + } + + return String.join(" > ", parts); + } + + private String inferPlatform(MerchantFeedRow row) { + String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category())); + if (department == null) return null; + + String lower = department.toLowerCase(); + if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15"; + if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10"; + if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47"; + + return "AR-15"; + } + + private String inferPartRole(MerchantFeedRow row) { + String cat = coalesce( + trimOrNull(row.subCategory()), + trimOrNull(row.category()) + ); + if (cat == null) return null; + + String lower = cat.toLowerCase(); + + if (lower.contains("handguard") || lower.contains("rail")) { + return "handguard"; + } + if (lower.contains("barrel")) { + return "barrel"; + } + if (lower.contains("upper")) { + return "upper-receiver"; + } + if (lower.contains("lower")) { + return "lower-receiver"; + } + if (lower.contains("magazine") || lower.contains("mag")) { + return "magazine"; + } + if (lower.contains("stock") || lower.contains("buttstock")) { + return "stock"; + } + if (lower.contains("grip")) { + return "grip"; + } + + return "unknown"; + } + @CacheEvict(value = "gunbuilderProducts", allEntries = true) + public void syncOffersOnly(Integer merchantId) { + log.info("Starting offers-only sync for merchantId={}", merchantId); + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + if (Boolean.FALSE.equals(merchant.getIsActive())) { + return; + } + + String feedUrl = merchant.getOfferFeedUrl() != null + ? merchant.getOfferFeedUrl() + : merchant.getFeedUrl(); + + if (feedUrl == null) { + throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); + } + + List> rows = fetchFeedRows(feedUrl); + + for (Map row : rows) { + upsertOfferOnlyFromRow(merchant, row); + } + + merchant.setLastOfferSyncAt(OffsetDateTime.now()); + merchantRepository.save(merchant); + log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size()); + } + + private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { + // For the offer-only sync, we key offers by the same identifier we used when creating them. + // In the current AvantLink-style feed, that is the SKU column. + String avantlinkProductId = trimOrNull(row.get("SKU")); + if (avantlinkProductId == null || avantlinkProductId.isBlank()) { + return; + } + + // Find existing offer + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElse(null); + + if (offer == null) { + // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. + return; + } + + // Parse price fields (column names match the main product feed) + BigDecimal price = parseBigDecimal(row.get("Sale Price")); + BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); + + // Update only *offer* fields – do not touch Product + offer.setPrice(price); + offer.setOriginalPrice(originalPrice); + offer.setInStock(parseInStock(row)); + + // Prefer a fresh Buy Link from the feed if present, otherwise keep existing + String newBuyUrl = trimOrNull(row.get("Buy Link")); + offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); + + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + private Boolean parseInStock(Map row) { + String inStock = trimOrNull(row.get("In Stock")); + if (inStock == null) return Boolean.FALSE; + + String lower = inStock.toLowerCase(); + if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { + return Boolean.TRUE; + } + if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { + return Boolean.FALSE; + } + + return Boolean.FALSE; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java index 1729056..dddc752 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/PsaServiceImpl.java @@ -1,41 +1,41 @@ -package group.goforward.ballistic.services.impl; -import group.goforward.ballistic.model.Psa; -import group.goforward.ballistic.repos.PsaRepository; -import group.goforward.ballistic.services.PsaService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@Service -public class PsaServiceImpl implements PsaService { - - private final PsaRepository psaRepository; - - @Autowired - public PsaServiceImpl(PsaRepository psaRepository) { - this.psaRepository = psaRepository; - } - - @Override - public List findAll() { - return psaRepository.findAll(); - } - - @Override - public Optional findById(UUID id) { - return psaRepository.findById(id); - } - - @Override - public Psa save(Psa psa) { - return psaRepository.save(psa); - } - - @Override - public void deleteById(UUID id) { - psaRepository.deleteById(id); - } -} +package group.goforward.ballistic.services.impl; +import group.goforward.ballistic.model.Psa; +import group.goforward.ballistic.repos.PsaRepository; +import group.goforward.ballistic.services.PsaService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class PsaServiceImpl implements PsaService { + + private final PsaRepository psaRepository; + + @Autowired + public PsaServiceImpl(PsaRepository psaRepository) { + this.psaRepository = psaRepository; + } + + @Override + public List findAll() { + return psaRepository.findAll(); + } + + @Override + public Optional findById(UUID id) { + return psaRepository.findById(id); + } + + @Override + public Psa save(Psa psa) { + return psaRepository.save(psa); + } + + @Override + public void deleteById(UUID id) { + psaRepository.deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java index 8d3d44a..8ae3d86 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/StatesServiceImpl.java @@ -1,38 +1,38 @@ -package group.goforward.ballistic.services.impl; - - -import group.goforward.ballistic.model.State; -import group.goforward.ballistic.repos.StateRepository; -import group.goforward.ballistic.services.StatesService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -public class StatesServiceImpl implements StatesService { - - @Autowired - private StateRepository repo; - - @Override - public List findAll() { - return repo.findAll(); - } - - @Override - public Optional findById(Integer id) { - return repo.findById(id); - } - - @Override - public State save(State item) { - return null; - } - - @Override - public void deleteById(Integer id) { - deleteById(id); - } -} +package group.goforward.ballistic.services.impl; + + +import group.goforward.ballistic.model.State; +import group.goforward.ballistic.repos.StateRepository; +import group.goforward.ballistic.services.StatesService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class StatesServiceImpl implements StatesService { + + @Autowired + private StateRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public State save(State item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java index a3b1cf8..3620bbf 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/UsersServiceImpl.java @@ -1,37 +1,37 @@ -package group.goforward.ballistic.services.impl; - -import group.goforward.ballistic.model.User; -import group.goforward.ballistic.repos.UserRepository; -import group.goforward.ballistic.services.UsersService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Optional; - -@Service -public class UsersServiceImpl implements UsersService { - - @Autowired - private UserRepository repo; - - @Override - public List findAll() { - return repo.findAll(); - } - - @Override - public Optional findById(Integer id) { - return repo.findById(id); - } - - @Override - public User save(User item) { - return null; - } - - @Override - public void deleteById(Integer id) { - deleteById(id); - } -} +package group.goforward.ballistic.services.impl; + +import group.goforward.ballistic.model.User; +import group.goforward.ballistic.repos.UserRepository; +import group.goforward.ballistic.services.UsersService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +public class UsersServiceImpl implements UsersService { + + @Autowired + private UserRepository repo; + + @Override + public List findAll() { + return repo.findAll(); + } + + @Override + public Optional findById(Integer id) { + return repo.findById(id); + } + + @Override + public User save(User item) { + return null; + } + + @Override + public void deleteById(Integer id) { + deleteById(id); + } +} diff --git a/src/main/java/group/goforward/ballistic/services/impl/package-info.java b/src/main/java/group/goforward/ballistic/services/impl/package-info.java index 8caffec..e6d7a02 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/package-info.java +++ b/src/main/java/group/goforward/ballistic/services/impl/package-info.java @@ -1,13 +1,13 @@ -/** - * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. - * This package includes Services implementations for Spring-Boot application - * - * - *

The main entry point for managing the inventory is the - * {@link group.goforward.ballistic.BallisticApplication} class.

- * - * @since 1.0 - * @author Don Strawsburg - * @version 1.1 - */ +/** + * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. + * This package includes Services implementations for Spring-Boot application + * + * + *

The main entry point for managing the inventory is the + * {@link group.goforward.ballistic.BallisticApplication} class.

+ * + * @since 1.0 + * @author Don Strawsburg + * @version 1.1 + */ package group.goforward.ballistic.services.impl; \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java index 26d6f6c..56da0ab 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java @@ -1,70 +1,70 @@ -// MerchantAdminDto.java -package group.goforward.ballistic.web.dto; - -import java.time.OffsetDateTime; - -public class MerchantAdminDto { - private Integer id; - private String name; - private String feedUrl; - private String offerFeedUrl; - private Boolean isActive; - private OffsetDateTime lastFullImportAt; - private OffsetDateTime lastOfferSyncAt; - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getFeedUrl() { - return feedUrl; - } - - public void setFeedUrl(String feedUrl) { - this.feedUrl = feedUrl; - } - - public String getOfferFeedUrl() { - return offerFeedUrl; - } - - public void setOfferFeedUrl(String offerFeedUrl) { - this.offerFeedUrl = offerFeedUrl; - } - - public Boolean getIsActive() { - return isActive; - } - - public void setIsActive(Boolean isActive) { - this.isActive = isActive; - } - - public OffsetDateTime getLastFullImportAt() { - return lastFullImportAt; - } - - public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { - this.lastFullImportAt = lastFullImportAt; - } - - public OffsetDateTime getLastOfferSyncAt() { - return lastOfferSyncAt; - } - - public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { - this.lastOfferSyncAt = lastOfferSyncAt; - } +// MerchantAdminDto.java +package group.goforward.ballistic.web.dto; + +import java.time.OffsetDateTime; + +public class MerchantAdminDto { + private Integer id; + private String name; + private String feedUrl; + private String offerFeedUrl; + private Boolean isActive; + private OffsetDateTime lastFullImportAt; + private OffsetDateTime lastOfferSyncAt; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFeedUrl() { + return feedUrl; + } + + public void setFeedUrl(String feedUrl) { + this.feedUrl = feedUrl; + } + + public String getOfferFeedUrl() { + return offerFeedUrl; + } + + public void setOfferFeedUrl(String offerFeedUrl) { + this.offerFeedUrl = offerFeedUrl; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getLastFullImportAt() { + return lastFullImportAt; + } + + public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { + this.lastFullImportAt = lastFullImportAt; + } + + public OffsetDateTime getLastOfferSyncAt() { + return lastOfferSyncAt; + } + + public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { + this.lastOfferSyncAt = lastOfferSyncAt; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java index bb7a703..8c8618e 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantCategoryMappingDto.java @@ -1,50 +1,50 @@ -package group.goforward.ballistic.web.dto; - -public class MerchantCategoryMappingDto { - - private Integer id; - private Integer merchantId; - private String merchantName; - private String rawCategory; - private String mappedPartRole; - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public Integer getMerchantId() { - return merchantId; - } - - public void setMerchantId(Integer merchantId) { - this.merchantId = merchantId; - } - - public String getMerchantName() { - return merchantName; - } - - public void setMerchantName(String merchantName) { - this.merchantName = merchantName; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - public String getMappedPartRole() { - return mappedPartRole; - } - - public void setMappedPartRole(String mappedPartRole) { - this.mappedPartRole = mappedPartRole; - } +package group.goforward.ballistic.web.dto; + +public class MerchantCategoryMappingDto { + + private Integer id; + private Integer merchantId; + private String merchantName; + private String rawCategory; + private String mappedPartRole; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java b/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java index 3fd40d4..980ecb8 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/ProductOfferDto.java @@ -1,70 +1,70 @@ -package group.goforward.ballistic.web.dto; - -import java.math.BigDecimal; -import java.time.OffsetDateTime; - -public class ProductOfferDto { - private String id; - private String merchantName; - private BigDecimal price; - private BigDecimal originalPrice; - private boolean inStock; - private String buyUrl; - private OffsetDateTime lastUpdated; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getMerchantName() { - return merchantName; - } - - public void setMerchantName(String merchantName) { - this.merchantName = merchantName; - } - - public BigDecimal getPrice() { - return price; - } - - public void setPrice(BigDecimal price) { - this.price = price; - } - - public BigDecimal getOriginalPrice() { - return originalPrice; - } - - public void setOriginalPrice(BigDecimal originalPrice) { - this.originalPrice = originalPrice; - } - - public boolean isInStock() { - return inStock; - } - - public void setInStock(boolean inStock) { - this.inStock = inStock; - } - - public String getBuyUrl() { - return buyUrl; - } - - public void setBuyUrl(String buyUrl) { - this.buyUrl = buyUrl; - } - - public OffsetDateTime getLastUpdated() { - return lastUpdated; - } - - public void setLastUpdated(OffsetDateTime lastUpdated) { - this.lastUpdated = lastUpdated; - } +package group.goforward.ballistic.web.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +public class ProductOfferDto { + private String id; + private String merchantName; + private BigDecimal price; + private BigDecimal originalPrice; + private boolean inStock; + private String buyUrl; + private OffsetDateTime lastUpdated; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMerchantName() { + return merchantName; + } + + public void setMerchantName(String merchantName) { + this.merchantName = merchantName; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public BigDecimal getOriginalPrice() { + return originalPrice; + } + + public void setOriginalPrice(BigDecimal originalPrice) { + this.originalPrice = originalPrice; + } + + public boolean isInStock() { + return inStock; + } + + public void setInStock(boolean inStock) { + this.inStock = inStock; + } + + public String getBuyUrl() { + return buyUrl; + } + + public void setBuyUrl(String buyUrl) { + this.buyUrl = buyUrl; + } + + public OffsetDateTime getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(OffsetDateTime lastUpdated) { + this.lastUpdated = lastUpdated; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java b/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java index 39c556a..c7f3cdc 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java +++ b/src/main/java/group/goforward/ballistic/web/dto/ProductSummaryDto.java @@ -1,79 +1,79 @@ -package group.goforward.ballistic.web.dto; - -import java.math.BigDecimal; - -public class ProductSummaryDto { - - private String id; // product UUID as string - private String name; - private String brand; - private String platform; - private String partRole; - private String categoryKey; - private BigDecimal price; - private String buyUrl; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getBrand() { - return brand; - } - - public void setBrand(String brand) { - this.brand = brand; - } - - public String getPlatform() { - return platform; - } - - public void setPlatform(String platform) { - this.platform = platform; - } - - public String getPartRole() { - return partRole; - } - - public void setPartRole(String partRole) { - this.partRole = partRole; - } - - public String getCategoryKey() { - return categoryKey; - } - - public void setCategoryKey(String categoryKey) { - this.categoryKey = categoryKey; - } - - public BigDecimal getPrice() { - return price; - } - - public void setPrice(BigDecimal price) { - this.price = price; - } - - public String getBuyUrl() { - return buyUrl; - } - - public void setBuyUrl(String buyUrl) { - this.buyUrl = buyUrl; - } +package group.goforward.ballistic.web.dto; + +import java.math.BigDecimal; + +public class ProductSummaryDto { + + private String id; // product UUID as string + private String name; + private String brand; + private String platform; + private String partRole; + private String categoryKey; + private BigDecimal price; + private String buyUrl; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getPartRole() { + return partRole; + } + + public void setPartRole(String partRole) { + this.partRole = partRole; + } + + public String getCategoryKey() { + return categoryKey; + } + + public void setCategoryKey(String categoryKey) { + this.categoryKey = categoryKey; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public String getBuyUrl() { + return buyUrl; + } + + public void setBuyUrl(String buyUrl) { + this.buyUrl = buyUrl; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java index f0d102a..d910b77 100644 --- a/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java +++ b/src/main/java/group/goforward/ballistic/web/dto/UpsertMerchantCategoryMappingRequest.java @@ -1,32 +1,32 @@ -package group.goforward.ballistic.web.dto; - -public class UpsertMerchantCategoryMappingRequest { - - private Integer merchantId; - private String rawCategory; - private String mappedPartRole; // can be null to "unmap" - - public Integer getMerchantId() { - return merchantId; - } - - public void setMerchantId(Integer merchantId) { - this.merchantId = merchantId; - } - - public String getRawCategory() { - return rawCategory; - } - - public void setRawCategory(String rawCategory) { - this.rawCategory = rawCategory; - } - - public String getMappedPartRole() { - return mappedPartRole; - } - - public void setMappedPartRole(String mappedPartRole) { - this.mappedPartRole = mappedPartRole; - } +package group.goforward.ballistic.web.dto; + +public class UpsertMerchantCategoryMappingRequest { + + private Integer merchantId; + private String rawCategory; + private String mappedPartRole; // can be null to "unmap" + + public Integer getMerchantId() { + return merchantId; + } + + public void setMerchantId(Integer merchantId) { + this.merchantId = merchantId; + } + + public String getRawCategory() { + return rawCategory; + } + + public void setRawCategory(String rawCategory) { + this.rawCategory = rawCategory; + } + + public String getMappedPartRole() { + return mappedPartRole; + } + + public void setMappedPartRole(String mappedPartRole) { + this.mappedPartRole = mappedPartRole; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java b/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java index 1e3fa4c..f6025d5 100644 --- a/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java +++ b/src/main/java/group/goforward/ballistic/web/mapper/ProductMapper.java @@ -1,30 +1,30 @@ -package group.goforward.ballistic.web.mapper; - -import group.goforward.ballistic.model.Product; -import group.goforward.ballistic.web.dto.ProductSummaryDto; - -import java.math.BigDecimal; - -public class ProductMapper { - - public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl) { - ProductSummaryDto dto = new ProductSummaryDto(); - - // Product ID -> String - dto.setId(String.valueOf(product.getId())); - - dto.setName(product.getName()); - dto.setBrand(product.getBrand() != null ? product.getBrand().getName() : null); - dto.setPlatform(product.getPlatform()); - dto.setPartRole(product.getPartRole()); - - // Use rawCategoryKey from the Product entity - dto.setCategoryKey(product.getRawCategoryKey()); - - // Price + buy URL from offers - dto.setPrice(price); - dto.setBuyUrl(buyUrl); - - return dto; - } +package group.goforward.ballistic.web.mapper; + +import group.goforward.ballistic.model.Product; +import group.goforward.ballistic.web.dto.ProductSummaryDto; + +import java.math.BigDecimal; + +public class ProductMapper { + + public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl) { + ProductSummaryDto dto = new ProductSummaryDto(); + + // Product ID -> String + dto.setId(String.valueOf(product.getId())); + + dto.setName(product.getName()); + dto.setBrand(product.getBrand() != null ? product.getBrand().getName() : null); + dto.setPlatform(product.getPlatform()); + dto.setPartRole(product.getPartRole()); + + // Use rawCategoryKey from the Product entity + dto.setCategoryKey(product.getRawCategoryKey()); + + // Price + buy URL from offers + dto.setPrice(price); + dto.setBuyUrl(buyUrl); + + return dto; + } } \ No newline at end of file