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[] 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[] 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 result = revenueReportService.getRevenueByCategory(startDate, endDate); + + // Assert + assertEquals(1, result.size()); + assertEquals("Electronics", result.get(0).getCategory()); + assertNull(result.get(0).getTotalRevenue()); + } + + @Test + void getRevenueByCategory_WithStartDateAfterEndDate_ShouldReturnEmptyList() { + // Arrange + LocalDate startDate = LocalDate.of(2025, 12, 31); + LocalDate endDate = LocalDate.of(2025, 1, 1); + + when(orderRepository.findRevenueByCategory(any(), any())) + .thenReturn(Collections.emptyList()); + + // Act + List result = revenueReportService.getRevenueByCategory(startDate, endDate); + + // Assert + assertTrue(result.isEmpty()); + } + + private RevenueSummaryProjection summaryProjection(LocalDate date, BigDecimal revenue) { + return new RevenueSummaryProjection() { + public LocalDate getPeriod() { + return date; + } + + public BigDecimal getTotalRevenue() { + return revenue; + } + }; + } + + private RevenueByCategoryProjection categoryProjection(String category, BigDecimal revenue) { + return new RevenueByCategoryProjection() { + public String getCategory() { + return category; + } + + public BigDecimal getTotalRevenue() { + return revenue; + } + }; + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..454c37d --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +# H2 Database Configuration for Tests +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..0139a73 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,14 @@ +# Test Database Configuration +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.username=sa +spring.datasource.password= +spring.datasource.driver-class-name=org.h2.Driver +# JPA/Hibernate Configuration +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect + +# H2 Console (optional, for debugging) +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console