diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3b41682
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml
new file mode 100644
index 0000000..ca05840
--- /dev/null
+++ b/.github/workflows/ci-cd.yml
@@ -0,0 +1,14 @@
+name: Use Template Java CI/CD
+
+on:
+ push:
+ branches: [ "main", "dev" ]
+ pull_request:
+ branches: [ "**" ]
+
+jobs:
+ call-ci:
+ uses: Podzilla/templates/.github/workflows/ci.yml@main
+ with:
+ branch: 'refs/heads/main' # <<< Passes the branch name dynamically
+ secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 0000000..c9ed1da
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -0,0 +1,12 @@
+name: Use Template Linter
+
+on:
+ pull_request:
+ branches: [ "**" ]
+
+jobs:
+ call-linter:
+ uses: Podzilla/templates/.github/workflows/super-linter.yml@main
+ with:
+ branch: 'dev'
+ secrets: inherit
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..549e00a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..d58dfb7
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e4e3051
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM openjdk:25-ea-4-jdk-oraclelinux9
+
+WORKDIR /app
+
+COPY ./target/*.jar analytics_app.jar
+
+EXPOSE 8080
+
+ENTRYPOINT ["java", "-jar", "analytics_app.jar"]
\ No newline at end of file
diff --git a/build_and_run.sh b/build_and_run.sh
new file mode 100755
index 0000000..02799e5
--- /dev/null
+++ b/build_and_run.sh
@@ -0,0 +1,3 @@
+mvn clean package -DskipTests
+docker compose down
+docker compose up -d --build
\ No newline at end of file
diff --git a/config/checkstyle/sun_checks.xml b/config/checkstyle/sun_checks.xml
new file mode 100644
index 0000000..c2fab2a
--- /dev/null
+++ b/config/checkstyle/sun_checks.xml
@@ -0,0 +1,194 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..816f9ca
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,52 @@
+
+services:
+ analytics-app:
+ build: .
+ container_name: analytics-app
+ ports:
+ - "8083:8080"
+ environment:
+ SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/analytics_db_dev
+ SPRING_DATASOURCE_USERNAME: postgres
+ SPRING_DATASOURCE_PASSWORD: 123
+ SPRING_RABBITMQ_HOST: host.docker.internal
+ SPRING_RABBITMQ_PORT: 5672
+ SPRING_RABBITMQ_USERNAME: guest
+ SPRING_RABBITMQ_PASSWORD: guest
+ depends_on:
+ - db
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+
+
+ db:
+ image: postgres
+ container_name: analytics-db
+ ports:
+ - "5435:5432"
+ environment:
+ POSTGRES_DB: analytics_db_dev
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: 123
+ volumes:
+ - db_data:/var/lib/postgresql/data
+
+
+ # rabbitmq:
+ # image: rabbitmq:3.12-management-alpine # Use RabbitMQ with the management plugin
+ # container_name: analytics-rabbitmq
+ # ports:
+ # - "5672:5672" # Default AMQP port
+ # - "15672:15672" # Management UI port (access at http://localhost:15672)
+ # environment:
+ # RABBITMQ_DEFAULT_USER: analytics_mq_user # RabbitMQ user
+ # RABBITMQ_DEFAULT_PASS: analytics_mq_password # RabbitMQ password
+ # healthcheck: # Basic health check for RabbitMQ
+ # test: ["CMD", "rabbitmq-diagnostics", "check_system_status"]
+ # interval: 10s
+ # timeout: 5s
+ # retries: 5
+
+
+volumes:
+ db_data:
diff --git a/eclipse-java-formatter.xml b/eclipse-java-formatter.xml
new file mode 100644
index 0000000..7c5b975
--- /dev/null
+++ b/eclipse-java-formatter.xml
@@ -0,0 +1,363 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mvnw b/mvnw
new file mode 100644
index 0000000..19529dd
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,259 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.2
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..249bdf3
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,149 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
+}
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..36744ae
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,192 @@
+
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.4.5
+
+
+
+ com.Podzilla
+ analytics
+ 0.0.1-SNAPSHOT
+ analytics
+ The Operational Analytics Service is an event-driven application designed to capture, process, and expose key operational data and derived insights from various upstream microservices (e.g., Warehouse, Courier, Order services). It acts as a centralized source of truth for historical operational events and their derived state, providing valuable analytics through a dedicated API
+
+
+ 21
+
+
+
+
+
+ io.github.cdimascio
+ java-dotenv
+ 5.2.2
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-amqp
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.5.0
+
+
+
+
+ com.github.Podzilla
+ podzilla-utils-lib
+ v1.1.13
+
+
+
+
+ jakarta.validation
+ jakarta.validation-api
+ 3.0.2
+
+
+ org.hibernate.validator
+ hibernate-validator
+ 8.0.1.Final
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.amqp
+ spring-rabbit-test
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.11.0
+ test
+
+
+ net.bytebuddy
+ byte-buddy-agent
+ 1.14.12
+ test
+
+
+ com.h2database
+ h2
+ test
+
+
+
+
+
+
+ jitpack.io
+ https://jitpack.io
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+ 3.3.0
+
+ config/checkstyle/sun_checks.xml
+ true
+ true
+
+
+
+ validate
+ validate
+
+ check
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/Podzilla/analytics/AnalyticsApplication.java b/src/main/java/com/Podzilla/analytics/AnalyticsApplication.java
new file mode 100644
index 0000000..cd1ea7d
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/AnalyticsApplication.java
@@ -0,0 +1,16 @@
+package com.Podzilla.analytics;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@ComponentScan(basePackages = { "com.podzilla", "com.Podzilla" })
+@EnableScheduling
+public class AnalyticsApplication {
+
+ public static void main(final String[] args) {
+ SpringApplication.run(AnalyticsApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/.gitkeep b/src/main/java/com/Podzilla/analytics/api/controllers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/CourierReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/CourierReportController.java
new file mode 100644
index 0000000..cf35201
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/CourierReportController.java
@@ -0,0 +1,89 @@
+package com.Podzilla.analytics.api.controllers;
+
+import java.util.List;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.Podzilla.analytics.api.dtos.DateRangeRequest;
+import com.Podzilla.analytics.api.dtos.courier.CourierAverageRatingResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierDeliveryCountResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierPerformanceReportResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierSuccessRateResponse;
+import com.Podzilla.analytics.services.CourierAnalyticsService;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Tag(name = "Courier Reports", description = "Endpoints for courier"
+ + " analytics and performance metrics")
+@RestController
+@RequestMapping("/courier-analytics")
+@RequiredArgsConstructor
+@Slf4j
+public final class CourierReportController {
+
+ private final CourierAnalyticsService courierAnalyticsService;
+
+ @Operation(summary = "Get delivery counts", description = "Returns the"
+ + " total number of deliveries completed by each courier")
+ @ApiResponse(responseCode = "200", description = "Successfully retrieved"
+ + " delivery counts")
+ @GetMapping("/delivery-counts")
+ public ResponseEntity> getDeliveryCounts(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /courier-analytics/delivery-counts "
+ + "with attributes: {}", dateRange);
+ List counts = courierAnalyticsService
+ .getCourierDeliveryCounts(dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(counts);
+ }
+
+ @Operation(summary = "Get courier success rate", description = "Returns "
+ + " the success rate of each courier")
+ @GetMapping("/success-rate")
+ public ResponseEntity> getSuccessRate(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /courier-analytics/success-rate "
+ + "with attributes: {}",
+ dateRange);
+ List rates = courierAnalyticsService
+ .getCourierSuccessRate(dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(rates);
+ }
+
+ @Operation(summary = "Get average courier ratings", description = "Fetches "
+ + " the average rating received by each courier")
+ @GetMapping("/average-rating")
+ public ResponseEntity> getAverageRating(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /courier-analytics/average-rating "
+ + "with attributes: {}", dateRange);
+ List ratings = courierAnalyticsService
+ .getCourierAverageRating(dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(ratings);
+ }
+
+ @Operation(summary = "Get courier performance report", description = ""
+ + "Returns a detailed performance report of each courier")
+ @GetMapping("/performance-report")
+ public ResponseEntity> getReport(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /courier-analytics/performance-report "
+ + "with attributes: {}", dateRange);
+ List report = courierAnalyticsService
+ .getCourierPerformanceReport(dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(report);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/CustomerReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/CustomerReportController.java
new file mode 100644
index 0000000..3b3055b
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/CustomerReportController.java
@@ -0,0 +1,44 @@
+package com.Podzilla.analytics.api.controllers;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+
+import com.Podzilla.analytics.services.CustomerAnalyticsService;
+import com.Podzilla.analytics.api.dtos.DateRangePaginationRequest;
+import com.Podzilla.analytics.api.dtos.customer.CustomersTopSpendersResponse;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
+@Tag(name = "Customer Reports", description = "APIs for customer analytics and"
+ + "reporting")
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/customer-analytics")
+@Slf4j
+public class CustomerReportController {
+ private final CustomerAnalyticsService customerAnalyticsService;
+
+ @Operation(summary = "Get top spending customers", description = "Returns"
+ + "a paginated list of customers who have spent"
+ + "the most money within the specified date range")
+ @GetMapping("/top-spenders")
+ public List getTopSpenders(
+ @Valid @ModelAttribute final DateRangePaginationRequest request) {
+ log.info("Request on: /customer-analytics/top-spenders "
+ + "with attributes: {}", request);
+ return customerAnalyticsService.getTopSpenders(
+ request.getStartDate(),
+ request.getEndDate(),
+ request.getPage(),
+ request.getSize()
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java
new file mode 100644
index 0000000..1f452d1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/FulfillmentReportController.java
@@ -0,0 +1,66 @@
+package com.Podzilla.analytics.api.controllers;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentTimeResponse;
+import com.Podzilla.analytics.services.FulfillmentAnalyticsService;
+
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/fulfillment-analytics")
+public class FulfillmentReportController {
+ private final FulfillmentAnalyticsService fulfillmentAnalyticsService;
+
+ @Operation(summary = "Get average time from order placement to shipping",
+ description = "Returns the average time (in hours) between when"
+ + " an order was placed and when it was shipped, grouped"
+ + " by the specified dimension")
+ @GetMapping("/place-to-ship-time")
+ public ResponseEntity> getPlaceToShipTime(
+ @Valid @ModelAttribute final FulfillmentPlaceToShipRequest req) {
+
+ log.info("Request on: /fulfillment-analytics/place-to-ship-time "
+ + "with attributes: {}", req);
+
+ final List reportData =
+ fulfillmentAnalyticsService.getPlaceToShipTimeResponse(
+ req.getStartDate(),
+ req.getEndDate(),
+ req.getGroupBy()
+ );
+ return ResponseEntity.ok(reportData);
+ }
+
+ @Operation(summary = "Get average time from shipping to delivery",
+ description = "Returns the average time (in hours) between when"
+ + " an order was shipped and when it was delivered, grouped"
+ + " by the specified dimension")
+ @GetMapping("/ship-to-deliver-time")
+ public ResponseEntity> getShipToDeliverTime(
+ @Valid @ModelAttribute final FulfillmentShipToDeliverRequest req) {
+
+ log.info("Request on: /fulfillment-analytics/ship-to-deliver-time "
+ + "with attributes: {}", req);
+
+ final List reportData =
+ fulfillmentAnalyticsService.getShipToDeliverTimeResponse(
+ req.getStartDate(),
+ req.getEndDate(),
+ req.getGroupBy());
+ return ResponseEntity.ok(reportData);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/InventoryReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/InventoryReportController.java
new file mode 100644
index 0000000..31a6858
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/InventoryReportController.java
@@ -0,0 +1,54 @@
+package com.Podzilla.analytics.api.controllers;
+
+import org.springframework.data.domain.Page;
+
+import com.Podzilla.analytics.api.dtos.inventory.InventoryValueByCategoryResponse;
+import com.Podzilla.analytics.api.dtos.inventory.LowStockProductResponse;
+import com.Podzilla.analytics.api.dtos.PaginationRequest;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.Podzilla.analytics.services.InventoryAnalyticsService;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import java.util.List;
+
+@Tag(name = "Inventory Reports", description = "APIs for inventory analytics"
+ + "and reporting")
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/inventory-analytics")
+@Slf4j
+public class InventoryReportController {
+ private final InventoryAnalyticsService inventoryAnalyticsService;
+
+ @Operation(summary = "Get inventory"
+ + "value by category", description = "Returns"
+ + "the total value of inventory "
+ + "grouped by product categories")
+ @GetMapping("/value/by-category")
+ public List getInventoryValueByCategory(
+
+ ) {
+ log.info("Request on: /inventory-analytics/value/by-category");
+ return inventoryAnalyticsService.getInventoryValueByCategory();
+ }
+
+ @Operation(summary = "Get low stock products", description = "Returns "
+ + "a paginated list of products that are running low on stock")
+ @GetMapping("/low-stock")
+ public Page getLowStockProducts(
+ @Valid @ModelAttribute final PaginationRequest paginationRequest) {
+ log.info("Request on: /inventory-analytics/low-stock"
+ + " with attributes: {}", paginationRequest);
+ return inventoryAnalyticsService.getLowStockProducts(
+ paginationRequest.getPage(),
+ paginationRequest.getSize());
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java
new file mode 100644
index 0000000..6e843ae
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/OrderReportController.java
@@ -0,0 +1,75 @@
+package com.Podzilla.analytics.api.controllers;
+
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.http.ResponseEntity;
+import java.util.List;
+
+import com.Podzilla.analytics.api.dtos.DateRangeRequest;
+import com.Podzilla.analytics.api.dtos.order.OrderFailureResponse;
+import com.Podzilla.analytics.api.dtos.order.OrderRegionResponse;
+import com.Podzilla.analytics.api.dtos.order.OrderStatusResponse;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/order-analytics")
+public class OrderReportController {
+ private final OrderAnalyticsService orderAnalyticsService;
+
+ @Operation(summary = "Get order counts and revenue by region",
+ description = "Returns the total number of orders"
+ + "placed in each region and their corresponding average revenue")
+ @GetMapping("/by-region")
+ public ResponseEntity> getOrdersByRegion(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /order-analytics/by-region"
+ + " with attributes: {}", dateRange);
+ List ordersByRegion = orderAnalyticsService
+ .getOrdersByRegion(
+ dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(ordersByRegion);
+ }
+
+ @Operation(summary = "Get order status counts",
+ description = "Returns the total number of orders"
+ + "in each status (e.g., COMPLETED, SHIPPED, FAILED)")
+ @GetMapping("/status-counts")
+ public ResponseEntity> getOrdersStatusCounts(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /order-analytics/status-counts"
+ + " with attributes: {}", dateRange);
+ List orderStatusCounts = orderAnalyticsService
+ .getOrdersStatusCounts(
+ dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(orderStatusCounts);
+ }
+
+ @Operation(summary = "Get order failures",
+ description = "Returns the percentage of failed orders"
+ + "and a list of the failure reasons"
+ + "with their corresponding frequency")
+ @GetMapping("/failures")
+ public ResponseEntity getOrdersFailures(
+ @Valid @ModelAttribute final DateRangeRequest dateRange) {
+ log.info("Request on: /order-analytics/failures"
+ + " with attributes: {}", dateRange);
+ OrderFailureResponse orderFailures = orderAnalyticsService
+ .getOrdersFailures(
+ dateRange.getStartDate(),
+ dateRange.getEndDate());
+ return ResponseEntity.ok(orderFailures);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProductReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProductReportController.java
new file mode 100644
index 0000000..388c46f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProductReportController.java
@@ -0,0 +1,42 @@
+package com.Podzilla.analytics.api.controllers;
+
+import java.util.List;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.Podzilla.analytics.api.dtos.product.TopSellerRequest;
+import com.Podzilla.analytics.api.dtos.product.TopSellerResponse;
+import com.Podzilla.analytics.services.ProductAnalyticsService;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@RequiredArgsConstructor
+@RestController
+@Slf4j
+@RequestMapping("/product-analytics")
+public class ProductReportController {
+
+ private final ProductAnalyticsService productAnalyticsService;
+
+ @GetMapping("/top-sellers")
+ public ResponseEntity> getTopSellers(
+ @Valid @ModelAttribute final TopSellerRequest requestDTO) {
+
+ log.info("Request on: /product-analytics/top-sellers"
+ + " with attributes: {}", requestDTO);
+
+ List topSellersList = productAnalyticsService
+ .getTopSellers(requestDTO.getStartDate(),
+ requestDTO.getEndDate(),
+ requestDTO.getLimit(),
+ requestDTO.getSortBy());
+
+ return ResponseEntity.ok(topSellersList);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java
new file mode 100644
index 0000000..78ac65e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/ProfitReportController.java
@@ -0,0 +1,47 @@
+package com.Podzilla.analytics.api.controllers;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.Podzilla.analytics.api.dtos.DateRangeRequest;
+import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory;
+import com.Podzilla.analytics.services.ProfitAnalyticsService;
+
+import io.swagger.v3.oas.annotations.Operation;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.List;
+
+/**
+ * REST controller for profit analytics operations.
+ * Provides endpoints to analyze revenue, cost, and profit metrics.
+ */
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/profit-analytics")
+public class ProfitReportController {
+ private final ProfitAnalyticsService profitAnalyticsService;
+
+ @Operation(summary = "Get profit by product category",
+ description = "Returns the revenue, cost, and profit metrics "
+ + "grouped by product category")
+ @GetMapping("/by-category")
+ public ResponseEntity> getProfitByCategory(
+ @Valid @ModelAttribute final DateRangeRequest request) {
+
+ log.info("Request on: /profit-analytics/by-category"
+ + " with attributes: {}", request);
+
+ List profitData = profitAnalyticsService
+ .getProfitByCategory(
+ request.getStartDate(),
+ request.getEndDate());
+ return ResponseEntity.ok(profitData);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/RabbitTesterController.java b/src/main/java/com/Podzilla/analytics/api/controllers/RabbitTesterController.java
new file mode 100644
index 0000000..0e3dd5e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/RabbitTesterController.java
@@ -0,0 +1,163 @@
+package com.Podzilla.analytics.api.controllers;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.List;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.Podzilla.analytics.messaging.AnalyticsRabbitListener;
+import com.podzilla.mq.events.BaseEvent;
+import com.podzilla.mq.events.ConfirmationType;
+import com.podzilla.mq.events.ProductSnapshot;
+import com.podzilla.mq.events.WarehouseOrderFulfillmentFailedEvent;
+import com.podzilla.mq.events.CourierRegisteredEvent;
+import com.podzilla.mq.events.CustomerRegisteredEvent;
+import com.podzilla.mq.events.DeliveryAddress;
+import com.podzilla.mq.events.InventoryUpdatedEvent;
+import com.podzilla.mq.events.OrderAssignedToCourierEvent;
+import com.podzilla.mq.events.OrderCancelledEvent;
+import com.podzilla.mq.events.OrderDeliveredEvent;
+import com.podzilla.mq.events.OrderDeliveryFailedEvent;
+import com.podzilla.mq.events.OrderOutForDeliveryEvent;
+import com.podzilla.mq.events.OrderPlacedEvent;
+import com.podzilla.mq.events.ProductCreatedEvent;
+
+import java.util.ArrayList;
+@RestController
+@RequestMapping("/rabbit-tester")
+public class RabbitTesterController {
+
+ static final int QUANTITY = 5;
+ @Autowired
+ private AnalyticsRabbitListener listener;
+
+ @GetMapping("/courier-registered-event")
+ public void testCourierRegisteredEvent() {
+ BaseEvent event = new CourierRegisteredEvent(
+ "87f23fee-2e09-4331-bc9c-912045ef0832",
+ "ahmad the courier", "010");
+ listener.handleUserEvents(event);
+ }
+
+ @GetMapping("/customer-registered-event")
+ public void testCustomerRegisteredEvent() {
+ BaseEvent event = new CustomerRegisteredEvent(
+ "27f7f5ca-6729-461e-882a-0c5123889bec",
+ "7amada");
+ listener.handleUserEvents(event);
+ }
+
+ @GetMapping("/order-assigned-to-courier-event")
+ public void testOrderAssignedToCourierEvent(
+ @RequestParam final String orderId,
+ @RequestParam final String courierId
+ ) {
+ BaseEvent event = new OrderAssignedToCourierEvent(
+ orderId,
+ courierId,
+ new BigDecimal("10.0"), 0.0, 0.0, "signature",
+ ConfirmationType.QR_CODE);
+ listener.handleOrderEvents(event);
+ }
+
+ @GetMapping("/order-cancelled-event")
+ public void testOrderCancelledEvent(
+ @RequestParam final String orderId
+ ) {
+ BaseEvent event = new OrderCancelledEvent(
+ orderId,
+ "2", // customerId (not used in the event)
+ "rabbit reason",
+ new ArrayList<>()
+ );
+ listener.handleOrderEvents(event);
+ }
+
+ @GetMapping("/order-delivered-event")
+ public void testOrderDeliveredEvent(
+ @RequestParam final String orderId
+ ) {
+ BaseEvent event = new OrderDeliveredEvent(
+ orderId, "2",
+ new BigDecimal("4.73"));
+ listener.handleOrderEvents(event);
+ }
+
+ @GetMapping("/order-delivery-failed-event")
+ public void testOrderDeliveryFailedEvent(
+ @RequestParam final String orderId
+ ) {
+ BaseEvent event = new OrderDeliveryFailedEvent(
+ orderId, "the rabit delivery failed reason", "2");
+ listener.handleOrderEvents(event);
+ }
+
+ @GetMapping("/order-out-for-delivery-event")
+ public void testOrderOutForDeliveryEvent(
+ @RequestParam final String orderId
+ ) {
+ BaseEvent event = new OrderOutForDeliveryEvent(
+ orderId, "2");
+ listener.handleOrderEvents(event);
+ }
+ @GetMapping("/order-fulfillment-failed-event")
+ public void testOrderFailedToFulfill(
+ @RequestParam final String orderId
+ ) {
+ BaseEvent event = new WarehouseOrderFulfillmentFailedEvent(
+ orderId, "order fulfillment failed rabbit reason");
+ listener.handleOrderEvents(event);
+ }
+
+ @GetMapping("/order-placed-event")
+ public void testOrderPlacedEvent(
+ @RequestParam final String customerId,
+ @RequestParam final String productId1,
+ @RequestParam final String productId2
+) {
+ BaseEvent event = new OrderPlacedEvent(
+ "a1aa7c7d-fe6a-491f-a2cc-b3b923340777",
+ customerId,
+ Arrays.asList(
+ new com.podzilla.mq.events.OrderItem(productId1,
+ QUANTITY, new BigDecimal("8.5")),
+ new com.podzilla.mq.events.OrderItem(productId2,
+ QUANTITY, new BigDecimal("12.75"))
+ ),
+ new DeliveryAddress(
+ "rabbit street",
+ "rabbit city wallahy",
+ "some state",
+ "some country",
+ "some postal code"),
+ new BigDecimal("13290.0"), 0.0, 0.0, "signature",
+ ConfirmationType.QR_CODE);
+ listener.handleOrderEvents(event);
+ }
+
+ @GetMapping("inventory-updated-event")
+ public void testInventoryUpdatedEvent(
+ @RequestParam final String productId,
+ @RequestParam final Integer quantity) {
+ BaseEvent event = new InventoryUpdatedEvent(
+ List.of(new ProductSnapshot(productId, quantity)));
+ listener.handleInventoryEvents(event);
+ }
+
+ @GetMapping("product-created-event")
+ public void testProductCreatedEvent() {
+ BaseEvent event = new ProductCreatedEvent(
+ "f12afb47-ad23-4ca8-a162-8b12de7a5e49",
+ "the rabbit product",
+ "some category",
+ new BigDecimal("10.0"),
+ Integer.valueOf(1));
+ listener.handleInventoryEvents(event);
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/controllers/RevenueReportController.java b/src/main/java/com/Podzilla/analytics/api/controllers/RevenueReportController.java
new file mode 100644
index 0000000..88fe2f1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/controllers/RevenueReportController.java
@@ -0,0 +1,50 @@
+package com.Podzilla.analytics.api.controllers;
+
+import java.util.List;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.Podzilla.analytics.api.dtos.revenue.RevenueByCategoryRequest;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueByCategoryResponse;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryRequest;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryResponse;
+import com.Podzilla.analytics.services.RevenueReportService;
+
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/revenue-analytics")
+public class RevenueReportController {
+ private final RevenueReportService revenueReportService;
+
+ @GetMapping("/summary")
+ public ResponseEntity> getRevenueSummary(
+ @Valid @ModelAttribute final RevenueSummaryRequest requestDTO) {
+ log.info("Request on: /revenue-analytics/summary"
+ + " with attributes: {}", requestDTO);
+ return ResponseEntity.ok(revenueReportService
+ .getRevenueSummary(requestDTO.getStartDate(),
+ requestDTO.getEndDate(),
+ requestDTO.getPeriod().name()));
+ }
+
+ @GetMapping("/by-category")
+ public ResponseEntity> getRevenueByCategory(
+ @Valid @ModelAttribute final RevenueByCategoryRequest requestDTO) {
+ log.info("Request on: /revenue-analytics/by-category"
+ + " with attributes: {}", requestDTO);
+ List summaryList = revenueReportService
+ .getRevenueByCategory(
+ requestDTO.getStartDate(),
+ requestDTO.getEndDate());
+ return ResponseEntity.ok(summaryList);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/.gitkeep b/src/main/java/com/Podzilla/analytics/api/dtos/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/DateRangePaginationRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/DateRangePaginationRequest.java
new file mode 100644
index 0000000..f27a6c8
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/DateRangePaginationRequest.java
@@ -0,0 +1,42 @@
+package com.Podzilla.analytics.api.dtos;
+
+import java.time.LocalDate;
+
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+import com.Podzilla.analytics.validation.annotations.ValidPagination;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@ValidDateRange
+@ValidPagination
+@Data
+@AllArgsConstructor
+public class DateRangePaginationRequest
+ implements IDateRangeRequest, IPaginationRequest {
+
+ @NotNull(message = "startDate is required")
+ @Schema(description = "Start date of the range "
+ + "(inclusive)", example = "2024-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull(message = "endDate is required")
+ @Schema(description = "End date of the range "
+ + "(inclusive)", example = "2024-01-31", required = true)
+ private LocalDate endDate;
+
+ @Min(value = 0, message = "Page "
+ + "number must be greater than or equal to 0")
+ @Schema(description = "Page number "
+ + "(zero-based)", example = "0", required = true)
+ private int page;
+
+ @Min(value = 1, message = "Page "
+ + "size must be greater than 0")
+ @Schema(description = "Number of "
+ + "items per page", example = "20", required = true)
+ private int size;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/DateRangeRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/DateRangeRequest.java
new file mode 100644
index 0000000..c372a3e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/DateRangeRequest.java
@@ -0,0 +1,30 @@
+package com.Podzilla.analytics.api.dtos;
+
+import java.time.LocalDate;
+
+import org.springframework.format.annotation.DateTimeFormat;
+
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@ValidDateRange
+@Data
+@AllArgsConstructor
+public class DateRangeRequest implements IDateRangeRequest {
+
+ @NotNull(message = "startDate is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "Start date of the range (inclusive)",
+ example = "2024-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull(message = "endDate is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "End date of the range (inclusive)",
+ example = "2024-01-31", required = true)
+ private LocalDate endDate;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/ErrorResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/ErrorResponse.java
new file mode 100644
index 0000000..9f54982
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/ErrorResponse.java
@@ -0,0 +1,20 @@
+package com.Podzilla.analytics.api.dtos;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ErrorResponse {
+ private LocalDateTime timestamp;
+ private int status;
+ private String message;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/IDateRangeRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/IDateRangeRequest.java
new file mode 100644
index 0000000..114cbd8
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/IDateRangeRequest.java
@@ -0,0 +1,8 @@
+package com.Podzilla.analytics.api.dtos;
+import java.time.LocalDate;
+
+
+public interface IDateRangeRequest {
+ LocalDate getStartDate();
+ LocalDate getEndDate();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/IPaginationRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/IPaginationRequest.java
new file mode 100644
index 0000000..52655c4
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/IPaginationRequest.java
@@ -0,0 +1,6 @@
+package com.Podzilla.analytics.api.dtos;
+
+public interface IPaginationRequest {
+ int getPage();
+ int getSize();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/PaginationRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/PaginationRequest.java
new file mode 100644
index 0000000..15c8ea8
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/PaginationRequest.java
@@ -0,0 +1,26 @@
+package com.Podzilla.analytics.api.dtos;
+import jakarta.validation.constraints.Min;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+import com.Podzilla.analytics.validation.annotations.ValidPagination;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@ValidPagination
+@Data
+@AllArgsConstructor
+public class PaginationRequest implements IPaginationRequest {
+
+ @Min(value = 0, message = "Page number "
+ + "must be greater than or equal to 0")
+ @Schema(description = "Page number "
+ + "(zero-based)", example = "0", required = true)
+ private int page;
+
+ @Min(value = 1, message = "Page size "
+ + "must be greater than 0")
+ @Schema(description = "Number of "
+ + "items per page", example = "20", required = true)
+ private int size;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierAverageRatingResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierAverageRatingResponse.java
new file mode 100644
index 0000000..3a3b506
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierAverageRatingResponse.java
@@ -0,0 +1,59 @@
+package com.Podzilla.analytics.api.dtos.courier;
+
+import java.math.BigDecimal;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+// import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+@Data
+// @Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CourierAverageRatingResponse {
+
+ @Schema(description = "ID of the courier", example = "101")
+ private UUID courierId;
+
+ @Schema(description = "Full name of the courier", example = "John Doe")
+ private String courierName;
+
+ @Schema(description = "Average rating of the courier", example = "4.6")
+ private BigDecimal averageRating;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+ public static class Builder {
+ private UUID courierId;
+ private String courierName;
+ private BigDecimal averageRating;
+
+ public Builder() { }
+
+ public Builder courierId(final UUID courierId) {
+ this.courierId = courierId;
+ return this;
+ }
+
+ public Builder courierName(final String courierName) {
+ this.courierName = courierName;
+ return this;
+ }
+
+ public Builder averageRating(final BigDecimal averageRating) {
+ this.averageRating = averageRating;
+ return this;
+ }
+
+ public CourierAverageRatingResponse build() {
+ return new CourierAverageRatingResponse(
+ courierId,
+ courierName,
+ averageRating
+ );
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierDeliveryCountResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierDeliveryCountResponse.java
new file mode 100644
index 0000000..ebe79b6
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierDeliveryCountResponse.java
@@ -0,0 +1,27 @@
+package com.Podzilla.analytics.api.dtos.courier;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CourierDeliveryCountResponse {
+
+ @Schema(description = "ID of the courier", example = "101")
+ private UUID courierId;
+
+ @Schema(description = "Full name of the courier", example = "Jane Smith")
+ private String courierName;
+
+ @Schema(
+ description = "Total number of deliveries (successful + failed)",
+ example = "134"
+ )
+ private Long deliveryCount;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierPerformanceReportResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierPerformanceReportResponse.java
new file mode 100644
index 0000000..cfa58fc
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierPerformanceReportResponse.java
@@ -0,0 +1,35 @@
+package com.Podzilla.analytics.api.dtos.courier;
+
+import java.math.BigDecimal;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CourierPerformanceReportResponse {
+
+ @Schema(description = "ID of the courier", example = "105")
+ private UUID courierId;
+
+ @Schema(description = "Full name of the courier", example = "Ali Hassan")
+ private String courierName;
+
+ @Schema(description = "Total number of deliveries", example = "87")
+ private Long deliveryCount;
+
+ @Schema(
+ description = "Success rate as a decimal value (e.g., 0.92 for 92%)",
+ example = "0.92"
+ )
+ private BigDecimal successRate;
+
+ @Schema(description = "Average customer rating", example = "4.8")
+ private BigDecimal averageRating;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierSuccessRateResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierSuccessRateResponse.java
new file mode 100644
index 0000000..2c8a8fa
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/courier/CourierSuccessRateResponse.java
@@ -0,0 +1,25 @@
+package com.Podzilla.analytics.api.dtos.courier;
+
+import java.math.BigDecimal;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CourierSuccessRateResponse {
+
+ @Schema(description = "ID of the courier", example = "103")
+ private UUID courierId;
+
+ @Schema(description = "Full name of the courier", example = "Fatima Ahmed")
+ private String courierName;
+
+ @Schema(description = "Delivery success rate", example = "0.87")
+ private BigDecimal successRate;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/customer/CustomersTopSpendersResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/customer/CustomersTopSpendersResponse.java
new file mode 100644
index 0000000..e427be1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/customer/CustomersTopSpendersResponse.java
@@ -0,0 +1,18 @@
+package com.Podzilla.analytics.api.dtos.customer;
+
+import java.math.BigDecimal;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import java.util.UUID;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CustomersTopSpendersResponse {
+ private UUID customerId;
+ private String customerName;
+ private BigDecimal totalSpending;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java
new file mode 100644
index 0000000..7ccc44d
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentPlaceToShipRequest.java
@@ -0,0 +1,46 @@
+package com.Podzilla.analytics.api.dtos.fulfillment;
+
+import java.time.LocalDate;
+
+import org.springframework.format.annotation.DateTimeFormat;
+
+import com.Podzilla.analytics.api.dtos.IDateRangeRequest;
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ValidDateRange
+public class FulfillmentPlaceToShipRequest implements IDateRangeRequest {
+
+ /**
+ * Enum for grouping options in place-to-ship analytics.
+ */
+ public enum PlaceToShipGroupBy {
+ REGION, OVERALL
+ }
+
+ @NotNull(message = "startDate is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "Start date of the range (inclusive)",
+ example = "2024-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull(message = "endDate is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "End date of the range (inclusive)",
+ example = "2024-01-31", required = true)
+ private LocalDate endDate;
+
+ @NotNull(message = "groupBy is required")
+ @Schema(description = "How to group the results (OVERALL, REGION, COURIER "
+ + "depending on endpoint)", example = "OVERALL", required = true)
+ private PlaceToShipGroupBy groupBy;
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java
new file mode 100644
index 0000000..f74755f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentShipToDeliverRequest.java
@@ -0,0 +1,49 @@
+package com.Podzilla.analytics.api.dtos.fulfillment;
+
+import java.time.LocalDate;
+
+import org.springframework.format.annotation.DateTimeFormat;
+
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+import com.Podzilla.analytics.api.dtos.IDateRangeRequest;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Unified request DTO for fulfillment analytics operations.
+ * Contains all parameters needed for analytics API endpoints.
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ValidDateRange
+public class FulfillmentShipToDeliverRequest implements IDateRangeRequest {
+
+ /**
+ * Enum for grouping options in ship-to-deliver analytics.
+ */
+ public enum ShipToDeliverGroupBy {
+ REGION, OVERALL, COURIER
+ }
+
+ @NotNull(message = "startDate is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "Start date of the range (inclusive)",
+ example = "2024-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull(message = "endDate is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "End date of the range (inclusive)",
+ example = "2024-01-31", required = true)
+ private LocalDate endDate;
+
+ @NotNull(message = "groupBy is required")
+ @Schema(description = "How to group the results (OVERALL, REGION, COURIER "
+ + "depending on endpoint)", example = "OVERALL", required = true)
+ private ShipToDeliverGroupBy groupBy;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentTimeResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentTimeResponse.java
new file mode 100644
index 0000000..c38e90b
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/fulfillment/FulfillmentTimeResponse.java
@@ -0,0 +1,26 @@
+package com.Podzilla.analytics.api.dtos.fulfillment;
+
+import java.math.BigDecimal;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class FulfillmentTimeResponse {
+
+ @Schema(
+ description = "Group by value (overall or by ID)",
+ example = "OVERALL")
+ private String groupByValue;
+
+ @Schema(
+ description = "Average duration in hours",
+ example = "48.5")
+ private BigDecimal averageDuration;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/inventory/InventoryValueByCategoryResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/inventory/InventoryValueByCategoryResponse.java
new file mode 100644
index 0000000..5623bff
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/inventory/InventoryValueByCategoryResponse.java
@@ -0,0 +1,16 @@
+package com.Podzilla.analytics.api.dtos.inventory;
+
+import java.math.BigDecimal;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class InventoryValueByCategoryResponse {
+ private String category;
+ private BigDecimal totalStockValue;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/inventory/LowStockProductResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/inventory/LowStockProductResponse.java
new file mode 100644
index 0000000..d4715cc
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/inventory/LowStockProductResponse.java
@@ -0,0 +1,18 @@
+package com.Podzilla.analytics.api.dtos.inventory;
+
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+import java.util.UUID;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class LowStockProductResponse {
+ private UUID productId;
+ private String productName;
+ private Long currentQuantity;
+ private Long threshold;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderFailureReasonsResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderFailureReasonsResponse.java
new file mode 100644
index 0000000..a94cddf
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderFailureReasonsResponse.java
@@ -0,0 +1,22 @@
+package com.Podzilla.analytics.api.dtos.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderFailureReasonsResponse {
+
+ @Schema(description = "Reason for order failure",
+ example = "Payment declined")
+ private String reason;
+
+ @Schema(description = "Count of orders with this failure reason",
+ example = "150")
+ private Long count;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderFailureResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderFailureResponse.java
new file mode 100644
index 0000000..913a9df
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderFailureResponse.java
@@ -0,0 +1,26 @@
+package com.Podzilla.analytics.api.dtos.order;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderFailureResponse {
+
+ @Schema(description = "Percentage of orders that failed",
+ example = "0.25")
+ private BigDecimal failureRate;
+
+ @Schema(description = "List of reasons for order failures",
+ example = "[{\"reason\": \"Out of stock\", \"count\": 10},"
+ + "{\"reason\": \"Payment failure\", \"count\": 5}]")
+ private List reasons;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderRegionResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderRegionResponse.java
new file mode 100644
index 0000000..cf2f9f2
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderRegionResponse.java
@@ -0,0 +1,29 @@
+package com.Podzilla.analytics.api.dtos.order;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.math.BigDecimal;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderRegionResponse {
+
+ @Schema(description = "city name", example = "Metropolis")
+ private String city;
+
+ @Schema(description = "country name", example = "USA")
+ private String country;
+
+ @Schema(description = "number of orders in the region",
+ example = "100")
+ private Long orderCount;
+
+ @Schema(description = "average revenue generated from orders in the region",
+ example = "10000.00")
+ private BigDecimal averageOrderValue;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderStatusResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderStatusResponse.java
new file mode 100644
index 0000000..858215f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/order/OrderStatusResponse.java
@@ -0,0 +1,22 @@
+package com.Podzilla.analytics.api.dtos.order;
+
+import com.Podzilla.analytics.models.Order.OrderStatus;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderStatusResponse {
+
+ @Schema(description = "Order status", example = "COMPLETED")
+ private OrderStatus status;
+
+ @Schema(description = "Count of orders with this status", example = "150")
+ private Long count;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/product/TopSellerRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/product/TopSellerRequest.java
new file mode 100644
index 0000000..db46713
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/product/TopSellerRequest.java
@@ -0,0 +1,54 @@
+package com.Podzilla.analytics.api.dtos.product;
+
+import java.time.LocalDate;
+
+import org.jetbrains.annotations.NotNull;
+import org.springframework.format.annotation.DateTimeFormat;
+
+
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+import com.Podzilla.analytics.api.dtos.IDateRangeRequest;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Positive;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@ValidDateRange
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class TopSellerRequest implements IDateRangeRequest {
+ @NotNull
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "Start date for the report (inclusive)",
+ example = "2024-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "End date for the report (inclusive)",
+ example = "2024-01-31", required = true)
+ private LocalDate endDate;
+
+ @NotNull
+ @Positive
+ @Schema(description = "Maximum number of top sellers to return",
+ example = "10", required = true)
+ private Integer limit;
+
+ @NotNull
+ @Schema(description = "Sort by revenue or units", required = true,
+ implementation = SortBy.class)
+ private SortBy sortBy;
+
+ public enum SortBy {
+ @Schema(description = "Sort by total revenue")
+ REVENUE,
+ @Schema(description = "Sort by total units sold")
+ UNITS
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/product/TopSellerResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/product/TopSellerResponse.java
new file mode 100644
index 0000000..46d5ae4
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/product/TopSellerResponse.java
@@ -0,0 +1,68 @@
+package com.Podzilla.analytics.api.dtos.product;
+
+import java.math.BigDecimal;
+
+import lombok.AllArgsConstructor;
+// import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.UUID;
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+// @Builder
+public class TopSellerResponse {
+ @Schema(
+ description = "Product ID",
+ example = "550e8400-e29b-41d4-a716-446655440000"
+ )
+ private UUID productId;
+ @Schema(description = "Product name", example = "Wireless Mouse")
+ private String productName;
+ @Schema(description = "Product category", example = "Electronics")
+ private String category;
+ @Schema(description = "Total value sold", example = "2500.75")
+ private BigDecimal value;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+ public static class Builder {
+ private UUID productId;
+ private String productName;
+ private String category;
+ private BigDecimal value;
+
+ public Builder productId(final UUID productId) {
+ this.productId = productId;
+ return this;
+ }
+
+ public Builder productName(final String productName) {
+ this.productName = productName;
+ return this;
+ }
+
+ public Builder category(final String category) {
+ this.category = category;
+ return this;
+ }
+
+ public Builder value(final BigDecimal value) {
+ this.value = value;
+ return this;
+ }
+
+ public TopSellerResponse build() {
+ return new TopSellerResponse(
+ productId,
+ productName,
+ category,
+ value
+ );
+ }
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/profit/ProfitByCategory.java b/src/main/java/com/Podzilla/analytics/api/dtos/profit/ProfitByCategory.java
new file mode 100644
index 0000000..f38927f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/profit/ProfitByCategory.java
@@ -0,0 +1,35 @@
+package com.Podzilla.analytics.api.dtos.profit;
+
+import java.math.BigDecimal;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProfitByCategory {
+
+ @Schema(description = "Product category name", example = "Electronics")
+ private String category;
+
+ @Schema(description = "Total revenue for the category in the given period",
+ example = "15000.50")
+ private BigDecimal totalRevenue;
+
+ @Schema(description = "Total cost for the category in the given period",
+ example = "10000.25")
+ private BigDecimal totalCost;
+
+ @Schema(description = "Gross profit (revenue - cost)",
+ example = "5000.25")
+ private BigDecimal grossProfit;
+
+ @Schema(description = "Gross profit margin percentage",
+ example = "33.33")
+ private BigDecimal grossProfitMargin;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueByCategoryRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueByCategoryRequest.java
new file mode 100644
index 0000000..0d558d1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueByCategoryRequest.java
@@ -0,0 +1,35 @@
+package com.Podzilla.analytics.api.dtos.revenue;
+
+import java.time.LocalDate;
+import jakarta.validation.constraints.NotNull;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+import com.Podzilla.analytics.api.dtos.IDateRangeRequest;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@ValidDateRange
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "Request parameters for fetching revenue by category")
+public class RevenueByCategoryRequest implements IDateRangeRequest {
+
+ @NotNull(message = "Start date is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "Start date for the revenue report (inclusive)",
+ example = "2023-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull(message = "End date is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "End date for the revenue report (inclusive)",
+ example = "2023-01-31", required = true)
+ private LocalDate endDate;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueByCategoryResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueByCategoryResponse.java
new file mode 100644
index 0000000..6b2ccab
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueByCategoryResponse.java
@@ -0,0 +1,22 @@
+package com.Podzilla.analytics.api.dtos.revenue;
+
+import java.math.BigDecimal;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class RevenueByCategoryResponse {
+ @Schema(description = "Category name", example = "Electronics")
+ private String category;
+ @Schema(description = "Total revenue for the category",
+ example = "12345.67")
+ private BigDecimal totalRevenue;
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueSummaryRequest.java b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueSummaryRequest.java
new file mode 100644
index 0000000..094fac5
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueSummaryRequest.java
@@ -0,0 +1,47 @@
+package com.Podzilla.analytics.api.dtos.revenue;
+
+import java.time.LocalDate;
+
+import jakarta.validation.constraints.NotNull;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import com.Podzilla.analytics.api.dtos.IDateRangeRequest;
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@ValidDateRange
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@Schema(description = "Request parameters for revenue summary")
+public class RevenueSummaryRequest implements IDateRangeRequest {
+
+ @NotNull(message = "Start date is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "Start date for the revenue summary (inclusive)",
+ example = "2023-01-01", required = true)
+ private LocalDate startDate;
+
+ @NotNull(message = "End date is required")
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
+ @Schema(description = "End date for the revenue summary (inclusive)",
+ example = "2023-01-31", required = true)
+ private LocalDate endDate;
+
+ @NotNull(message = "Period is required")
+ @Schema(description = "Period granularity for summary",
+ required = true, implementation = Period.class)
+ private Period period;
+
+ public enum Period {
+ DAILY,
+ WEEKLY,
+ MONTHLY
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueSummaryResponse.java b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueSummaryResponse.java
new file mode 100644
index 0000000..227ef85
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/dtos/revenue/RevenueSummaryResponse.java
@@ -0,0 +1,50 @@
+package com.Podzilla.analytics.api.dtos.revenue;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import lombok.AllArgsConstructor;
+// import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+// @Builder
+public class RevenueSummaryResponse {
+ @Schema(description = "Start date of the period for the revenue summary",
+ example = "2023-01-01")
+ private LocalDate periodStartDate;
+
+ @Schema(description = "Total revenue for the specified period",
+ example = "12345.67")
+ private BigDecimal totalRevenue;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private LocalDate periodStartDate;
+ private BigDecimal totalRevenue;
+ public Builder() { }
+
+ public Builder periodStartDate(final LocalDate periodStartDate) {
+ this.periodStartDate = periodStartDate;
+ return this;
+ }
+
+ public Builder totalRevenue(final BigDecimal totalRevenue) {
+ this.totalRevenue = totalRevenue;
+ return this;
+ }
+
+ public RevenueSummaryResponse build() {
+ return new RevenueSummaryResponse(periodStartDate, totalRevenue);
+ }
+ }
+}
+
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/courier/CourierPerformanceProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/courier/CourierPerformanceProjection.java
new file mode 100644
index 0000000..904176f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/courier/CourierPerformanceProjection.java
@@ -0,0 +1,16 @@
+package com.Podzilla.analytics.api.projections.courier;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public interface CourierPerformanceProjection {
+ UUID getCourierId();
+
+ String getCourierName();
+
+ Long getDeliveryCount();
+
+ Long getCompletedCount();
+
+ BigDecimal getAverageRating();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/customer/CustomersTopSpendersProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/customer/CustomersTopSpendersProjection.java
new file mode 100644
index 0000000..27da2a7
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/customer/CustomersTopSpendersProjection.java
@@ -0,0 +1,12 @@
+package com.Podzilla.analytics.api.projections.customer;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public interface CustomersTopSpendersProjection {
+ UUID getCustomerId();
+
+ String getCustomerName();
+
+ BigDecimal getTotalSpending();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/fulfillment/FulfillmentTimeProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/fulfillment/FulfillmentTimeProjection.java
new file mode 100644
index 0000000..a32be7e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/fulfillment/FulfillmentTimeProjection.java
@@ -0,0 +1,6 @@
+package com.Podzilla.analytics.api.projections.fulfillment;
+
+public interface FulfillmentTimeProjection {
+ String getGroupByValue();
+ Double getAverageDuration();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/inventory/InventoryValueByCategoryProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/inventory/InventoryValueByCategoryProjection.java
new file mode 100644
index 0000000..476b819
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/inventory/InventoryValueByCategoryProjection.java
@@ -0,0 +1,9 @@
+package com.Podzilla.analytics.api.projections.inventory;
+
+import java.math.BigDecimal;
+
+public interface InventoryValueByCategoryProjection {
+ String getCategory();
+
+ BigDecimal getTotalStockValue();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/inventory/LowStockProductProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/inventory/LowStockProductProjection.java
new file mode 100644
index 0000000..afd24f1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/inventory/LowStockProductProjection.java
@@ -0,0 +1,12 @@
+package com.Podzilla.analytics.api.projections.inventory;
+
+import java.util.UUID;
+public interface LowStockProductProjection {
+ UUID getProductId();
+
+ String getProductName();
+
+ Long getCurrentQuantity();
+
+ Long getThreshold();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/order/OrderFailureRateProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderFailureRateProjection.java
new file mode 100644
index 0000000..7488684
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderFailureRateProjection.java
@@ -0,0 +1,7 @@
+package com.Podzilla.analytics.api.projections.order;
+
+import java.math.BigDecimal;
+
+public interface OrderFailureRateProjection {
+ BigDecimal getFailureRate();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/order/OrderFailureReasonsProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderFailureReasonsProjection.java
new file mode 100644
index 0000000..ca6e1d9
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderFailureReasonsProjection.java
@@ -0,0 +1,6 @@
+package com.Podzilla.analytics.api.projections.order;
+
+public interface OrderFailureReasonsProjection {
+ String getReason();
+ Long getCount();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/order/OrderRegionProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderRegionProjection.java
new file mode 100644
index 0000000..c1e9acc
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderRegionProjection.java
@@ -0,0 +1,10 @@
+package com.Podzilla.analytics.api.projections.order;
+
+import java.math.BigDecimal;
+
+public interface OrderRegionProjection {
+ String getCity();
+ String getCountry();
+ Long getOrderCount();
+ BigDecimal getAverageOrderValue();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/order/OrderStatusProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderStatusProjection.java
new file mode 100644
index 0000000..93ba6b8
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/order/OrderStatusProjection.java
@@ -0,0 +1,7 @@
+package com.Podzilla.analytics.api.projections.order;
+import com.Podzilla.analytics.models.Order.OrderStatus;
+
+public interface OrderStatusProjection {
+ OrderStatus getStatus();
+ Long getCount();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/product/TopSellingProductProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/product/TopSellingProductProjection.java
new file mode 100644
index 0000000..184fee1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/product/TopSellingProductProjection.java
@@ -0,0 +1,12 @@
+package com.Podzilla.analytics.api.projections.product;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+public interface TopSellingProductProjection {
+ UUID getId();
+ String getName();
+ String getCategory();
+ BigDecimal getTotalRevenue();
+ Long getTotalUnits();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java
new file mode 100644
index 0000000..23a8968
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/profit/ProfitByCategoryProjection.java
@@ -0,0 +1,12 @@
+package com.Podzilla.analytics.api.projections.profit;
+
+import java.math.BigDecimal;
+
+/**
+ * Projection interface for profit by category query results
+ */
+public interface ProfitByCategoryProjection {
+ String getCategory();
+ BigDecimal getTotalRevenue();
+ BigDecimal getTotalCost();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/revenue/RevenueByCategoryProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/revenue/RevenueByCategoryProjection.java
new file mode 100644
index 0000000..bee429c
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/revenue/RevenueByCategoryProjection.java
@@ -0,0 +1,9 @@
+package com.Podzilla.analytics.api.projections.revenue;
+
+
+import java.math.BigDecimal;
+
+public interface RevenueByCategoryProjection {
+ String getCategory();
+ BigDecimal getTotalRevenue();
+}
diff --git a/src/main/java/com/Podzilla/analytics/api/projections/revenue/RevenueSummaryProjection.java b/src/main/java/com/Podzilla/analytics/api/projections/revenue/RevenueSummaryProjection.java
new file mode 100644
index 0000000..75bf684
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/api/projections/revenue/RevenueSummaryProjection.java
@@ -0,0 +1,9 @@
+package com.Podzilla.analytics.api.projections.revenue;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+public interface RevenueSummaryProjection {
+ LocalDate getPeriod();
+ BigDecimal getTotalRevenue();
+}
diff --git a/src/main/java/com/Podzilla/analytics/config/DatabaseSeeder.java b/src/main/java/com/Podzilla/analytics/config/DatabaseSeeder.java
new file mode 100644
index 0000000..57eb4a0
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/config/DatabaseSeeder.java
@@ -0,0 +1,371 @@
+package com.Podzilla.analytics.config;
+
+import com.Podzilla.analytics.models.Courier;
+import com.Podzilla.analytics.models.Customer;
+import com.Podzilla.analytics.models.Order;
+import com.Podzilla.analytics.models.Product;
+import com.Podzilla.analytics.models.ProductSnapshot;
+import com.Podzilla.analytics.models.Region;
+import com.Podzilla.analytics.models.OrderItem;
+import com.Podzilla.analytics.repositories.CourierRepository;
+import com.Podzilla.analytics.repositories.CustomerRepository;
+import com.Podzilla.analytics.repositories.ProductSnapshotRepository;
+import com.Podzilla.analytics.repositories.OrderRepository;
+import com.Podzilla.analytics.repositories.ProductRepository;
+import com.Podzilla.analytics.repositories.RegionRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+
+@Component
+@RequiredArgsConstructor
+public class DatabaseSeeder implements CommandLineRunner {
+
+ private final CourierRepository courierRepository;
+ private final CustomerRepository customerRepository;
+ private final ProductRepository productRepository;
+ private final RegionRepository regionRepository;
+ private final OrderRepository orderRepository;
+ private final ProductSnapshotRepository productSnapshotRepository;
+
+ private final Random random = new Random();
+ private static final int LOW_STOCK_PROD1 = 10;
+ private static final int LOW_STOCK_PROD2 = 20;
+ private static final int LOW_STOCK_PROD3 = 50;
+ private static final int LOW_STOCK_PROD4 = 30;
+
+ private static final BigDecimal PRICE_PROD1 = new BigDecimal("199.99");
+ private static final BigDecimal PRICE_PROD2 = new BigDecimal("99.99");
+ private static final BigDecimal PRICE_PROD3 = new BigDecimal("49.50");
+ private static final BigDecimal PRICE_PROD4 = new BigDecimal("19.95");
+
+ private static final BigDecimal RATING_GOOD = new BigDecimal("4.5");
+ private static final BigDecimal RATING_POOR = new BigDecimal("2.0");
+ private static final BigDecimal RATING_EXCELLENT = new BigDecimal("5.0");
+
+ private static final int ORDER_ITEM_COUNT_3 = 3;
+ private static final int ORDER_ITEM_COUNT_2 = 2;
+
+ private static final int ORDER_1_DAYS_PRIOR = 10;
+ private static final int ORDER_1_HOUR = 9;
+ private static final int ORDER_1_MINUTE = 30;
+ private static final int ORDER_1_SHIP_HOURS = 4;
+ private static final int ORDER_1_DELIVER_DAYS = 2;
+ private static final int ORDER_1_DELIVER_HOURS = 1;
+
+ private static final int ORDER_2_DAYS_PRIOR = 3;
+ private static final int ORDER_2_HOUR = 14;
+ private static final int ORDER_2_MINUTE = 0;
+ private static final int ORDER_2_SHIP_DAYS = 1;
+ private static final int ORDER_2_SHIP_HOURS = 1;
+
+ private static final int ORDER_3_DAYS_PRIOR = 5;
+ private static final int ORDER_3_HOUR = 11;
+ private static final int ORDER_3_MINUTE = 15;
+ private static final int ORDER_3_SHIP_HOURS = 6;
+ private static final int ORDER_3_FINAL_DAYS = 3;
+
+ private static final int ORDER_4_DAYS_PRIOR = 1;
+ private static final int ORDER_4_HOUR = 16;
+ private static final int ORDER_4_MINUTE = 45;
+ private static final int ORDER_4_SHIP_HOURS = 3;
+ private static final int ORDER_4_DELIVER_HOURS = 20;
+
+ private static final int INVENTORY_SNAPSHOT_DAYS_PRIOR_1 = 5;
+ private static final int INVENTORY_SNAPSHOT_DAYS_PRIOR_2 = 1;
+ private static final int INVENTORY_RANGE_PROD1 = 50;
+ private static final int INVENTORY_RANGE_PROD2 = 100;
+ private static final int INVENTORY_RANGE_PROD3 = 150;
+ private static final int INVENTORY_RANGE_PROD4 = 80;
+ private static final int INVENTORY_QUANTITY_PROD1 = 40;
+ private static final int INVENTORY_QUANTITY_PROD2 = 90;
+ private static final int INVENTORY_QUANTITY_PROD3 = 140;
+ private static final int INVENTORY_QUANTITY_PROD4 = 70;
+
+ private static final int INDEX_THREE = 3;
+
+ @Override
+ @Transactional
+ public void run(final String... args) {
+ System.out.println("Checking if database needs seeding...");
+
+ if (courierRepository.count() > 0) {
+ System.out.println("Database already seeded. Skipping.");
+ return;
+ }
+
+ System.out.println("Seeding database...");
+
+ List regions = seedRegions();
+ System.out.println("Seeded Regions: " + regions.size());
+
+ List products = seedProducts();
+ System.out.println("Seeded Products: " + products.size());
+
+ List couriers = seedCouriers();
+ System.out.println("Seeded Couriers: " + couriers.size());
+
+ List customers = seedCustomers();
+ System.out.println("Seeded Customers: " + customers.size());
+
+ System.out.println("Seeding Orders and SalesLineItems...");
+ seedOrders(customers, couriers, regions, products);
+ System.out.println("Seeded Orders: " + orderRepository.count());
+
+ System.out.println("Seeding Inventory Snapshots...");
+ seedProductSnapshots(products);
+ System.out.println("Seeded Product Snapshots: "
+ + productSnapshotRepository.count());
+
+ System.out.println("Database seeding finished.");
+ }
+
+ private List seedRegions() {
+ Region region1 = regionRepository.save(
+ Region.builder()
+ // .id(UUID.randomUUID())
+ .city("Metropolis").state("NY")
+ .country("USA").postalCode("10001")
+ .build());
+ Region region2 = regionRepository.save(
+ Region.builder()
+ // .id(UUID.randomUUID())
+ .city("Gotham").state("NJ")
+ .country("USA").postalCode("07001")
+ .build());
+ Region region3 = regionRepository.save(
+ Region.builder()
+ // .id(UUID.randomUUID())
+ .city("Star City").state("CA")
+ .country("USA").postalCode("90210")
+ .build());
+ return Arrays.asList(region1, region2, region3);
+ }
+
+ private List seedProducts() {
+ Product prod1 = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("Podzilla Pro").category("Electronics")
+ .cost(PRICE_PROD1)
+ .lowStockThreshold(LOW_STOCK_PROD1).build());
+ Product prod2 = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("Podzilla Mini").category("Electronics")
+ .cost(PRICE_PROD2)
+ .lowStockThreshold(LOW_STOCK_PROD2).build());
+ Product prod3 = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("Charging Case").category("Accessories")
+ .cost(PRICE_PROD3)
+ .lowStockThreshold(LOW_STOCK_PROD3).build());
+ Product prod4 = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("Podzilla Cover").category("Accessories")
+ .cost(PRICE_PROD4)
+ .lowStockThreshold(LOW_STOCK_PROD4).build());
+ return Arrays.asList(prod1, prod2, prod3, prod4);
+ }
+
+ private List seedCouriers() {
+ Courier courier1 = courierRepository.save(
+ Courier.builder()
+ .id(UUID.randomUUID())
+ .name("Speedy Delivery Inc.").build());
+ Courier courier2 = courierRepository.save(
+ Courier.builder()
+ .id(UUID.randomUUID())
+ .name("Reliable Couriers Co.").build());
+ Courier courier3 = courierRepository.save(
+ Courier.builder()
+ .id(UUID.randomUUID())
+ .name("Overnight Express").build());
+ return Arrays.asList(courier1, courier2, courier3);
+ }
+
+ private List seedCustomers() {
+ Customer cust1 = customerRepository.save(
+ Customer.builder()
+ .id(UUID.randomUUID())
+ .name("Alice Smith").build());
+ Customer cust2 = customerRepository.save(
+ Customer.builder()
+ .id(UUID.randomUUID())
+ .name("Bob Johnson").build());
+ Customer cust3 = customerRepository.save(
+ Customer.builder()
+ .id(UUID.randomUUID())
+ .name("Charlie Brown").build());
+ return Arrays.asList(cust1, cust2, cust3);
+ }
+
+ private void seedOrders(
+ final List customers,
+ final List couriers,
+ final List regions,
+ final List products) {
+ LocalDate today = LocalDate.now();
+
+ // Order 1
+ LocalDateTime placed1 = today.minusDays(ORDER_1_DAYS_PRIOR)
+ .atTime(ORDER_1_HOUR, ORDER_1_MINUTE);
+ Order order1 = Order.builder()
+ .id(UUID.randomUUID())
+ .customer(customers.get(0)).courier(couriers.get(0))
+ .region(regions.get(0))
+ .status(Order.OrderStatus.DELIVERED)
+ .orderPlacedTimestamp(placed1)
+ .shippedTimestamp(placed1.plusHours(ORDER_1_SHIP_HOURS))
+ .deliveredTimestamp(placed1.plusDays(ORDER_1_DELIVER_DAYS)
+ .plusHours(ORDER_1_DELIVER_HOURS))
+ .finalStatusTimestamp(placed1.plusDays(ORDER_1_DELIVER_DAYS)
+ .plusHours(ORDER_1_DELIVER_HOURS))
+ .numberOfItems(ORDER_ITEM_COUNT_3)
+ .totalAmount(BigDecimal.ZERO)
+ .courierRating(RATING_GOOD)
+ .build();
+ OrderItem itemFirstOrderFirst = OrderItem.builder()
+ .order(order1).product(products.get(0)).quantity(1)
+ .pricePerUnit(PRICE_PROD1).build();
+ OrderItem itemFirstOrderSecond = OrderItem.builder()
+ .order(order1).product(products.get(2)).quantity(2)
+ .pricePerUnit(PRICE_PROD3).build();
+ order1.setOrderItems(Arrays.asList(itemFirstOrderFirst,
+ itemFirstOrderSecond));
+ order1.setNumberOfItems(itemFirstOrderFirst.getQuantity()
+ + itemFirstOrderSecond.getQuantity());
+ order1.setTotalAmount(
+ itemFirstOrderFirst.getPricePerUnit().multiply(
+ BigDecimal.valueOf(itemFirstOrderFirst.getQuantity()))
+ .add(itemFirstOrderSecond.getPricePerUnit().multiply(
+ BigDecimal.valueOf(
+ itemFirstOrderSecond.getQuantity()))));
+ orderRepository.save(order1);
+
+ // Order 2
+ LocalDateTime placed2 = today.minusDays(ORDER_2_DAYS_PRIOR)
+ .atTime(ORDER_2_HOUR, ORDER_2_MINUTE);
+ Order order2 = Order.builder()
+ .id(UUID.randomUUID())
+ .customer(customers.get(1)).courier(couriers.get(1))
+ .region(regions.get(1))
+ .status(Order.OrderStatus.SHIPPED)
+ .orderPlacedTimestamp(placed2)
+ .shippedTimestamp(placed2.plusDays(ORDER_2_SHIP_DAYS)
+ .plusHours(ORDER_2_SHIP_HOURS))
+ .finalStatusTimestamp(placed2.plusDays(ORDER_2_SHIP_DAYS)
+ .plusHours(ORDER_2_SHIP_HOURS))
+ .courierRating(null).failureReason(null)
+ .build();
+ OrderItem itemSecondOrderFirst = OrderItem.builder()
+ .order(order2).product(products.get(1)).quantity(1)
+ .pricePerUnit(PRICE_PROD2).build();
+ order2.setOrderItems(List.of(itemSecondOrderFirst));
+ order2.setNumberOfItems(itemSecondOrderFirst.getQuantity());
+ order2.setTotalAmount(
+ itemSecondOrderFirst.getPricePerUnit().multiply(
+ BigDecimal.valueOf(itemSecondOrderFirst
+ .getQuantity())));
+ orderRepository.save(order2);
+
+ // Order 3
+ LocalDateTime placed3 = today.minusDays(ORDER_3_DAYS_PRIOR)
+ .atTime(ORDER_3_HOUR, ORDER_3_MINUTE);
+ Order order3 = Order.builder()
+ .id(UUID.randomUUID())
+ .customer(customers.get(0)).courier(couriers.get(0))
+ .region(regions.get(2))
+ .orderPlacedTimestamp(placed3)
+ .status(Order.OrderStatus.DELIVERY_FAILED)
+ .orderPlacedTimestamp(placed3)
+ .shippedTimestamp(placed3.plusHours(ORDER_3_SHIP_HOURS))
+ .deliveredTimestamp(null)
+ .finalStatusTimestamp(placed3.plusDays(ORDER_3_FINAL_DAYS))
+ .failureReason("Delivery address incorrect")
+ .courierRating(RATING_POOR)
+ .build();
+ OrderItem itemThirdOrderFirst = OrderItem.builder()
+ .order(order3).product(products.get(INDEX_THREE)).quantity(1)
+ .pricePerUnit(PRICE_PROD4).build();
+ order3.setOrderItems(List.of(itemThirdOrderFirst));
+ order3.setNumberOfItems(itemThirdOrderFirst.getQuantity());
+ order3.setTotalAmount(
+ itemThirdOrderFirst.getPricePerUnit().multiply(
+ BigDecimal.valueOf(itemThirdOrderFirst.getQuantity())));
+ orderRepository.save(order3);
+
+ // Order 4
+ LocalDateTime placed4 = today.minusDays(ORDER_4_DAYS_PRIOR)
+ .atTime(ORDER_4_HOUR, ORDER_4_MINUTE);
+ Order order4 = Order.builder()
+ .id(UUID.randomUUID())
+ .customer(customers.get(2)).courier(couriers.get(1))
+ .region(regions.get(0))
+ .status(Order.OrderStatus.DELIVERED)
+ .orderPlacedTimestamp(placed4)
+ .shippedTimestamp(placed4.plusHours(ORDER_4_SHIP_HOURS))
+ .deliveredTimestamp(placed4.plusHours(ORDER_4_DELIVER_HOURS))
+ .finalStatusTimestamp(placed4.plusHours(ORDER_4_DELIVER_HOURS))
+ .numberOfItems(ORDER_ITEM_COUNT_2)
+ .totalAmount(BigDecimal.ZERO)
+ .courierRating(RATING_EXCELLENT)
+ .build();
+ OrderItem itemFourthOrderFirst = OrderItem.builder()
+ .order(order4).product(products.get(0)).quantity(1)
+ .pricePerUnit(PRICE_PROD1).build();
+ OrderItem itemFourthOrderSecond = OrderItem.builder()
+ .order(order4).product(products.get(INDEX_THREE)).quantity(1)
+ .pricePerUnit(PRICE_PROD4).build();
+ order4.setOrderItems(Arrays.asList(itemFourthOrderFirst,
+ itemFourthOrderSecond));
+ order4.setNumberOfItems(
+ itemFourthOrderFirst.getQuantity() + itemFourthOrderSecond
+ .getQuantity());
+ order4.setTotalAmount(
+ itemFourthOrderFirst.getPricePerUnit().multiply(
+ BigDecimal.valueOf(itemFourthOrderFirst.getQuantity()))
+ .add(itemFourthOrderSecond.getPricePerUnit().multiply(
+ BigDecimal.valueOf(itemFourthOrderSecond
+ .getQuantity()))));
+ orderRepository.save(order4);
+ }
+
+ private void seedProductSnapshots(final List products) {
+ seedProductSnapshot(products.get(0), INVENTORY_RANGE_PROD1,
+ INVENTORY_QUANTITY_PROD1);
+ seedProductSnapshot(products.get(1), INVENTORY_RANGE_PROD2,
+ INVENTORY_QUANTITY_PROD2);
+ seedProductSnapshot(products.get(2), INVENTORY_RANGE_PROD3,
+ INVENTORY_QUANTITY_PROD3);
+ seedProductSnapshot(products.get(INDEX_THREE), INVENTORY_RANGE_PROD4,
+ INVENTORY_QUANTITY_PROD4);
+ }
+
+ private void seedProductSnapshot(
+ final Product product, final int range, final int quantity) {
+ productSnapshotRepository.save(
+ ProductSnapshot.builder()
+ .product(product)
+ .quantity(random.nextInt(range)
+ + product.getLowStockThreshold())
+ .timestamp(LocalDateTime.now().minusDays(
+ INVENTORY_SNAPSHOT_DAYS_PRIOR_1))
+ .build());
+ productSnapshotRepository.save(
+ ProductSnapshot.builder()
+ .product(product)
+ .quantity(random.nextInt(quantity)
+ + product.getLowStockThreshold())
+ .timestamp(LocalDateTime.now().minusDays(
+ INVENTORY_SNAPSHOT_DAYS_PRIOR_2))
+ .build());
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/config/GlobalExceptionHandler.java b/src/main/java/com/Podzilla/analytics/config/GlobalExceptionHandler.java
new file mode 100644
index 0000000..7b49be1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/config/GlobalExceptionHandler.java
@@ -0,0 +1,77 @@
+package com.Podzilla.analytics.config;
+
+import java.time.LocalDateTime;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.BindException;
+import org.springframework.validation.ObjectError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.context.request.WebRequest;
+import com.Podzilla.analytics.api.dtos.ErrorResponse;
+import lombok.extern.slf4j.Slf4j;
+
+@ControllerAdvice
+@Slf4j
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ public ResponseEntity handleMethodArgumentNotValidException(
+ final MethodArgumentNotValidException ex,
+ final WebRequest request) {
+ return createSimpleErrorResponse(ex, request);
+ }
+
+ @ExceptionHandler(BindException.class)
+ public ResponseEntity handleBindException(
+ final BindException ex,
+ final WebRequest request) {
+ return createSimpleErrorResponse(ex, request);
+ }
+
+ private ResponseEntity createSimpleErrorResponse(
+ final BindException ex,
+ final WebRequest request) {
+
+ String errorMessage = ex.getBindingResult().getAllErrors().stream()
+ .map(ObjectError::getDefaultMessage)
+ .filter(m -> m != null && !m.isEmpty())
+ .findFirst()
+ .orElse("Validation failed for request input."
+ + " Please check your request parameters.");
+
+ log.warn("Validation failed for request URI: {}. Message: {}",
+ request.getDescription(false), errorMessage);
+
+ ErrorResponse errorResponse = ErrorResponse.builder()
+ .timestamp(LocalDateTime.now())
+ .status(HttpStatus.BAD_REQUEST.value())
+ .message(errorMessage)
+ .build();
+
+ return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleGenericException(
+ final Exception ex,
+ final WebRequest request) {
+
+ log.error("Unexpected error occurred processing request {}: {}",
+ request.getDescription(false),
+ ex.getMessage(),
+ ex);
+
+ ErrorResponse errorResponse = ErrorResponse.builder()
+ .timestamp(LocalDateTime.now())
+ .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
+ .message("An unexpected internal server error"
+ + " occurred. Please try again later.")
+ .build();
+
+ return new ResponseEntity<>(errorResponse,
+ HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/config/OpenApiConfig.java b/src/main/java/com/Podzilla/analytics/config/OpenApiConfig.java
new file mode 100644
index 0000000..0796b98
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/config/OpenApiConfig.java
@@ -0,0 +1,19 @@
+package com.Podzilla.analytics.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+ @Bean
+ public OpenAPI customOpenAPI() {
+ return new OpenAPI()
+ .info(new Info()
+ .title("Podzilla Analytics API")
+ .version("1.0")
+ .description(
+ "API documentation for analytics services."));
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/eventhandler/DTOs/ADTO.java b/src/main/java/com/Podzilla/analytics/eventhandler/DTOs/ADTO.java
new file mode 100644
index 0000000..3a24c6c
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/eventhandler/DTOs/ADTO.java
@@ -0,0 +1,5 @@
+package com.Podzilla.analytics.eventhandler.DTOs;
+
+public class ADTO {
+ private String a;
+}
diff --git a/src/main/java/com/Podzilla/analytics/eventhandler/EventHandlerDispatcher.java b/src/main/java/com/Podzilla/analytics/eventhandler/EventHandlerDispatcher.java
new file mode 100644
index 0000000..0dc8729
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/eventhandler/EventHandlerDispatcher.java
@@ -0,0 +1,37 @@
+package com.Podzilla.analytics.eventhandler;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class EventHandlerDispatcher {
+ private final Map, IEventHandler>> handlers;
+
+ public EventHandlerDispatcher() {
+ handlers = new ConcurrentHashMap<>();
+ }
+
+ public void registerHandler(final Class dto,
+ final IEventHandler handler) {
+ if (dto == null || handler == null) {
+ throw new IllegalArgumentException("DTO and Handler"
+ + " cannot be null");
+ }
+ handlers.put(dto, handler);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void dispatch(final T dto) {
+ if (dto == null) {
+ throw new IllegalArgumentException("Event DTO cannot be null");
+ }
+
+ IEventHandler handler = (IEventHandler) handlers
+ .get(dto.getClass());
+ if (handler != null) {
+ handler.handle(dto);
+ } else {
+ throw new RuntimeException("No handler found for: "
+ + dto.getClass());
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/eventhandler/EventHandlerDispatcherConfig.java b/src/main/java/com/Podzilla/analytics/eventhandler/EventHandlerDispatcherConfig.java
new file mode 100644
index 0000000..4265b05
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/eventhandler/EventHandlerDispatcherConfig.java
@@ -0,0 +1,19 @@
+package com.Podzilla.analytics.eventhandler;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class EventHandlerDispatcherConfig {
+
+ @Bean
+ public EventHandlerDispatcher commandDispatcher() {
+ EventHandlerDispatcher dispatcher = new EventHandlerDispatcher();
+
+ // Register all event handlers here
+ // Example:
+ // dispatcher.registerHandler(aDTO.class, new aHandler());
+
+ return dispatcher;
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/eventhandler/IEventHandler.java b/src/main/java/com/Podzilla/analytics/eventhandler/IEventHandler.java
new file mode 100644
index 0000000..c0edc83
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/eventhandler/IEventHandler.java
@@ -0,0 +1,6 @@
+package com.Podzilla.analytics.eventhandler;
+
+
+public interface IEventHandler { //T should be the DTO of the event
+ void handle(T eventDto);
+}
diff --git a/src/main/java/com/Podzilla/analytics/eventhandler/handlers/AHandler.java b/src/main/java/com/Podzilla/analytics/eventhandler/handlers/AHandler.java
new file mode 100644
index 0000000..776e677
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/eventhandler/handlers/AHandler.java
@@ -0,0 +1,11 @@
+package com.Podzilla.analytics.eventhandler.handlers;
+
+import com.Podzilla.analytics.eventhandler.IEventHandler;
+import com.Podzilla.analytics.eventhandler.DTOs.ADTO;
+
+public class AHandler implements IEventHandler {
+
+ @Override
+ public void handle(final ADTO eventDto) {
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/AnalyticsRabbitListener.java b/src/main/java/com/Podzilla/analytics/messaging/AnalyticsRabbitListener.java
new file mode 100644
index 0000000..78ec3a9
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/AnalyticsRabbitListener.java
@@ -0,0 +1,40 @@
+package com.Podzilla.analytics.messaging;
+
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import com.podzilla.mq.EventsConstants;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.podzilla.mq.events.BaseEvent;
+import org.springframework.stereotype.Service;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+@Service
+public class AnalyticsRabbitListener {
+
+ @Autowired
+ private InvokerDispatcher dispatcher;
+
+ @RabbitListener(
+ queues = EventsConstants.ANALYTICS_USER_EVENT_QUEUE
+ )
+ public void handleUserEvents(final BaseEvent userEvent) {
+ log.info("Received user event: {}", userEvent);
+ dispatcher.dispatch(userEvent);
+ }
+
+ @RabbitListener(
+ queues = EventsConstants.ANALYTICS_ORDER_EVENT_QUEUE
+ )
+ public void handleOrderEvents(final BaseEvent orderEvent) {
+ log.info("Received order event: {}", orderEvent);
+ dispatcher.dispatch(orderEvent);
+ }
+
+ @RabbitListener(
+ queues = EventsConstants.ANALYTICS_INVENTORY_EVENT_QUEUE
+ )
+ public void handleInventoryEvents(final BaseEvent inventoryEvent) {
+ log.info("Received inventory event: {}", inventoryEvent);
+ dispatcher.dispatch(inventoryEvent);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/InvokerDispatcher.java b/src/main/java/com/Podzilla/analytics/messaging/InvokerDispatcher.java
new file mode 100644
index 0000000..00cb016
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/InvokerDispatcher.java
@@ -0,0 +1,40 @@
+package com.Podzilla.analytics.messaging;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+
+public class InvokerDispatcher {
+ private final Map, Invoker>> invokers;
+
+ public InvokerDispatcher() {
+ this.invokers = new ConcurrentHashMap<>();
+ }
+
+ public void registerInvoker(
+ final Class event, final Invoker invoker
+ ) {
+ if (event == null || invoker == null) {
+ throw new IllegalArgumentException(
+ "Event and Invoker cannot be null"
+ );
+ }
+ invokers.put(event, invoker);
+ }
+
+ @SuppressWarnings("unchecked")
+ public void dispatch(final T event) {
+ if (event == null) {
+ throw new IllegalArgumentException("Event cannot be null");
+ }
+
+ Invoker invoker = (Invoker) invokers.get(event.getClass());
+ if (invoker != null) {
+ invoker.invoke(event);
+ } else {
+ throw new RuntimeException("No invoker found for: "
+ + event.getClass());
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/InvokerDispatcherConfig.java b/src/main/java/com/Podzilla/analytics/messaging/InvokerDispatcherConfig.java
new file mode 100644
index 0000000..bae26ac
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/InvokerDispatcherConfig.java
@@ -0,0 +1,115 @@
+package com.Podzilla.analytics.messaging;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import com.Podzilla.analytics.messaging.invokers.user.CourierRegisteredInvoker;
+import com.Podzilla.analytics.messaging.invokers.user.CustomerRegisteredInvoker;
+import com.Podzilla.analytics.messaging.invokers.order.OrderAssignedToCourierInvoker;
+import com.Podzilla.analytics.messaging.invokers.order.OrderDeliveryFailedInvoker;
+import com.Podzilla.analytics.messaging.invokers.order.OrderPlacedInvoker;
+import com.Podzilla.analytics.messaging.invokers.order.OrderCancelledInvoker;
+import com.Podzilla.analytics.messaging.invokers.order.OrderDeliveredInvoker;
+import com.Podzilla.analytics.messaging.invokers.order.OrderOutForDeliveryInvoker;
+import com.Podzilla.analytics.messaging.invokers.InvokerFactory;
+import com.Podzilla.analytics.messaging.invokers.inventory.InventoryUpdatedInvoker;
+import com.Podzilla.analytics.messaging.invokers.inventory.ProductCreatedInvoker;
+import com.Podzilla.analytics.messaging.invokers.inventory.OrderFulfillmentFailedInvoker;
+
+import com.podzilla.mq.events.CourierRegisteredEvent;
+import com.podzilla.mq.events.CustomerRegisteredEvent;
+import com.podzilla.mq.events.OrderAssignedToCourierEvent;
+import com.podzilla.mq.events.OrderDeliveryFailedEvent;
+import com.podzilla.mq.events.OrderPlacedEvent;
+import com.podzilla.mq.events.OrderCancelledEvent;
+import com.podzilla.mq.events.OrderDeliveredEvent;
+import com.podzilla.mq.events.OrderOutForDeliveryEvent;
+import com.podzilla.mq.events.InventoryUpdatedEvent;
+import com.podzilla.mq.events.ProductCreatedEvent;
+import com.podzilla.mq.events.WarehouseOrderFulfillmentFailedEvent;
+
+
+@Configuration
+public class InvokerDispatcherConfig {
+
+ @Autowired
+ private final InvokerFactory invokerFactory;
+
+ public InvokerDispatcherConfig(final InvokerFactory invokerFactory) {
+ this.invokerFactory = invokerFactory;
+ }
+
+ @Bean
+ public InvokerDispatcher invokerDispatcher() {
+ InvokerDispatcher dispatcher = new InvokerDispatcher();
+
+ registerUserInvokers(dispatcher);
+ registerOrderInvokers(dispatcher);
+ registerInventoryInvokers(dispatcher);
+
+ return dispatcher;
+ }
+
+ private void registerUserInvokers(
+ final InvokerDispatcher dispatcher
+ ) {
+ dispatcher.registerInvoker(
+ CourierRegisteredEvent.class,
+ invokerFactory.createInvoker(CourierRegisteredInvoker.class)
+ );
+
+ dispatcher.registerInvoker(
+ CustomerRegisteredEvent.class,
+ invokerFactory.createInvoker(CustomerRegisteredInvoker.class)
+ );
+ }
+
+ private void registerOrderInvokers(
+ final InvokerDispatcher dispatcher
+ ) {
+ dispatcher.registerInvoker(
+ OrderAssignedToCourierEvent.class,
+ invokerFactory.createInvoker(OrderAssignedToCourierInvoker.class)
+ );
+ dispatcher.registerInvoker(
+ OrderCancelledEvent.class,
+ invokerFactory.createInvoker(OrderCancelledInvoker.class)
+ );
+ dispatcher.registerInvoker(
+ OrderDeliveredEvent.class,
+ invokerFactory.createInvoker(OrderDeliveredInvoker.class)
+ );
+ dispatcher.registerInvoker(
+ OrderDeliveryFailedEvent.class,
+ invokerFactory.createInvoker(OrderDeliveryFailedInvoker.class)
+ );
+ dispatcher.registerInvoker(
+ OrderOutForDeliveryEvent.class,
+ invokerFactory.createInvoker(OrderOutForDeliveryInvoker.class)
+ );
+ dispatcher.registerInvoker(
+ OrderPlacedEvent.class,
+ invokerFactory.createInvoker(OrderPlacedInvoker.class)
+ );
+ }
+
+ private void registerInventoryInvokers(
+ final InvokerDispatcher dispatcher
+ ) {
+ dispatcher.registerInvoker(
+ InventoryUpdatedEvent.class,
+ invokerFactory.createInvoker(InventoryUpdatedInvoker.class)
+ );
+ dispatcher.registerInvoker(
+ ProductCreatedEvent.class,
+ invokerFactory.createInvoker(ProductCreatedInvoker.class)
+ );
+
+ dispatcher.registerInvoker(
+ WarehouseOrderFulfillmentFailedEvent.class,
+ invokerFactory.createInvoker(OrderFulfillmentFailedInvoker.class)
+ );
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/Command.java b/src/main/java/com/Podzilla/analytics/messaging/commands/Command.java
new file mode 100644
index 0000000..3fb749f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/Command.java
@@ -0,0 +1,5 @@
+package com.Podzilla.analytics.messaging.commands;
+
+public interface Command {
+ void execute();
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/CommandFactory.java b/src/main/java/com/Podzilla/analytics/messaging/commands/CommandFactory.java
new file mode 100644
index 0000000..d385e02
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/CommandFactory.java
@@ -0,0 +1,200 @@
+package com.Podzilla.analytics.messaging.commands;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import com.Podzilla.analytics.services.CustomerAnalyticsService;
+import com.Podzilla.analytics.services.CourierAnalyticsService;
+import com.Podzilla.analytics.services.ProductAnalyticsService;
+import com.Podzilla.analytics.services.InventoryAnalyticsService;
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+import com.Podzilla.analytics.services.RegionService;
+import com.Podzilla.analytics.messaging.commands.user.RegisterCustomerCommand;
+import com.Podzilla.analytics.messaging.commands.user.RegisterCourierCommand;
+import com.Podzilla.analytics.messaging.commands.inventory.CreateProductCommand;
+import com.Podzilla.analytics.messaging.commands.inventory.UpdateInventoryCommand;
+import com.Podzilla.analytics.messaging.commands.inventory.MarkOrderAsFailedToFulfillCommand;
+import com.Podzilla.analytics.messaging.commands.order.PlaceOrderCommand;
+import com.Podzilla.analytics.messaging.commands.order.AssignCourierToOrderCommand;
+import com.Podzilla.analytics.messaging.commands.order.CancelOrderCommand;
+import com.Podzilla.analytics.messaging.commands.order.MarkOrderAsOutForDeliveryCommand;
+import com.Podzilla.analytics.messaging.commands.order.MarkOrderAsDeliveredCommand;
+import com.Podzilla.analytics.messaging.commands.order.MarkOrderAsFailedToDeliverCommand;
+
+
+import com.podzilla.mq.events.DeliveryAddress;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+@Component
+public class CommandFactory {
+
+ @Autowired
+ private CustomerAnalyticsService customerAnalyticsService;
+
+ @Autowired
+ private CourierAnalyticsService courierAnalyticsService;
+
+ @Autowired
+ private ProductAnalyticsService productAnalyticsService;
+
+ @Autowired
+ private InventoryAnalyticsService inventoryAnalyticsService;
+
+ @Autowired
+ private OrderAnalyticsService orderAnalyticsService;
+
+
+ @Autowired
+ private RegionService regionService;
+
+ public RegisterCustomerCommand createRegisterCustomerCommand(
+ final String customerId,
+ final String customerName
+ ) {
+ return RegisterCustomerCommand.builder()
+ .customerAnalyticsService(customerAnalyticsService)
+ .customerId(customerId)
+ .customerName(customerName)
+ .build();
+ }
+
+ public RegisterCourierCommand createRegisterCourierCommand(
+ final String courierId,
+ final String courierName
+ ) {
+ return RegisterCourierCommand.builder()
+ .courierAnalyticsService(courierAnalyticsService)
+ .courierId(courierId)
+ .courierName(courierName)
+ .build();
+ }
+
+ public CreateProductCommand createCreateProductCommand(
+ final String productId,
+ final String productName,
+ final String productCategory,
+ final BigDecimal productCost,
+ final Integer productLowStockThreshold
+ ) {
+ return CreateProductCommand.builder()
+ .productAnalyticsService(productAnalyticsService)
+ .productId(productId)
+ .productName(productName)
+ .productCategory(productCategory)
+ .productCost(productCost)
+ .productLowStockThreshold(productLowStockThreshold)
+ .build();
+ }
+
+ public UpdateInventoryCommand createUpdateInventoryCommand(
+ final String productId,
+ final Integer quantity,
+ final Instant timestamp
+ ) {
+ return UpdateInventoryCommand.builder()
+ .inventoryAnalyticsService(inventoryAnalyticsService)
+ .productId(productId)
+ .quantity(quantity)
+ .timestamp(timestamp)
+ .build();
+ }
+
+ public PlaceOrderCommand createPlaceOrderCommand(
+ final String orderId,
+ final String customerId,
+ final List items,
+ final DeliveryAddress deliveryAddress,
+ final BigDecimal totalAmount,
+ final Instant timestamp
+ ) {
+ return PlaceOrderCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .regionService(regionService)
+ .orderId(orderId)
+ .customerId(customerId)
+ .items(items)
+ .deliveryAddress(deliveryAddress)
+ .totalAmount(totalAmount)
+ .timestamp(timestamp)
+ .build();
+ }
+
+ public CancelOrderCommand createCancelOrderCommand(
+ final String orderId,
+ final String reason,
+ final Instant timestamp
+ ) {
+ return CancelOrderCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .orderId(orderId)
+ .reason(reason)
+ .timestamp(timestamp)
+ .build();
+ }
+
+ public AssignCourierToOrderCommand createAssignCourierToOrderCommand(
+ final String orderId,
+ final String courierId
+ ) {
+ return AssignCourierToOrderCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .orderId(orderId)
+ .courierId(courierId)
+ .build();
+ }
+
+ public MarkOrderAsOutForDeliveryCommand
+ createMarkOrderAsOutForDeliveryCommand(
+ final String orderId,
+ final Instant timestamp
+ ) {
+ return MarkOrderAsOutForDeliveryCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .orderId(orderId)
+ .timestamp(timestamp)
+ .build();
+ }
+
+ public MarkOrderAsDeliveredCommand createMarkOrderAsDeliveredCommand(
+ final String orderId,
+ final BigDecimal courierRating,
+ final Instant timestamp
+ ) {
+ return MarkOrderAsDeliveredCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .orderId(orderId)
+ .courierRating(courierRating)
+ .timestamp(timestamp)
+ .build();
+ }
+
+ public MarkOrderAsFailedToDeliverCommand
+ createMarkOrderAsFailedToDeliverCommand(
+ final String orderId,
+ final String reason,
+ final Instant timestamp
+ ) {
+ return MarkOrderAsFailedToDeliverCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .orderId(orderId)
+ .reason(reason)
+ .timestamp(timestamp)
+ .build();
+ }
+
+ public MarkOrderAsFailedToFulfillCommand
+ createMarkOrderAsFailedToFulfillCommand(
+ final String orderId,
+ final String reason,
+ final Instant timestamp
+ ) {
+ return MarkOrderAsFailedToFulfillCommand.builder()
+ .orderAnalyticsService(orderAnalyticsService)
+ .orderId(orderId)
+ .reason(reason)
+ .timestamp(timestamp)
+ .build();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/CreateProductCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/CreateProductCommand.java
new file mode 100644
index 0000000..a13cb48
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/CreateProductCommand.java
@@ -0,0 +1,30 @@
+package com.Podzilla.analytics.messaging.commands.inventory;
+
+import java.math.BigDecimal;
+import com.Podzilla.analytics.services.ProductAnalyticsService;
+
+import lombok.Builder;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+
+@Builder
+public class CreateProductCommand implements Command {
+
+ private ProductAnalyticsService productAnalyticsService;
+ private String productId;
+ private String productName;
+ private String productCategory;
+ private BigDecimal productCost;
+ private Integer productLowStockThreshold;
+
+ @Override
+ public void execute() {
+ productAnalyticsService.saveProduct(
+ productId,
+ productName,
+ productCategory,
+ productCost,
+ productLowStockThreshold
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/MarkOrderAsFailedToFulfillCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/MarkOrderAsFailedToFulfillCommand.java
new file mode 100644
index 0000000..8340fc3
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/MarkOrderAsFailedToFulfillCommand.java
@@ -0,0 +1,25 @@
+package com.Podzilla.analytics.messaging.commands.inventory;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+
+import java.time.Instant;
+import lombok.Builder;
+
+@Builder
+public class MarkOrderAsFailedToFulfillCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private String orderId;
+ private String reason;
+ private Instant timestamp;
+
+ @Override
+ public void execute() {
+ orderAnalyticsService.markOrderAsFailedToFulfill(
+ orderId,
+ reason,
+ timestamp
+ );
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/UpdateInventoryCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/UpdateInventoryCommand.java
new file mode 100644
index 0000000..1bbfcc2
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/inventory/UpdateInventoryCommand.java
@@ -0,0 +1,25 @@
+package com.Podzilla.analytics.messaging.commands.inventory;
+
+import com.Podzilla.analytics.services.InventoryAnalyticsService;
+
+import lombok.Builder;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+import java.time.Instant;
+
+@Builder
+public class UpdateInventoryCommand implements Command {
+ private final InventoryAnalyticsService inventoryAnalyticsService;
+ private final String productId;
+ private final Integer quantity;
+ private final Instant timestamp;
+
+ @Override
+ public void execute() {
+ inventoryAnalyticsService.saveInventorySnapshot(
+ productId,
+ quantity,
+ timestamp
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/order/AssignCourierToOrderCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/order/AssignCourierToOrderCommand.java
new file mode 100644
index 0000000..4dbc0c7
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/order/AssignCourierToOrderCommand.java
@@ -0,0 +1,17 @@
+package com.Podzilla.analytics.messaging.commands.order;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+import lombok.Builder;
+
+@Builder
+public class AssignCourierToOrderCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private String orderId;
+ private String courierId;
+
+ @Override
+ public void execute() {
+ orderAnalyticsService.assignCourier(orderId, courierId);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/order/CancelOrderCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/order/CancelOrderCommand.java
new file mode 100644
index 0000000..d11ca82
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/order/CancelOrderCommand.java
@@ -0,0 +1,25 @@
+package com.Podzilla.analytics.messaging.commands.order;
+
+import java.time.Instant;
+
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+import com.Podzilla.analytics.messaging.commands.Command;
+
+import lombok.Builder;
+
+@Builder
+public class CancelOrderCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private String orderId;
+ private String reason;
+ private Instant timestamp;
+
+ @Override
+ public void execute() {
+ orderAnalyticsService.cancelOrder(
+ orderId,
+ reason,
+ timestamp
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsDeliveredCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsDeliveredCommand.java
new file mode 100644
index 0000000..bd5e3e5
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsDeliveredCommand.java
@@ -0,0 +1,27 @@
+package com.Podzilla.analytics.messaging.commands.order;
+
+import java.math.BigDecimal;
+
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+
+import lombok.Builder;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+import java.time.Instant;
+
+@Builder
+public class MarkOrderAsDeliveredCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private String orderId;
+ private BigDecimal courierRating;
+ private Instant timestamp;
+
+ @Override
+ public void execute() {
+ orderAnalyticsService.markOrderAsDelivered(
+ orderId,
+ courierRating,
+ timestamp
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsFailedToDeliverCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsFailedToDeliverCommand.java
new file mode 100644
index 0000000..f788710
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsFailedToDeliverCommand.java
@@ -0,0 +1,25 @@
+package com.Podzilla.analytics.messaging.commands.order;
+
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+import com.Podzilla.analytics.messaging.commands.Command;
+
+import java.time.Instant;
+import lombok.Builder;
+
+@Builder
+public class MarkOrderAsFailedToDeliverCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private String orderId;
+ private String reason;
+ private Instant timestamp;
+
+ @Override
+ public void execute() {
+ orderAnalyticsService.markOrderAsFailedToDeliver(
+ orderId,
+ reason,
+ timestamp
+ );
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsOutForDeliveryCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsOutForDeliveryCommand.java
new file mode 100644
index 0000000..867f4c1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/order/MarkOrderAsOutForDeliveryCommand.java
@@ -0,0 +1,20 @@
+package com.Podzilla.analytics.messaging.commands.order;
+
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+import com.Podzilla.analytics.messaging.commands.Command;
+import java.time.Instant;
+
+import lombok.Builder;
+
+@Builder
+public class MarkOrderAsOutForDeliveryCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private String orderId;
+ private Instant timestamp;
+
+ @Override
+ public void execute() {
+ orderAnalyticsService.markOrderAsOutForDelivery(orderId, timestamp);
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/order/PlaceOrderCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/order/PlaceOrderCommand.java
new file mode 100644
index 0000000..9415b8a
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/order/PlaceOrderCommand.java
@@ -0,0 +1,43 @@
+package com.Podzilla.analytics.messaging.commands.order;
+
+import java.math.BigDecimal;
+
+import com.Podzilla.analytics.services.OrderAnalyticsService;
+import com.Podzilla.analytics.services.RegionService;
+import com.podzilla.mq.events.DeliveryAddress;
+import com.Podzilla.analytics.messaging.commands.Command;
+import com.Podzilla.analytics.models.Region;
+
+import java.util.List;
+import java.time.Instant;
+import lombok.Builder;
+
+@Builder
+public class PlaceOrderCommand implements Command {
+ private OrderAnalyticsService orderAnalyticsService;
+ private RegionService regionService;
+ private String orderId;
+ private String customerId;
+ private List items;
+ private DeliveryAddress deliveryAddress;
+ private BigDecimal totalAmount;
+ private Instant timestamp;
+
+ @Override
+ public void execute() {
+ Region region = regionService.saveRegion(
+ deliveryAddress.getCity(),
+ deliveryAddress.getState(),
+ deliveryAddress.getCountry(),
+ deliveryAddress.getPostalCode()
+ );
+ orderAnalyticsService.saveOrder(
+ orderId,
+ customerId,
+ items,
+ region,
+ totalAmount,
+ timestamp
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/user/RegisterCourierCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/user/RegisterCourierCommand.java
new file mode 100644
index 0000000..58d638e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/user/RegisterCourierCommand.java
@@ -0,0 +1,22 @@
+package com.Podzilla.analytics.messaging.commands.user;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+import com.Podzilla.analytics.services.CourierAnalyticsService;
+
+import lombok.Builder;
+
+@Builder
+public class RegisterCourierCommand implements Command {
+
+ private CourierAnalyticsService courierAnalyticsService;
+ private String courierId;
+ private String courierName;
+
+ @Override
+ public void execute() {
+ courierAnalyticsService.saveCourier(
+ courierId,
+ courierName
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/commands/user/RegisterCustomerCommand.java b/src/main/java/com/Podzilla/analytics/messaging/commands/user/RegisterCustomerCommand.java
new file mode 100644
index 0000000..6d05d5b
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/commands/user/RegisterCustomerCommand.java
@@ -0,0 +1,23 @@
+package com.Podzilla.analytics.messaging.commands.user;
+
+import com.Podzilla.analytics.messaging.commands.Command;
+import com.Podzilla.analytics.services.CustomerAnalyticsService;
+
+import lombok.Builder;
+
+@Builder
+public class RegisterCustomerCommand implements Command {
+
+ private CustomerAnalyticsService customerAnalyticsService;
+ private String customerId;
+ private String customerName;
+
+ @Override
+ public void execute() {
+ customerAnalyticsService.saveCustomer(
+ customerId,
+ customerName
+ );
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/Invoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/Invoker.java
new file mode 100644
index 0000000..8d01d14
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/Invoker.java
@@ -0,0 +1,5 @@
+package com.Podzilla.analytics.messaging.invokers;
+
+public interface Invoker { // T should be the BaseEvent subclass of the event
+ void invoke(T event);
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/InvokerFactory.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/InvokerFactory.java
new file mode 100644
index 0000000..8577f28
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/InvokerFactory.java
@@ -0,0 +1,35 @@
+package com.Podzilla.analytics.messaging.invokers;
+
+import java.lang.reflect.Constructor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+
+@Component
+public class InvokerFactory {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+
+ public InvokerFactory(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ public > T createInvoker(
+ final Class invokerClass
+ ) {
+ try {
+ Constructor constructor =
+ invokerClass.getConstructor(CommandFactory.class);
+ return constructor.newInstance(commandFactory);
+ } catch (Exception e) {
+ throw new RuntimeException(
+ "Failed to create invoker of type: " + invokerClass.getName(), e
+ );
+ }
+}
+
+}
+
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/InventoryUpdatedInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/InventoryUpdatedInvoker.java
new file mode 100644
index 0000000..efae48e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/InventoryUpdatedInvoker.java
@@ -0,0 +1,30 @@
+package com.Podzilla.analytics.messaging.invokers.inventory;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.commands.inventory.UpdateInventoryCommand;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.InventoryUpdatedEvent;
+
+public class InventoryUpdatedInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+
+ public InventoryUpdatedInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final InventoryUpdatedEvent event) {
+ event.getProductSnapshots().stream().map(
+ snapshot -> commandFactory.createUpdateInventoryCommand(
+ snapshot.getProductId(),
+ snapshot.getNewQuantity(),
+ event.getTimestamp()))
+ .forEach(UpdateInventoryCommand::execute);
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/OrderFulfillmentFailedInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/OrderFulfillmentFailedInvoker.java
new file mode 100644
index 0000000..ecb813d
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/OrderFulfillmentFailedInvoker.java
@@ -0,0 +1,29 @@
+package com.Podzilla.analytics.messaging.invokers.inventory;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.WarehouseOrderFulfillmentFailedEvent;
+import com.Podzilla.analytics.messaging.commands.inventory.MarkOrderAsFailedToFulfillCommand;
+
+public class OrderFulfillmentFailedInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+
+ public OrderFulfillmentFailedInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final WarehouseOrderFulfillmentFailedEvent event) {
+ MarkOrderAsFailedToFulfillCommand command =
+ commandFactory.createMarkOrderAsFailedToFulfillCommand(
+ event.getOrderId(),
+ event.getReason(),
+ event.getTimestamp()
+ );
+ command.execute();
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/ProductCreatedInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/ProductCreatedInvoker.java
new file mode 100644
index 0000000..ad36a28
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/inventory/ProductCreatedInvoker.java
@@ -0,0 +1,31 @@
+package com.Podzilla.analytics.messaging.invokers.inventory;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.commands.inventory.CreateProductCommand;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.ProductCreatedEvent;
+
+public class ProductCreatedInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public ProductCreatedInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final ProductCreatedEvent event) {
+ CreateProductCommand command = commandFactory
+ .createCreateProductCommand(
+ event.getProductId(),
+ event.getName(),
+ event.getCategory(),
+ event.getCost(),
+ event.getLowStockThreshold()
+ );
+ command.execute();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderAssignedToCourierInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderAssignedToCourierInvoker.java
new file mode 100644
index 0000000..81b53d0
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderAssignedToCourierInvoker.java
@@ -0,0 +1,28 @@
+package com.Podzilla.analytics.messaging.invokers.order;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.OrderAssignedToCourierEvent;
+import com.Podzilla.analytics.messaging.commands.order.AssignCourierToOrderCommand;
+
+public class OrderAssignedToCourierInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public OrderAssignedToCourierInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final OrderAssignedToCourierEvent event) {
+ AssignCourierToOrderCommand command = commandFactory
+ .createAssignCourierToOrderCommand(
+ event.getOrderId(),
+ event.getCourierId()
+ );
+ command.execute();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderCancelledInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderCancelledInvoker.java
new file mode 100644
index 0000000..4b81e0a
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderCancelledInvoker.java
@@ -0,0 +1,28 @@
+package com.Podzilla.analytics.messaging.invokers.order;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.OrderCancelledEvent;
+import com.Podzilla.analytics.messaging.commands.order.CancelOrderCommand;
+public class OrderCancelledInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public OrderCancelledInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final OrderCancelledEvent event) {
+ CancelOrderCommand command = commandFactory
+ .createCancelOrderCommand(
+ event.getOrderId(),
+ event.getReason(),
+ event.getTimestamp()
+ );
+ command.execute();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderDeliveredInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderDeliveredInvoker.java
new file mode 100644
index 0000000..c28e276
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderDeliveredInvoker.java
@@ -0,0 +1,30 @@
+package com.Podzilla.analytics.messaging.invokers.order;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.commands.order.MarkOrderAsDeliveredCommand;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.OrderDeliveredEvent;
+
+public class OrderDeliveredInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public OrderDeliveredInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final OrderDeliveredEvent event) {
+ MarkOrderAsDeliveredCommand command =
+ commandFactory.createMarkOrderAsDeliveredCommand(
+ event.getOrderId(),
+ event.getCourierRating(),
+ event.getTimestamp()
+ );
+ command.execute();
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderDeliveryFailedInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderDeliveryFailedInvoker.java
new file mode 100644
index 0000000..ddbdaa5
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderDeliveryFailedInvoker.java
@@ -0,0 +1,30 @@
+package com.Podzilla.analytics.messaging.invokers.order;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.OrderDeliveryFailedEvent;
+import com.Podzilla.analytics.messaging.commands.order.MarkOrderAsFailedToDeliverCommand;
+
+public class OrderDeliveryFailedInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public OrderDeliveryFailedInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final OrderDeliveryFailedEvent event) {
+ MarkOrderAsFailedToDeliverCommand command =
+ commandFactory.createMarkOrderAsFailedToDeliverCommand(
+ event.getOrderId(),
+ event.getReason(),
+ event.getTimestamp()
+ );
+ command.execute();
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderOutForDeliveryInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderOutForDeliveryInvoker.java
new file mode 100644
index 0000000..7ceed8e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderOutForDeliveryInvoker.java
@@ -0,0 +1,28 @@
+package com.Podzilla.analytics.messaging.invokers.order;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.OrderOutForDeliveryEvent;
+import com.Podzilla.analytics.messaging.commands.order.MarkOrderAsOutForDeliveryCommand;
+
+public class OrderOutForDeliveryInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public OrderOutForDeliveryInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final OrderOutForDeliveryEvent event) {
+ MarkOrderAsOutForDeliveryCommand command = commandFactory
+ .createMarkOrderAsOutForDeliveryCommand(
+ event.getOrderId(),
+ event.getTimestamp()
+ );
+ command.execute();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderPlacedInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderPlacedInvoker.java
new file mode 100644
index 0000000..923a6ca
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/order/OrderPlacedInvoker.java
@@ -0,0 +1,33 @@
+package com.Podzilla.analytics.messaging.invokers.order;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.commands.order.PlaceOrderCommand;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.OrderPlacedEvent;
+
+public class OrderPlacedInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public OrderPlacedInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final OrderPlacedEvent event) {
+ PlaceOrderCommand command = commandFactory
+ .createPlaceOrderCommand(
+ event.getOrderId(),
+ event.getCustomerId(),
+ event.getItems(),
+ event.getDeliveryAddress(),
+ event.getTotalAmount(),
+ event.getTimestamp()
+ );
+ command.execute();
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/user/CourierRegisteredInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/user/CourierRegisteredInvoker.java
new file mode 100644
index 0000000..760625b
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/user/CourierRegisteredInvoker.java
@@ -0,0 +1,28 @@
+package com.Podzilla.analytics.messaging.invokers.user;
+
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.CourierRegisteredEvent;
+import org.springframework.beans.factory.annotation.Autowired;
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.commands.user.RegisterCourierCommand;
+
+
+public class CourierRegisteredInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public CourierRegisteredInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final CourierRegisteredEvent event) {
+ RegisterCourierCommand command = commandFactory
+ .createRegisterCourierCommand(
+ event.getCourierId(),
+ event.getName()
+ );
+ command.execute();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/messaging/invokers/user/CustomerRegisteredInvoker.java b/src/main/java/com/Podzilla/analytics/messaging/invokers/user/CustomerRegisteredInvoker.java
new file mode 100644
index 0000000..0c0ca4a
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/messaging/invokers/user/CustomerRegisteredInvoker.java
@@ -0,0 +1,29 @@
+package com.Podzilla.analytics.messaging.invokers.user;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import com.Podzilla.analytics.messaging.commands.CommandFactory;
+import com.Podzilla.analytics.messaging.invokers.Invoker;
+import com.podzilla.mq.events.CustomerRegisteredEvent;
+import com.Podzilla.analytics.messaging.commands.user.RegisterCustomerCommand;
+
+public class CustomerRegisteredInvoker
+ implements Invoker {
+
+ @Autowired
+ private final CommandFactory commandFactory;
+ public CustomerRegisteredInvoker(final CommandFactory commandFactory) {
+ this.commandFactory = commandFactory;
+ }
+
+ @Override
+ public void invoke(final CustomerRegisteredEvent event) {
+ RegisterCustomerCommand command = commandFactory
+ .createRegisterCustomerCommand(
+ event.getCustomerId(),
+ event.getName()
+ );
+ command.execute();
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/Courier.java b/src/main/java/com/Podzilla/analytics/models/Courier.java
new file mode 100644
index 0000000..e1fd7fa
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/Courier.java
@@ -0,0 +1,59 @@
+package com.Podzilla.analytics.models;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.UUID;
+
+@Entity
+@Table(name = "couriers")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Courier {
+ @Id
+ private UUID id;
+ private String name;
+
+ @OneToMany(mappedBy = "courier", cascade = CascadeType.ALL)
+ private List orders;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private UUID id;
+ private String name;
+ private List orders;
+
+ public Builder() {
+ }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder name(final String name) {
+ this.name = name;
+ return this;
+ }
+
+ public Builder orders(final List orders) {
+ this.orders = orders;
+ return this;
+ }
+
+ public Courier build() {
+ return new Courier(id, name, orders);
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/Customer.java b/src/main/java/com/Podzilla/analytics/models/Customer.java
new file mode 100644
index 0000000..123ca36
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/Customer.java
@@ -0,0 +1,57 @@
+package com.Podzilla.analytics.models;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.UUID;
+
+@Entity
+@Table(name = "customers")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Customer {
+ @Id
+ private UUID id;
+ private String name;
+
+ @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
+ private List orders;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+ public static class Builder {
+ private UUID id;
+ private String name;
+ private List orders;
+
+ public Builder() { }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder name(final String name) {
+ this.name = name;
+ return this;
+ }
+
+ public Builder orders(final List orders) {
+ this.orders = orders;
+ return this;
+ }
+
+ public Customer build() {
+ return new Customer(id, name, orders);
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/Order.java b/src/main/java/com/Podzilla/analytics/models/Order.java
new file mode 100644
index 0000000..c109d57
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/Order.java
@@ -0,0 +1,214 @@
+package com.Podzilla.analytics.models;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+@Entity
+@Table(name = "orders")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Order {
+ @Id
+ private UUID id;
+
+ private BigDecimal totalAmount;
+
+ private LocalDateTime orderPlacedTimestamp;
+ private LocalDateTime orderFulfillmentFailedTimestamp;
+ private LocalDateTime orderCancelledTimestamp;
+ private LocalDateTime shippedTimestamp;
+ private LocalDateTime deliveredTimestamp;
+ private LocalDateTime orderDeliveryFailedTimestamp;
+ private LocalDateTime finalStatusTimestamp;
+
+ @Enumerated(EnumType.STRING)
+ private OrderStatus status;
+
+ @Column(nullable = true)
+ private String failureReason;
+
+ private int numberOfItems;
+ private BigDecimal courierRating;
+
+ @ManyToOne
+ @JoinColumn(name = "customer_id", nullable = true)
+ private Customer customer;
+
+ @ManyToOne
+ @JoinColumn(name = "courier_id", nullable = true)
+ private Courier courier;
+
+ @ManyToOne
+ @JoinColumn(name = "region_id", nullable = true)
+ private Region region;
+
+ @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
+ private List orderItems;
+
+ public enum OrderStatus {
+ PLACED,
+ FULFILLMENT_FAILED,
+ CANCELLED,
+ SHIPPED,
+ DELIVERED,
+ DELIVERY_FAILED
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private UUID id;
+ private BigDecimal totalAmount;
+ private LocalDateTime orderPlacedTimestamp;
+ private LocalDateTime orderFulfillmentFailedTimestamp;
+ private LocalDateTime orderCancelledTimestamp;
+ private LocalDateTime shippedTimestamp;
+ private LocalDateTime deliveredTimestamp;
+ private LocalDateTime orderDeliveryFailedTimestamp;
+ private LocalDateTime finalStatusTimestamp;
+ private OrderStatus status;
+ private String failureReason;
+ private int numberOfItems;
+ private BigDecimal courierRating;
+ private Customer customer;
+ private Courier courier;
+ private Region region;
+ private List orderItems;
+
+ public Builder() {
+ }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder totalAmount(final BigDecimal totalAmount) {
+ this.totalAmount = totalAmount;
+ return this;
+ }
+
+ public Builder orderPlacedTimestamp(
+ final LocalDateTime orderPlacedTimestamp) {
+ this.orderPlacedTimestamp = orderPlacedTimestamp;
+ return this;
+ }
+
+ public Builder orderFulfillmentFailedTimestamp(
+ final LocalDateTime orderFulfillmentFailedTimestamp) {
+ this.orderFulfillmentFailedTimestamp =
+ orderFulfillmentFailedTimestamp;
+ return this;
+ }
+
+ public Builder orderCancelledTimestamp(
+ final LocalDateTime orderCancelledTimestamp) {
+ this.orderCancelledTimestamp = orderCancelledTimestamp;
+ return this;
+ }
+
+ public Builder shippedTimestamp(final LocalDateTime shippedTimestamp) {
+ this.shippedTimestamp = shippedTimestamp;
+ return this;
+ }
+
+ public Builder deliveredTimestamp(
+ final LocalDateTime deliveredTimestamp) {
+ this.deliveredTimestamp = deliveredTimestamp;
+ return this;
+ }
+
+ public Builder orderDeliveryFailedTimestamp(
+ final LocalDateTime orderDeliveryFailedTimestamp) {
+ this.orderDeliveryFailedTimestamp = orderDeliveryFailedTimestamp;
+ return this;
+ }
+
+ public Builder finalStatusTimestamp(
+ final LocalDateTime finalStatusTimestamp) {
+ this.finalStatusTimestamp = finalStatusTimestamp;
+ return this;
+ }
+
+ public Builder status(final OrderStatus status) {
+ this.status = status;
+ return this;
+ }
+
+ public Builder failureReason(final String failureReason) {
+ this.failureReason = failureReason;
+ return this;
+ }
+
+ public Builder numberOfItems(final int numberOfItems) {
+ this.numberOfItems = numberOfItems;
+ return this;
+ }
+
+ public Builder courierRating(final BigDecimal courierRating) {
+ this.courierRating = courierRating;
+ return this;
+ }
+
+ public Builder customer(final Customer customer) {
+ this.customer = customer;
+ return this;
+ }
+
+ public Builder courier(final Courier courier) {
+ this.courier = courier;
+ return this;
+ }
+
+ public Builder region(final Region region) {
+ this.region = region;
+ return this;
+ }
+
+ public Builder orderItems(
+ final List orderItems) {
+ this.orderItems = orderItems;
+ return this;
+ }
+
+ public Order build() {
+ return new Order(
+ id,
+ totalAmount,
+ orderPlacedTimestamp,
+ orderFulfillmentFailedTimestamp,
+ orderCancelledTimestamp,
+ shippedTimestamp,
+ deliveredTimestamp,
+ orderDeliveryFailedTimestamp,
+ finalStatusTimestamp,
+ status,
+ failureReason,
+ numberOfItems,
+ courierRating,
+ customer,
+ courier,
+ region,
+ orderItems);
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/OrderItem.java b/src/main/java/com/Podzilla/analytics/models/OrderItem.java
new file mode 100644
index 0000000..06e07a4
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/OrderItem.java
@@ -0,0 +1,87 @@
+package com.Podzilla.analytics.models;
+
+import java.math.BigDecimal;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+@Entity
+@Table(name = "order_items")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class OrderItem {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ private int quantity;
+ private BigDecimal pricePerUnit;
+
+ @ManyToOne
+ @JoinColumn(name = "product_id", nullable = false)
+ private Product product;
+
+ @ManyToOne
+ @JoinColumn(name = "order_id", nullable = false)
+ private Order order;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private UUID id;
+ private int quantity;
+ private BigDecimal pricePerUnit;
+ private Product product;
+ private Order order;
+
+ public Builder() {
+ }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder quantity(final int quantity) {
+ this.quantity = quantity;
+ return this;
+ }
+
+ public Builder pricePerUnit(final BigDecimal pricePerUnit) {
+ this.pricePerUnit = pricePerUnit;
+ return this;
+ }
+
+ public Builder product(final Product product) {
+ this.product = product;
+ return this;
+ }
+
+ public Builder order(final Order order) {
+ this.order = order;
+ return this;
+ }
+
+ public OrderItem build() {
+ return new OrderItem(
+ id,
+ quantity,
+ pricePerUnit,
+ product,
+ order);
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/Product.java b/src/main/java/com/Podzilla/analytics/models/Product.java
new file mode 100644
index 0000000..bda85d5
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/Product.java
@@ -0,0 +1,83 @@
+package com.Podzilla.analytics.models;
+
+import java.math.BigDecimal;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+import java.util.List;
+
+@Entity
+@Table(name = "products")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Product {
+ @Id
+ private UUID id;
+ private String name;
+ private String category;
+ private BigDecimal cost;
+ private int lowStockThreshold;
+
+ @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
+ private List orderItems;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private UUID id;
+ private String name;
+ private String category;
+ private BigDecimal cost;
+ private int lowStockThreshold;
+ private List orderItems;
+
+ public Builder() {
+ }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder name(final String name) {
+ this.name = name;
+ return this;
+ }
+
+ public Builder category(final String category) {
+ this.category = category;
+ return this;
+ }
+
+ public Builder cost(final BigDecimal cost) {
+ this.cost = cost;
+ return this;
+ }
+
+ public Builder lowStockThreshold(final int lowStockThreshold) {
+ this.lowStockThreshold = lowStockThreshold;
+ return this;
+ }
+
+ public Builder orderItems(final List orderItems) {
+ this.orderItems = orderItems;
+ return this;
+ }
+
+ public Product build() {
+ return new Product(
+ id, name, category, cost, lowStockThreshold, orderItems);
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/ProductSnapshot.java b/src/main/java/com/Podzilla/analytics/models/ProductSnapshot.java
new file mode 100644
index 0000000..8dda8de
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/ProductSnapshot.java
@@ -0,0 +1,73 @@
+package com.Podzilla.analytics.models;
+
+import java.time.LocalDateTime;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+@Entity
+@Table(name = "product_snapshots")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ProductSnapshot {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ private LocalDateTime timestamp;
+
+ @ManyToOne
+ @JoinColumn(name = "product_id", nullable = false)
+ private Product product;
+
+ private int quantity;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private UUID id;
+ private LocalDateTime timestamp;
+ private Product product;
+ private int quantity;
+
+ public Builder() {
+ }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder timestamp(final LocalDateTime timestamp) {
+ this.timestamp = timestamp;
+ return this;
+ }
+
+ public Builder product(final Product product) {
+ this.product = product;
+ return this;
+ }
+
+ public Builder quantity(final int quantity) {
+ this.quantity = quantity;
+ return this;
+ }
+
+ public ProductSnapshot build() {
+ return new ProductSnapshot(id, timestamp, product, quantity);
+ }
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/models/Region.java b/src/main/java/com/Podzilla/analytics/models/Region.java
new file mode 100644
index 0000000..5ba9fb4
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/models/Region.java
@@ -0,0 +1,69 @@
+package com.Podzilla.analytics.models;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.UUID;
+
+@Entity
+@Table(name = "regions")
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class Region {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+ private String city;
+ private String state;
+ private String country;
+ private String postalCode;
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private UUID id;
+ private String city;
+ private String state;
+ private String country;
+ private String postalCode;
+
+ public Builder() { }
+
+ public Builder id(final UUID id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder city(final String city) {
+ this.city = city;
+ return this;
+ }
+
+ public Builder state(final String state) {
+ this.state = state;
+ return this;
+ }
+
+ public Builder country(final String country) {
+ this.country = country;
+ return this;
+ }
+
+ public Builder postalCode(final String postalCode) {
+ this.postalCode = postalCode;
+ return this;
+ }
+
+ public Region build() {
+ return new Region(id, city, state, country, postalCode);
+ }
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/CourierRepository.java b/src/main/java/com/Podzilla/analytics/repositories/CourierRepository.java
new file mode 100644
index 0000000..56925c1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/CourierRepository.java
@@ -0,0 +1,32 @@
+package com.Podzilla.analytics.repositories;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import com.Podzilla.analytics.api.projections.courier.CourierPerformanceProjection;
+import com.Podzilla.analytics.models.Courier;
+
+public interface CourierRepository extends JpaRepository {
+
+ @Query("SELECT c.id AS courierId, "
+ + "c.name AS courierName, "
+ + "COUNT(o.id) AS deliveryCount, "
+ + "SUM(CASE WHEN o.status = 'DELIVERED' THEN 1 ELSE 0 END) "
+ + "AS completedCount, "
+ + "AVG(CASE WHEN o.status = 'DELIVERED' THEN o.courierRating "
+ + "ELSE NULL END) AS averageRating "
+ + "FROM Courier c "
+ + "LEFT JOIN Order o "
+ + "ON c.id = o.courier.id "
+ + "AND o.finalStatusTimestamp BETWEEN :startDate AND :endDate "
+ + "GROUP BY c.id, c.name ")
+ List findCourierPerformanceBetweenDates(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/CustomerRepository.java b/src/main/java/com/Podzilla/analytics/repositories/CustomerRepository.java
new file mode 100644
index 0000000..d50e880
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/CustomerRepository.java
@@ -0,0 +1,31 @@
+package com.Podzilla.analytics.repositories;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import com.Podzilla.analytics.api.projections.customer.CustomersTopSpendersProjection;
+import com.Podzilla.analytics.models.Customer;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Repository
+public interface CustomerRepository extends JpaRepository {
+
+ @Query("SELECT c.id AS customerId, c.name AS customerName, "
+ + "COALESCE(SUM(o.totalAmount), 0) AS totalSpending "
+ + "FROM Customer c "
+ + "INNER JOIN c.orders o "
+ + "WITH o.finalStatusTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.status = 'DELIVERED' "
+ + "GROUP BY c.id, c.name "
+ + "ORDER BY totalSpending DESC")
+ Page findTopSpenders(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate,
+ Pageable pageable);
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/OrderItemRepository.java b/src/main/java/com/Podzilla/analytics/repositories/OrderItemRepository.java
new file mode 100644
index 0000000..94ddd06
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/OrderItemRepository.java
@@ -0,0 +1,27 @@
+package com.Podzilla.analytics.repositories;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import com.Podzilla.analytics.models.OrderItem;
+import com.Podzilla.analytics.api.projections.profit.ProfitByCategoryProjection;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+public interface OrderItemRepository
+ extends JpaRepository {
+ @Query("SELECT oi.product.category as category, "
+ + "SUM(oi.quantity * oi.pricePerUnit) as totalRevenue, "
+ + "SUM(oi.quantity * oi.product.cost) as totalCost "
+ + "FROM OrderItem oi "
+ + "WHERE oi.order.finalStatusTimestamp BETWEEN "
+ + ":startDate AND :endDate "
+ + "AND oi.order.status = 'DELIVERED' "
+ + "GROUP BY oi.product.category")
+ List findSalesByCategoryBetweenDates(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java b/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java
new file mode 100644
index 0000000..7fcb5b5
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/OrderRepository.java
@@ -0,0 +1,181 @@
+package com.Podzilla.analytics.repositories;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.Podzilla.analytics.api.projections.fulfillment.FulfillmentTimeProjection;
+import com.Podzilla.analytics.api.projections.order.OrderFailureRateProjection;
+import com.Podzilla.analytics.api.projections.order.OrderFailureReasonsProjection;
+import com.Podzilla.analytics.api.projections.order.OrderRegionProjection;
+import com.Podzilla.analytics.api.projections.order.OrderStatusProjection;
+import com.Podzilla.analytics.api.projections.revenue.RevenueByCategoryProjection;
+import com.Podzilla.analytics.api.projections.revenue.RevenueSummaryProjection;
+import com.Podzilla.analytics.models.Order;
+import java.util.UUID;
+
+public interface OrderRepository extends JpaRepository {
+
+ @Query("SELECT 'OVERALL' AS groupByValue, "
+ + "AVG( (EXTRACT(EPOCH FROM o.shippedTimestamp) - "
+ + "EXTRACT(EPOCH FROM o.orderPlacedTimestamp)) / 3600) "
+ + "AS averageDuration "
+ + "FROM Order o "
+ + "WHERE o.orderPlacedTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.shippedTimestamp IS NOT NULL")
+ FulfillmentTimeProjection findPlaceToShipTimeOverall(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT CONCAT('RegionID_', r.id) AS groupByValue, "
+ + "AVG( (EXTRACT(EPOCH FROM o.shippedTimestamp) - "
+ + "EXTRACT(EPOCH FROM o.orderPlacedTimestamp)) / 3600) "
+ + "AS averageDuration "
+ + "FROM Order o "
+ + "JOIN o.region r "
+ + "WHERE o.orderPlacedTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.shippedTimestamp IS NOT NULL "
+ + "GROUP BY r.id")
+ List findPlaceToShipTimeByRegion(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT 'OVERALL' AS groupByValue, "
+ + "AVG( (EXTRACT(EPOCH FROM o.deliveredTimestamp) - "
+ + "EXTRACT(EPOCH FROM o.shippedTimestamp)) / 3600) "
+ + "AS averageDuration "
+ + "FROM Order o "
+ + "WHERE o.shippedTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.deliveredTimestamp IS NOT NULL "
+ + "AND o.status = 'DELIVERED'")
+ FulfillmentTimeProjection findShipToDeliverTimeOverall(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT CONCAT('RegionID_', r.id) AS groupByValue, "
+ + "AVG( (EXTRACT(EPOCH FROM o.deliveredTimestamp) - "
+ + "EXTRACT(EPOCH FROM o.shippedTimestamp)) / 3600) "
+ + "AS averageDuration "
+ + "FROM Order o "
+ + "JOIN o.region r "
+ + "WHERE o.shippedTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.deliveredTimestamp IS NOT NULL "
+ + "AND o.status = 'DELIVERED' "
+ + "GROUP BY r.id")
+ List findShipToDeliverTimeByRegion(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT CONCAT('CourierID_', c.id) AS groupByValue, "
+ + "AVG( (EXTRACT(EPOCH FROM o.deliveredTimestamp) - "
+ + "EXTRACT(EPOCH FROM o.shippedTimestamp)) / 3600) "
+ + "AS averageDuration "
+ + "FROM Order o "
+ + "JOIN o.courier c "
+ + "WHERE o.shippedTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.deliveredTimestamp IS NOT NULL "
+ + "AND o.status = 'DELIVERED' "
+ + "GROUP BY c.id")
+ List findShipToDeliverTimeByCourier(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT r.city AS city, "
+ + "r.country AS country, "
+ + "COUNT(o) AS orderCount, "
+ + "AVG(o.totalAmount) AS averageOrderValue "
+ + "FROM Order o "
+ + "JOIN o.region r "
+ + "WHERE o.finalStatusTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.status = 'DELIVERED' "
+ + "GROUP BY r.city, r.country "
+ + "ORDER BY orderCount DESC, averageOrderValue DESC")
+ List findOrdersByRegion(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT o.status AS status, "
+ + "COUNT(o) AS count "
+ + "FROM Order o "
+ + "WHERE o.finalStatusTimestamp BETWEEN :startDate AND :endDate "
+ + "GROUP BY o.status "
+ + "ORDER BY count DESC")
+ List findOrderStatusCounts(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT o.failureReason AS reason, "
+ + "COUNT(o) AS count "
+ + "FROM Order o "
+ + "WHERE o.finalStatusTimestamp BETWEEN :startDate AND :endDate "
+ + "AND o.status IN ('DELIVERY_FAILED', 'CANCELLED') "
+ + "GROUP BY o.failureReason "
+ + "ORDER BY count DESC")
+ List findFailureReasons(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT COALESCE(SUM(CASE WHEN o.status = 'DELIVERY_FAILED' "
+ + "THEN 1 ELSE 0 END) * 1.0 "
+ + "/ NULLIF(COUNT(o), 0), 0) AS failureRate "
+ + "FROM Order o "
+ + "WHERE o.finalStatusTimestamp BETWEEN :startDate AND :endDate")
+ OrderFailureRateProjection calculateFailureRate(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Query("SELECT period AS period, "
+ + "SUM(rev) AS totalRevenue "
+ + "FROM ( "
+ + " SELECT CASE "
+ + " WHEN :reportPeriod = 'DAILY' "
+ + " THEN CAST(o.orderPlacedTimestamp AS date) "
+ + " WHEN :reportPeriod = 'WEEKLY' "
+ + " THEN FUNCTION('DATE_TRUNC','week',o.orderPlacedTimestamp) "
+ + " WHEN :reportPeriod = 'MONTHLY' "
+ + " THEN FUNCTION('DATE_TRUNC','month',o.orderPlacedTimestamp) "
+ + " END AS period, "
+ + " o.totalAmount AS rev "
+ + "FROM Order o "
+ + "WHERE o.finalStatusTimestamp >= :startDate "
+ + "AND o.finalStatusTimestamp < :endDate "
+ + "AND o.status = 'DELIVERED' "
+ + ") x "
+ + "GROUP BY period "
+ + "ORDER BY totalRevenue DESC")
+ List findRevenueSummaryByPeriod(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate,
+ @Param("reportPeriod") String reportPeriod);
+
+ @Query("SELECT p.category AS category, "
+ + "SUM(oi.quantity * oi.pricePerUnit) AS totalRevenue "
+ + "FROM OrderItem oi "
+ + "JOIN oi.order o "
+ + "JOIN oi.product p "
+ + "WHERE o.finalStatusTimestamp >= :startDate "
+ + "AND o.finalStatusTimestamp < :endDate "
+ + "AND o.status = 'DELIVERED' "
+ + "GROUP BY p.category "
+ + "ORDER BY totalRevenue DESC")
+ List findRevenueByCategory(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate);
+
+ @Modifying
+ @Transactional
+ @Query("DELETE FROM OrderItem oi "
+ + "WHERE oi.order.id IN (SELECT o.id FROM Order o "
+ + "WHERE o.orderPlacedTimestamp < :cutoff)")
+ void deleteOrderItemsOlderThan(@Param("cutoff") LocalDateTime cutoff);
+
+ @Modifying
+ @Transactional
+ @Query("DELETE FROM Order o WHERE o.orderPlacedTimestamp < :cutoff")
+ void deleteOrdersOlderThan(@Param("cutoff") LocalDateTime cutoff);
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/ProductRepository.java b/src/main/java/com/Podzilla/analytics/repositories/ProductRepository.java
new file mode 100644
index 0000000..f4037b1
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/ProductRepository.java
@@ -0,0 +1,47 @@
+package com.Podzilla.analytics.repositories;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import com.Podzilla.analytics.api.projections.product.TopSellingProductProjection;
+import com.Podzilla.analytics.models.Product;
+import java.util.UUID;
+
+public interface ProductRepository extends JpaRepository {
+
+ @Query("SELECT p.id AS id, "
+ + "p.name AS name, "
+ + "p.category AS category, "
+ + "COALESCE(SUM(CASE WHEN o.finalStatusTimestamp >= :startDate "
+ + "AND o.finalStatusTimestamp < :endDate "
+ + "AND o.status = 'DELIVERED' THEN oi.quantity * oi.pricePerUnit "
+ + "ELSE 0 END), 0) "
+ + "AS totalRevenue, "
+ + "COALESCE(SUM(CASE WHEN o.finalStatusTimestamp >= :startDate "
+ + "AND o.finalStatusTimestamp < :endDate "
+ + "AND o.status = 'DELIVERED' THEN oi.quantity ELSE 0 END), 0) "
+ + "AS totalUnits "
+ + "FROM Product p "
+ + "LEFT JOIN p.orderItems oi "
+ + "LEFT JOIN oi.order o "
+ + "GROUP BY p.id, p.name, p.category "
+ + "ORDER BY CASE WHEN :sortBy = 'REVENUE' "
+ + "THEN COALESCE(SUM(CASE WHEN o.finalStatusTimestamp >= "
+ + ":startDate AND o.finalStatusTimestamp < :endDate "
+ + "AND o.status = 'DELIVERED' THEN oi.quantity * oi.pricePerUnit "
+ + "ELSE 0 END), 0) "
+ + "ELSE COALESCE(SUM(CASE WHEN o.finalStatusTimestamp >= "
+ + ":startDate AND o.finalStatusTimestamp < :endDate "
+ + "AND o.status = 'DELIVERED' THEN oi.quantity ELSE 0 END), 0) "
+ + "END DESC "
+ + "LIMIT :limit")
+ List findTopSellers(
+ @Param("startDate") LocalDateTime startDate,
+ @Param("endDate") LocalDateTime endDate,
+ @Param("limit") Integer limit,
+ @Param("sortBy") String sortBy);
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/ProductSnapshotRepository.java b/src/main/java/com/Podzilla/analytics/repositories/ProductSnapshotRepository.java
new file mode 100644
index 0000000..90795ce
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/ProductSnapshotRepository.java
@@ -0,0 +1,41 @@
+package com.Podzilla.analytics.repositories;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import com.Podzilla.analytics.api.projections.inventory.InventoryValueByCategoryProjection;
+import com.Podzilla.analytics.api.projections.inventory.LowStockProductProjection;
+import com.Podzilla.analytics.models.ProductSnapshot;
+
+@Repository
+public interface ProductSnapshotRepository
+ extends JpaRepository {
+
+ @Query("SELECT p.category AS category, "
+ + "SUM(s.quantity * p.cost) AS totalStockValue "
+ + "FROM ProductSnapshot s "
+ + "JOIN s.product p "
+ + "WHERE s.timestamp = (SELECT MAX(s2.timestamp) "
+ + "FROM ProductSnapshot s2 "
+ + "WHERE s2.product.id = s.product.id) "
+ + "GROUP BY p.category")
+ List getInventoryValueByCategory();
+
+ @Query("SELECT p.id AS productId, "
+ + "p.name AS productName, "
+ + "s.quantity AS currentQuantity, "
+ + "p.lowStockThreshold AS threshold "
+ + "FROM ProductSnapshot s "
+ + "JOIN s.product p "
+ + "WHERE s.timestamp = (SELECT MAX(s2.timestamp) "
+ + "FROM ProductSnapshot s2 "
+ + "WHERE s2.product.id = s.product.id) "
+ + "AND s.quantity <= p.lowStockThreshold")
+ Page getLowStockProducts(Pageable pageable);
+}
diff --git a/src/main/java/com/Podzilla/analytics/repositories/RegionRepository.java b/src/main/java/com/Podzilla/analytics/repositories/RegionRepository.java
new file mode 100644
index 0000000..5aa30d8
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/repositories/RegionRepository.java
@@ -0,0 +1,9 @@
+package com.Podzilla.analytics.repositories;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import com.Podzilla.analytics.models.Region;
+import java.util.UUID;
+
+public interface RegionRepository extends JpaRepository {
+}
diff --git a/src/main/java/com/Podzilla/analytics/scheduling/CleanupScheduler.java b/src/main/java/com/Podzilla/analytics/scheduling/CleanupScheduler.java
new file mode 100644
index 0000000..d72f4b6
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/scheduling/CleanupScheduler.java
@@ -0,0 +1,32 @@
+package com.Podzilla.analytics.scheduling;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import com.Podzilla.analytics.repositories.OrderRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.transaction.annotation.Transactional;
+import java.time.LocalDateTime;
+
+@Component
+public class CleanupScheduler {
+
+ private final OrderRepository orderRepository;
+
+ @Autowired
+ public CleanupScheduler(final OrderRepository orderRepository) {
+ this.orderRepository = orderRepository;
+ }
+
+ // // Runs at 2:00 AM on the first day of every month
+ @Scheduled(cron = "0 0 2 1 * ?")
+ @Transactional
+ public void monthlyCleanup() {
+ System.out.println("Starting monthly cleanup");
+ LocalDateTime cutoff = LocalDateTime.now().minusMonths(1);
+ // Delete order items first
+ orderRepository.deleteOrderItemsOlderThan(cutoff);
+ // Then delete orders
+ orderRepository.deleteOrdersOlderThan(cutoff);
+ System.out.println("Monthly cleanup completed");
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/.gitkeep b/src/main/java/com/Podzilla/analytics/services/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/java/com/Podzilla/analytics/services/CourierAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/CourierAnalyticsService.java
new file mode 100644
index 0000000..0238f3a
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/CourierAnalyticsService.java
@@ -0,0 +1,119 @@
+package com.Podzilla.analytics.services;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.courier.CourierAverageRatingResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierDeliveryCountResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierPerformanceReportResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierSuccessRateResponse;
+import com.Podzilla.analytics.api.projections.courier.CourierPerformanceProjection;
+import com.Podzilla.analytics.models.Courier;
+import com.Podzilla.analytics.repositories.CourierRepository;
+import com.Podzilla.analytics.util.MetricCalculator;
+import com.Podzilla.analytics.util.StringToUUIDParser;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@RequiredArgsConstructor
+@Service
+@Slf4j
+public class CourierAnalyticsService {
+ private final CourierRepository courierRepository;
+
+ private List getCourierPerformanceData(
+ final LocalDate startDate,
+ final LocalDate endDate
+ ) {
+ LocalDateTime startDateTime = startDate.atStartOfDay();
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+
+ return courierRepository.findCourierPerformanceBetweenDates(
+ startDateTime,
+ endDateTime
+ );
+ }
+
+ public List getCourierDeliveryCounts(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting courier delivery counts between {} and {}",
+ startDate, endDate);
+ return getCourierPerformanceData(startDate, endDate).stream()
+ .map(data -> CourierDeliveryCountResponse.builder()
+ .courierId(data.getCourierId())
+ .courierName(data.getCourierName())
+ .deliveryCount(data.getDeliveryCount())
+ .build())
+ .toList();
+ }
+
+ public List getCourierSuccessRate(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting courier success rates between {} and {}",
+ startDate, endDate);
+ return getCourierPerformanceData(startDate, endDate).stream()
+ .map(data -> CourierSuccessRateResponse.builder()
+ .courierId(data.getCourierId())
+ .courierName(data.getCourierName())
+ .successRate(
+ MetricCalculator.calculateRate(
+ data.getCompletedCount(),
+ data.getDeliveryCount()))
+ .build())
+ .toList();
+ }
+
+ public List getCourierAverageRating(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting courier average ratings between {} and {}",
+ startDate, endDate);
+ return getCourierPerformanceData(startDate, endDate).stream()
+ .map(data -> CourierAverageRatingResponse.builder()
+ .courierId(data.getCourierId())
+ .courierName(data.getCourierName())
+ .averageRating(data.getAverageRating())
+ .build())
+ .toList();
+ }
+
+ public List getCourierPerformanceReport(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting courier performance report between {} and {}",
+ startDate, endDate);
+ return getCourierPerformanceData(startDate, endDate).stream()
+ .map(data -> CourierPerformanceReportResponse.builder()
+ .courierId(data.getCourierId())
+ .courierName(data.getCourierName())
+ .deliveryCount(data.getDeliveryCount())
+ .successRate(
+ MetricCalculator.calculateRate(
+ data.getCompletedCount(),
+ data.getDeliveryCount()))
+ .averageRating(data.getAverageRating())
+ .build())
+ .toList();
+ }
+
+ public void saveCourier(
+ final String courierId,
+ final String courierName) {
+ log.info("Saving courier with id: {} and name: {}",
+ courierId, courierName);
+ UUID id = StringToUUIDParser.parseStringToUUID(courierId);
+ Courier courier = Courier.builder()
+ .id(id)
+ .name(courierName)
+ .build();
+ courierRepository.save(courier);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/CustomerAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/CustomerAnalyticsService.java
new file mode 100644
index 0000000..2f14ea0
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/CustomerAnalyticsService.java
@@ -0,0 +1,63 @@
+package com.Podzilla.analytics.services;
+
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.customer.CustomersTopSpendersResponse;
+import com.Podzilla.analytics.repositories.CustomerRepository;
+import com.Podzilla.analytics.util.DatetimeFormatter;
+import com.Podzilla.analytics.util.StringToUUIDParser;
+import com.Podzilla.analytics.models.Customer;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.time.LocalDateTime;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class CustomerAnalyticsService {
+ private final CustomerRepository customerRepository;
+
+ public List getTopSpenders(
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final int page,
+ final int size) {
+ log.info("Getting top spenders between {} and {}, page: {},"
+ + " size: {}", startDate, endDate, page, size);
+ LocalDateTime startDateTime = DatetimeFormatter
+ .convertStartDateToDatetime(startDate);
+ LocalDateTime endDateTime = DatetimeFormatter
+ .convertEndDateToDatetime(endDate);
+ PageRequest pageRequest = PageRequest.of(page, size);
+ List topSpenders = customerRepository
+ .findTopSpenders(startDateTime, endDateTime, pageRequest)
+ .stream()
+ .map(row -> CustomersTopSpendersResponse.builder()
+ .customerId(row.getCustomerId())
+ .customerName(row.getCustomerName())
+ .totalSpending(row.getTotalSpending())
+ .build())
+ .toList();
+ log.info("Found {} top spenders", topSpenders.size());
+ return topSpenders;
+ }
+
+ public void saveCustomer(
+ final String customerId,
+ final String customerName) {
+ UUID id = StringToUUIDParser.parseStringToUUID(customerId);
+ Customer customer = Customer.builder()
+ .id(id)
+ .name(customerName)
+ .build();
+ log.info("Saving customer: {} with ID: {}",
+ customer.getName(), customer.getId());
+ customerRepository.save(customer);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java
new file mode 100644
index 0000000..67f47a6
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/FulfillmentAnalyticsService.java
@@ -0,0 +1,126 @@
+package com.Podzilla.analytics.services;
+
+import org.springframework.stereotype.Service;
+import com.Podzilla.analytics.repositories.OrderRepository;
+import com.Podzilla.analytics.util.DatetimeFormatter;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentTimeResponse;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest.PlaceToShipGroupBy;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest.ShipToDeliverGroupBy;
+import com.Podzilla.analytics.api.projections.fulfillment.FulfillmentTimeProjection;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Service
+@Slf4j
+public class FulfillmentAnalyticsService {
+
+ private final OrderRepository orderRepository;
+
+ public List getPlaceToShipTimeResponse(
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final PlaceToShipGroupBy groupBy) {
+ log.info("Getting place-to-ship time response between {} and {}"
+ + " with groupBy {}", startDate, endDate, groupBy);
+ LocalDateTime startDateTime = DatetimeFormatter
+ .convertStartDateToDatetime(startDate);
+ LocalDateTime endDateTime = DatetimeFormatter
+ .convertEndDateToDatetime(endDate);
+ List results = new ArrayList<>();
+
+ switch (groupBy) {
+ case OVERALL:
+ log.debug("Fetching overall place-to-ship time");
+ FulfillmentTimeProjection overall = orderRepository
+ .findPlaceToShipTimeOverall(startDateTime, endDateTime);
+ if (overall != null && overall.getAverageDuration() != null) {
+ results.add(convertToResponse(overall));
+ }
+ break;
+ case REGION:
+ log.debug("Fetching place-to-ship time by region");
+ List byRegion = orderRepository
+ .findPlaceToShipTimeByRegion(
+ startDateTime, endDateTime);
+ results.addAll(byRegion.stream()
+ .map(this::convertToResponse)
+ .collect(Collectors.toList()));
+ break;
+ default:
+ log.warn("Unknown groupBy value for place-to-ship: {}",
+ groupBy);
+ // Handle unknown groupBy or throw an exception
+ break;
+ }
+
+ return results;
+ }
+
+ public List getShipToDeliverTimeResponse(
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final ShipToDeliverGroupBy groupBy) {
+ log.info("Getting ship-to-deliver time response between {} and"
+ + " {} with groupBy {}", startDate, endDate,
+ groupBy);
+ LocalDateTime startDateTime = DatetimeFormatter
+ .convertStartDateToDatetime(startDate);
+ LocalDateTime endDateTime = DatetimeFormatter
+ .convertEndDateToDatetime(endDate);
+ List results = new ArrayList<>();
+
+ switch (groupBy) {
+ case OVERALL:
+ log.debug("Fetching overall ship-to-deliver time");
+ FulfillmentTimeProjection overall = orderRepository
+ .findShipToDeliverTimeOverall(
+ startDateTime, endDateTime);
+ if (overall != null && overall.getAverageDuration() != null) {
+ results.add(convertToResponse(overall));
+ }
+ break;
+ case REGION:
+ log.debug("Fetching ship-to-deliver time by region");
+ List byRegion = orderRepository
+ .findShipToDeliverTimeByRegion(
+ startDateTime, endDateTime);
+ results.addAll(byRegion.stream()
+ .map(this::convertToResponse)
+ .collect(Collectors.toList()));
+ break;
+ case COURIER:
+ log.debug("Fetching ship-to-deliver time by courier");
+ List byCourier = orderRepository
+ .findShipToDeliverTimeByCourier(
+ startDateTime, endDateTime);
+ results.addAll(byCourier.stream()
+ .map(this::convertToResponse)
+ .collect(Collectors.toList()));
+ break;
+ default:
+ log.warn("Unknown groupBy value for ship-to-deliver: {}",
+ groupBy);
+ // Handle unknown groupBy or throw an exception
+ break;
+ }
+
+ return results;
+ }
+
+ private FulfillmentTimeResponse convertToResponse(
+ final FulfillmentTimeProjection projection) {
+ return FulfillmentTimeResponse.builder()
+ .groupByValue(projection.getGroupByValue())
+ .averageDuration(
+ BigDecimal.valueOf(projection.getAverageDuration()))
+ .build();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/InventoryAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/InventoryAnalyticsService.java
new file mode 100644
index 0000000..1bb1255
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/InventoryAnalyticsService.java
@@ -0,0 +1,85 @@
+package com.Podzilla.analytics.services;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.inventory.InventoryValueByCategoryResponse;
+import com.Podzilla.analytics.api.dtos.inventory.LowStockProductResponse;
+import com.Podzilla.analytics.repositories.ProductSnapshotRepository;
+import com.Podzilla.analytics.repositories.ProductRepository;
+import com.Podzilla.analytics.models.Product;
+import com.Podzilla.analytics.models.ProductSnapshot;
+import com.Podzilla.analytics.util.StringToUUIDParser;
+import com.Podzilla.analytics.util.DatetimeFormatter;
+
+import java.util.List;
+import java.util.UUID;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class InventoryAnalyticsService {
+ private final ProductSnapshotRepository inventoryRepo;
+ private final ProductRepository productRepository;
+
+ public List getInventoryValueByCategory(
+
+ ) {
+ log.info("Getting inventory value by category");
+ List invVByCy = inventoryRepo
+ .getInventoryValueByCategory()
+ .stream()
+ .map(row -> InventoryValueByCategoryResponse.builder()
+ .category(row.getCategory())
+ .totalStockValue(row.getTotalStockValue())
+ .build())
+ .toList();
+ return invVByCy;
+ }
+
+ public Page getLowStockProducts(
+ final int page, final int size) {
+ log.info("Getting low stock products, page: {}, size: {}",
+ page, size);
+ PageRequest pageRequest = PageRequest.of(page, size);
+ Page lowStockPro = inventoryRepo
+ .getLowStockProducts(pageRequest)
+ .map(row -> LowStockProductResponse.builder()
+ .productId(row.getProductId())
+ .productName(row.getProductName())
+ .currentQuantity(row.getCurrentQuantity())
+ .threshold(row.getThreshold())
+ .build());
+ return lowStockPro;
+ }
+
+ public void saveInventorySnapshot(
+ final String productId,
+ final Integer quantity,
+ final Instant timestamp) {
+ log.info("Saving inventory snapshot for productId: {},"
+ + " quantity: {}, timestamp: {}", productId, quantity,
+ timestamp);
+ UUID productUUID = StringToUUIDParser.parseStringToUUID(productId);
+ Product product = productRepository.findById(productUUID)
+ .orElseThrow(
+ () -> new IllegalArgumentException(
+ "Product not found"));
+ LocalDateTime snapshotTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timestamp);
+ ProductSnapshot inventorySnapshot = ProductSnapshot.builder()
+ .product(product)
+ .quantity(quantity)
+ .timestamp(snapshotTimestamp)
+ .build();
+
+ inventoryRepo.save(inventorySnapshot);
+ log.info("Inventory snapshot saved for productId: {}",
+ productId);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java
new file mode 100644
index 0000000..3a6e491
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/OrderAnalyticsService.java
@@ -0,0 +1,270 @@
+package com.Podzilla.analytics.services;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.order.OrderFailureResponse;
+import com.Podzilla.analytics.api.dtos.order.OrderFailureReasonsResponse;
+import com.Podzilla.analytics.api.dtos.order.OrderRegionResponse;
+import com.Podzilla.analytics.api.dtos.order.OrderStatusResponse;
+import com.Podzilla.analytics.api.projections.order.OrderFailureRateProjection;
+import com.Podzilla.analytics.api.projections.order.OrderFailureReasonsProjection;
+import com.Podzilla.analytics.api.projections.order.OrderRegionProjection;
+import com.Podzilla.analytics.api.projections.order.OrderStatusProjection;
+import com.Podzilla.analytics.repositories.CourierRepository;
+import com.Podzilla.analytics.repositories.CustomerRepository;
+import com.Podzilla.analytics.repositories.OrderRepository;
+import com.Podzilla.analytics.util.DatetimeFormatter;
+import com.Podzilla.analytics.models.Order;
+import com.Podzilla.analytics.models.Order.OrderStatus;
+import com.Podzilla.analytics.models.Region;
+import com.Podzilla.analytics.models.Customer;
+import com.Podzilla.analytics.models.Courier;
+import com.Podzilla.analytics.util.StringToUUIDParser;
+
+import lombok.RequiredArgsConstructor;
+import java.util.UUID;
+import lombok.extern.slf4j.Slf4j;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class OrderAnalyticsService {
+
+ private final OrderRepository orderRepository;
+ private final CustomerRepository customerRepository;
+ private final CourierRepository courierRepository;
+ private final OrderItemService orderItemService;
+
+ public List getOrdersByRegion(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting orders by region between {} and {}",
+ startDate, endDate);
+ LocalDateTime startDateTime = DatetimeFormatter
+ .convertStartDateToDatetime(startDate);
+ LocalDateTime endDateTime = DatetimeFormatter
+ .convertEndDateToDatetime(endDate);
+ List ordersByRegion = orderRepository
+ .findOrdersByRegion(startDateTime, endDateTime);
+ return ordersByRegion.stream()
+ .map(data -> OrderRegionResponse.builder()
+ .city(data.getCity())
+ .country(data.getCountry())
+ .orderCount(data.getOrderCount())
+ .averageOrderValue(data.getAverageOrderValue())
+ .build())
+ .toList();
+ }
+
+ public List getOrdersStatusCounts(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting order status counts between {} and {}",
+ startDate, endDate);
+ LocalDateTime startDateTime = DatetimeFormatter
+ .convertStartDateToDatetime(startDate);
+ LocalDateTime endDateTime = DatetimeFormatter
+ .convertEndDateToDatetime(endDate);
+ List orderStatusCounts = orderRepository
+ .findOrderStatusCounts(startDateTime,
+ endDateTime);
+ return orderStatusCounts.stream()
+ .map(data -> OrderStatusResponse.builder()
+ .status(data.getStatus())
+ .count(data.getCount())
+ .build())
+ .toList();
+ }
+
+ public OrderFailureResponse getOrdersFailures(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting order failures between {} and {}",
+ startDate, endDate);
+ LocalDateTime startDateTime = DatetimeFormatter
+ .convertStartDateToDatetime(startDate);
+ LocalDateTime endDateTime = DatetimeFormatter
+ .convertEndDateToDatetime(endDate);
+ List failureReasons = orderRepository
+ .findFailureReasons(startDateTime,
+ endDateTime);
+ OrderFailureRateProjection failureRate = orderRepository
+ .calculateFailureRate(startDateTime, endDateTime);
+ List failureReasonsDTO = failureReasons
+ .stream()
+ .map(data -> OrderFailureReasonsResponse.builder()
+ .reason(data.getReason())
+ .count(data.getCount())
+ .build())
+ .toList();
+ return OrderFailureResponse.builder()
+ .reasons(failureReasonsDTO)
+ .failureRate(failureRate.getFailureRate())
+ .build();
+ }
+
+ public Order saveOrder(
+ final String orderId,
+ final String customerId,
+ final List items,
+ final Region region,
+ final BigDecimal totalAmount,
+ final Instant timeStamp) {
+ log.info("Saving order with orderId: {}, customerId: {},"
+ + " region: {}, totalAmount: {}, timeStamp: {}",
+ orderId, customerId, region, totalAmount, timeStamp);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ UUID customerUUID = StringToUUIDParser.parseStringToUUID(customerId);
+ Customer customer = customerRepository.findById(customerUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Customer not found"));
+ int numberOfItems = items.stream()
+ .mapToInt(com.podzilla.mq.events.OrderItem::getQuantity)
+ .sum();
+ LocalDateTime orderPlacedTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timeStamp);
+ Order order = Order.builder()
+ .id(orderUUID)
+ .totalAmount(totalAmount)
+ .orderPlacedTimestamp(orderPlacedTimestamp)
+ .finalStatusTimestamp(orderPlacedTimestamp)
+ .region(region)
+ .customer(customer)
+ .numberOfItems(numberOfItems)
+ .status(OrderStatus.PLACED)
+ .build();
+ orderRepository.save(order);
+
+ List orderItems = items
+ .stream()
+ .map(item -> orderItemService.saveOrderItem(
+ item.getQuantity(),
+ item.getPricePerUnit(),
+ item.getProductId(),
+ orderUUID.toString()))
+ .toList();
+ order.setOrderItems(orderItems);
+ return orderRepository.save(order);
+ }
+
+ public Order cancelOrder(
+ final String orderId,
+ final String reason,
+ final Instant timeStamp) {
+ log.info("Cancelling order with orderId: {}, reason: {},"
+ + " timeStamp: {}", orderId, reason, timeStamp);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ LocalDateTime orderCancelledTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timeStamp);
+ Order order = orderRepository.findById(orderUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Order not found"));
+ order.setStatus(OrderStatus.CANCELLED);
+ order.setFailureReason(reason);
+ order.setOrderCancelledTimestamp(orderCancelledTimestamp);
+ order.setFinalStatusTimestamp(orderCancelledTimestamp);
+ return orderRepository.save(order);
+ }
+
+ public void assignCourier(
+ final String orderId,
+ final String courierId) {
+ log.info("Assigning courier with courierId: {} "
+ + "to orderId: {}", courierId, orderId);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ UUID courierUUID = StringToUUIDParser.parseStringToUUID(courierId);
+ Order order = orderRepository.findById(orderUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Order not found"));
+ Courier courier = courierRepository.findById(courierUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Courier not found"));
+ order.setCourier(courier);
+ orderRepository.save(order);
+ }
+
+ public void markOrderAsOutForDelivery(
+ final String orderId,
+ final Instant timeStamp) {
+ log.info("Marking order as out for delivery."
+ + " orderId: {}, timeStamp: {}", orderId, timeStamp);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ LocalDateTime orderOutForDeliveryTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timeStamp);
+ Order order = orderRepository.findById(orderUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Order not found"));
+ order.setStatus(OrderStatus.SHIPPED);
+ order.setShippedTimestamp(orderOutForDeliveryTimestamp);
+ order.setFinalStatusTimestamp(orderOutForDeliveryTimestamp);
+ orderRepository.save(order);
+ }
+
+ public void markOrderAsDelivered(
+ final String orderId,
+ final BigDecimal courierRating,
+ final Instant timeStamp) {
+ log.info("Marking order as delivered. orderId: {},"
+ + " courierRating: {}, timeStamp: {}", orderId, courierRating,
+ timeStamp);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ LocalDateTime orderDeliveredTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timeStamp);
+ Order order = orderRepository.findById(orderUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Order not found"));
+ order.setStatus(OrderStatus.DELIVERED);
+ order.setDeliveredTimestamp(orderDeliveredTimestamp);
+ order.setFinalStatusTimestamp(orderDeliveredTimestamp);
+ order.setCourierRating(courierRating);
+ orderRepository.save(order);
+ }
+
+ public void markOrderAsFailedToDeliver(
+ final String orderId,
+ final String reason,
+ final Instant timeStamp) {
+ log.info("Marking order as failed to deliver."
+ + " orderId: {}, reason: {}, timeStamp: {}", orderId, reason,
+ timeStamp);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ LocalDateTime orderFailedToDeliverTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timeStamp);
+ Order order = orderRepository.findById(orderUUID)
+ .orElseThrow(() -> new RuntimeException(
+ "Order not found"));
+ order.setStatus(OrderStatus.DELIVERY_FAILED);
+ order.setFailureReason(reason);
+ order.setOrderDeliveryFailedTimestamp(
+ orderFailedToDeliverTimestamp);
+ order.setFinalStatusTimestamp(orderFailedToDeliverTimestamp);
+ orderRepository.save(order);
+ }
+
+ public void markOrderAsFailedToFulfill(
+ final String orderId,
+ final String reason,
+ final Instant timeStamp) {
+ log.info("Marking order as failed to fulfill."
+ + " orderId: {}, reason: {}, timeStamp: {}", orderId, reason,
+ timeStamp);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+ LocalDateTime orderFulfillmentFailedTimestamp = DatetimeFormatter
+ .convertIntsantToDateTime(timeStamp);
+ Order order = Order.builder()
+ .id(orderUUID)
+ .status(OrderStatus.FULFILLMENT_FAILED)
+ .failureReason(reason)
+ .orderFulfillmentFailedTimestamp(
+ orderFulfillmentFailedTimestamp)
+ .finalStatusTimestamp(orderFulfillmentFailedTimestamp)
+ .build();
+ orderRepository.save(order);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/OrderItemService.java b/src/main/java/com/Podzilla/analytics/services/OrderItemService.java
new file mode 100644
index 0000000..9a5c09c
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/OrderItemService.java
@@ -0,0 +1,77 @@
+package com.Podzilla.analytics.services;
+
+import org.springframework.stereotype.Service;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import com.Podzilla.analytics.repositories.OrderItemRepository;
+import com.Podzilla.analytics.repositories.ProductRepository;
+import com.Podzilla.analytics.repositories.OrderRepository;
+
+import com.Podzilla.analytics.util.StringToUUIDParser;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import com.Podzilla.analytics.models.OrderItem;
+import com.Podzilla.analytics.models.Product;
+import com.Podzilla.analytics.models.Order;
+
+import java.math.BigDecimal;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class OrderItemService {
+ @Autowired
+ private final OrderItemRepository orderItemRepository;
+
+ @Autowired
+ private final ProductRepository productRepository;
+
+ @Autowired
+ private final OrderRepository orderRepository;
+
+ public OrderItem saveOrderItem(
+ final int quantity,
+ final BigDecimal pricePerUnit,
+ final String productId,
+ final String orderId) {
+ log.info("Attempting to save OrderItem."
+ + " quantity: {}, pricePerUnit: {}, productId: {}, orderId: {}",
+ quantity, pricePerUnit, productId,
+ orderId);
+
+ UUID productUUID = StringToUUIDParser.parseStringToUUID(productId);
+ UUID orderUUID = StringToUUIDParser.parseStringToUUID(orderId);
+
+ log.debug("Parsed productId to UUID: {}, orderId to UUID: {}",
+ productUUID, orderUUID);
+
+ Product product = productRepository.findById(productUUID)
+ .orElseThrow(() -> {
+ log.error("Product not found. productId: {}",
+ productId);
+ return new RuntimeException("Product not found");
+ });
+
+ Order order = orderRepository.findById(orderUUID)
+ .orElseThrow(() -> {
+ log.error("Order not found. orderId: {}", orderId);
+ return new RuntimeException("Order not found");
+ });
+
+ OrderItem orderItem = OrderItem.builder()
+ .quantity(quantity)
+ .pricePerUnit(pricePerUnit)
+ .product(product)
+ .order(order)
+ .build();
+
+ log.info("Saving OrderItem for orderId: {}, productId: {}",
+ orderId, productId);
+ OrderItem savedOrderItem = orderItemRepository.save(orderItem);
+ log.info("OrderItem saved successfully. orderItemId: {}",
+ savedOrderItem.getId());
+
+ return savedOrderItem;
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/ProductAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/ProductAnalyticsService.java
new file mode 100644
index 0000000..21ca5f9
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/ProductAnalyticsService.java
@@ -0,0 +1,107 @@
+package com.Podzilla.analytics.services;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.product.TopSellerRequest.SortBy;
+import com.Podzilla.analytics.api.projections.product.TopSellingProductProjection;
+import com.Podzilla.analytics.api.dtos.product.TopSellerResponse;
+import com.Podzilla.analytics.repositories.ProductRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import com.Podzilla.analytics.models.Product;
+import com.Podzilla.analytics.util.StringToUUIDParser;
+import java.util.UUID;
+
+@RequiredArgsConstructor
+@Service
+@Slf4j
+public class ProductAnalyticsService {
+
+ private final ProductRepository productRepository;
+
+ private static final int DAYS_TO_INCLUDE_END_DATE = 1;
+ private static final int SUBLIST_START_INDEX = 0;
+
+ /**
+ * Retrieves the top-selling products within a specified date range.
+ *
+ * @param startDate the start date of the range
+ * @param endDate the end date of the range
+ * @param limit the maximum number of results to return
+ * @param sortBy the sorting criteria (units sold or revenue)
+ * @return a list of top-selling products
+ */
+ public List getTopSellers(
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final Integer limit,
+ final SortBy sortBy) {
+ log.info("Getting top sellers between {} and {}"
+ + " with limit {} and sortBy {}", startDate,
+ endDate, limit, sortBy);
+
+ final String sortByString = sortBy != null ? sortBy.name()
+ : SortBy.REVENUE.name();
+
+ final LocalDateTime startDateTime = startDate.atStartOfDay();
+ final LocalDateTime endDateTime = endDate
+ .plusDays(DAYS_TO_INCLUDE_END_DATE).atStartOfDay();
+
+ final List queryResults = productRepository
+ .findTopSellers(startDateTime,
+ endDateTime,
+ limit, sortByString);
+
+ log.debug("Query returned {} top sellers", queryResults.size());
+
+ List topSellersList = new ArrayList<>();
+
+ for (TopSellingProductProjection row : queryResults) {
+ BigDecimal value = (sortBy == SortBy.UNITS)
+ ? BigDecimal.valueOf(row.getTotalUnits())
+ : row.getTotalRevenue();
+ TopSellerResponse topSellerItem = TopSellerResponse.builder()
+ .productId(row.getId())
+ .productName(row.getName())
+ .category(row.getCategory())
+ .value(value)
+ .build();
+
+ topSellersList.add(topSellerItem);
+ }
+ topSellersList.sort((a, b) -> b.getValue().compareTo(a.getValue()));
+ if (limit != null && limit > 0 && limit < topSellersList.size()) {
+ log.debug("Limiting top sellers list to {}", limit);
+ topSellersList = topSellersList.subList(SUBLIST_START_INDEX, limit);
+ }
+
+ log.info("Returning {} top sellers", topSellersList.size());
+ return topSellersList;
+ }
+
+ public void saveProduct(
+ final String productId,
+ final String productName,
+ final String productCategory,
+ final BigDecimal productCost,
+ final Integer productLowStockThreshold) {
+ log.info("Saving product with id: {}, name: {}, "
+ + " category: {}", productId, productName, productCategory);
+ UUID id = StringToUUIDParser.parseStringToUUID(productId);
+ Product product = Product.builder()
+ .id(id)
+ .name(productName)
+ .category(productCategory)
+ .cost(productCost)
+ .lowStockThreshold(productLowStockThreshold)
+ .build();
+ productRepository.save(product);
+ log.debug("Product saved: {}", product);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java
new file mode 100644
index 0000000..72a8fa5
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/ProfitAnalyticsService.java
@@ -0,0 +1,68 @@
+package com.Podzilla.analytics.services;
+
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory;
+import com.Podzilla.analytics.api.projections.profit.ProfitByCategoryProjection;
+import com.Podzilla.analytics.repositories.OrderItemRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Service
+@Slf4j
+public class ProfitAnalyticsService {
+ private final OrderItemRepository salesLineItemRepository;
+ private static final int PERCENTAGE_PRECISION = 4;
+
+ public List getProfitByCategory(
+ final LocalDate startDate,
+ final LocalDate endDate) {
+ log.info("Getting profit by category between {} and {}",
+ startDate, endDate);
+ LocalDateTime startDateTime = startDate.atStartOfDay();
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+
+ List salesData = salesLineItemRepository
+ .findSalesByCategoryBetweenDates(startDateTime, endDateTime);
+
+ log.debug("Fetched {} sales data records for categories",
+ salesData.size());
+
+ return salesData.stream()
+ .map(this::convertToDTO)
+ .collect(Collectors.toList());
+ }
+
+ private ProfitByCategory convertToDTO(
+ final ProfitByCategoryProjection projection) {
+ BigDecimal totalRevenue = projection.getTotalRevenue();
+ BigDecimal totalCost = projection.getTotalCost();
+ BigDecimal grossProfit = totalRevenue.subtract(totalCost);
+
+ BigDecimal grossProfitMargin = BigDecimal.ZERO;
+ if (totalRevenue.compareTo(BigDecimal.ZERO) > 0) {
+ grossProfitMargin = grossProfit
+ .divide(totalRevenue, PERCENTAGE_PRECISION,
+ RoundingMode.HALF_UP)
+ .multiply(new BigDecimal("100"));
+ }
+
+ return ProfitByCategory.builder()
+ .category(projection.getCategory())
+ .totalRevenue(totalRevenue)
+ .totalCost(totalCost)
+ .grossProfit(grossProfit)
+ .grossProfitMargin(grossProfitMargin)
+ .build();
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/RegionService.java b/src/main/java/com/Podzilla/analytics/services/RegionService.java
new file mode 100644
index 0000000..cc26dec
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/RegionService.java
@@ -0,0 +1,39 @@
+package com.Podzilla.analytics.services;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.repositories.RegionRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import com.Podzilla.analytics.models.Region;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class RegionService {
+
+ @Autowired
+ private final RegionRepository regionRepository;
+
+ public Region saveRegion(
+ final String city,
+ final String state,
+ final String country,
+ final String postalCode) {
+ log.info("Saving region with city: {}, state: {},"
+ + " country: {}, postalCode: {}",
+ city, state, country, postalCode);
+ Region region = Region.builder()
+ .city(city)
+ .state(state)
+ .country(country)
+ .postalCode(postalCode)
+ .build();
+ Region savedRegion = regionRepository.save(region);
+ log.info("Region saved successfully with id: {}",
+ savedRegion.getId());
+ return savedRegion;
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/services/RevenueReportService.java b/src/main/java/com/Podzilla/analytics/services/RevenueReportService.java
new file mode 100644
index 0000000..990f051
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/services/RevenueReportService.java
@@ -0,0 +1,96 @@
+package com.Podzilla.analytics.services;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+import com.Podzilla.analytics.api.dtos.revenue.RevenueByCategoryResponse;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryResponse;
+import com.Podzilla.analytics.api.projections.revenue.RevenueByCategoryProjection;
+import com.Podzilla.analytics.api.projections.revenue.RevenueSummaryProjection;
+import com.Podzilla.analytics.repositories.OrderRepository;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+@RequiredArgsConstructor
+@Service
+@Slf4j
+public class RevenueReportService {
+
+ private final OrderRepository orderRepository;
+
+ public List getRevenueSummary(
+ final LocalDate startDate,
+ final LocalDate endDate,
+ final String periodString) {
+
+ LocalDateTime startDateTime = startDate.atStartOfDay();
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+ log.info("Getting revenue summary between {} and {}"
+ + " for period '{}'", startDate, endDate, periodString);
+
+ final List revenueData = orderRepository
+ .findRevenueSummaryByPeriod(
+ startDateTime,
+ endDateTime, periodString);
+
+ final List summaryList = new ArrayList<>();
+
+ for (RevenueSummaryProjection row : revenueData) {
+ RevenueSummaryResponse summaryItem = RevenueSummaryResponse
+ .builder()
+ .periodStartDate(row.getPeriod())
+ .totalRevenue(row.getTotalRevenue())
+ .build();
+
+ summaryList.add(summaryItem);
+ }
+
+ log.info("Revenue summary result size: {}", summaryList.size());
+ return summaryList;
+ }
+
+ /**
+ * Gets completed order revenue summarized by product category
+ * for a date range.
+ *
+ * @param startDate The start date (inclusive).
+ * @param endDate The end date (exclusive).
+ * @return A list of revenue summaries per category.
+ */
+ public List getRevenueByCategory(
+ final LocalDate startDate, final LocalDate endDate) {
+
+ LocalDateTime startDateTime = startDate.atStartOfDay();
+ LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);
+ log.info("Getting revenue by category between"
+ + " {} and {}", startDate, endDate);
+
+ final List queryResults = orderRepository
+ .findRevenueByCategory(
+ startDateTime,
+ endDateTime);
+
+ final List summaryList = new ArrayList<>();
+
+ // Each row is [category_string, total_revenue_bigdecimal]
+ for (RevenueByCategoryProjection row : queryResults) {
+ RevenueByCategoryResponse summaryItem = RevenueByCategoryResponse
+ .builder()
+ .category(row.getCategory())
+ .totalRevenue(row.getTotalRevenue())
+ .build();
+
+ summaryList.add(summaryItem);
+ }
+
+ log.info("Revenue by category result"
+ + " size: {}", summaryList.size());
+ return summaryList;
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/util/DatetimeFormatter.java b/src/main/java/com/Podzilla/analytics/util/DatetimeFormatter.java
new file mode 100644
index 0000000..83b7f67
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/util/DatetimeFormatter.java
@@ -0,0 +1,28 @@
+package com.Podzilla.analytics.util;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.Instant;
+import java.time.ZoneId;
+
+public class DatetimeFormatter {
+ public static LocalDateTime convertStartDateToDatetime(
+ final LocalDate startDate
+ ) {
+ return startDate.atStartOfDay();
+ }
+ public static LocalDateTime convertEndDateToDatetime(
+ final LocalDate endDate
+ ) {
+ return endDate.atTime(LocalTime.MAX);
+ }
+ public static LocalDateTime convertIntsantToDateTime(
+ final Instant timestamp
+ ) {
+ return LocalDateTime.ofInstant(
+ timestamp,
+ ZoneId.systemDefault()
+ );
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/util/MetricCalculator.java b/src/main/java/com/Podzilla/analytics/util/MetricCalculator.java
new file mode 100644
index 0000000..a32839b
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/util/MetricCalculator.java
@@ -0,0 +1,52 @@
+package com.Podzilla.analytics.util;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+public final class MetricCalculator {
+ private static final int DEFAULT_SCALE = 2;
+
+ private MetricCalculator() {
+ throw new UnsupportedOperationException(
+ "This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * Calculates a success rate or percentage.
+ *
+ * @param numerator The count of successful outcomes (e.g., completed
+ * deliveries).
+ * @param denominator The total count of attempts (e.g., completed + failed
+ * deliveries).
+ * @param scale The number of decimal places for the result.
+ * @param roundingMode The rounding mode to apply.
+ * @return The calculated percentage as a BigDecimal, or BigDecimal.ZERO
+ * if the denominator is zero.
+ */
+ public static BigDecimal calculateRate(final long numerator,
+ final long denominator, final int scale,
+ final RoundingMode roundingMode) {
+ if (denominator == 0) {
+ return BigDecimal.ZERO;
+ }
+ if (numerator < 0 || denominator < 0) {
+ return BigDecimal.ZERO;
+ }
+ return BigDecimal.valueOf(numerator)
+ .divide(BigDecimal.valueOf(denominator), scale, roundingMode);
+ }
+
+ /**
+ * Calculates a success rate or percentage using default scale and rounding.
+ *
+ * @param numerator The count of successful outcomes.
+ * @param denominator The total count of attempts.
+ * @return The calculated percentage (scale 2, HALF_UP rounding), or
+ * BigDecimal.ZERO if denominator is zero.
+ */
+ public static BigDecimal calculateRate(final long numerator,
+ final long denominator) {
+ return calculateRate(numerator, denominator, DEFAULT_SCALE,
+ RoundingMode.HALF_UP);
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/util/StringToUUIDParser.java b/src/main/java/com/Podzilla/analytics/util/StringToUUIDParser.java
new file mode 100644
index 0000000..678cbb8
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/util/StringToUUIDParser.java
@@ -0,0 +1,14 @@
+package com.Podzilla.analytics.util;
+
+import java.util.UUID;
+
+public class StringToUUIDParser {
+ public static UUID parseStringToUUID(final String str) {
+ try {
+ return UUID.fromString(str);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid input: " + str, e);
+ }
+ }
+
+}
diff --git a/src/main/java/com/Podzilla/analytics/validation/annotations/ValidDateRange.java b/src/main/java/com/Podzilla/analytics/validation/annotations/ValidDateRange.java
new file mode 100644
index 0000000..4bb2af3
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/validation/annotations/ValidDateRange.java
@@ -0,0 +1,24 @@
+package com.Podzilla.analytics.validation.annotations;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import com.Podzilla.analytics.validation.validators.DateRangeValidator;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Documented
+@Constraint(validatedBy = DateRangeValidator.class)
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ValidDateRange {
+ String message() default "endDate must be after startDate";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/src/main/java/com/Podzilla/analytics/validation/annotations/ValidPagination.java b/src/main/java/com/Podzilla/analytics/validation/annotations/ValidPagination.java
new file mode 100644
index 0000000..3f0b745
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/validation/annotations/ValidPagination.java
@@ -0,0 +1,25 @@
+package com.Podzilla.analytics.validation.annotations;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import com.Podzilla.analytics.validation.validators.PaginationValidator;
+
+import jakarta.validation.Constraint;
+import jakarta.validation.Payload;
+
+@Documented
+@Constraint(validatedBy = PaginationValidator.class)
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ValidPagination {
+ String message() default "Page must be greater than or equal to 0 "
+ + "and size must be greater than 0";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+}
diff --git a/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java b/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java
new file mode 100644
index 0000000..eddcd1e
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/validation/validators/DateRangeValidator.java
@@ -0,0 +1,19 @@
+package com.Podzilla.analytics.validation.validators;
+
+import com.Podzilla.analytics.api.dtos.IDateRangeRequest;
+import com.Podzilla.analytics.validation.annotations.ValidDateRange;
+
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public final class DateRangeValidator implements
+ ConstraintValidator {
+ @Override
+ public boolean isValid(final IDateRangeRequest request,
+ final ConstraintValidatorContext context) {
+ if (request.getStartDate() == null || request.getEndDate() == null) {
+ return true;
+ }
+ return request.getEndDate().isAfter(request.getStartDate());
+ }
+}
diff --git a/src/main/java/com/Podzilla/analytics/validation/validators/PaginationValidator.java b/src/main/java/com/Podzilla/analytics/validation/validators/PaginationValidator.java
new file mode 100644
index 0000000..a9bad9f
--- /dev/null
+++ b/src/main/java/com/Podzilla/analytics/validation/validators/PaginationValidator.java
@@ -0,0 +1,17 @@
+package com.Podzilla.analytics.validation.validators;
+
+import com.Podzilla.analytics.api.dtos.IPaginationRequest;
+import com.Podzilla.analytics.validation.annotations.ValidPagination;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+
+public final class PaginationValidator implements
+ ConstraintValidator {
+
+ @Override
+ public boolean isValid(final IPaginationRequest request,
+ final ConstraintValidatorContext context) {
+ return request.getPage() >= 0 && request.getSize() > 0;
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..4de7429
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,22 @@
+spring.application.name=analytics
+server.servlet.context-path=/api
+
+# Database Configuration
+spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/analytics_db_dev}
+spring.datasource.username=${SPRING_DATASOURCE_USERNAME:postgres}
+spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:123}
+spring.datasource.driver-class-name=org.postgresql.Driver
+
+# JPA & Hibernate Configuration
+spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.generate-ddl=true
+# spring.jpa.show-sql=true
+spring.jpa.properties.hibernate.format_sql=true
+spring.jpa.properties.hibernate.use_sql_comments=true
+
+# RabbitMQ Configuration
+spring.rabbitmq.host=${SPRING_RABBITMQ_HOST:localhost}
+spring.rabbitmq.port=${SPRING_RABBITMQ_PORT:5672}
+spring.rabbitmq.username=${SPRING_RABBITMQ_USERNAME:guest}
+spring.rabbitmq.password=${SPRING_RABBITMQ_PASSWORD:guest}
diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico
new file mode 100644
index 0000000..e69de29
diff --git a/src/test/java/com/Podzilla/analytics/AnalyticsApplicationTests.java b/src/test/java/com/Podzilla/analytics/AnalyticsApplicationTests.java
new file mode 100644
index 0000000..4a25c58
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/AnalyticsApplicationTests.java
@@ -0,0 +1,13 @@
+// package com.Podzilla.analytics;
+
+// import org.junit.jupiter.api.Test;
+// import org.springframework.boot.test.context.SpringBootTest;
+
+// @SpringBootTest
+// class AnalyticsApplicationTests {
+
+// @Test
+// void contextLoads() {
+// }
+
+// }
diff --git a/src/test/java/com/Podzilla/analytics/controllers/CourierAnalyticsControllerTest.java b/src/test/java/com/Podzilla/analytics/controllers/CourierAnalyticsControllerTest.java
new file mode 100644
index 0000000..7a31e5a
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/controllers/CourierAnalyticsControllerTest.java
@@ -0,0 +1,330 @@
+package com.Podzilla.analytics.controllers;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+import jakarta.persistence.EntityManager;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.Podzilla.analytics.models.Courier;
+import com.Podzilla.analytics.models.Customer;
+import com.Podzilla.analytics.models.Order;
+import com.Podzilla.analytics.models.Region;
+import com.Podzilla.analytics.repositories.CourierRepository;
+import com.Podzilla.analytics.repositories.CustomerRepository;
+import com.Podzilla.analytics.repositories.OrderRepository;
+import com.Podzilla.analytics.repositories.RegionRepository;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import static org.hamcrest.Matchers.*;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@Transactional
+@ActiveProfiles("test")
+public class CourierAnalyticsControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private CourierRepository courierRepository;
+ @Autowired
+ private CustomerRepository customerRepository;
+ @Autowired
+ private RegionRepository regionRepository;
+ @Autowired
+ private OrderRepository orderRepository;
+
+ @Autowired
+ private EntityManager entityManager;
+
+ private static final DateTimeFormatter ISO_LOCAL_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
+
+ private Customer customer1;
+ private Region region1;
+ private Courier courierJane;
+ private Courier courierJohn;
+
+ @BeforeEach
+ void setUp() {
+ orderRepository.deleteAll();
+ courierRepository.deleteAll();
+ customerRepository.deleteAll();
+ regionRepository.deleteAll();
+
+ entityManager.flush();
+ entityManager.clear();
+
+ customer1 = customerRepository.save(Customer.builder()
+ .id(UUID.randomUUID())
+ .name("John Doe").build());
+ region1 = regionRepository.save(Region.builder()
+ // .id(UUID.randomUUID())
+ .city("Sample City")
+ .state("Sample State")
+ .country("Sample Country")
+ .postalCode("12345")
+ .build());
+
+ courierJane = courierRepository.save(Courier.builder()
+ .id(UUID.randomUUID())
+ .name("Jane Smith")
+ .build());
+
+ courierJohn = courierRepository.save(Courier.builder()
+ .id(UUID.randomUUID())
+ .name("John Doe")
+ .build());
+
+ orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("50.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(3))
+ .status(Order.OrderStatus.DELIVERED)
+ .numberOfItems(1)
+ .courierRating(new BigDecimal("4.0"))
+ .customer(customer1)
+ .courier(courierJane)
+ .region(region1)
+ .build());
+
+ orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("75.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(3))
+ .status(Order.OrderStatus.DELIVERED)
+ .numberOfItems(1)
+ .courierRating(new BigDecimal("4.0"))
+ .customer(customer1)
+ .courier(courierJane)
+ .region(region1)
+ .build());
+
+ orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("120.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(1))
+ .status(Order.OrderStatus.DELIVERED)
+ .numberOfItems(2)
+ .courierRating(new BigDecimal("5.0"))
+ .customer(customer1)
+ .courier(courierJane)
+ .region(region1)
+ .build());
+
+ orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("30.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(2))
+ .status(Order.OrderStatus.DELIVERY_FAILED)
+ .numberOfItems(1)
+ .courierRating(null)
+ .customer(customer1)
+ .courier(courierJohn)
+ .region(region1)
+ .build());
+
+ orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("90.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(2))
+ .status(Order.OrderStatus.DELIVERED)
+ .numberOfItems(1)
+ .courierRating(new BigDecimal("3.0"))
+ .customer(customer1)
+ .courier(courierJohn)
+ .region(region1)
+ .build());
+
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @AfterEach
+ void tearDown() {
+ orderRepository.deleteAll();
+ courierRepository.deleteAll();
+ customerRepository.deleteAll();
+ regionRepository.deleteAll();
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @Test
+ void contextLoads() {
+ }
+
+ @Test
+ void getCourierDeliveryCounts_shouldReturnCountsForSpecificDateRange() throws Exception {
+ LocalDate startDate = LocalDate.now().minusDays(4);
+ LocalDate endDate = LocalDate.now().minusDays(2);
+
+ mockMvc.perform(get("/courier-analytics/delivery-counts")
+ .param("startDate", startDate.format(
+ ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(
+ ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].deliveryCount").value(hasItem(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].deliveryCount").value(hasItem(2)));
+ }
+
+ @Test
+ void getCourierDeliveryCounts_shouldReturnZeroCountsWhenNoDeliveriesInDateRange()
+ throws Exception {
+ LocalDate startDate = LocalDate.now().plusDays(1);
+ LocalDate endDate = LocalDate.now().plusDays(2);
+
+ mockMvc.perform(get("/courier-analytics/delivery-counts")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].courierName").value(hasItem("Jane Smith")))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].deliveryCount").value(hasItem(0)))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].courierName").value(hasItem("John Doe")))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].deliveryCount").value(hasItem(0)));
+ }
+
+ @Test
+ void getCourierDeliveryCounts_shouldHandleInvalidDateRange_startDateAfterEndDate()
+ throws Exception {
+ LocalDateTime startDate = LocalDateTime.now().minusDays(1);
+ LocalDateTime endDate = LocalDateTime.now().minusDays(3);
+
+ mockMvc.perform(get("/courier-analytics/delivery-counts")
+ .param("startDate", startDate.format(
+ ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(
+ ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void getCourierSuccessRate_shouldReturnSuccessRatesForSpecificDateRange()
+ throws Exception {
+ LocalDateTime startDate = LocalDateTime.now().minusDays(4);
+ LocalDateTime endDate = LocalDateTime.now().minusDays(2);
+
+ mockMvc.perform(get("/courier-analytics/success-rate")
+ .param("startDate", startDate.format(
+ ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(
+ ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(
+ jsonPath("$[?(@.courierName == 'Jane Smith')].successRate").value(hasItem(closeTo(1.0, 0.001))))
+ .andExpect(
+ jsonPath("$[?(@.courierName == 'John Doe')].successRate").value(hasItem(closeTo(0.5, 0.001))));
+ }
+
+ @Test
+ void getCourierAverageRating_shouldReturnAllAverageRatingsWhenNoDateRangeProvided()
+ throws Exception {
+ LocalDateTime startDate = LocalDateTime.now().minusDays(4);
+ LocalDateTime endDate = LocalDateTime.now();
+ mockMvc.perform(get("/courier-analytics/average-rating")
+ .param("startDate", startDate.format(
+ ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(
+ ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].averageRating")
+ .value(hasItem(closeTo(4.333, 0.001))))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].averageRating")
+ .value(hasItem(closeTo(3.0, 0.001))));
+ }
+
+ @Test
+ void getCourierAverageRating_shouldReturnAverageRatingsForSpecificDateRange()
+ throws Exception {
+
+ LocalDateTime startDate = LocalDateTime.now().minusDays(4);
+ LocalDateTime endDate = LocalDateTime.now().minusDays(2);
+
+ mockMvc.perform(get("/courier-analytics/average-rating")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].averageRating")
+ .value(hasItem(closeTo(4.0, 0.001))))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].averageRating")
+ .value(hasItem(closeTo(3.0, 0.001))));
+ }
+
+ @Test
+ void getCourierPerformanceReport_shouldReturnAllPerformanceReportsWhenNoDateRangeProvided()
+ throws Exception {
+ LocalDateTime startDate = LocalDateTime.now().minusDays(4);
+ LocalDateTime endDate = LocalDateTime.now();
+ mockMvc.perform(get("/courier-analytics/performance-report")
+ .param("startDate", startDate.format(
+ ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(
+ ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].deliveryCount").value(hasItem(3)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].successRate")
+ .value(hasItem(closeTo(1.0, 0.001))))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].averageRating")
+ .value(hasItem(closeTo(4.333, 0.001))))
+
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].deliveryCount").value(hasItem(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].successRate")
+ .value(hasItem(closeTo(0.5, 0.001))))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].averageRating")
+ .value(hasItem(closeTo(3.0, 0.001))));
+ }
+
+ @Test
+ void getCourierPerformanceReport_shouldReturnPerformanceReportsForSpecificDateRange()
+ throws Exception {
+ LocalDateTime startDate = LocalDateTime.now().minusDays(4);
+ LocalDateTime endDate = LocalDateTime.now().minusDays(2);
+
+ mockMvc.perform(get("/courier-analytics/performance-report")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE)))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].deliveryCount").value(hasItem(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].successRate")
+ .value(hasItem(closeTo(1.0, 0.001))))
+ .andExpect(jsonPath("$[?(@.courierName == 'Jane Smith')].averageRating")
+ .value(hasItem(closeTo(4.0, 0.001))))
+
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].deliveryCount").value(hasItem(2)))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].successRate")
+ .value(hasItem(closeTo(0.5, 0.001))))
+ .andExpect(jsonPath("$[?(@.courierName == 'John Doe')].averageRating")
+ .value(hasItem(closeTo(3.0, 0.001))));
+ }
+}
diff --git a/src/test/java/com/Podzilla/analytics/controllers/CustomerReportControllerTest.java b/src/test/java/com/Podzilla/analytics/controllers/CustomerReportControllerTest.java
new file mode 100644
index 0000000..a39e7bf
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/controllers/CustomerReportControllerTest.java
@@ -0,0 +1,221 @@
+package com.Podzilla.analytics.controllers;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.hamcrest.Matchers.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+import jakarta.persistence.EntityManager;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.Podzilla.analytics.models.Customer;
+import com.Podzilla.analytics.models.Order;
+import com.Podzilla.analytics.models.Region;
+import com.Podzilla.analytics.repositories.CustomerRepository;
+import com.Podzilla.analytics.repositories.OrderRepository;
+import com.Podzilla.analytics.repositories.RegionRepository;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@Transactional
+@ActiveProfiles("test")
+class CustomerReportControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private CustomerRepository customerRepository;
+
+ @Autowired
+ private OrderRepository orderRepository;
+
+ @Autowired
+ private RegionRepository regionRepository;
+
+ @Autowired
+ private EntityManager entityManager;
+
+ private static final DateTimeFormatter ISO_LOCAL_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
+
+ private Customer customer1;
+ private Customer customer2;
+ private Region region1;
+ private Order order1;
+ private Order order2;
+ private Order order3;
+
+ @BeforeEach
+ void setUp() {
+ orderRepository.deleteAll();
+ customerRepository.deleteAll();
+ regionRepository.deleteAll();
+ entityManager.flush();
+ entityManager.clear();
+
+ // Create test region
+ region1 = regionRepository.save(Region.builder()
+ .city("Sample City")
+ .state("Sample State")
+ .country("Sample Country")
+ .postalCode("12345")
+ .build());
+
+ // Create test customers
+ customer1 = customerRepository.save(Customer.builder()
+ .id(UUID.randomUUID())
+ .name("John Doe")
+ .build());
+
+ customer2 = customerRepository.save(Customer.builder()
+ .id(UUID.randomUUID())
+ .name("Jane Smith")
+ .build());
+
+ // Create test orders
+ order1 = orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("1000.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(2))
+ .status(Order.OrderStatus.DELIVERED)
+ .customer(customer1)
+ .region(region1)
+ .build());
+
+ order2 = orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("500.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(1))
+ .status(Order.OrderStatus.DELIVERED)
+ .customer(customer1)
+ .region(region1)
+ .build());
+
+ order3 = orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("2000.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(3))
+ .status(Order.OrderStatus.DELIVERED)
+ .customer(customer2)
+ .region(region1)
+ .build());
+
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @AfterEach
+ void tearDown() {
+ orderRepository.deleteAll();
+ customerRepository.deleteAll();
+ regionRepository.deleteAll();
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @Test
+ void contextLoads() {
+ }
+
+ @Test
+ void getTopSpenders_ShouldReturnListOfTopSpenders() throws Exception {
+ LocalDate startDate = LocalDate.now().minusDays(4);
+ LocalDate endDate = LocalDate.now();
+
+ mockMvc.perform(get("/customer-analytics/top-spenders")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE))
+ .param("page", "0")
+ .param("size", "10"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].customerName").value(customer2.getName()))
+ .andExpect(jsonPath("$[0].totalSpending").value(closeTo(order3.getTotalAmount().doubleValue(), 0.01)))
+ .andExpect(jsonPath("$[1].customerName").value(customer1.getName()))
+ .andExpect(jsonPath("$[1].totalSpending")
+ .value(closeTo(order1.getTotalAmount().add(order2.getTotalAmount()).doubleValue(), 0.01)));
+ }
+
+ @Test
+ void getTopSpenders_ShouldReturnEmptyListWhenNoOrdersInDateRange() throws Exception {
+ LocalDate startDate = LocalDate.now().plusDays(1);
+ LocalDate endDate = LocalDate.now().plusDays(2);
+
+ mockMvc.perform(get("/customer-analytics/top-spenders")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE))
+ .param("page", "0")
+ .param("size", "10"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0)));
+ }
+
+ @Test
+ void getTopSpenders_ShouldHandlePagination() throws Exception {
+ LocalDate startDate = LocalDate.now().minusDays(4);
+ LocalDate endDate = LocalDate.now();
+
+ mockMvc.perform(get("/customer-analytics/top-spenders")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE))
+ .param("page", "0")
+ .param("size", "1"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].customerName").value(customer2.getName()))
+ .andExpect(jsonPath("$[0].totalSpending").value(closeTo(order3.getTotalAmount().doubleValue(), 0.01)));
+ }
+
+ @Test
+ void getTopSpenders_ShouldExcludeFailedOrders() throws Exception {
+ Order failedOrder = orderRepository.save(Order.builder()
+ .id(UUID.randomUUID())
+ .totalAmount(new BigDecimal("3000.00"))
+ .finalStatusTimestamp(LocalDateTime.now().minusDays(1))
+ .status(Order.OrderStatus.DELIVERY_FAILED)
+ .customer(customer1)
+ .region(region1)
+ .build());
+
+ entityManager.flush();
+ entityManager.clear();
+
+ LocalDate startDate = LocalDate.now().minusDays(4);
+ LocalDate endDate = LocalDate.now();
+
+ mockMvc.perform(get("/customer-analytics/top-spenders")
+ .param("startDate", startDate.format(ISO_LOCAL_DATE))
+ .param("endDate", endDate.format(ISO_LOCAL_DATE))
+ .param("page", "0")
+ .param("size", "10"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[0].customerName").value(customer2.getName()))
+ .andExpect(jsonPath("$[0].totalSpending").value(closeTo(order3.getTotalAmount().doubleValue(), 0.01)))
+ .andExpect(jsonPath("$[1].customerName").value(customer1.getName()))
+ .andExpect(jsonPath("$[1].totalSpending")
+ .value(closeTo(order1.getTotalAmount().add(order2.getTotalAmount()).doubleValue(), 0.01)))
+ .andExpect(jsonPath("$[1].totalSpending").value(not(closeTo(order1.getTotalAmount()
+ .add(order2.getTotalAmount()).add(failedOrder.getTotalAmount()).doubleValue(), 0.01))));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/controllers/FulfillmentReportControllerTest.java b/src/test/java/com/Podzilla/analytics/controllers/FulfillmentReportControllerTest.java
new file mode 100644
index 0000000..394b713
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/controllers/FulfillmentReportControllerTest.java
@@ -0,0 +1,434 @@
+package com.Podzilla.analytics.controllers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentPlaceToShipRequest.PlaceToShipGroupBy;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentShipToDeliverRequest.ShipToDeliverGroupBy;
+import com.Podzilla.analytics.api.dtos.fulfillment.FulfillmentTimeResponse;
+import com.Podzilla.analytics.services.FulfillmentAnalyticsService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class FulfillmentReportControllerTest {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @MockBean
+ private FulfillmentAnalyticsService mockService;
+
+ private ObjectMapper objectMapper;
+ private LocalDate startDate;
+ private LocalDate endDate;
+ private List overallTimeResponses;
+ private List regionTimeResponses;
+ private List courierTimeResponses;
+
+ @BeforeEach
+ public void setup() {
+ objectMapper = new ObjectMapper();
+ objectMapper.registerModule(new JavaTimeModule());
+
+ startDate = LocalDate.of(2024, 1, 1);
+ endDate = LocalDate.of(2024, 1, 31);
+
+ // Setup test data
+ overallTimeResponses = Arrays.asList(
+ FulfillmentTimeResponse.builder()
+ .groupByValue("OVERALL")
+ .averageDuration(BigDecimal.valueOf(24.5))
+ .build());
+
+ regionTimeResponses = Arrays.asList(
+ FulfillmentTimeResponse.builder()
+ .groupByValue("RegionID_1")
+ .averageDuration(BigDecimal.valueOf(20.2))
+ .build(),
+ FulfillmentTimeResponse.builder()
+ .groupByValue("RegionID_2")
+ .averageDuration(BigDecimal.valueOf(28.7))
+ .build());
+
+ courierTimeResponses = Arrays.asList(
+ FulfillmentTimeResponse.builder()
+ .groupByValue("CourierID_1")
+ .averageDuration(BigDecimal.valueOf(18.3))
+ .build(),
+ FulfillmentTimeResponse.builder()
+ .groupByValue("CourierID_2")
+ .averageDuration(BigDecimal.valueOf(22.1))
+ .build());
+ }
+
+ @Test
+ public void testGetPlaceToShipTime_Overall() {
+ // Configure mock service
+ when(mockService.getPlaceToShipTimeResponse(
+ startDate, endDate, PlaceToShipGroupBy.OVERALL))
+ .thenReturn(overallTimeResponses);
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/place-to-ship-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().size()).isEqualTo(1);
+ assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL");
+ assertThat(response.getBody().get(0).getAverageDuration()).isEqualTo(BigDecimal.valueOf(24.5));
+ }
+
+ @Test
+ public void testGetPlaceToShipTime_ByRegion() {
+ // Configure mock service
+ when(mockService.getPlaceToShipTimeResponse(
+ startDate, endDate, PlaceToShipGroupBy.REGION))
+ .thenReturn(regionTimeResponses);
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/place-to-ship-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", PlaceToShipGroupBy.REGION.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().size()).isEqualTo(2);
+ assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("RegionID_1");
+ assertThat(response.getBody().get(1).getGroupByValue()).isEqualTo("RegionID_2");
+ }
+
+ @Test
+ public void testGetShipToDeliverTime_Overall() {
+ // Configure mock service
+ when(mockService.getShipToDeliverTimeResponse(
+ startDate, endDate, ShipToDeliverGroupBy.OVERALL))
+ .thenReturn(overallTimeResponses);
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL");
+ }
+
+ @Test
+ public void testGetShipToDeliverTime_ByRegion() {
+ // Configure mock service
+ when(mockService.getShipToDeliverTimeResponse(
+ startDate, endDate, ShipToDeliverGroupBy.REGION))
+ .thenReturn(regionTimeResponses);
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", ShipToDeliverGroupBy.REGION.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("RegionID_1");
+ assertThat(response.getBody().get(1).getGroupByValue()).isEqualTo("RegionID_2");
+ }
+
+ @Test
+ public void testGetShipToDeliverTime_ByCourier() {
+ // Configure mock service
+ when(mockService.getShipToDeliverTimeResponse(
+ startDate, endDate, ShipToDeliverGroupBy.COURIER))
+ .thenReturn(courierTimeResponses);
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", ShipToDeliverGroupBy.COURIER.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("CourierID_1");
+ assertThat(response.getBody().get(1).getGroupByValue()).isEqualTo("CourierID_2");
+ }
+
+ // Edge case tests
+
+ @Test
+ public void testGetPlaceToShipTime_EmptyResponse() {
+ // Configure mock service to return empty list
+ when(mockService.getPlaceToShipTimeResponse(
+ startDate, endDate, PlaceToShipGroupBy.OVERALL))
+ .thenReturn(Collections.emptyList());
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/place-to-ship-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody()).isEmpty();
+ }
+
+ @Test
+ public void testGetShipToDeliverTime_EmptyResponse() {
+ // Configure mock service to return empty list
+ when(mockService.getShipToDeliverTimeResponse(
+ startDate, endDate, ShipToDeliverGroupBy.OVERALL))
+ .thenReturn(Collections.emptyList());
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody()).isEmpty();
+ }
+
+ @Test
+ public void testGetPlaceToShipTime_InvalidGroupBy() {
+ // Build URL without groupBy param
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/place-to-ship-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void testGetShipToDeliverTime_InvalidGroupBy() {
+ // Build URL without groupBy param
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ // @Test
+ // public void testGetPlaceToShipTime_SameDayRange() {
+ // // Test same start and end date
+ // LocalDate sameDate = LocalDate.of(2024, 1, 1);
+
+ // // Configure mock service
+ // when(mockService.getPlaceToShipTimeResponse(
+ // sameDate, sameDate, PlaceToShipGroupBy.OVERALL))
+ // .thenReturn(overallTimeResponses);
+
+ // // Build URL with query parameters
+ // String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/place-to-ship-time")
+ // .queryParam("startDate", sameDate.toString())
+ // .queryParam("endDate", sameDate.toString())
+ // .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString())
+ // .toUriString();
+
+ // // Execute request
+ // ResponseEntity> response = restTemplate.exchange(
+ // url,
+ // HttpMethod.GET,
+ // null,
+ // new ParameterizedTypeReference>() {});
+
+ // // Verify
+ // assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ // assertThat(response.getBody()).isNotNull();
+ // assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL");
+ // }
+
+ // @Test
+ // public void testGetShipToDeliverTime_SameDayRange() {
+ // // Test same start and end date
+ // LocalDate sameDate = LocalDate.of(2024, 1, 1);
+
+ // // Configure mock service
+ // when(mockService.getShipToDeliverTimeResponse(
+ // sameDate, sameDate, ShipToDeliverGroupBy.OVERALL))
+ // .thenReturn(overallTimeResponses);
+
+ // // Build URL with query parameters
+ // String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ // .queryParam("startDate", sameDate.toString())
+ // .queryParam("endDate", sameDate.toString())
+ // .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString())
+ // .toUriString();
+
+ // // Execute request
+ // ResponseEntity> response = restTemplate.exchange(
+ // url,
+ // HttpMethod.GET,
+ // null,
+ // new ParameterizedTypeReference>() {});
+
+ // // Verify
+ // assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ // assertThat(response.getBody()).isNotNull();
+ // assertThat(response.getBody().get(0).getGroupByValue()).isEqualTo("OVERALL");
+ // }
+
+ @Test
+ public void testGetPlaceToShipTime_FutureDates() {
+ // Test future dates
+ LocalDate futureStart = LocalDate.now().plusDays(1);
+ LocalDate futureEnd = LocalDate.now().plusDays(30);
+
+ // Configure mock service - should return empty for future dates
+ when(mockService.getPlaceToShipTimeResponse(
+ futureStart, futureEnd, PlaceToShipGroupBy.OVERALL))
+ .thenReturn(Collections.emptyList());
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/place-to-ship-time")
+ .queryParam("startDate", futureStart.toString())
+ .queryParam("endDate", futureEnd.toString())
+ .queryParam("groupBy", PlaceToShipGroupBy.OVERALL.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody()).isEmpty();
+ }
+
+ @Test
+ public void testGetShipToDeliverTime_ServiceException() {
+ // Configure mock service to throw exception
+ when(mockService.getShipToDeliverTimeResponse(
+ any(), any(), any()))
+ .thenThrow(new RuntimeException("Service error"));
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/fulfillment-analytics/ship-to-deliver-time")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .queryParam("groupBy", ShipToDeliverGroupBy.OVERALL.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/controllers/InventoryReportControllerTest.java b/src/test/java/com/Podzilla/analytics/controllers/InventoryReportControllerTest.java
new file mode 100644
index 0000000..a4af6b3
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/controllers/InventoryReportControllerTest.java
@@ -0,0 +1,210 @@
+package com.Podzilla.analytics.controllers;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.hamcrest.Matchers.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import jakarta.persistence.EntityManager;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.Podzilla.analytics.models.Product;
+import com.Podzilla.analytics.models.ProductSnapshot;
+import com.Podzilla.analytics.repositories.ProductRepository;
+import com.Podzilla.analytics.repositories.ProductSnapshotRepository;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@Transactional
+@ActiveProfiles("test")
+class InventoryReportControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ProductRepository productRepository;
+
+ @Autowired
+ private ProductSnapshotRepository productSnapshotRepository;
+
+ @Autowired
+ private EntityManager entityManager;
+
+ private Product electronicsProduct;
+ private Product clothingProduct;
+
+ @BeforeEach
+ void setUp() {
+ productSnapshotRepository.deleteAll();
+ productRepository.deleteAll();
+ entityManager.flush();
+ entityManager.clear();
+
+ // Create test products
+ electronicsProduct = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("Laptop")
+ .category("Electronics")
+ .cost(new BigDecimal("1000.00"))
+ .lowStockThreshold(10)
+ .build());
+
+ clothingProduct = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("T-Shirt")
+ .category("Clothing")
+ .cost(new BigDecimal("20.00"))
+ .lowStockThreshold(5)
+ .build());
+
+ entityManager.flush();
+ entityManager.clear();
+
+ // Create product snapshots in a separate transaction
+ ProductSnapshot electronicsSnapshot = ProductSnapshot.builder()
+ .timestamp(LocalDateTime.now())
+ .product(electronicsProduct)
+ .quantity(15)
+ .build();
+
+ ProductSnapshot clothingSnapshot = ProductSnapshot.builder()
+ .timestamp(LocalDateTime.now())
+ .product(clothingProduct)
+ .quantity(3) // Below threshold
+ .build();
+
+ productSnapshotRepository.save(electronicsSnapshot);
+ productSnapshotRepository.save(clothingSnapshot);
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @AfterEach
+ void tearDown() {
+ productSnapshotRepository.deleteAll();
+ productRepository.deleteAll();
+ entityManager.flush();
+ entityManager.clear();
+ }
+
+ @Test
+ void contextLoads() {
+ }
+
+ @Test
+ void getInventoryValueByCategory_ShouldReturnListOfCategoryValues() throws Exception {
+ mockMvc.perform(get("/inventory-analytics/value/by-category"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(2)))
+ .andExpect(jsonPath("$[?(@.category == '" + electronicsProduct.getCategory()
+ + "')].totalStockValue")
+ .value(hasItem(closeTo(electronicsProduct.getCost()
+ .multiply(new BigDecimal("15"))
+ .doubleValue(), 0.01))))
+ .andExpect(jsonPath("$[?(@.category == '" + clothingProduct.getCategory()
+ + "')].totalStockValue")
+ .value(hasItem(closeTo(clothingProduct.getCost()
+ .multiply(new BigDecimal("3"))
+ .doubleValue(), 0.01))));
+ }
+
+ @Test
+ void getLowStockProducts_ShouldReturnOnlyProductsBelowThreshold() throws Exception {
+ mockMvc.perform(get("/inventory-analytics/low-stock")
+ .param("page", "0")
+ .param("size", "10"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content", hasSize(1)))
+ .andExpect(jsonPath("$.content[0].productName").value(clothingProduct.getName()))
+ .andExpect(jsonPath("$.content[0].currentQuantity").value(3))
+ .andExpect(jsonPath("$.content[0].threshold")
+ .value(clothingProduct.getLowStockThreshold()));
+ }
+
+ @Test
+ void getLowStockProducts_ShouldReturnEmptyListWhenNoProductsBelowThreshold() throws Exception {
+ // Create a new snapshot with quantity above threshold
+ ProductSnapshot newSnapshot = ProductSnapshot.builder()
+ .timestamp(LocalDateTime.now())
+ .product(clothingProduct)
+ .quantity(clothingProduct.getLowStockThreshold() + 1)
+ .build();
+
+ productSnapshotRepository.save(newSnapshot);
+ entityManager.flush();
+ entityManager.clear();
+
+ mockMvc.perform(get("/inventory-analytics/low-stock")
+ .param("page", "0")
+ .param("size", "10"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content", hasSize(0)));
+ }
+
+ @Test
+ void getLowStockProducts_ShouldHandlePagination() throws Exception {
+ Product product2 = productRepository.save(Product.builder()
+ .id(UUID.randomUUID())
+ .name("Jeans")
+ .category(clothingProduct.getCategory())
+ .cost(new BigDecimal("50.00"))
+ .lowStockThreshold(8)
+ .build());
+
+ entityManager.flush();
+ entityManager.clear();
+
+ ProductSnapshot snapshot2 = ProductSnapshot.builder()
+ .timestamp(LocalDateTime.now())
+ .product(product2)
+ .quantity(5)
+ .build();
+
+ productSnapshotRepository.save(snapshot2);
+ entityManager.flush();
+ entityManager.clear();
+
+ mockMvc.perform(get("/inventory-analytics/low-stock")
+ .param("page", "0")
+ .param("size", "1"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content", hasSize(1)))
+ .andExpect(jsonPath("$.content[0].productName").value(clothingProduct.getName()))
+ .andExpect(jsonPath("$.content[0].currentQuantity").value(3))
+ .andExpect(jsonPath("$.content[0].threshold")
+ .value(clothingProduct.getLowStockThreshold()))
+ .andExpect(jsonPath("$.totalElements").value(2))
+ .andExpect(jsonPath("$.totalPages").value(2));
+
+ mockMvc.perform(get("/inventory-analytics/low-stock")
+ .param("page", "1")
+ .param("size", "1"))
+ .andDo(MockMvcResultHandlers.print())
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.content", hasSize(1)))
+ .andExpect(jsonPath("$.content[0].productName").value(product2.getName()))
+ .andExpect(jsonPath("$.content[0].currentQuantity").value(5))
+ .andExpect(jsonPath("$.content[0].threshold").value(product2.getLowStockThreshold()))
+ .andExpect(jsonPath("$.totalElements").value(2))
+ .andExpect(jsonPath("$.totalPages").value(2));
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/controllers/ProfitReportControllerTest.java b/src/test/java/com/Podzilla/analytics/controllers/ProfitReportControllerTest.java
new file mode 100644
index 0000000..fab6d70
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/controllers/ProfitReportControllerTest.java
@@ -0,0 +1,283 @@
+package com.Podzilla.analytics.controllers;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import com.Podzilla.analytics.api.dtos.profit.ProfitByCategory;
+import com.Podzilla.analytics.services.ProfitAnalyticsService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class ProfitReportControllerTest {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @MockBean
+ private ProfitAnalyticsService mockService;
+
+ private ObjectMapper objectMapper;
+ private LocalDate startDate;
+ private LocalDate endDate;
+ private List profitData;
+
+ @BeforeEach
+ public void setup() {
+ objectMapper = new ObjectMapper();
+ objectMapper.registerModule(new JavaTimeModule());
+
+ startDate = LocalDate.of(2024, 1, 1);
+ endDate = LocalDate.of(2024, 1, 31);
+
+ // Setup test data
+ profitData = Arrays.asList(
+ ProfitByCategory.builder()
+ .category("Electronics")
+ .totalRevenue(BigDecimal.valueOf(10000.50))
+ .totalCost(BigDecimal.valueOf(7500.25))
+ .grossProfit(BigDecimal.valueOf(2500.25))
+ .grossProfitMargin(BigDecimal.valueOf(25.00))
+ .build(),
+ ProfitByCategory.builder()
+ .category("Clothing")
+ .totalRevenue(BigDecimal.valueOf(5500.75))
+ .totalCost(BigDecimal.valueOf(3000.50))
+ .grossProfit(BigDecimal.valueOf(2500.25))
+ .grossProfitMargin(BigDecimal.valueOf(45.45))
+ .build());
+ }
+
+ @Test
+ public void testGetProfitByCategory_Success() {
+ // Configure mock service
+ when(mockService.getProfitByCategory(startDate, endDate))
+ .thenReturn(profitData);
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().size()).isEqualTo(2);
+ assertThat(response.getBody().get(0).getCategory()).isEqualTo("Electronics");
+ assertThat(response.getBody().get(0).getTotalRevenue()).isEqualTo(BigDecimal.valueOf(10000.50));
+ assertThat(response.getBody().get(0).getGrossProfit()).isEqualTo(BigDecimal.valueOf(2500.25));
+ assertThat(response.getBody().get(1).getCategory()).isEqualTo("Clothing");
+ assertThat(response.getBody().get(1).getGrossProfitMargin()).isEqualTo(BigDecimal.valueOf(45.45));
+ }
+
+ @Test
+ public void testGetProfitByCategory_EmptyResult() {
+ // Configure mock service to return empty list
+ when(mockService.getProfitByCategory(startDate, endDate))
+ .thenReturn(Collections.emptyList());
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody()).isEmpty();
+ }
+
+ @Test
+ public void testGetProfitByCategory_MissingStartDate() {
+ // Build URL with missing required parameter
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void testGetProfitByCategory_MissingEndDate() {
+ // Build URL with missing required parameter
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", startDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void testGetProfitByCategory_InvalidDateFormat() {
+ // Build URL with invalid date format
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", "2024-01-01")
+ .queryParam("endDate", "invalid-date")
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void testGetProfitByCategory_StartDateAfterEndDate() {
+ // Set up dates where start is after end
+ LocalDate invalidStart = LocalDate.of(2024, 2, 1);
+ LocalDate invalidEnd = LocalDate.of(2024, 1, 1);
+
+ // Build URL with invalid date range
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", invalidStart.toString())
+ .queryParam("endDate", invalidEnd.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
+ @Test
+ public void testGetProfitByCategory_FutureDateRange() {
+ // Set up future dates
+ LocalDate futureStart = LocalDate.now().plusDays(1);
+ LocalDate futureEnd = LocalDate.now().plusDays(30);
+
+ // Configure mock service - should return empty data for future dates
+ when(mockService.getProfitByCategory(futureStart, futureEnd))
+ .thenReturn(Collections.emptyList());
+
+ // Build URL with future date range
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", futureStart.toString())
+ .queryParam("endDate", futureEnd.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody()).isEmpty();
+ }
+
+ @Test
+ public void testGetProfitByCategory_ConsecutiveDayRange() {
+ // Test consecutive dates (1 day range)
+ LocalDate startDate = LocalDate.of(2024, 1, 1);
+ LocalDate endDate = LocalDate.of(2024, 1, 2);
+
+ // Configure mock service
+ when(mockService.getProfitByCategory(startDate, endDate))
+ .thenReturn(profitData);
+
+ // Build URL with consecutive days for start and end
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity> response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference>() {});
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().get(0).getCategory()).isEqualTo("Electronics");
+ }
+
+ @Test
+ public void testGetProfitByCategory_ServiceException() {
+ // Configure mock service to throw exception
+ when(mockService.getProfitByCategory(any(), any()))
+ .thenThrow(new RuntimeException("Service error"));
+
+ // Build URL with query parameters
+ String url = UriComponentsBuilder.fromPath("/profit-analytics/by-category")
+ .queryParam("startDate", startDate.toString())
+ .queryParam("endDate", endDate.toString())
+ .toUriString();
+
+ // Execute request
+ ResponseEntity response = restTemplate.exchange(
+ url,
+ HttpMethod.GET,
+ null,
+ String.class);
+
+ // Verify
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/controllers/RevenueReportControllerTest.java b/src/test/java/com/Podzilla/analytics/controllers/RevenueReportControllerTest.java
new file mode 100644
index 0000000..ac9d401
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/controllers/RevenueReportControllerTest.java
@@ -0,0 +1,117 @@
+package com.Podzilla.analytics.controllers;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Collections;
+import java.util.List;
+
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import org.junit.jupiter.api.Test;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest; // Added
+import org.springframework.http.MediaType;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryRequest.Period;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryResponse;
+import com.Podzilla.analytics.services.RevenueReportService;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+class RevenueReportControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockitoBean
+ private RevenueReportService revenueReportService;
+
+ // Helper method to create a valid URL with parameters
+ private String buildSummaryUrl(LocalDate startDate, LocalDate endDate, Period period) {
+ return String.format("/revenue-analytics/summary?startDate=%s&endDate=%s&period=%s",
+ startDate, endDate, period);
+ }
+
+ @Test
+ void getRevenueSummary_ValidRequest_ReturnsOkAndSummaryList() throws Exception {
+ // Arrange: Define test data and mock service behavior
+ LocalDate startDate = LocalDate.of(2023, 1, 1);
+ LocalDate endDate = LocalDate.of(2023, 1, 31);
+ Period period = Period.MONTHLY;
+
+ RevenueSummaryResponse mockResponse = RevenueSummaryResponse.builder()
+ .periodStartDate(startDate)
+ .totalRevenue(BigDecimal.valueOf(1500.50))
+ .build();
+ List mockSummaryList = Collections.singletonList(mockResponse);
+
+ // Mock the service call - expect any RevenueSummaryRequest and return the mock
+ // list
+ when(revenueReportService.getRevenueSummary(any(), any(), any()))
+ .thenReturn(mockSummaryList);
+
+ // Act: Perform the HTTP GET request
+ mockMvc.perform(get(buildSummaryUrl(startDate, endDate, period))
+ .contentType(MediaType.APPLICATION_JSON)) // Although GET, setting content type is harmless
+ .andExpect(status().isOk()) // Assert: Expect HTTP 200 OK
+ .andExpect(jsonPath("$", hasSize(1))) // Expect a JSON array with one element
+ .andExpect(jsonPath("$[0].periodStartDate", is(startDate.toString()))) // Check response fields
+ .andExpect(jsonPath("$[0].totalRevenue", is(1500.50)));
+ }
+
+ @Test
+ void getRevenueSummary_MissingStartDate_ReturnsBadRequest() throws Exception {
+ // Arrange: Missing startDate parameter
+ LocalDate endDate = LocalDate.of(2023, 1, 31);
+ Period period = Period.MONTHLY;
+
+ // Act & Assert: Perform request and expect HTTP 400 Bad Request due to @NotNull
+ mockMvc.perform(get("/revenue-analytics/summary?endDate=" + endDate + "&period=" + period)
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ // You could add more assertions here to check the response body for validation
+ // error details
+ }
+
+ @Test
+ void getRevenueSummary_EndDateBeforeStartDate_ReturnsBadRequest() throws Exception {
+ // Arrange: Invalid date range (endDate before startDate) - testing @AssertTrue
+ LocalDate startDate = LocalDate.of(2023, 1, 31);
+ LocalDate endDate = LocalDate.of(2023, 1, 1); // Invalid date range
+ Period period = Period.MONTHLY;
+
+ // Act & Assert: Perform request and expect HTTP 400 Bad Request due to
+ // @AssertTrue
+ mockMvc.perform(get(buildSummaryUrl(startDate, endDate, period))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest());
+ // Again, check response body for specific validation error message if needed
+ }
+
+ @Test
+ void getRevenueSummary_ServiceReturnsEmptyList_ReturnsOkAndEmptyList() throws Exception {
+ // Arrange: Service returns an empty list
+ LocalDate startDate = LocalDate.of(2023, 1, 1);
+ LocalDate endDate = LocalDate.of(2023, 1, 31);
+ Period period = Period.MONTHLY;
+
+ List mockSummaryList = Collections.emptyList();
+
+ when(revenueReportService.getRevenueSummary(any(), any(), any()))
+ .thenReturn(mockSummaryList);
+
+ // Act & Assert: Perform request and expect HTTP 200 OK with an empty JSON array
+ mockMvc.perform(get(buildSummaryUrl(startDate, endDate, period))
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(0))); // Expect an empty JSON array
+ }
+}
diff --git a/src/test/java/com/Podzilla/analytics/integration/ProductAnalyticsServiceIntegrationTest.java b/src/test/java/com/Podzilla/analytics/integration/ProductAnalyticsServiceIntegrationTest.java
new file mode 100644
index 0000000..4a8d2ca
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/integration/ProductAnalyticsServiceIntegrationTest.java
@@ -0,0 +1,679 @@
+// package com.Podzilla.analytics.integration;
+
+// import java.math.BigDecimal;
+// import java.time.LocalDate;
+// import java.time.LocalDateTime;
+// import java.util.List;
+// import java.util.Map;
+// import java.util.UUID;
+// import java.util.stream.Collectors;
+
+// import static org.assertj.core.api.Assertions.assertThat;
+// import static org.junit.jupiter.api.Assertions.assertTrue;
+// import org.junit.jupiter.api.BeforeEach;
+// import org.junit.jupiter.api.DisplayName;
+// import org.junit.jupiter.api.Nested;
+// import org.junit.jupiter.api.Test;
+// import org.springframework.beans.factory.annotation.Autowired;
+// import org.springframework.boot.test.context.SpringBootTest;
+
+// import com.Podzilla.analytics.api.dtos.product.TopSellerRequest;
+// import com.Podzilla.analytics.api.dtos.product.TopSellerResponse;
+// import com.Podzilla.analytics.models.Courier;
+// import com.Podzilla.analytics.models.Customer;
+// import com.Podzilla.analytics.models.Order;
+// import com.Podzilla.analytics.models.Product;
+// import com.Podzilla.analytics.models.Region;
+// import com.Podzilla.analytics.models.SalesLineItem;
+// import com.Podzilla.analytics.repositories.CourierRepository;
+// import com.Podzilla.analytics.repositories.CustomerRepository;
+// import com.Podzilla.analytics.repositories.OrderRepository;
+// import com.Podzilla.analytics.repositories.ProductRepository;
+// import com.Podzilla.analytics.repositories.RegionRepository;
+// import com.Podzilla.analytics.repositories.SalesLineItemRepository;
+// import com.Podzilla.analytics.services.ProductAnalyticsService;
+
+// import jakarta.transaction.Transactional;
+
+// @SpringBootTest
+// @Transactional
+// class ProductAnalyticsServiceIntegrationTest {
+
+// @Autowired
+// private ProductAnalyticsService productAnalyticsService;
+
+// @Autowired
+// private ProductRepository productRepository;
+
+// @Autowired
+// private OrderRepository orderRepository;
+
+// @Autowired
+// private SalesLineItemRepository salesLineItemRepository;
+
+// @Autowired
+// private CustomerRepository customerRepository;
+
+// @Autowired
+// private CourierRepository courierRepository;
+
+// @Autowired
+// private RegionRepository regionRepository;
+
+// // Class-level test data objects
+// private Product phone;
+// private Product laptop;
+// private Product book;
+// private Product tablet;
+// private Product headphones;
+
+// private Customer customer;
+// private Courier courier;
+// private Region region;
+
+// private Order order1; // May 1st
+// private Order order2; // May 2nd
+// private Order order3; // May 3rd
+// private Order order4; // May 4th - Failed order
+// private Order order5; // May 5th - Products with same revenue but different units
+// private Order order6; // April 30th - Outside default test range
+
+// @BeforeEach
+// void setUp() {
+// insertTestData();
+// }
+
+// private void insertTestData() {
+// // Create test products
+// phone = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Smartphone")
+// .category("Electronics")
+// .cost(new BigDecimal("300.00"))
+// .lowStockThreshold(5)
+// .build();
+
+// laptop = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Laptop")
+// .category("Electronics")
+// .cost(new BigDecimal("700.00"))
+// .lowStockThreshold(3)
+// .build();
+
+// book = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Programming Book")
+// .category("Books")
+// .cost(new BigDecimal("20.00"))
+// .lowStockThreshold(10)
+// .build();
+
+// tablet = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Tablet")
+// .category("Electronics")
+// .cost(new BigDecimal("200.00"))
+// .lowStockThreshold(5)
+// .build();
+
+// headphones = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Wireless Headphones")
+// .category("Audio")
+// .cost(new BigDecimal("80.00"))
+// .lowStockThreshold(8)
+// .build();
+
+// productRepository.saveAll(List.of(phone, laptop, book, tablet, headphones));
+
+// // Create required entities for orders
+// customer = Customer.builder()
+// .id(UUID.randomUUID())
+// .name("Test Customer")
+// .build();
+// customerRepository.save(customer);
+
+// courier = Courier.builder()
+// .id(UUID.randomUUID())
+// .name("Test Courier")
+// .status(Courier.CourierStatus.ACTIVE)
+// .build();
+// courierRepository.save(courier);
+
+// region = Region.builder()
+// .id(UUID.randomUUID())
+// .city("Test City")
+// .state("Test State")
+// .country("Test Country")
+// .postalCode("12345")
+// .build();
+// regionRepository.save(region);
+
+// // Create orders with different dates and statuses
+// order1 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 5, 1, 10, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 5, 1, 15, 0))
+// .status(Order.OrderStatus.COMPLETED)
+// .totalAmount(new BigDecimal("2000.00"))
+// .customer(customer)
+// .courier(courier)
+// .region(region)
+// .build();
+
+// order2 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 5, 2, 11, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 5, 2, 16, 0))
+// .status(Order.OrderStatus.COMPLETED)
+// .totalAmount(new BigDecimal("1500.00"))
+// .customer(customer)
+// .courier(courier)
+// .region(region)
+// .build();
+
+// order3 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 5, 3, 9, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 5, 3, 14, 0))
+// .status(Order.OrderStatus.COMPLETED)
+// .totalAmount(new BigDecimal("800.00"))
+// .customer(customer)
+// .courier(courier)
+// .region(region)
+// .build();
+
+// order4 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 5, 4, 10, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 5, 4, 12, 0))
+// .status(Order.OrderStatus.FAILED) // Failed order - should be excluded
+// .failureReason("Payment declined")
+// .totalAmount(new BigDecimal("1200.00"))
+// .customer(customer)
+// .courier(courier)
+// .region(region)
+// .build();
+
+// order5 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 5, 5, 14, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 5, 5, 18, 0))
+// .status(Order.OrderStatus.COMPLETED)
+// .totalAmount(new BigDecimal("1000.00"))
+// .customer(customer)
+// .courier(courier)
+// .region(region)
+// .build();
+
+// // Order outside of default test date range
+// order6 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 4, 30, 9, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 4, 30, 15, 0))
+// .status(Order.OrderStatus.COMPLETED)
+// .totalAmount(new BigDecimal("750.00"))
+// .customer(customer)
+// .courier(courier)
+// .region(region)
+// .build();
+
+// orderRepository.saveAll(List.of(order1, order2, order3, order4, order5, order6));
+
+// // Create sales line items with different quantities and prices
+// // Order 1 - May 1
+// SalesLineItem item1_1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order1)
+// .product(phone)
+// .quantity(2) // 2 phones
+// .pricePerUnit(new BigDecimal("500.00")) // $500 each
+// .build();
+
+// SalesLineItem item1_2 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order1)
+// .product(laptop)
+// .quantity(1) // 1 laptop
+// .pricePerUnit(new BigDecimal("1000.00")) // $1000 each
+// .build();
+
+// // Order 2 - May 2
+// SalesLineItem item2_1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order2)
+// .product(phone)
+// .quantity(3) // 3 phones
+// .pricePerUnit(new BigDecimal("500.00")) // $500 each
+// .build();
+
+// // Order 3 - May 3
+// SalesLineItem item3_1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order3)
+// .product(book)
+// .quantity(5) // 5 books
+// .pricePerUnit(new BigDecimal("40.00")) // $40 each
+// .build();
+
+// SalesLineItem item3_2 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order3)
+// .product(tablet)
+// .quantity(2) // 2 tablets
+// .pricePerUnit(new BigDecimal("300.00")) // $300 each
+// .build();
+
+// // Order 4 - May 4 (Failed order)
+// SalesLineItem item4_1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order4)
+// .product(laptop)
+// .quantity(1) // 1 laptop
+// .pricePerUnit(new BigDecimal("1200.00")) // $1200 each
+// .build();
+
+// // Order 5 - May 5 (Same revenue different products)
+// SalesLineItem item5_1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order5)
+// .product(headphones)
+// .quantity(5) // 5 headphones
+// .pricePerUnit(new BigDecimal("100.00")) // $100 each = $500 total
+// .build();
+
+// SalesLineItem item5_2 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order5)
+// .product(tablet)
+// .quantity(1) // 1 tablet
+// .pricePerUnit(new BigDecimal("500.00")) // $500 each = $500 total (same as headphones)
+// .build();
+
+// // Order 6 - April 30 (Outside default range)
+// SalesLineItem item6_1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order6)
+// .product(phone)
+// .quantity(1) // 1 phone
+// .pricePerUnit(new BigDecimal("450.00")) // $450 each
+// .build();
+
+// SalesLineItem item6_2 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order6)
+// .product(book)
+// .quantity(10) // 10 books
+// .pricePerUnit(new BigDecimal("30.00")) // $30 each
+// .build();
+
+// salesLineItemRepository.saveAll(List.of(
+// item1_1, item1_2, item2_1, item3_1, item3_2,
+// item4_1, item5_1, item5_2, item6_1, item6_2));
+// }
+
+// @Nested
+// @DisplayName("Basic Functionality Tests")
+// class BasicFunctionalityTests {
+
+// @Test
+// @DisplayName("Get top sellers by revenue should return products in correct order")
+// void getTopSellers_byRevenue_shouldReturnCorrectOrder() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 6))
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// System.out.println("Results: " + results);
+// assertThat(results).hasSize(5); // Phone, Laptop, Tablet, Headphones, Book
+
+// // Verify proper ordering by revenue
+// assertThat(results.get(0).getProductName()).isEqualTo("Smartphone");
+// assertThat(results.get(0).getValue()).isEqualByComparingTo("2500.00"); // 5 phones * $500
+// assertThat(results.get(1).getProductName()).isEqualTo("Tablet");
+// assertThat(results.get(1).getValue()).isEqualByComparingTo("1100.00"); // (2 * $300) + (1 * $500)
+// assertThat(results.get(2).getProductName()).isEqualTo("Laptop");
+// assertThat(results.get(2).getValue()).isEqualByComparingTo("1000.00"); // 1 laptop * $1000
+
+// assertThat(results.get(3).getProductName()).isEqualTo("Wireless Headphones");
+// assertThat(results.get(3).getValue()).isEqualByComparingTo("500.00"); // 5 * $100
+// }
+
+// @Test
+// @DisplayName("Get top sellers by units should return products in correct order")
+// void getTopSellers_byUnits_shouldReturnCorrectOrder() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 6))
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.UNITS)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// assertThat(results).hasSize(5);
+
+// // Order by units sold
+// assertThat(results.get(0).getProductName()).isEqualTo("Smartphone");
+// assertThat(results.get(0).getValue()).isEqualByComparingTo("5"); // 2 + 3 phones
+// assertThat(results.get(1).getProductName()).isEqualTo("Wireless Headphones");
+// assertThat(results.get(1).getValue()).isEqualByComparingTo("5"); // 5 headphones
+// assertThat(results.get(2).getProductName()).isEqualTo("Programming Book");
+// assertThat(results.get(2).getValue()).isEqualByComparingTo("5"); // 5 books
+
+// // Check correct tie-breaking behavior
+// Map orderMap = results.stream()
+// .collect(Collectors.toMap(TopSellerResponse::getProductName,
+// r -> r.getValue().intValue()));
+
+// // Assuming tie-breaking is by revenue (which is how the repository query is
+// // sorted)
+// assertTrue(orderMap.get("Smartphone") >= orderMap.get("Wireless Headphones"));
+// assertTrue(orderMap.get("Wireless Headphones") >= orderMap.get("Programming Book"));
+// }
+
+// @Test
+// @DisplayName("Get top sellers with limit should respect the limit parameter")
+// void getTopSellers_withLimit_shouldRespectLimit() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 6))
+// .limit(2)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+// // System.out.println("Results:**-*-*-*-**-* " + results);
+
+// assertThat(results).hasSize(2);
+// assertThat(results.get(0).getProductName()).isEqualTo("Smartphone");
+// assertThat(results.get(1).getProductName()).isEqualTo("Tablet");
+// }
+
+// @Test
+// @DisplayName("Get top sellers with date range should only include orders in range")
+// void getTopSellers_withDateRange_shouldOnlyIncludeOrdersInRange() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 2)) // Start from May 2nd
+// .endDate(LocalDate.of(2024, 5, 4)) // End before May 4th
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .limit(5)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Should have only phone, book, and tablet (from orders 2 and 3)
+// assertThat(results).hasSize(3);
+
+// // First should be phone with only Order 2 revenue
+// assertThat(results.get(0).getProductName()).isEqualTo("Smartphone");
+// assertThat(results.get(0).getValue()).isEqualByComparingTo("1500.00"); // Only order 2: 3 phones * $500
+
+// // Should include tablets from order 3
+// boolean hasTablet = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Tablet")
+// && r.getValue().compareTo(new BigDecimal("600.00")) == 0);
+// assertThat(hasTablet).isTrue();
+
+// // Should include books from order 3
+// boolean hasBook = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Programming Book")
+// && r.getValue().compareTo(new BigDecimal("200.00")) == 0);
+// assertThat(hasBook).isTrue();
+
+// // Should NOT include laptop (only in order 1)
+// boolean hasLaptop = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Laptop"));
+// assertThat(hasLaptop).isFalse();
+// }
+// }
+
+// @Nested
+// @DisplayName("Edge Case Tests")
+// class EdgeCaseTests {
+
+// @Test
+// @DisplayName("Get top sellers with empty result set should return empty list")
+// void getTopSellers_withNoMatchingData_shouldReturnEmptyList() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 6, 1)) // Future date with no data
+// .endDate(LocalDate.of(2024, 6, 2))
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .limit(5)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// assertThat(results).isEmpty();
+// }
+
+// @Test
+// @DisplayName("Get top sellers with zero limit should return all results")
+// void getTopSellers_withZeroLimit_shouldReturnAllResults() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 6))
+// .limit(0) // Zero limit
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Should return all 4 products with sales in the period
+// assertThat(results).hasSize(0);
+// }
+
+// @Test
+// @DisplayName("Get top sellers with single day range (inclusive) should work correctly")
+// void getTopSellers_withSingleDayRange_shouldWorkCorrectly() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 1)) // End date inclusive
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Should only include products from order1 (May 1st)
+// assertThat(results).hasSize(2);
+
+// // Smartphone should be included
+// boolean hasPhone = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Smartphone")
+// && r.getValue().compareTo(new BigDecimal("1000.00")) == 0);
+// assertThat(hasPhone).isTrue();
+
+// // Laptop should be included
+// boolean hasLaptop = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Laptop")
+// && r.getValue().compareTo(new BigDecimal("1000.00")) == 0);
+// assertThat(hasLaptop).isTrue();
+// }
+
+// @Test
+// @DisplayName("Get top sellers should exclude failed orders")
+// void getTopSellers_shouldExcludeFailedOrders() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 4)) // Only include May 4th (failed order day)
+// .endDate(LocalDate.of(2024, 5, 4))
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Should be empty because the only order on May 4th was failed
+// assertThat(results).isEmpty();
+
+// // Specifically, the laptop from the failed order should not be included
+// boolean hasLaptop = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Laptop"));
+// assertThat(hasLaptop).isFalse();
+// }
+
+// @Test
+// @DisplayName("Get top sellers including boundary dates should work correctly")
+// void getTopSellers_withBoundaryDates_shouldWorkCorrectly() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 4, 30)) // Include April 30
+// .endDate(LocalDate.of(2024, 4, 30)) // Exclude May 1
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Should only include products from April 30th (order6)
+// assertThat(results).hasSize(2);
+
+// // Book should be included
+// boolean hasBook = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Programming Book")
+// && r.getValue().compareTo(new BigDecimal("300.00")) == 0);
+// assertThat(hasBook).isTrue();
+
+// // Phone should be included
+// boolean hasPhone = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Smartphone")
+// && r.getValue().compareTo(new BigDecimal("450.00")) == 0);
+// assertThat(hasPhone).isTrue();
+// }
+// }
+
+// @Nested
+// @DisplayName("Sorting and Value Tests")
+// class SortingAndValueTests {
+
+// @Test
+// @DisplayName("Products with same revenue but different units should sort by revenue first")
+// void getTopSellers_withSameRevenue_shouldSortCorrectly() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 5)) // Only include May 5th order
+// .endDate(LocalDate.of(2024, 5, 6))
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Should have both products with $500 revenue
+// assertThat(results).hasSize(2);
+
+// // Both should have same revenue value
+// assertThat(results.get(0).getValue()).isEqualByComparingTo(results.get(1).getValue());
+// assertThat(results.get(0).getValue()).isEqualByComparingTo("500.00");
+
+// // Check units separately to verify the data is correct
+// // (This doesn't test sorting order, but verifies the test data is as expected)
+// boolean hasTablet = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Tablet"));
+// boolean hasHeadphones = results.stream()
+// .anyMatch(r -> r.getProductName().equals("Wireless Headphones"));
+
+// assertThat(hasTablet).isTrue();
+// assertThat(hasHeadphones).isTrue();
+// }
+
+// @Test
+// @DisplayName("Get top sellers by units with products having same units should respect secondary sort")
+// void getTopSellers_byUnitsWithSameUnits_shouldRespectSecondarySorting() {
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 6))
+// .sortBy(TopSellerRequest.SortBy.UNITS).limit(10)
+// .build();
+
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+
+// // Find all products with 5 units
+// List productsWithFiveUnits = results.stream()
+// .filter(r -> r.getValue().compareTo(BigDecimal.valueOf(5)) == 0)
+// .collect(Collectors.toList());
+
+// // Should have 3 products with 5 units (phone, headphones, book)
+// assertThat(productsWithFiveUnits.size()).isEqualTo(3);
+
+// // Verify that secondary sorting works (we expect by revenue)
+// // Get product names in order
+// List productOrder = productsWithFiveUnits.stream()
+// .map(TopSellerResponse::getProductName)
+// .collect(Collectors.toList());
+
+// // Expected order: Smartphone ($2500), Headphones ($500), Book ($200)
+// int smartphoneIdx = productOrder.indexOf("Smartphone");
+// int headphonesIdx = productOrder.indexOf("Wireless Headphones");
+// int bookIdx = productOrder.indexOf("Programming Book");
+
+// assertTrue(smartphoneIdx < headphonesIdx, "Smartphone should come before Headphones");
+// assertTrue(headphonesIdx < bookIdx, "Headphones should come before Programming Book");
+// }
+// }
+
+// @Nested
+// @DisplayName("Request Parameter Tests")
+// class RequestParameterTests {
+
+// @Test
+// @DisplayName("Get top sellers with swapped date range should handle gracefully")
+// void getTopSellers_withSwappedDateRange_shouldHandleGracefully() {
+// // Start date is after end date - test depends on how service handles this
+// TopSellerRequest request = TopSellerRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 6)) // Start after end
+// .endDate(LocalDate.of(2024, 5, 1)) // End before start
+// .limit(5)
+// .sortBy(TopSellerRequest.SortBy.REVENUE)
+// .build();
+
+// // If service handles swapped dates, this may return empty result
+// // or throw an exception
+// List results = productAnalyticsService.getTopSellers(request.getStartDate(),
+// request.getEndDate(),
+// request.getLimit(),
+// request.getSortBy());
+// // Should return empty list if swapped dates are handled
+// assertThat(results).isEmpty();
+// // If exception is expected, you may need to adjust this test
+// // assertThrows(IllegalArgumentException.class, () ->
+// // productAnalyticsService.getTopSellers(request));
+// }
+// }
+// }
diff --git a/src/test/java/com/Podzilla/analytics/integration/RevenueReportServiceIntegrationTest.java b/src/test/java/com/Podzilla/analytics/integration/RevenueReportServiceIntegrationTest.java
new file mode 100644
index 0000000..225bd12
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/integration/RevenueReportServiceIntegrationTest.java
@@ -0,0 +1,164 @@
+// package com.Podzilla.analytics.integration;
+
+// import com.Podzilla.analytics.api.dtos.revenue.RevenueByCategoryResponse;
+// import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryRequest;
+// import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryResponse;
+// import com.Podzilla.analytics.models.Courier;
+// import com.Podzilla.analytics.models.Customer;
+// import com.Podzilla.analytics.models.Order;
+// import com.Podzilla.analytics.models.Product;
+// import com.Podzilla.analytics.models.Region;
+// import com.Podzilla.analytics.models.SalesLineItem;
+// import com.Podzilla.analytics.repositories.CourierRepository;
+// import com.Podzilla.analytics.repositories.CustomerRepository;
+// import com.Podzilla.analytics.repositories.OrderRepository;
+// import com.Podzilla.analytics.repositories.ProductRepository;
+// import com.Podzilla.analytics.repositories.RegionRepository;
+// import com.Podzilla.analytics.repositories.SalesLineItemRepository;
+// import com.Podzilla.analytics.services.RevenueReportService;
+
+// import jakarta.transaction.Transactional;
+// import org.junit.jupiter.api.BeforeEach;
+// import org.junit.jupiter.api.Test;
+// import org.springframework.beans.factory.annotation.Autowired;
+// import org.springframework.boot.test.context.SpringBootTest;
+
+// import java.math.BigDecimal;
+// import java.time.LocalDate;
+// import java.time.LocalDateTime;
+// import java.util.List;
+// import java.util.UUID;
+
+// import static org.assertj.core.api.Assertions.assertThat;
+
+// @SpringBootTest
+// @Transactional
+// public class RevenueReportServiceIntegrationTest {
+
+// @Autowired
+// private RevenueReportService revenueReportService;
+
+// @Autowired
+// private OrderRepository orderRepository;
+
+// @Autowired
+// private ProductRepository productRepository;
+
+// @Autowired
+// private SalesLineItemRepository salesLineItemRepository;
+
+// @Autowired
+// private CourierRepository courierRepository;
+
+// @Autowired
+// private CustomerRepository customerRepository;
+
+// @Autowired
+// private RegionRepository regionRepository;
+
+// @BeforeEach
+// public void setUp() {
+// insertTestData();
+// }
+
+// private void insertTestData() {
+// // Create and save region
+// Region region = Region.builder()
+// .id(UUID.randomUUID())
+// .city("Test City")
+// .state("Test State")
+// .country("Test Country")
+// .postalCode("12345")
+// .build();
+// region = regionRepository.save(region);
+
+// // Create courier
+// Courier courier = Courier.builder()
+// .id(UUID.randomUUID())
+// .name("Test Courier")
+// .status(Courier.CourierStatus.ACTIVE)
+// .build();
+// courier = courierRepository.save(courier);
+
+// // Create customer
+// Customer customer = Customer.builder()
+// .id(UUID.randomUUID())
+// .name("Test Customer")
+// .build();
+// customer = customerRepository.save(customer);
+
+// // Create products
+// Product product1 = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Phone Case")
+// .category("Accessories")
+// .build();
+
+// Product product2 = Product.builder()
+// .id(UUID.randomUUID())
+// .name("Wireless Mouse")
+// .category("Electronics")
+// .build();
+
+// productRepository.saveAll(List.of(product1, product2));
+
+// // Create order with all required relationships
+// Order order1 = Order.builder()
+// .id(UUID.randomUUID())
+// .orderPlacedTimestamp(LocalDateTime.of(2024, 5, 1, 10, 0))
+// .finalStatusTimestamp(LocalDateTime.of(2024, 5, 1, 11, 0))
+// .status(Order.OrderStatus.COMPLETED)
+// .totalAmount(new BigDecimal("100.00"))
+// .courier(courier)
+// .customer(customer)
+// .region(region)
+// .build();
+
+// orderRepository.save(order1);
+
+// SalesLineItem item1 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order1)
+// .product(product1)
+// .quantity(2)
+// .pricePerUnit(new BigDecimal("10.00"))
+// .build();
+
+// SalesLineItem item2 = SalesLineItem.builder()
+// .id(UUID.randomUUID())
+// .order(order1)
+// .product(product2)
+// .quantity(1)
+// .pricePerUnit(new BigDecimal("80.00"))
+// .build();
+
+// salesLineItemRepository.saveAll(List.of(item1, item2));
+// }
+
+// @Test
+// public void getRevenueByCategory_shouldReturnExpectedResults() {
+// List results = revenueReportService.getRevenueByCategory(
+// LocalDate.of(2024, 5, 1),
+// LocalDate.of(2024, 5, 3)
+// );
+
+// assertThat(results).isNotEmpty();
+// assertThat(results.get(0).getCategory()).isEqualTo("Electronics");
+// }
+
+// @Test
+// public void getRevenueSummary_shouldReturnExpectedResults() {
+// RevenueSummaryRequest request = RevenueSummaryRequest.builder()
+// .startDate(LocalDate.of(2024, 5, 1))
+// .endDate(LocalDate.of(2024, 5, 3))
+// .period(RevenueSummaryRequest.Period.DAILY)
+// .build();
+
+// List summary = revenueReportService.getRevenueSummary(request.getStartDate(),
+// request.getEndDate(),
+// request.getPeriod().name());
+
+// assertThat(summary).isNotEmpty();
+// assertThat(summary.get(0).getTotalRevenue()).isEqualByComparingTo("100.00");
+// }
+// }
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/services/CourierAnalyticsServiceTest.java b/src/test/java/com/Podzilla/analytics/services/CourierAnalyticsServiceTest.java
new file mode 100644
index 0000000..52a5dba
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/services/CourierAnalyticsServiceTest.java
@@ -0,0 +1,357 @@
+package com.Podzilla.analytics.services;
+
+import com.Podzilla.analytics.api.dtos.courier.CourierAverageRatingResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierDeliveryCountResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierPerformanceReportResponse;
+import com.Podzilla.analytics.api.dtos.courier.CourierSuccessRateResponse;
+import com.Podzilla.analytics.api.projections.courier.CourierPerformanceProjection;
+import com.Podzilla.analytics.repositories.CourierRepository;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class CourierAnalyticsServiceTest {
+
+ @Mock
+ private CourierRepository courierRepository;
+
+ @InjectMocks
+ private CourierAnalyticsService courierAnalyticsService;
+
+ private LocalDate testStartDate;
+ private LocalDate testEndDate;
+ private LocalDateTime expectedStartDateTime;
+ private LocalDateTime expectedEndDateTime;
+
+ @BeforeEach
+ void setUp() {
+ testStartDate = LocalDate.of(2024, 1, 1);
+ testEndDate = LocalDate.of(2024, 1, 31);
+ expectedStartDateTime = testStartDate.atStartOfDay();
+ expectedEndDateTime = testEndDate.atTime(LocalTime.MAX);
+ }
+
+ private CourierPerformanceProjection createMockProjection(
+ UUID courierId, String courierName, Long deliveryCount, Long completedCount, BigDecimal averageRating) {
+ CourierPerformanceProjection mockProjection = Mockito.mock(CourierPerformanceProjection.class);
+ Mockito.lenient().when(mockProjection.getCourierId()).thenReturn(courierId);
+ Mockito.lenient().when(mockProjection.getCourierName()).thenReturn(courierName);
+ Mockito.lenient().when(mockProjection.getDeliveryCount()).thenReturn(deliveryCount);
+ Mockito.lenient().when(mockProjection.getCompletedCount()).thenReturn(completedCount);
+ Mockito.lenient().when(mockProjection.getAverageRating()).thenReturn(averageRating);
+ return mockProjection;
+ }
+
+ @Test
+ void getCourierDeliveryCounts_shouldReturnCorrectCountsForMultipleCouriers() {
+ // Arrange
+ UUID courierId1 = UUID.randomUUID();
+ UUID courierId2 = UUID.randomUUID();
+ CourierPerformanceProjection janeData = createMockProjection(
+ courierId1, "Jane", 10L, 8L, new BigDecimal("4.5"));
+ CourierPerformanceProjection johnData = createMockProjection(
+ courierId2, "John", 5L, 5L, new BigDecimal("4.0"));
+
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(janeData, johnData));
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierDeliveryCounts(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ CourierDeliveryCountResponse janeResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("Jane"))
+ .findFirst().orElse(null);
+ assertNotNull(janeResponse);
+ assertEquals(courierId1, janeResponse.getCourierId());
+ assertEquals(10, janeResponse.getDeliveryCount());
+
+ CourierDeliveryCountResponse johnResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("John"))
+ .findFirst().orElse(null);
+ assertNotNull(johnResponse);
+ assertEquals(courierId2, johnResponse.getCourierId());
+ assertEquals(5, johnResponse.getDeliveryCount());
+
+ // Verify repository method was called with correct LocalDateTime arguments
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierDeliveryCounts_shouldReturnEmptyListWhenNoData() {
+ // Arrange
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierDeliveryCounts(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierSuccessRate_shouldReturnCorrectRates() {
+ // Arrange
+ // Jane: 8 completed out of 10 deliveries = 80%
+ UUID courierId1 = UUID.randomUUID();
+ UUID courierId2 = UUID.randomUUID();
+ UUID courierId3 = UUID.randomUUID();
+ CourierPerformanceProjection janeData = createMockProjection(
+ courierId1, "Jane", 10L, 8L, new BigDecimal("4.5"));
+ // John: 5 completed out of 5 deliveries = 100%
+ CourierPerformanceProjection johnData = createMockProjection(
+ courierId2, "John", 5L, 5L, new BigDecimal("4.0"));
+ // Peter: 0 completed out of 2 deliveries = 0%
+ CourierPerformanceProjection peterData = createMockProjection(
+ courierId3, "Peter", 2L, 0L, new BigDecimal("3.0"));
+
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(janeData, johnData, peterData));
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierSuccessRate(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(3, result.size());
+
+ CourierSuccessRateResponse janeResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("Jane"))
+ .findFirst().orElse(null);
+ assertNotNull(janeResponse);
+ assertEquals(courierId1, janeResponse.getCourierId());
+ assertTrue(janeResponse.getSuccessRate().compareTo(new BigDecimal("0.80")) == 0);
+
+ CourierSuccessRateResponse johnResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("John"))
+ .findFirst().orElse(null);
+ assertNotNull(johnResponse);
+ assertEquals(courierId2, johnResponse.getCourierId());
+ assertTrue(johnResponse.getSuccessRate().compareTo(new BigDecimal("1.00")) == 0);
+
+ CourierSuccessRateResponse peterResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("Peter"))
+ .findFirst().orElse(null);
+ assertNotNull(peterResponse);
+ assertEquals(courierId3, peterResponse.getCourierId());
+ assertTrue(peterResponse.getSuccessRate().compareTo(new BigDecimal("0.00")) == 0);
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierSuccessRate_shouldHandleZeroDeliveryCountGracefully() {
+ // Arrange
+ // Mark: 0 completed out of 0 deliveries. MetricCalculator should handle this
+ // (e.g., return 0 or null)
+ UUID MarkId = UUID.randomUUID();
+ CourierPerformanceProjection markData = createMockProjection(
+ MarkId, "Mark", 0L, 0L, new BigDecimal("0.0"));
+
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Collections.singletonList(markData));
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierSuccessRate(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ CourierSuccessRateResponse markResponse = result.get(0);
+ assertEquals(MarkId, markResponse.getCourierId());
+ assertTrue(markResponse.getSuccessRate().compareTo(new BigDecimal("0.00")) == 0);
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierSuccessRate_shouldReturnEmptyListWhenNoData() {
+ // Arrange
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierSuccessRate(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierAverageRating_shouldReturnCorrectAverageRatings() {
+ UUID courierId1 = UUID.randomUUID();
+ UUID courierId2 = UUID.randomUUID();
+ UUID courierId3 = UUID.randomUUID();
+ // Arrange
+ CourierPerformanceProjection janeData = createMockProjection(
+ courierId1, "Jane", 10L, 8L, new BigDecimal("4.5"));
+ CourierPerformanceProjection johnData = createMockProjection(
+ courierId2, "John", 5L, 5L, new BigDecimal("4.0"));
+ // Peter: No rating available or 0.0 rating (depends on projection and database)
+ CourierPerformanceProjection peterData = createMockProjection(
+ courierId3, "Peter", 2L, 0L, null); // Assuming null for no rating
+
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(janeData, johnData, peterData));
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierAverageRating(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(3, result.size());
+
+ CourierAverageRatingResponse janeResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("Jane"))
+ .findFirst().orElse(null);
+ assertNotNull(janeResponse);
+ assertEquals(courierId1, janeResponse.getCourierId());
+ assertEquals(new BigDecimal("4.5"), janeResponse.getAverageRating());
+
+ CourierAverageRatingResponse johnResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("John"))
+ .findFirst().orElse(null);
+ assertNotNull(johnResponse);
+ assertEquals(courierId2, johnResponse.getCourierId());
+ assertEquals(new BigDecimal("4.0"), johnResponse.getAverageRating());
+
+ CourierAverageRatingResponse peterResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("Peter"))
+ .findFirst().orElse(null);
+ assertNotNull(peterResponse);
+ assertNull(peterResponse.getAverageRating());
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierAverageRating_shouldReturnEmptyListWhenNoData() {
+ // Arrange
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierAverageRating(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierPerformanceReport_shouldReturnComprehensiveReport() {
+ // Arrange
+ // Jane: 8 completed out of 10 deliveries = 80%, Avg Rating 4.5
+ UUID courierId1 = UUID.randomUUID();
+ UUID courierId2 = UUID.randomUUID();
+ CourierPerformanceProjection janeData = createMockProjection(
+ courierId1, "Jane", 10L, 8L, new BigDecimal("4.5"));
+ // John: 5 completed out of 5 deliveries = 100%, Avg Rating 4.0
+ CourierPerformanceProjection johnData = createMockProjection(
+ courierId2, "John", 5L, 5L, new BigDecimal("4.0"));
+
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Arrays.asList(janeData, johnData));
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierPerformanceReport(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ CourierPerformanceReportResponse janeResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("Jane"))
+ .findFirst().orElse(null);
+ assertNotNull(janeResponse);
+ assertEquals(courierId1, janeResponse.getCourierId());
+ assertEquals(10, janeResponse.getDeliveryCount());
+ assertTrue(janeResponse.getSuccessRate().compareTo(new BigDecimal("0.80")) == 0);
+ assertTrue(janeResponse.getAverageRating().compareTo(new BigDecimal("4.5")) == 0);
+
+ CourierPerformanceReportResponse johnResponse = result.stream()
+ .filter(r -> r.getCourierName().equals("John"))
+ .findFirst().orElse(null);
+ assertNotNull(johnResponse);
+ assertEquals(courierId2, johnResponse.getCourierId());
+ assertEquals(5, johnResponse.getDeliveryCount());
+ assertTrue(johnResponse.getSuccessRate().compareTo(new BigDecimal("1.00")) == 0);
+ assertTrue(johnResponse.getAverageRating().compareTo(new BigDecimal("4.0")) == 0);
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+
+ @Test
+ void getCourierPerformanceReport_shouldReturnEmptyListWhenNoData() {
+ // Arrange
+ when(courierRepository.findCourierPerformanceBetweenDates(
+ any(LocalDateTime.class), any(LocalDateTime.class)))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = courierAnalyticsService
+ .getCourierPerformanceReport(testStartDate, testEndDate);
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+
+ Mockito.verify(courierRepository).findCourierPerformanceBetweenDates(
+ expectedStartDateTime, expectedEndDateTime);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/services/CustomerReportServiceTest.java b/src/test/java/com/Podzilla/analytics/services/CustomerReportServiceTest.java
new file mode 100644
index 0000000..ccff387
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/services/CustomerReportServiceTest.java
@@ -0,0 +1,165 @@
+package com.Podzilla.analytics.services;
+
+import com.Podzilla.analytics.api.dtos.customer.CustomersTopSpendersResponse;
+import com.Podzilla.analytics.api.projections.customer.CustomersTopSpendersProjection;
+import com.Podzilla.analytics.repositories.CustomerRepository;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class CustomerReportServiceTest {
+
+ @Mock
+ private CustomerRepository customerRepository;
+
+ @InjectMocks
+ private CustomerAnalyticsService customerAnalyticsService;
+
+ private LocalDate testStartDate;
+ private LocalDate testEndDate;
+ private LocalDateTime expectedStartDateTime;
+ private LocalDateTime expectedEndDateTime;
+
+ @BeforeEach
+ void setUp() {
+ testStartDate = LocalDate.of(2024, 1, 1);
+ testEndDate = LocalDate.of(2024, 1, 31);
+ expectedStartDateTime = testStartDate.atStartOfDay();
+ expectedEndDateTime = testEndDate.atTime(LocalTime.MAX);
+ }
+
+ private CustomersTopSpendersProjection createMockProjection(
+ UUID customerId, String customerName, BigDecimal totalSpending) {
+ CustomersTopSpendersProjection mockProjection = Mockito.mock(CustomersTopSpendersProjection.class);
+ Mockito.lenient().when(mockProjection.getCustomerId()).thenReturn(customerId);
+ Mockito.lenient().when(mockProjection.getCustomerName()).thenReturn(customerName);
+ Mockito.lenient().when(mockProjection.getTotalSpending()).thenReturn(totalSpending);
+ return mockProjection;
+ }
+
+ @Test
+ void getTopSpenders_shouldReturnCorrectSpendersForMultipleCustomers() {
+ // Arrange
+ UUID customerId1 = UUID.randomUUID();
+ UUID customerId2 = UUID.randomUUID();
+ CustomersTopSpendersProjection janeData = createMockProjection(
+ customerId1, "Jane", new BigDecimal("5000.00"));
+ CustomersTopSpendersProjection johnData = createMockProjection(
+ customerId2, "John", new BigDecimal("3000.00"));
+
+ Page mockPage = new PageImpl<>(
+ Arrays.asList(janeData, johnData));
+
+ when(customerRepository.findTopSpenders(
+ any(LocalDateTime.class),
+ any(LocalDateTime.class),
+ any(PageRequest.class)))
+ .thenReturn(mockPage);
+
+ // Act
+ List result = customerAnalyticsService
+ .getTopSpenders(testStartDate, testEndDate, 0, 10);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ CustomersTopSpendersResponse janeResponse = result.stream()
+ .filter(r -> r.getCustomerName().equals("Jane"))
+ .findFirst().orElse(null);
+ assertNotNull(janeResponse);
+ assertEquals(customerId1, janeResponse.getCustomerId());
+ assertEquals(new BigDecimal("5000.00"), janeResponse.getTotalSpending());
+
+ CustomersTopSpendersResponse johnResponse = result.stream()
+ .filter(r -> r.getCustomerName().equals("John"))
+ .findFirst().orElse(null);
+ assertNotNull(johnResponse);
+ assertEquals(customerId2, johnResponse.getCustomerId());
+ assertEquals(new BigDecimal("3000.00"), johnResponse.getTotalSpending());
+
+ // Verify repository method was called with correct arguments
+ Mockito.verify(customerRepository).findTopSpenders(
+ expectedStartDateTime,
+ expectedEndDateTime,
+ PageRequest.of(0, 10));
+ }
+
+ @Test
+ void getTopSpenders_shouldReturnEmptyListWhenNoData() {
+ // Arrange
+ Page emptyPage = new PageImpl<>(Collections.emptyList());
+ when(customerRepository.findTopSpenders(
+ any(LocalDateTime.class),
+ any(LocalDateTime.class),
+ any(PageRequest.class)))
+ .thenReturn(emptyPage);
+
+ // Act
+ List result = customerAnalyticsService
+ .getTopSpenders(testStartDate, testEndDate, 0, 10);
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+
+ Mockito.verify(customerRepository).findTopSpenders(
+ expectedStartDateTime,
+ expectedEndDateTime,
+ PageRequest.of(0, 10));
+ }
+
+ @Test
+ void getTopSpenders_shouldHandlePagination() {
+ // Arrange
+ UUID customerId1 = UUID.randomUUID();
+ CustomersTopSpendersProjection janeData = createMockProjection(
+ customerId1, "Jane", new BigDecimal("5000.00"));
+
+ Page mockPage = new PageImpl<>(
+ Collections.singletonList(janeData));
+
+ when(customerRepository.findTopSpenders(
+ any(LocalDateTime.class),
+ any(LocalDateTime.class),
+ any(PageRequest.class)))
+ .thenReturn(mockPage);
+
+ // Act
+ List result = customerAnalyticsService
+ .getTopSpenders(testStartDate, testEndDate, 0, 1);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals("Jane", result.get(0).getCustomerName());
+ assertEquals(new BigDecimal("5000.00"), result.get(0).getTotalSpending());
+
+ Mockito.verify(customerRepository).findTopSpenders(
+ expectedStartDateTime,
+ expectedEndDateTime,
+ PageRequest.of(0, 1));
+ }
+}
diff --git a/src/test/java/com/Podzilla/analytics/services/InventoryReportServiceTest.java b/src/test/java/com/Podzilla/analytics/services/InventoryReportServiceTest.java
new file mode 100644
index 0000000..ce60a56
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/services/InventoryReportServiceTest.java
@@ -0,0 +1,195 @@
+package com.Podzilla.analytics.services;
+
+import com.Podzilla.analytics.api.dtos.inventory.InventoryValueByCategoryResponse;
+import com.Podzilla.analytics.api.dtos.inventory.LowStockProductResponse;
+import com.Podzilla.analytics.api.projections.inventory.InventoryValueByCategoryProjection;
+import com.Podzilla.analytics.api.projections.inventory.LowStockProductProjection;
+import com.Podzilla.analytics.repositories.ProductSnapshotRepository;
+
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class InventoryReportServiceTest {
+
+ @Mock
+ private ProductSnapshotRepository inventoryRepo;
+
+ @InjectMocks
+ private InventoryAnalyticsService inventoryAnalyticsService;
+
+ private InventoryValueByCategoryProjection createMockInventoryProjection(
+ String category, BigDecimal totalStockValue) {
+ InventoryValueByCategoryProjection mockProjection = Mockito.mock(InventoryValueByCategoryProjection.class);
+ Mockito.lenient().when(mockProjection.getCategory()).thenReturn(category);
+ Mockito.lenient().when(mockProjection.getTotalStockValue()).thenReturn(totalStockValue);
+ return mockProjection;
+ }
+
+ private LowStockProductProjection createMockLowStockProjection(
+ UUID productId, String productName, Long currentQuantity, Long threshold) {
+ LowStockProductProjection mockProjection = Mockito.mock(LowStockProductProjection.class);
+ Mockito.lenient().when(mockProjection.getProductId()).thenReturn(productId);
+ Mockito.lenient().when(mockProjection.getProductName()).thenReturn(productName);
+ Mockito.lenient().when(mockProjection.getCurrentQuantity()).thenReturn(currentQuantity);
+ Mockito.lenient().when(mockProjection.getThreshold()).thenReturn(threshold);
+ return mockProjection;
+ }
+
+ @Test
+ void getInventoryValueByCategory_shouldReturnCorrectValuesForMultipleCategories() {
+ // Arrange
+ InventoryValueByCategoryProjection electronicsData = createMockInventoryProjection(
+ "Electronics", new BigDecimal("50000.00"));
+ InventoryValueByCategoryProjection clothingData = createMockInventoryProjection(
+ "Clothing", new BigDecimal("20000.00"));
+
+ when(inventoryRepo.getInventoryValueByCategory())
+ .thenReturn(Arrays.asList(electronicsData, clothingData));
+
+ // Act
+ List result = inventoryAnalyticsService
+ .getInventoryValueByCategory();
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ InventoryValueByCategoryResponse electronicsResponse = result.stream()
+ .filter(r -> r.getCategory().equals("Electronics"))
+ .findFirst().orElse(null);
+ assertNotNull(electronicsResponse);
+ assertEquals(new BigDecimal("50000.00"), electronicsResponse.getTotalStockValue());
+
+ InventoryValueByCategoryResponse clothingResponse = result.stream()
+ .filter(r -> r.getCategory().equals("Clothing"))
+ .findFirst().orElse(null);
+ assertNotNull(clothingResponse);
+ assertEquals(new BigDecimal("20000.00"), clothingResponse.getTotalStockValue());
+
+ Mockito.verify(inventoryRepo).getInventoryValueByCategory();
+ }
+
+ @Test
+ void getInventoryValueByCategory_shouldReturnEmptyListWhenNoData() {
+ // Arrange
+ when(inventoryRepo.getInventoryValueByCategory())
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = inventoryAnalyticsService
+ .getInventoryValueByCategory();
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+
+ Mockito.verify(inventoryRepo).getInventoryValueByCategory();
+ }
+
+ @Test
+ void getLowStockProducts_shouldReturnCorrectProducts() {
+ // Arrange
+ UUID productId1 = UUID.randomUUID();
+ UUID productId2 = UUID.randomUUID();
+ LowStockProductProjection product1Data = createMockLowStockProjection(
+ productId1, "Laptop", 5L, 10L);
+ LowStockProductProjection product2Data = createMockLowStockProjection(
+ productId2, "Mouse", 2L, 5L);
+
+ Page mockPage = new PageImpl<>(
+ Arrays.asList(product1Data, product2Data));
+
+ when(inventoryRepo.getLowStockProducts(any(PageRequest.class)))
+ .thenReturn(mockPage);
+
+ // Act
+ Page result = inventoryAnalyticsService
+ .getLowStockProducts(0, 10);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(2, result.getContent().size());
+
+ LowStockProductResponse product1Response = result.getContent().stream()
+ .filter(p -> p.getProductName().equals("Laptop"))
+ .findFirst().orElse(null);
+ assertNotNull(product1Response);
+ assertEquals(productId1, product1Response.getProductId());
+ assertEquals(5, product1Response.getCurrentQuantity());
+ assertEquals(10, product1Response.getThreshold());
+
+ LowStockProductResponse product2Response = result.getContent().stream()
+ .filter(p -> p.getProductName().equals("Mouse"))
+ .findFirst().orElse(null);
+ assertNotNull(product2Response);
+ assertEquals(productId2, product2Response.getProductId());
+ assertEquals(2, product2Response.getCurrentQuantity());
+ assertEquals(5, product2Response.getThreshold());
+
+ Mockito.verify(inventoryRepo).getLowStockProducts(PageRequest.of(0, 10));
+ }
+
+ @Test
+ void getLowStockProducts_shouldReturnEmptyPageWhenNoData() {
+ // Arrange
+ Page emptyPage = new PageImpl<>(Collections.emptyList());
+ when(inventoryRepo.getLowStockProducts(any(PageRequest.class)))
+ .thenReturn(emptyPage);
+
+ // Act
+ Page result = inventoryAnalyticsService
+ .getLowStockProducts(0, 10);
+
+ // Assert
+ assertNotNull(result);
+ assertTrue(result.getContent().isEmpty());
+
+ Mockito.verify(inventoryRepo).getLowStockProducts(PageRequest.of(0, 10));
+ }
+
+ @Test
+ void getLowStockProducts_shouldHandlePagination() {
+ // Arrange
+ UUID productId = UUID.randomUUID();
+ LowStockProductProjection productData = createMockLowStockProjection(
+ productId, "Laptop", 5L, 10L);
+
+ Page mockPage = new PageImpl<>(
+ Collections.singletonList(productData));
+
+ when(inventoryRepo.getLowStockProducts(any(PageRequest.class)))
+ .thenReturn(mockPage);
+
+ // Act
+ Page result = inventoryAnalyticsService
+ .getLowStockProducts(0, 1);
+
+ // Assert
+ assertNotNull(result);
+ assertEquals(1, result.getContent().size());
+ assertEquals("Laptop", result.getContent().get(0).getProductName());
+ assertEquals(5, result.getContent().get(0).getCurrentQuantity());
+ assertEquals(10, result.getContent().get(0).getThreshold());
+
+ Mockito.verify(inventoryRepo).getLowStockProducts(PageRequest.of(0, 1));
+ }
+}
diff --git a/src/test/java/com/Podzilla/analytics/services/ProductAnalyticsServiceTest.java b/src/test/java/com/Podzilla/analytics/services/ProductAnalyticsServiceTest.java
new file mode 100644
index 0000000..6d6ecd8
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/services/ProductAnalyticsServiceTest.java
@@ -0,0 +1,238 @@
+package com.Podzilla.analytics.services;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List; // Keep import if TopSellerRequest still uses LocalDate
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals; // Import LocalDateTime
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import org.mockito.Mock;
+import static org.mockito.Mockito.when;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.Podzilla.analytics.api.dtos.product.TopSellerRequest;
+import com.Podzilla.analytics.api.dtos.product.TopSellerResponse;
+import com.Podzilla.analytics.api.projections.product.TopSellingProductProjection;
+import com.Podzilla.analytics.repositories.ProductRepository;
+
+@ExtendWith(MockitoExtension.class)
+class ProductAnalyticsServiceTest {
+
+ @Mock
+ private ProductRepository productRepository;
+
+ private ProductAnalyticsService productAnalyticsService;
+
+ @BeforeEach
+ void setUp() {
+ productAnalyticsService = new ProductAnalyticsService(productRepository);
+ }
+
+ @Test
+ void getTopSellers_SortByRevenue_ShouldReturnCorrectList() {
+ // Arrange
+ // Assuming TopSellerRequest still uses LocalDate for input
+ LocalDate requestStartDate = LocalDate.of(2025, 1, 1);
+ LocalDate requestEndDate = LocalDate.of(2025, 12, 31);
+
+ TopSellerRequest request = TopSellerRequest.builder()
+ .startDate(requestStartDate)
+ .endDate(requestEndDate)
+ .limit(2) // Ensure limit is set to 2
+ .sortBy(TopSellerRequest.SortBy.REVENUE)
+ .build();
+
+ // Convert LocalDate from request to LocalDateTime for repository call
+ // Start of the start day
+ LocalDateTime startDate = requestStartDate.atStartOfDay();
+ // Start of the day AFTER the end day to include the whole end day in the query
+ LocalDateTime endDate = requestEndDate.plusDays(1).atStartOfDay();
+
+ UUID productId1 = UUID.randomUUID();
+ UUID productId2 = UUID.randomUUID();
+ // Mocking the repository to return 2 projections
+ List projections = Arrays.asList(
+ createProjection(productId1, "iPhone", "Electronics", new BigDecimal("1000.00"), 5L),
+ createProjection(productId2, "MacBook", "Electronics", new BigDecimal("2000.00"), 2L));
+
+ // Ensure the mock returns the correct results based on the given arguments
+ // Use LocalDateTime for the eq() matchers
+ when(productRepository.findTopSellers(
+ eq(startDate),
+ eq(endDate),
+ eq(2),
+ eq("REVENUE")))
+ .thenReturn(projections);
+
+ // Act
+ List result = productAnalyticsService.getTopSellers(request.getStartDate(),
+ request.getEndDate(), request.getLimit(), request.getSortBy());
+
+ // Log the result to help with debugging
+ result.forEach(
+ item -> System.out.println("Product ID: " + item.getProductId() + " Revenue: " + item.getValue()));
+
+ // Assert (Ensure the order is correct as per revenue)
+ assertEquals(2, result.size(), "Expected 2 products in the list.");
+ assertEquals(productId2, result.get(0).getProductId()); // MacBook should come first due to higher revenue
+ assertEquals("MacBook", result.get(0).getProductName());
+ assertEquals("Electronics", result.get(0).getCategory());
+ assertEquals(new BigDecimal("2000.00"), result.get(0).getValue());
+
+ assertEquals(productId1, result.get(1).getProductId());
+ assertEquals("iPhone", result.get(1).getProductName());
+ assertEquals("Electronics", result.get(1).getCategory());
+ assertEquals(new BigDecimal("1000.00"), result.get(1).getValue());
+ }
+
+ @Test
+ void getTopSellers_SortByUnits_ShouldReturnCorrectList() {
+ // Arrange
+ LocalDate requestStartDate = LocalDate.of(2025, 1, 1);
+ LocalDate requestEndDate = LocalDate.of(2025, 12, 31);
+
+ TopSellerRequest request = TopSellerRequest.builder()
+ .startDate(requestStartDate)
+ .endDate(requestEndDate)
+ .limit(2)
+ .sortBy(TopSellerRequest.SortBy.UNITS)
+ .build();
+
+ // Convert LocalDate from request to LocalDateTime for repository call
+ LocalDateTime startDate = requestStartDate.atStartOfDay();
+ LocalDateTime endDate = requestEndDate.plusDays(1).atStartOfDay();
+
+ UUID productId1 = UUID.randomUUID();
+ UUID productId2 = UUID.randomUUID();
+ List projections = Arrays.asList(
+ createProjection(productId1, "iPhone", "Electronics", new BigDecimal("1000.00"), 5L),
+ createProjection(productId2, "MacBook", "Electronics", new BigDecimal("2000.00"), 2L));
+
+ // Use LocalDateTime for the eq() matchers
+ when(productRepository.findTopSellers(
+ eq(startDate),
+ eq(endDate),
+ eq(2),
+ eq("UNITS")))
+ .thenReturn(projections);
+
+ // Act
+ List result = productAnalyticsService.getTopSellers(request.getStartDate(),
+ request.getEndDate(), request.getLimit(), request.getSortBy());
+
+ // Assert (Ensure the order is correct as per units)
+ assertEquals(2, result.size());
+ assertEquals(productId1, result.get(0).getProductId()); // iPhone comes first because of more units sold
+ assertEquals("iPhone", result.get(0).getProductName());
+ assertEquals("Electronics", result.get(0).getCategory());
+ // Note: The projection returns revenue and units as BigDecimal and Long
+ // respectively.
+ assertEquals(new BigDecimal("5"), result.get(0).getValue());
+
+ assertEquals(productId2, result.get(1).getProductId());
+ assertEquals("MacBook", result.get(1).getProductName());
+ assertEquals("Electronics", result.get(1).getCategory());
+ assertEquals(new BigDecimal("2"), result.get(1).getValue());
+ }
+
+ @Test
+ void getTopSellers_WithEmptyResult_ShouldReturnEmptyList() {
+ // Arrange
+ LocalDate requestStartDate = LocalDate.of(2025, 1, 1);
+ LocalDate requestEndDate = LocalDate.of(2025, 12, 31);
+
+ TopSellerRequest request = TopSellerRequest.builder()
+ .startDate(requestStartDate)
+ .endDate(requestEndDate)
+ .limit(10)
+ .sortBy(TopSellerRequest.SortBy.REVENUE)
+ .build();
+
+ // Use any() matchers for LocalDateTime parameters
+ when(productRepository.findTopSellers(any(LocalDateTime.class), any(LocalDateTime.class), any(), any()))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = productAnalyticsService.getTopSellers(request.getStartDate(),
+ request.getEndDate(), request.getLimit(), request.getSortBy());
+
+ // Assert
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getTopSellers_WithZeroLimit_ShouldReturnEmptyList() {
+ // Arrange
+ LocalDate requestStartDate = LocalDate.of(2025, 1, 1);
+ LocalDate requestEndDate = LocalDate.of(2025, 12, 31);
+ TopSellerRequest request = TopSellerRequest.builder()
+ .startDate(requestStartDate)
+ .endDate(requestEndDate)
+ .limit(0)
+ .sortBy(TopSellerRequest.SortBy.REVENUE)
+ .build();
+
+ // Convert LocalDate from request to LocalDateTime for repository call
+ LocalDateTime startDate = requestStartDate.atStartOfDay();
+ LocalDateTime endDate = requestEndDate.plusDays(1).atStartOfDay();
+
+ // Use LocalDateTime for the eq() matchers
+ when(productRepository.findTopSellers(
+ eq(startDate),
+ eq(endDate),
+ eq(0),
+ eq("REVENUE")))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = productAnalyticsService.getTopSellers(request.getStartDate(),
+ request.getEndDate(), request.getLimit(), request.getSortBy());
+
+ // Assert
+ assertTrue(result.isEmpty());
+ }
+
+ private TopSellingProductProjection createProjection(
+ final UUID id,
+ final String name,
+ final String category,
+ final BigDecimal revenue,
+ final Long units) {
+ return new TopSellingProductProjection() {
+
+ @Override
+ public UUID getId() {
+ return id;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getCategory() {
+ return category;
+ }
+
+ @Override
+ public BigDecimal getTotalRevenue() {
+ return revenue;
+ }
+
+ @Override
+ public Long getTotalUnits() {
+ return units;
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/Podzilla/analytics/services/RevenueReportServiceTest.java b/src/test/java/com/Podzilla/analytics/services/RevenueReportServiceTest.java
new file mode 100644
index 0000000..8cd3c6f
--- /dev/null
+++ b/src/test/java/com/Podzilla/analytics/services/RevenueReportServiceTest.java
@@ -0,0 +1,225 @@
+package com.Podzilla.analytics.services;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.Podzilla.analytics.api.dtos.revenue.RevenueByCategoryResponse;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryRequest;
+import com.Podzilla.analytics.api.dtos.revenue.RevenueSummaryResponse;
+import com.Podzilla.analytics.api.projections.revenue.RevenueByCategoryProjection;
+import com.Podzilla.analytics.api.projections.revenue.RevenueSummaryProjection;
+import com.Podzilla.analytics.repositories.OrderRepository;
+
+@ExtendWith(MockitoExtension.class)
+class RevenueReportServiceTest {
+
+ @Mock
+ private OrderRepository orderRepository;
+
+ private RevenueReportService revenueReportService;
+
+ @BeforeEach
+ void setUp() {
+ revenueReportService = new RevenueReportService(orderRepository);
+ }
+
+ @Test
+ void getRevenueSummary_WithValidData_ShouldReturnCorrectSummary() {
+ // Arrange
+ LocalDate startDate = LocalDate.of(2025, 1, 1);
+ LocalDate endDate = LocalDate.of(2025, 12, 31);
+ RevenueSummaryRequest request = RevenueSummaryRequest.builder()
+ .startDate(startDate)
+ .endDate(endDate)
+ .period(RevenueSummaryRequest.Period.MONTHLY)
+ .build();
+
+ List projections = Arrays.asList(
+ summaryProjection(LocalDate.of(2025, 1, 1), new BigDecimal("1000.00")),
+ summaryProjection(LocalDate.of(2025, 2, 1), new BigDecimal("2000.00")));
+
+ when(orderRepository.findRevenueSummaryByPeriod(eq(startDate.atStartOfDay()), eq(endDate.atTime(LocalTime.MAX)),
+ eq("MONTHLY")))
+ .thenReturn(projections);
+
+ // Act
+ List result = revenueReportService.getRevenueSummary(request.getStartDate(),
+ request.getEndDate(), request.getPeriod().name());
+
+ // Assert
+ assertEquals(2, result.size());
+ assertEquals(LocalDate.of(2025, 1, 1), result.get(0).getPeriodStartDate());
+ assertEquals(new BigDecimal("1000.00"), result.get(0).getTotalRevenue());
+ assertEquals(LocalDate.of(2025, 2, 1), result.get(1).getPeriodStartDate());
+ assertEquals(new BigDecimal("2000.00"), result.get(1).getTotalRevenue());
+ }
+
+ @Test
+ void getRevenueSummary_WithEmptyData_ShouldReturnEmptyList() {
+ // Arrange
+ LocalDate startDate = LocalDate.of(2025, 1, 1);
+ LocalDate endDate = LocalDate.of(2025, 12, 31);
+ RevenueSummaryRequest request = RevenueSummaryRequest.builder()
+ .startDate(startDate)
+ .endDate(endDate)
+ .period(RevenueSummaryRequest.Period.MONTHLY)
+ .build();
+
+ when(orderRepository.findRevenueSummaryByPeriod(any(), any(), any()))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = revenueReportService.getRevenueSummary(request.getStartDate(),
+ request.getEndDate(), request.getPeriod().name());
+
+ // Assert
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getRevenueSummary_WithStartDateAfterEndDate_ShouldReturnEmptyList() {
+ // Arrange
+ LocalDate startDate = LocalDate.of(2025, 12, 31);
+ LocalDate endDate = LocalDate.of(2025, 1, 1);
+ RevenueSummaryRequest request = RevenueSummaryRequest.builder()
+ .startDate(startDate)
+ .endDate(endDate)
+ .period(RevenueSummaryRequest.Period.MONTHLY)
+ .build();
+
+ when(orderRepository.findRevenueSummaryByPeriod(any(), any(), any()))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = revenueReportService.getRevenueSummary(request.getStartDate(),
+ request.getEndDate(), request.getPeriod().name());
+
+ // Assert
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getRevenueByCategory_WithValidData_ShouldReturnCorrectCategories() {
+ // Arrange
+ LocalDate startDate = LocalDate.of(2025, 1, 1);
+ LocalDate endDate = LocalDate.of(2025, 12, 31);
+ List projections = Arrays.asList(
+ categoryProjection("Books", new BigDecimal("3000.00")),
+ categoryProjection("Electronics", new BigDecimal("5000.00")));
+
+ when(orderRepository.findRevenueByCategory(eq(startDate.atStartOfDay()), eq(endDate.atTime(
+ LocalTime.MAX))))
+ .thenReturn(projections);// Act
+ List result = revenueReportService.getRevenueByCategory(startDate, endDate);
+
+ // Assert
+ assertEquals(2, result.size());
+ assertEquals("Books", result.get(0).getCategory());
+ assertEquals(new BigDecimal("3000.00"), result.get(0).getTotalRevenue());
+ assertEquals("Electronics", result.get(1).getCategory());
+ assertEquals(new BigDecimal("5000.00"), result.get(1).getTotalRevenue());
+ }
+
+ @Test
+ void getRevenueByCategory_WithEmptyData_ShouldReturnEmptyList() {
+ // Arrange
+ LocalDate startDate = LocalDate.of(2025, 1, 1);
+ LocalDate endDate = LocalDate.of(2025, 12, 31);
+
+ when(orderRepository.findRevenueByCategory(any(), any()))
+ .thenReturn(Collections.emptyList());
+
+ // Act
+ List result = revenueReportService.getRevenueByCategory(startDate, endDate);
+
+ // Assert
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getRevenueByCategory_WithNullRevenue_ShouldHandleGracefully() {
+ // Arrange
+ LocalDate startDate = LocalDate.of(2025, 1, 1);
+ LocalDate endDate = LocalDate.of(2025, 12, 31);
+
+ List projections = Arrays.asList(
+ new RevenueByCategoryProjection() {
+ @Override
+ public String getCategory() {
+ return "Electronics";
+ }
+
+ @Override
+ public BigDecimal getTotalRevenue() {
+ return null;
+ }
+ });
+
+ when(orderRepository.findRevenueByCategory(eq(startDate.atStartOfDay()), eq(endDate.atTime(
+ LocalTime.MAX))))
+ .thenReturn(projections);
+
+ // Act
+ List