diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1506a9f6..844752c0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,17 +1,19 @@ --- -name: Bug Report +name: πŸ› Bug Report about: Create a report to help us improve -title: "bug:" +title: "[BUG]:" labels: bug -assignees: '' --- -### Describe the bug +### 🚨Describe the bug _A clear and concise description of what the bug is._ -### Steps to Reproduce +### πŸ›  Steps to Reproduce 1. Send POST to `/api/v1/forklifts` with... 2. See error... +### Screenshots +_If applicable, add screenshots to help explain your problem._ + ### Expected Behavior _What should have happened?_ diff --git a/.github/ISSUE_TEMPLATE/ci_cd.md b/.github/ISSUE_TEMPLATE/ci_cd.md index 449f5925..23445d4d 100644 --- a/.github/ISSUE_TEMPLATE/ci_cd.md +++ b/.github/ISSUE_TEMPLATE/ci_cd.md @@ -1,7 +1,7 @@ --- name: "πŸš€ Infrastructure & CI/CD" about: Setup automation, Docker configurations, or deployment pipelines. -title: "[DEVOPS]: " +title: "[DEVOPS]:" labels: devops, automation --- @@ -17,5 +17,4 @@ Describe why this infrastructure change is needed (e.g., "The build process is m - [ ] No secrets are exposed in the repository. - [ ] [Optional] Deployment to environment is successful. -### πŸ“š Resources -Link to documentation (e.g., GitHub Actions, Docker Hub). + diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 00000000..8fb8dfcb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,27 @@ +--- +name: πŸ“š Documentation +about: Improve README, ARCHITECTURE, CONTRIBUTING, or code comments +title: "[DOCS]:" +labels: documentation +--- + +### πŸ“ What's Missing or Unclear? +_Describe what documentation is outdated, missing, or confusing._ + +Example: +- The "Quick Start" guide doesn't explain how to run integration tests. +- ARCHITECTURE.md lacks explanation of the planning domain. +- Method `buildCurrentState()` needs better JavaDoc. + +### πŸ“ Suggested Content +_What should the documentation explain or clarify?_ + +### πŸ“‚ Location +_Which file(s) need updating?_ +- [ ] README.md +- [ ] ARCHITECTURE.md +- [ ] CONTRIBUTING.md +- [ ] JavaDoc / Code Comments +- [ ] Other: _____ + + diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md index df56ca8f..90967fd4 100644 --- a/.github/ISSUE_TEMPLATE/feature.md +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -1,7 +1,7 @@ --- -name: Feature Request +name: ✨ Feature about: Create a new feature or Epic for the warehouse engine -title: "feat: " +title: "[FEAT]:" labels: enhancement assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md index 34ba1b67..81c3db51 100644 --- a/.github/ISSUE_TEMPLATE/idea.md +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -8,7 +8,7 @@ labels: spike, enhancement ### 🌟 The "Big Picture" What is the core idea? (e.g., "Implement a 'What-If' mode to simulate warehouse layout changes"). -### πŸš€ Business / User Value +### πŸš€ Added Value Why is this worth building? How does it make the Warehouse Dispatcher better or more "Senior"? ### πŸ›  Potential Implementation diff --git a/.github/ISSUE_TEMPLATE/logic_fix.md b/.github/ISSUE_TEMPLATE/logic_fix.md index ada42a43..3e773ba9 100644 --- a/.github/ISSUE_TEMPLATE/logic_fix.md +++ b/.github/ISSUE_TEMPLATE/logic_fix.md @@ -1,7 +1,7 @@ --- -name: Logic or Optimization Refactor +name: πŸ”¨ Logic Fix about: Fix mathematical errors or improve solver efficiency -title: "logic:" +title: "[LOGIC]:" labels: solver, math assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/research_spike.md b/.github/ISSUE_TEMPLATE/research_spike.md index b391629a..e1708790 100644 --- a/.github/ISSUE_TEMPLATE/research_spike.md +++ b/.github/ISSUE_TEMPLATE/research_spike.md @@ -1,7 +1,7 @@ --- name: "πŸ”¬ Research / Spike" about: Investigate a new technology or approach before implementation. -title: "[SPIKE]: " +title: "[SPIKE]:" labels: research, documentation --- diff --git a/.github/ISSUE_TEMPLATE/tech_debt.md b/.github/ISSUE_TEMPLATE/tech_debt.md index edf750c1..fd92f003 100644 --- a/.github/ISSUE_TEMPLATE/tech_debt.md +++ b/.github/ISSUE_TEMPLATE/tech_debt.md @@ -1,7 +1,7 @@ --- -name: Technical Debt +name: 🧹 Technical Debt about: Clean up code, improve performance, or update dependencies -title: "refactor: " +title: "[REFACTOR]:" labels: tech-debt --- ### The Debt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a746c34b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,241 @@ +name: Continuous Integration + +on: + push: + branches: [develop, main, 'devops/**', 'docs/**'] + pull_request: + branches: [develop, main] + +permissions: + contents: write + +jobs: + quality: + name: Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Execute Test Suite with Maven + run: | + # Checkstyle is intentionally disabled for the v0.1.0 release. + # It will be reintroduced as a proper quality gate in Milestone 2. + # + # Previous temporary setup: + # mvn clean package -Dspotless.check.skip=false -Dcheckstyle.failOnViolation=false + + mvn clean package \ + -Dspotless.check.skip=false + timeout-minutes: 15 + + - name: Evaluate Build Status + id: build-status + if: always() + run: | + if [ "${{ job.status }}" = "success" ]; then + echo "build_status=passing" >> $GITHUB_OUTPUT + echo "build_color=brightgreen" >> $GITHUB_OUTPUT + else + echo "build_status=failing" >> $GITHUB_OUTPUT + echo "build_color=red" >> $GITHUB_OUTPUT + fi + + # deactivation the javadoc badge for v0.1.0 + #- name: Calculate Service Javadoc Coverage + # id: calc-doc-coverage + #if: always() + #run: python scripts/ci/javadoc_coverage.py + + - name: Calculate JaCoCo Test Coverage + id: jacoco-coverage + if: always() + run: python scripts/ci/jacoco_coverage.py + + # - name: Generate Dynamic Doc Badge + # uses: schneegans/dynamic-badges-action@v1.7.0 + # if: always() + # with: + # auth: ${{ secrets.GIST_SECRET }} + # gistID: 27dc15f2c2aeef4b021fdff63d7ba722 + # filename: lift-nexus-docs.json + # label: doc coverage + # message: "${{ steps.calc-doc-coverage.outputs.doc_percentage || '0' }}%" + # color: ${{ steps.calc-doc-coverage.outputs.doc_color || 'red' }} + + - name: Generate Dynamic Test Coverage Badge + uses: schneegans/dynamic-badges-action@v1.7.0 + if: always() + with: + auth: ${{ secrets.GIST_SECRET }} + gistID: 27dc15f2c2aeef4b021fdff63d7ba722 + filename: lift-nexus-coverage.json + label: test coverage + message: "${{ steps.jacoco-coverage.outputs.test_percentage || '0' }}%" + color: ${{ steps.jacoco-coverage.outputs.test_color || 'red' }} + + - name: Prepare Quality Artifacts + if: always() + run: | + mkdir -p ci-artifacts/quality/target/site + + + if [ -f target/openapi.json ]; then + cp target/openapi.json ci-artifacts/quality/target/openapi.json + fi + + if [ -d target/site/jacoco ]; then + cp -r target/site/jacoco ci-artifacts/quality/target/site/jacoco + fi + + + + - name: Upload Quality Artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: quality-artifacts + path: ci-artifacts/quality/ + if-no-files-found: warn + retention-days: 7 + + - name: Upload Production Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: warehouse-dispatcher-build-assets + path: | + target/*.jar + target/openapi.json + retention-days: 7 + + documentation: + name: Documentation + runs-on: ubuntu-latest + needs: quality + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Set up Python for MkDocs + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + cache-dependency-path: docs/requirements-docs.txt + + - name: Install MkDocs Dependencies + run: pip install -r docs/requirements-docs.txt + + - name: Build MkDocs Documentation + run: mkdocs build + + - name: Generate Javadoc + run: | + mvn javadoc:javadoc \ + -Dquiet=true \ + -Ddoclint=none \ + -Dcheckstyle.skip=true \ + -Dmaven.checkstyle.skip=true \ + -Dspotless.check.skip=true + + test -f target/reports/apidocs/index.html + + - name: Prepare Documentation Artifacts + run: | + mkdir -p ci-artifacts/docs/build + mkdir -p ci-artifacts/docs/target/reports + + cp -r build/site ci-artifacts/docs/build/site + cp -r target/reports/apidocs ci-artifacts/docs/target/reports/apidocs + + - name: Upload Documentation Artifacts + uses: actions/upload-artifact@v4 + with: + name: documentation-artifacts + path: ci-artifacts/docs/ + if-no-files-found: error + retention-days: 7 + + deploy: + name: Deploy GitHub Pages + runs-on: ubuntu-latest + needs: [quality, documentation] + if: ( + github.ref == 'refs/heads/main' || + github.ref == 'refs/heads/develop' || + startsWith(github.ref, 'refs/heads/devops/') || + startsWith(github.ref, 'refs/heads/docs/') ) + + steps: + - name: Checkout Source Code + uses: actions/checkout@v4 + + - name: Download Quality Artifacts + uses: actions/download-artifact@v4 + with: + name: quality-artifacts + path: . + + - name: Download Documentation Artifacts + uses: actions/download-artifact@v4 + with: + name: documentation-artifacts + path: . + + - name: Debug Downloaded Artifacts + run: find . -maxdepth 5 -type d | sort + + - name: Prepare GitHub Pages + run: bash scripts/ci/prepare_pages.sh + + - name: Deploy main to root + if: github.ref == 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages + publish_branch: gh-pages + cname: lift-nexus.amine-bahij.dev + keep_files: true + + - name: Deploy develop preview to /dev-branch + if: github.ref == 'refs/heads/develop' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages + publish_branch: gh-pages + destination_dir: dev + keep_files: true + + - name: Deploy devops preview to /preview + if: (startsWith(github.ref, 'refs/heads/devops/') || + startsWith(github.ref, 'refs/heads/docs/')) + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./gh-pages + publish_branch: gh-pages + destination_dir: preview + keep_files: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..65038bd0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +release.properties +.flattened-pom.xml + +# IDE +.idea/ +*.iml +*.iws +*.ipr +*.swp +*.swo +*~ +.vscode/ +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Java +*.class +*.jar +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# Environment +.env +.env.local +.env.*.local + +# Build +build/ +out/ + +# Gradle (if ever added) +.gradle/ +gradle/ +gradlew +gradlew.bat + +# Docker volumes +postgres_data/ + +# OS +.DS_Store +Thumbs.db + + +# python virtual environment +.venv/ +venv/ +scripts/ci/venv/ +scripts/ci/.venv/ + diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 00000000..2cc7d4a5 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..366a23b3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to Lift Nexus API are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.1.0] - 2026-06-14 + +### Added + +* Initial static warehouse dispatching MVP. +* Warehouse domain model for forklifts, forklift types, storage bins, load units, and transport orders. +* REST APIs for managing the current warehouse state. +* Asynchronous dispatch job workflow with job status tracking. +* Timefold Solver integration for simplified forklift-to-order assignment and sequencing. +* Initial hard constraints for forklift capacity and equipment compatibility. +* Initial soft constraint for reducing estimated travel distance. +* PostgreSQL persistence with Flyway-based schema migrations. +* Docker Compose setup for local development. +* OpenAPI/Swagger documentation for API exploration. +* MkDocs-based project documentation. +* JUnit 5 and Testcontainers-based testing setup. +* JaCoCo test coverage reporting. +* GitHub Actions workflow for build, test, documentation, and report generation. + +### Changed + +* Prepared the project documentation for the first application-ready release. +* Improved README structure, project scope, limitations, and roadmap presentation. +* Clarified that the current system is a portfolio MVP and not a production warehouse management system. +* Documented the current static dispatching scope and known limitations. +* Disabled Checkstyle as a temporary release blocker for `v0.1.0`. + +### Removed + +* Removed unreliable Checkstyle-based documentation coverage reporting from the release pipeline. +* Removed the documentation coverage badge from the README. + +### Known Limitations + +* The system is not production-ready. +* No authentication or authorization is implemented yet. +* Dispatching is currently static and based on a warehouse state snapshot. +* The warehouse topology and distance model are simplified. +* The current constraint set is intentionally limited. +* Observability is basic. +* No production deployment setup is included yet. +* The project has not been tested in a production-like environment. + +### Notes + +This release marks the first stable portfolio milestone of Lift Nexus API. + +The goal of `v0.1.0` is to provide a clean and explainable backend MVP that demonstrates how optimization logic can be integrated into a Spring Boot application with persistence, asynchronous jobs, tests, documentation, and CI-generated reports. + +Future work will continue in Milestone 2, with a focus on dynamic dispatching, better solver orchestration, and reintroducing Checkstyle as a proper quality gate. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8f6b2b90 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# ============================================================ +# Stage 1: Build the application +# ============================================================ +# We use the official Maven image which bundles Eclipse Temurin +# JDK 21 with Maven 3.9 on Alpine Linux. +FROM maven:3.9-eclipse-temurin-21-alpine AS builder + +WORKDIR /workspace + +# Give Maven enough heap. The default is often too small for +# Spring Boot's dependency resolution and annotation processing. +ENV MAVEN_OPTS="-Xmx1024m -XX:MaxMetaspaceSize=256m" + +# Layer caching trick: copy pom.xml FIRST so dependencies download +# into a cached layer. +COPY pom.xml ./ + +RUN mvn dependency:go-offline -B -DskipTests -Dspotless.check.skip=true -Dcheckstyle.skip=true +COPY src ./src + +RUN mvn package -DskipTests -Dspotless.check.skip=true -Dcheckstyle.skip=true -B + +# ============================================================ +# Stage 2: Create the runtime image +# ============================================================ +# JRE-only Alpine image (no JDK, no Maven). Shrinks the final +# image from ~600MB to ~200MB compared to using the builder image. +FROM eclipse-temurin:21-jre-alpine AS runtime + +LABEL org.opencontainers.image.title="Lift Nexus API" +LABEL org.opencontainers.image.description="Async constraint-based optimization engine for warehouse dispatching" +LABEL org.opencontainers.image.authors="Mohamed Amine Bahij " +LABEL org.opencontainers.image.url="https://github.com/v1rex/lift-nexus-api" +LABEL org.opencontainers.image.licenses="Apache-2.0" + +# Security: run as non-root user, not root. +RUN addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +# --from=builder: this is the multi-stage magic. Only the JAR +# makes it to the final image. Everything else is discarded. +COPY --from=builder /workspace/target/*.jar app.jar + +USER appuser + +EXPOSE 8080 + +# exec form ["..."] ensures the JVM receives OS signals directly +# for graceful shutdown. -XX:+UseZGenerational enables ZGC +# generational mode for sub-millisecond pause times. +ENTRYPOINT ["java", \ + "-XX:+UseZGC", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..4c9aad74 --- /dev/null +++ b/LICENSE @@ -0,0 +1,209 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object + form, made available under the License, as indicated by a copyright + notice that is included in or attached to the work (an example is + provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original Work and any modifications or additions to that Work + or Derivative Works thereof, that is intentionally submitted to + Licensor for inclusion in the Work by the copyright owner or by + an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. + + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable copyright license to reproduce, prepare + Derivative Works of, publicly display, publicly perform, sublicense, + and distribute the Work and such Derivative Works in Source or Object + form. + +3. Grant of Patent License. + + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable (except as stated in this section) patent + license to make, have made, use, offer to sell, sell, import, and + otherwise transfer the Work, where such license applies only to those + patent claims licensable by such Contributor that are necessarily + infringed by their Contribution(s) alone or by combination of their + Contribution(s) with the Work to which such Contribution(s) was + submitted. If You institute patent litigation against any entity + (including a cross-claim or counterclaim in a lawsuit) alleging that + the Work or a Contribution incorporated within the Work constitutes + direct or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate as of + the date such litigation is filed. + +4. Redistribution. + + You may reproduce and distribute copies of the Work or Derivative Works + thereof in any medium, with or without modifications, and in Source or + Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works + a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that + You distribute, all copyright, patent, trademark, and attribution + notices from the Source form of the Work, excluding those notices + that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained within + such NOTICE file, excluding those notices that do not pertain to + any part of the Derivative Works, in at least one of the following + places: within a NOTICE text file distributed as part of the + Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display + generated by the Derivative Works, if and wherever such third-party + notices normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. You may + add Your own attribution notices within Derivative Works that You + distribute, alongside or as an addendum to the NOTICE text from + the Work, provided that such additional attribution notices cannot + be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated + in this License. + +5. Submission of Contributions. + + Unless You explicitly state otherwise, any Contribution intentionally + submitted for inclusion in the Work by You to the Licensor shall be + under the terms and conditions of this License, without any additional + terms or conditions. Notwithstanding the above, nothing herein shall + supersede or modify the terms of any separate license agreement you may + have executed with Licensor regarding such Contributions. + +6. Trademarks. + + This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, except as + required for reasonable and customary use in describing the origin of + the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + + Unless required by applicable law or agreed to in writing, Licensor + provides the Work (and each Contributor provides its Contributions) on + an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + express or implied, including, without limitation, any warranties or + conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR + A PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. + + In no event and under no legal theory, whether in tort (including + negligence), contract, or otherwise, unless required by applicable law + (such as deliberate and grossly negligent acts) or agreed to in writing, + shall any Contributor be liable to You for damages, including any + direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or + inability to use the Work (including but not limited to damages for + loss of goodwill, work stoppage, computer failure or malfunction, or + any and all other commercial damages or losses), even if such + Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + + While redistributing the Work or Derivative Works thereof, You may + choose to offer, and charge a fee for, acceptance of support, warranty, + indemnity, or other liability obligations and/or rights consistent with + this License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf of + any other Contributor, and only if You agree to indemnify, defend, and + hold each Contributor harmless for any liability incurred by, or claims + asserted against, such Contributor by reason of your accepting any such + warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include the + brackets!) The text should be enclosed in the appropriate comment + syntax for the file format. We also recommend that a file or class name + and description of purpose be included on the same "printed page" as + the copyright notice for easier identification within third-party + archives. + + Copyright 2026 Mohamed Amine Bahij + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..5a6b3e13 --- /dev/null +++ b/README.md @@ -0,0 +1,224 @@ +[![Build](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/v1rex/27dc15f2c2aeef4b021fdff63d7ba722/raw/lift-nexus-metrics.json&style=for-the-badge&logo=github&logoColor=white)](https://github.com/v1rex/lift-nexus-api/actions/workflows/ci.yml) +[![Test Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/v1rex/27dc15f2c2aeef4b021fdff63d7ba722/raw/lift-nexus-coverage.json?v=1&style=for-the-badge&logo=github-actions&logoColor=white)](https://v1rex.github.io/lift-nexus-api/coverage/) +![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge) + +
+
+ + Lift Nexus API Logo + + +

Lift Nexus API

+ +

+ Spring Boot backend MVP for warehouse dispatch optimization.
+ Built to explore domain modeling, async job handling, PostgreSQL/Flyway persistence, + integration testing, and Timefold-based constraint solving. +
+
+ Read the docs Β» + · + β–Ά 5-minute demo Β» + · + Getting started + · + Roadmap +

+
+ +--- + +> **Status:** Portfolio / learning project. The current focus is a static dispatching MVP, not a production warehouse management system. + +## About + +**Lift Nexus API** models a simplified warehouse dispatching scenario where transport orders need to be assigned to forklifts. + +The project goes beyond a basic CRUD API by combining: +- a warehouse domain model for forklifts, load units, storage bins, and transport orders +- asynchronous optimization jobs with status tracking +- Timefold Solver for constraint-based assignment planning +- PostgreSQL persistence with Flyway migrations +- OpenAPI documentation and Docker-based local setup +- unit and integration tests with JUnit 5 and Testcontainers + +The goal is to experiment with backend architecture and optimization in a realistic intralogistics domain. + +[Read the full documentation](https://lift-nexus.amine-bahij.dev/) + +## Tech Stack +![Java 21+](https://img.shields.io/badge/Java-21+-ED8936?style=for-the-badge&logo=openjdk&logoColor=white) +![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.0.5-6DB33F?style=for-the-badge&logo=spring-boot&logoColor=white) +![Timefold](https://img.shields.io/badge/Timefold-blue?style=for-the-badge&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-336791?style=for-the-badge&logo=postgresql&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=for-the-badge&logo=docker&logoColor=white) + +![JPA](https://img.shields.io/badge/Spring%20Data%20JPA-6DB33F?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyYy01LjUyMyAwLTEwIDQuNDc3LTEwIDEwczQuNDc3IDEwIDEwIDEwIDEwLTQuNDc3IDEwLTEwLTQuNDc3LTEwLTEwLTEwem0wIDE4Yy00LjQxIDAtOC0zLjU5LTgtOHMzLjU5LTggOC04IDggMy41OSA4IDgtMy41OSA4LTggOHptMy41LTljLS4yOC0uNDUtLjczLS43NS0xLjI1LS43NS0uODI4IDAtMS41LjY3Mi0xLjUgMS41cy42NzIgMS41IDEuNSAxLjUuOTcyLS4zIDEuMjUtLjc1Ii8+PC9zdmc+) +![Flyway](https://img.shields.io/badge/Flyway-CC0200?style=for-the-badge&logoColor=white) +![Maven](https://img.shields.io/badge/Maven-C71A36?style=for-the-badge&logo=apache-maven&logoColor=white) + +![JUnit5](https://img.shields.io/badge/JUnit%205-25A162?style=for-the-badge&logoColor=white) +![Testcontainers](https://img.shields.io/badge/Testcontainers-092E20?style=for-the-badge&logoColor=white) +![Spotless](https://img.shields.io/badge/Spotless-333333?style=for-the-badge&logoColor=white) +![JaCoCo](https://img.shields.io/badge/JaCoCo-4285F4?style=for-the-badge&logoColor=white) + +## Features + +- Manage forklifts, load units, storage bins, and transport orders through REST endpoints +- Start dispatch optimization as an asynchronous job +- Poll job status and retrieve optimization results +- Apply initial hard and soft constraints for forklift-to-order assignment planning +- Run locally with Docker Compose +- Validate database changes through Flyway migrations +- Generate and inspect API documentation through Swagger UI + + +## Demo + +

+ Lift Nexus API demo +

+ +This demo shows the `v0.1.0` workflow running locally with Docker Compose and Postman: seeded warehouse data, a submitted dispatch job, and the final solver result. + +For the full walkthrough, see the [Demo documentation](https://lift-nexus.amine-bahij.dev/site/demo/). + + + +## Getting Started + +### Prerequisites + +For the recommended setup: + +```bash +docker --version +docker compose version +``` + +For local development without running the app container: + +```bash +java -version +./mvnw -version +``` + +### Run with Docker Compose + +```bash +git clone https://github.com/v1rex/lift-nexus-api.git +cd lift-nexus-api +docker compose up -d +``` + +The API should be available at: + +- Swagger UI: `http://localhost:8080/swagger-ui.html` +- Hosted docs: `https://lift-nexus.amine-bahij.dev/` + +Stop the environment: + +```bash +docker compose down +``` + +### Development Mode + +Run PostgreSQL in Docker and start the application from your IDE or terminal: + +```bash +docker compose up -d db +./mvnw clean spring-boot:run +``` + +## Testing and Code Quality + +```bash +./mvnw clean test # Unit tests +./mvnw clean verify # Full verification, including integration tests +./mvnw spotless:check # Formatting check +./mvnw spotless:apply # Apply formatting +``` + +Coverage report: + +```bash +open target/site/jacoco/index.html +``` + +## Documentation + +The full project documentation is available here: + +**[lift-nexus.amine-bahij.dev](https://lift-nexus.amine-bahij.dev/)** + +It includes the project overview, architecture, domain model, optimization approach, API usage, testing strategy, known limitations, and roadmap. + +For release notes, see [CHANGELOG.md](./CHANGELOG.md). + +## Current Scope and Limitations + +Lift Nexus API is an MVP / portfolio project, not a production warehouse management system yet. + +Main limitations: +- No authentication or authorization yet +- Simplified warehouse topology and pathfinding +- Limited solver constraints +- Basic observability only +- Not tested in a production-like deployment environment yet + +See the full [documentation](https://lift-nexus.amine-bahij.dev/site/limitations/) for detailed limitations and planned improvements. + +## Roadmap + +| Milestone | Status | Focus | +|----------|--------------|-------| +| Core MVP - Static Dispatching | Released in v0.1.0 | Physical warehouse model and one-shot optimization | +| Dynamic Dispatching | Planned | React to warehouse changes instead of running only one-shot dispatching | +| Production Constraints | Planned | Add more realistic planning constraints such as deadlines, priorities, equipment compatibility, and energy-aware dispatching | +| Auth, Monitoring, Deployment | Planned | Improve security, observability, and deployment readiness | +| Performance/Benchmarking | Planned | Measure behavior under larger scenarios | + +See the [open issues](https://github.com/v1rex/lift-nexus-api/issues) and the [project roadmap](https://lift-nexus.amine-bahij.dev/site/roadmap/) for more details. + +## What I Learned + +I built this project to go beyond simple CRUD applications and practice backend architecture in a more realistic optimization-driven domain. + +Coming from an academic optimization background, where I previously worked with mathematical modeling in GurobiPy, this project helped me understand how optimization can be integrated into a real backend application using Timefold. + +Through this project, I learned and applied: + +- domain modeling for warehouse dispatching +- asynchronous job handling +- database migrations with Flyway +- integration testing with PostgreSQL and Testcontainers +- separating API, service, persistence, and planning concerns +- constraint solving with Timefold +- documenting architectural trade-offs and limitations + +For more context, read my article: [Why I am building Lift Nexus API](https://amine-bahij.dev/writing/why-i-am-building-lift-nexus-api/). + + +## License + +Distributed under the Apache License 2.0. See [LICENSE](./LICENSE) for more information. + +```text +Copyright 2026 Mohamed Amine Bahij +``` + +## Contact + +**Amine Bahij** + +- GitHub: [@v1rex](https://github.com/v1rex) +- Email: contact@amine-bahij.dev +- Project: [https://github.com/v1rex/lift-nexus-api](https://github.com/v1rex/lift-nexus-api) + +## Acknowledgments + +- [Timefold](https://timefold.ai/) – Constraint solver +- [Spring Boot](https://spring.io/projects/spring-boot) – Java application framework +- [PostgreSQL](https://www.postgresql.org/) – Relational database +- [Flyway](https://flywaydb.org/) – Database migrations diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..36043285 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + db: + image: postgres:16 + container_name: lift-nexus-postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:-warehouse_db} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-your_password} + # Healthcheck: tells Docker (and dependent services) when + # PostgreSQL is actually ready to accept connections, not just + # when the process started. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-warehouse_db}"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: Dockerfile + container_name: lift-nexus-api + ports: + - "8080:8080" + environment: + # Override the datasource URL to use Docker network hostname. + # Inside the Docker network, 'db' resolves to the PostgreSQL + # container. This replaces 'localhost' from application-dev.properties. + SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-warehouse_db} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-postgres} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-your_password} + # Activate the 'dev' profile for Flyway + data seeder + SPRING_PROFILES_ACTIVE: dev + depends_on: + db: + # Wait for the healthcheck to pass, not just for the + # container to start. Without this, the app might try to + # connect before PostgreSQL is accepting connections. + condition: service_healthy \ No newline at end of file diff --git a/docs/api.html b/docs/api.html new file mode 100644 index 00000000..ddb12084 --- /dev/null +++ b/docs/api.html @@ -0,0 +1,18 @@ + + + + Lift Nexus API Reference + + + + + + + + + + diff --git a/docs/assets/demo.gif b/docs/assets/demo.gif new file mode 100644 index 00000000..336d704c Binary files /dev/null and b/docs/assets/demo.gif differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 00000000..fc60a970 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/assets/logo_horizontal_with_gradient.png b/docs/assets/logo_horizontal_with_gradient.png new file mode 100644 index 00000000..980a15e3 Binary files /dev/null and b/docs/assets/logo_horizontal_with_gradient.png differ diff --git a/docs/assets/logo_horizontal_without_gradient.png b/docs/assets/logo_horizontal_without_gradient.png new file mode 100644 index 00000000..5face4c9 Binary files /dev/null and b/docs/assets/logo_horizontal_without_gradient.png differ diff --git a/docs/assets/logo_horizontal_without_gradient_slim.png b/docs/assets/logo_horizontal_without_gradient_slim.png new file mode 100644 index 00000000..153b6916 Binary files /dev/null and b/docs/assets/logo_horizontal_without_gradient_slim.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 00000000..82da35f9 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,485 @@ + + + + + + Lift Nexus β€” Documentation + + + + + + +
+
+ + +
Portfolio / Learning Project β€’ Static Dispatching MVP
+ +

Warehouse dispatch optimization, documented clearly.

+

+ Lift Nexus API is a Spring Boot backend MVP for warehouse dispatch optimization. + It combines domain modeling, asynchronous job handling, PostgreSQL/Flyway persistence, + integration testing, and Timefold-based constraint solving. +

+ + +
+ +
+
+

Project Overview

+

+ This project was built to go beyond simple CRUD applications and explore how + optimization can be integrated into a maintainable backend architecture. +

+
    +
  • Warehouse domain model with forklifts, load units, storage bins, and transport orders
  • +
  • Asynchronous dispatch jobs with status tracking
  • +
  • Constraint-based assignment planning with Timefold
  • +
  • PostgreSQL persistence, Flyway migrations, and integration testing
  • +
+
+ +
+

Tech Stack

+
+ Java 21 + Spring Boot + Timefold + PostgreSQL + Flyway + Docker + JUnit 5 + Testcontainers +
+
+
+ +
+
+

Project Documentation

+

+ Documentation pages describing the current system, architecture decisions, + domain model, optimization approach, API usage, testing strategy, and roadmap. +

+
+ + +
+ +
+
+

Generated Reports

+

+ Automatically generated documentation and reports from the current build pipeline. +

+
+ + +
+ + +
+ + \ No newline at end of file diff --git a/docs/mkdocs/api-usage.md b/docs/mkdocs/api-usage.md new file mode 100644 index 00000000..44b1c863 --- /dev/null +++ b/docs/mkdocs/api-usage.md @@ -0,0 +1,202 @@ +# API Usage + +This page explains how to use the Lift Nexus API for the current static dispatching workflow. + +!!! info "MVP scope" + This documentation reflects the v0.1.0 static dispatching MVP. The API does not yet support authentication, real-time replanning, or production deployment. See [Known Limitations](limitations.md) for details. + +--- + +## Prerequisites + +Start the application and PostgreSQL database: + +```bash +docker compose up --build +``` + +The API will be available at `http://localhost:8080`. Swagger UI provides interactive documentation at: + +``` +http://localhost:8080/swagger-ui.html +``` + +!!! tip "Explore Swagger UI" + Swagger UI shows the full request and response schemas for every endpoint. Use it to inspect field names, data types, validation rules, and example payloads. This page focuses on the workflow β€” the exact schemas are best explored interactively. + +--- + +## Typical static dispatching flow + +The current MVP supports a one-shot optimization workflow: + +1. Create forklift types and forklifts β€” the vehicles that execute work. +2. Create storage bins β€” the warehouse locations. +3. Create load units β€” the goods that need to be moved. +4. Create transport orders β€” work items describing movements. +5. Submit a dispatch job β€” the solver runs asynchronously. +6. Poll the job status β€” monitor progress until completion. +7. Inspect the result β€” check forklift assignments and solver score. + +This flow is **static**: the solver takes a snapshot of the current warehouse state. If the warehouse changes after a job starts, the plan is not automatically updated. See [Optimization Approach](optimization.md) for the architectural context. + +--- + +## Step-by-step walkthrough + +### 1. Create forklift types + +Forklift types define the technical capabilities of your fleet (model name, capacity, equipment category, energy specs). + +**Endpoint:** `POST /api/v1/forklift-types` + +Example fields: `modelName`, `equipmentType`, `maxCapacityKg`, `totalBatteryCapacitykWh`, `baseEnergyConsumptionPerMeter`. + +```bash +# List all forklift types (paginated) +GET /api/v1/forklift-types +``` + +### 2. Register forklifts + +Each forklift belongs to a forklift type, has a unique fleet number, and can be assigned a current location and operational status. + +**Endpoint:** `POST /api/v1/forklifts` + +Example fields: `fleetNumber`, `forkliftTypeId`, `currentStorageBinId` (optional), `status`, `currentBatteryPercentage`. + +Additional forklift endpoints: + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/v1/forklifts` | List all (paginated, 15 per page) | +| `GET` | `/api/v1/forklifts/{id}` | Get by ID | +| `GET` | `/api/v1/forklifts/search?minCapacity=…&status=…` | Filter by capacity or status | +| `PUT` | `/api/v1/forklifts/{id}/location` | Move to a different storage bin | +| `PATCH` | `/api/v1/forklifts/{id}/status?status=…` | Change operational status | + +### 3. Create storage bins + +Storage bins are warehouse locations identified by a code and a 3D coordinate. Each bin belongs to a zone type (e.g., `STORAGE`, `STAGING_IN`, `STAGING_OUT`). + +**Endpoint:** `POST /api/v1/storage-bins` + +Example fields: `binCode`, `coordinate` (x, y, z), `zoneType`, `maxWeightCapacityKg`. + +```bash +# List all storage bins (paginated) +GET /api/v1/storage-bins +``` + +### 4. Register load units + +Load units represent physical goods stored in the warehouse. Each has a unique tracking code, a weight, a status, and an optional storage bin location. + +**Endpoint:** `POST /api/v1/load-units` + +Example fields: `trackingCode`, `weightKg`, `status`, `currentStorageBinId`. + +Additional load unit endpoints: + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/v1/load-units` | List all (paginated) | +| `GET` | `/api/v1/load-units/{id}` | Get by ID | +| `GET` | `/api/v1/load-units/tracking/{trackingCode}` | Find by tracking code | +| `GET` | `/api/v1/load-units/status/{status}` | Filter by status | + +### 5. Create transport orders + +A transport order describes a movement: move a specific load unit from a source bin to a destination bin. You can optionally specify a required equipment type, which the solver will respect when assigning forklifts. + +**Endpoint:** `POST /api/v1/transport-orders` + +Example fields: `targetLoadUnitId`, `sourceBinId`, `destinationBinId`, `requiredEquipment` (optional). + +Additional transport order endpoints: + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/v1/transport-orders/{id}` | Get by ID | +| `GET` | `/api/v1/transport-orders/search?status=…&minWeight=…` | Search with filters | +| `PUT` | `/api/v1/transport-orders/{id}/status` | Update status along lifecycle | + +### 6. Submit a dispatch job + +Once the warehouse state is ready, submit a dispatch job. The solver runs asynchronously and returns a job ID for tracking. + +**Endpoint:** `POST /api/v1/dispatcher/jobs` + +```json +// Response (HTTP 202) +{ + "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +The job progresses through states: `QUEUED` β†’ `SOLVING` β†’ `COMPLETED` (or `FAILED` / `ABORTED`). + +### 7. Poll job status + +Use the job ID to check progress: + +**Endpoint:** `GET /api/v1/dispatcher/jobs/{jobId}` + +```json +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "status": "COMPLETED", + "createdAt": "2026-06-13T14:30:00Z", + "completedAt": "2026-06-13T14:30:05Z", + "finalScore": "0hard/-150soft" +} +``` + +Wait until the status is `COMPLETED` before inspecting assignments. + +### 8. Terminate a job (optional) + +A running or queued job can be aborted: + +**Endpoint:** `DELETE /api/v1/dispatcher/jobs/{jobId}` + +Returns `204 No Content` on success. The job is marked as `ABORTED` and partial results are discarded. + +--- + +## Seeded development data + +When the application starts with the `dev` profile (default in Docker Compose), the `DataSeeder` populates the database with sample data: + +- 2 forklift types (a side loader and a reach truck) +- 4 storage bins (dock, two storage zones, shipping) +- 3 load units of varying weights +- 2 forklifts +- 3 transport orders (one already assigned, two open) + +This enables testing of the dispatch flow immediately without creating data manually. + +--- + +## Endpoint summary + +| Group | Base path | Endpoints | +|-------|-----------|-----------| +| Forklift Types | `/api/v1/forklift-types` | `GET /`, `GET /{id}`, `POST /` | +| Forklifts | `/api/v1/forklifts` | `GET /`, `GET /{id}`, `GET /search`, `POST /`, `PUT /{id}/location`, `PATCH /{id}/status` | +| Storage Bins | `/api/v1/storage-bins` | `GET /`, `GET /{id}`, `POST /` | +| Load Units | `/api/v1/load-units` | `GET /`, `GET /{id}`, `GET /tracking/{code}`, `GET /status/{status}`, `POST /` | +| Transport Orders | `/api/v1/transport-orders` | `GET /{id}`, `GET /search`, `POST /`, `PUT /{id}/status` | +| Dispatcher | `/api/v1/dispatcher/jobs` | `POST /`, `GET /{jobId}`, `DELETE /{jobId}` | + +--- + +## Current limitations + +- **No authentication or authorization.** All endpoints are publicly accessible. Security is out of scope for the current MVP. +- **Static one-shot dispatching.** The solver processes a warehouse snapshot. It does not react to changes in real time. +- **Simplified warehouse topology.** Distances are estimated using 3D coordinates. The system does not model aisles, one-way paths, or blocked zones. +- **Limited constraint set.** Three constraints are active: forklift capacity, equipment compatibility, and travel distance. See [Optimization Approach](optimization.md) for the full constraint model. +- **Not production-ready.** The API is designed for local development and portfolio demonstration, not for a live warehouse operations environment. + +See [Known Limitations](limitations.md) for a more detailed discussion. diff --git a/docs/mkdocs/architecture.md b/docs/mkdocs/architecture.md new file mode 100644 index 00000000..18040243 --- /dev/null +++ b/docs/mkdocs/architecture.md @@ -0,0 +1,327 @@ +# Architecture + +Lift Nexus API is currently built as a **modular monolith**. The goal is to keep the project simple to run and understand while still separating the main warehouse dispatching concerns into clear modules. + +This page documents the current architecture decisions, module boundaries, communication rules, and known trade-offs. It focuses on the current MVP state, not a future production system. + +!!! info "Project status" + Lift Nexus API is a portfolio / learning project. The current architecture is designed for a [static dispatching MVP](roadmap.md) and is expected to evolve as the project grows. + +--- + +## Architecture philosophy + +!!! dev-note "Developer note" + The architecture of Lift Nexus API follows a simple principle: **keep the system understandable first, then evolve it when the problem requires it**. Overcomplicating the system too early, while I am still learning the domain, would be overkill. + + For this MVP, I prefer clear module boundaries, simple deployment, and testable application logic over introducing distributed infrastructure too early. The goal is not to build the most advanced architecture possible, but to build an architecture that matches the current project scope, helps me learn how to integrate Timefold into a backend application, and helps me reason about the domain. + + In practice, this means organizing code around warehouse capabilities, keeping controllers thin, avoiding direct repository access across modules, and documenting trade-offs instead of hiding them. + + Core Philosophy: "Start Small". + + +--- + +## Why a modular monolith? + +!!! dev-note "Developer note" + I intentionally chose a modular monolith because this is a learning project and I decided not to introduce distributed-system complexity too early. + + I am still learning architecture patterns such as microservices and event-driven systems. For this project, I wanted to focus first on understanding the warehouse domain, building a clean Spring Boot backend, integrating persistence and testing, and connecting an optimization solver to the application. + + The current package boundaries let me practice modular design while keeping the system simple to run, debug, and evolve. + + At this stage, adding distributed architecture would make the prototype harder to understand, run, debug, and test without solving the main learning goal yet. The current package boundaries let me practice modular design while keeping the system simple to evolve. + +--- + +## Module overview + +The codebase is organized by business capability rather than by technical layer. + +```text +src/main/java/com/v1rex/liftnexus/ +β”œβ”€β”€ common/ +β”œβ”€β”€ forklift/ +β”œβ”€β”€ loadunit/ +β”œβ”€β”€ storagebin/ +β”œβ”€β”€ transportorder/ +└── planning/ +``` + +| Module | Responsibility | +|---|---| +| `common` | Shared exception handling and reusable infrastructure concerns | +| `forklift` | Forklifts and forklift types | +| `loadunit` | Physical load units stored in the warehouse | +| `storagebin` | Simplified warehouse storage locations | +| `transportorder` | Orders that move load units from source to target bins | +| `planning` | Dispatch jobs, solver orchestration, and Timefold integration | + +--- + +## High-level architecture + +```mermaid +flowchart TB + Client[Client / Swagger UI] + + subgraph API[REST API Layer] + ForkliftAPI[Forklift API] + LoadUnitAPI[Load Unit API] + StorageBinAPI[Storage Bin API] + OrderAPI[Transport Order API] + PlanningAPI[Planning / Dispatch API] + end + + subgraph Domain[Application Modules] + Forklift[Forklift Module] + LoadUnit[Load Unit Module] + StorageBin[Storage Bin Module] + TransportOrder[Transport Order Module] + Planning[Planning Module] + end + + subgraph Persistence[Persistence] + DB[(PostgreSQL)] + Flyway[Flyway Migrations] + end + + subgraph Solver[Optimization] + Timefold[Timefold Solver] + Constraints[Constraint Classes] + end + + Client --> API + + ForkliftAPI --> Forklift + LoadUnitAPI --> LoadUnit + StorageBinAPI --> StorageBin + OrderAPI --> TransportOrder + PlanningAPI --> Planning + + Forklift --> DB + LoadUnit --> DB + StorageBin --> DB + TransportOrder --> DB + Planning --> DB + + Flyway --> DB + + Planning --> Timefold + Timefold --> Constraints +``` + +The `planning` module is the orchestration point for dispatch optimization. It loads the required warehouse state, starts the solver process, tracks the dispatch job, and stores the result. + +--- + +## Layering inside a module + +Most modules follow the same basic structure: + +```mermaid +flowchart TD + Controller[Controller
HTTP routing and validation] + DTO[Request / Response DTOs] + Service[Service
business rules and transactions] + Repository[Repository
Spring Data JPA] + Entity[Entity
JPA model] + + Controller --> DTO + Controller --> Service + Service --> Repository + Repository --> Entity +``` + +### Controller layer + +Controllers should stay thin. Their main responsibilities are: + +- expose REST endpoints +- validate input with framework annotations +- map requests and responses +- delegate business logic to services + +### Service layer + +Services contain the application logic: + +- transaction boundaries +- business rule checks +- cross-module orchestration +- domain-specific exceptions +- entity lifecycle operations + +### Repository layer + +Repositories are responsible for persistence through Spring Data JPA. Database schema changes are managed through Flyway migrations. + +--- + +## Module boundary rules + +The project intentionally avoids direct repository access across modules. + +```mermaid +flowchart LR + PlanningService[Planning Service] + ForkliftService[Forklift Service] + ForkliftRepository[Forklift Repository] + + PlanningService -->|allowed| ForkliftService + ForkliftService --> ForkliftRepository + + PlanningService -. not allowed .-> ForkliftRepository +``` + +The rule is: + +!!! important + A service from one module should not inject a repository from another module. Cross-module access should always go through the other module's service layer. + +This keeps module boundaries visible and prevents the planning module from becoming tightly coupled to every persistence detail in the system. + + +!!! dev-note "Developer note" + This boundary rule is something I started using in another project and reused here because it made the code easier for me to understand and refactor. + + The planning module needs information from forklifts, storage bins, load units, and transport orders, but it should not know the persistence details of those modules. Going through services keeps those boundaries visible. + +--- + +## Dispatch flow + +The current MVP supports a static, one-shot optimization flow. + +```mermaid +sequenceDiagram + participant Client + participant API as Dispatch Controller + participant Service as Warehouse Dispatcher Service + participant DB as PostgreSQL + participant Solver as Timefold Solver + + Client->>API: Start dispatch job + API->>Service: create and start job + Service->>DB: load forklifts, bins, load units, orders + Service->>Solver: solve assignment problem + Solver-->>Service: return solution + Service->>DB: store job status and result + Client->>API: Poll job status + API->>Service: read job + Service->>DB: fetch job result + Service-->>API: return status/result + API-->>Client: response +``` + +This flow is intentionally simple. The current system does not continuously react to every warehouse event yet. Dynamic dispatching is planned as a later milestone. + +--- + +## Exception handling + +The project uses a shared exception approach based on: + +- domain-specific exceptions +- machine-readable error codes +- a global exception handler +- consistent error responses + +```mermaid +flowchart TD + DomainException[Domain Exception] + ErrorCode[Error Code] + GlobalHandler[Global Exception Handler] + ProblemDetail[ProblemDetail Response] + + DomainException --> ErrorCode + DomainException --> GlobalHandler + GlobalHandler --> ProblemDetail +``` + +This makes API errors more predictable than returning ad-hoc exception messages from different controllers. + +The current design is useful for a structured API, but it is also intentionally simple. It can be improved later with more detailed error documentation in the API reference. + +--- + +## Planning and optimization module + +The planning module is responsible for connecting the backend application with Timefold Solver. + +```mermaid +flowchart LR + Dispatcher[Warehouse Dispatcher Service] + Problem[Planning Problem] + Solver[Timefold Solver] + ConstraintProvider[Constraint Provider] + Result[Dispatch Result] + + Dispatcher --> Problem + Problem --> Solver + Solver --> ConstraintProvider + Solver --> Result + Result --> Dispatcher +``` + +The constraint provider delegates individual business rules to separate constraint classes where possible. This keeps the optimization logic easier to read and test than placing every rule in a single large class. + +Examples of current or planned constraint categories: + +- forklift capacity +- equipment compatibility +- travel distance / deadheading +- order priority +- deadlines +- energy-aware dispatching + +More detail is documented in the [Optimization Approach](optimization.md) page. + +--- + +## Current trade-offs + +The current architecture is intentionally pragmatic. Some choices are good enough for the MVP, but not final. + +### JPA model and optimization model are still close + +Some model classes currently serve both persistence and optimization needs. This is acceptable for the MVP, but it couples the database model to the solver model. + + +### Services may grow over time + +The current project follows a "thin controller, thicker service" style. This is simple and readable for the MVP, but larger workflows can make services too broad. + +Possible future improvements: + +- command/query handlers +- clearer application services +- dedicated orchestration classes for planning workflows + +### Dynamic dispatching is not implemented yet + +The current architecture focuses on static dispatching. The system starts an optimization job based on the current state and returns a result. It does not yet continuously react to warehouse changes in real time. + +!!! dev-note "Developer note" + Some edge are still not yet to be covered: What happens when the state of warehouse changes during solving? How to trigger re-optimization when new orders arrive or forklifts break down? + + +--- + +## Why this architecture is useful for the project + +This architecture gives the project a structure that is more realistic than a basic CRUD application while still staying manageable for a single-developer portfolio project. + +It supports the current goals: + +- model a warehouse dispatching domain +- expose a clean REST API +- persist state in PostgreSQL +- run asynchronous dispatch jobs +- integrate Timefold Solver +- test persistence and application logic +- document current limitations clearly + +The design is not meant to be final. It is a foundation for learning, iteration, future improvements and experimentation. diff --git a/docs/mkdocs/assets/logo_without_text_black.png b/docs/mkdocs/assets/logo_without_text_black.png new file mode 100644 index 00000000..426c1b98 Binary files /dev/null and b/docs/mkdocs/assets/logo_without_text_black.png differ diff --git a/docs/mkdocs/assets/logo_without_text_white.png b/docs/mkdocs/assets/logo_without_text_white.png new file mode 100644 index 00000000..3745badb Binary files /dev/null and b/docs/mkdocs/assets/logo_without_text_white.png differ diff --git a/docs/mkdocs/demo.md b/docs/mkdocs/demo.md new file mode 100644 index 00000000..8ec1f872 --- /dev/null +++ b/docs/mkdocs/demo.md @@ -0,0 +1,130 @@ +# Demo Guide + +This page walks through the static dispatching workflow in under 5 minutes using the seeded development data. No manual data entry required. + +!!! info "Prerequisites" + Docker and Docker Compose must be installed. All data is pre-seeded β€” you only need to start the application. + +--- + +## 1. Start the application + +```bash +docker compose up -d +``` + +Wait a few seconds for PostgreSQL and the API to start. The `dev` profile loads sample data automatically. + +--- + +## 2. Open Swagger UI + +Navigate to: + +``` +http://localhost:8080/swagger-ui.html +``` + +All steps below can be executed directly in Swagger UI. Each endpoint shows the full request/response schema. + +--- + +## 3. Inspect the seeded warehouse + +The `DataSeeder` creates a small warehouse with 2 forklifts, 4 storage bins, 3 load units, and 3 transport orders β€” 2 of which are open and ready for dispatch. + +**List forklifts:** + +`GET /api/v1/forklifts` + +You should see `FLEET-HEAVY-01` (a side loader at the dock) and `FLEET-REACH-01` (a reach truck in zone A). One is already assigned to an in-progress order. + +**List open transport orders:** + +`GET /api/v1/transport-orders/search?status=OPEN` + +Two open orders are returned β€” one requiring a side loader for the heavy load and one requiring a reach truck for the light load. These are the work items the solver will assign. + +--- + +## 4. Submit a dispatch job + +`POST /api/v1/dispatcher/jobs` + +No request body required. The response is `202 Accepted` with a job ID: + +```json +{ + "jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +} +``` + +The solver starts asynchronously. The job transitions through `QUEUED` β†’ `SOLVING` β†’ `COMPLETED`. + +--- + +## 5. Poll job status + +`GET /api/v1/dispatcher/jobs/{jobId}` + +Use the `jobId` from step 4. Poll until `status` is `COMPLETED` (typically a few seconds for the seeded dataset): + +```json +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "status": "COMPLETED", + "createdAt": "2026-06-14T12:00:00Z", + "completedAt": "2026-06-14T12:00:04Z", + "finalScore": "0hard/-150soft" +} +``` + +A `finalScore` of `0hard` means no hard constraints were violated β€” all assignments respect capacity and equipment requirements. The negative soft score reflects the travel distance penalty the solver minimized. + +--- + +## 6. Inspect the result + +The solved assignments are persisted to the database. To see them: + +**Check transport orders after solving:** + +`GET /api/v1/transport-orders/search` + +The previously open orders should now show `status: ASSIGNED` and include an `assignedForklift` field. Each forklift received the orders it is capable of handling based on capacity and equipment compatibility. + +**Check forklifts after solving:** + +`GET /api/v1/forklifts` + +Each forklift's `transportOrders` array now reflects the sequence the solver determined. + +--- + +## 7. Stop the environment + +```bash +docker compose down +``` + +--- + +## What just happened? + +| Step | What the solver did | +|------------|---------------------| +| Load | Read all forklifts, storage bins, load units, and open transport orders | +| Constraint | Enforced that no forklift receives an order exceeding its capacity or requiring incompatible equipment | +| Optimize | Sequenced orders to minimize total estimated travel distance | +| Persist | Wrote the solved assignments back to forklifts and transport orders | + +This is a **static** dispatch β€” the solver took a snapshot of the current warehouse state. If the warehouse changes later, a new job must be submitted. + +--- + +## Explore further + +- OpenAPI reference: `http://localhost:8080/swagger-ui.html` +- Postman collection: `docs/postman/lift-nexus-api-v0.1.0.postman_collection.json` +- [API Usage](api-usage.md) β€” full endpoint reference +- [Optimization Approach](optimization.md) β€” how the solver works diff --git a/docs/mkdocs/domain-model.md b/docs/mkdocs/domain-model.md new file mode 100644 index 00000000..9e2384b3 --- /dev/null +++ b/docs/mkdocs/domain-model.md @@ -0,0 +1,269 @@ +# Domain Model + +Lift Nexus API models a simplified warehouse dispatching domain. + +The current MVP focuses on the objects needed to create a warehouse state, start a dispatch job, and assign transport orders to forklifts. + +!!! info "MVP scope" + The domain model is intentionally simplified. It is designed to support the current static dispatching workflow, not to represent every detail of a real warehouse management system. + +--- + +## Domain overview + +The main domain concepts are: + +- **Forklift Types** define the technical capabilities of forklifts. +- **Forklifts** execute transport orders. +- **Storage Bins** represent warehouse locations. +- **Load Units** represent physical goods or containers stored in the warehouse. +- **Transport Orders** describe movements from a source bin to a target bin. +- **Dispatch Jobs** represent optimization runs. +- **Warehouse Schedule** represents the assignment result of a dispatch job. + +```mermaid +flowchart LR + ForkliftType[Forklift Type] + Forklift[Forklift] + StorageBin[Storage Bin] + LoadUnit[Load Unit] + TransportOrder[Transport Order] + DispatchJob[Dispatch Job] + WarehouseSchedule[Warehouse Schedule] + + ForkliftType --> Forklift + StorageBin --> LoadUnit + LoadUnit --> TransportOrder + Forklift --> DispatchJob + TransportOrder --> DispatchJob + DispatchJob --> WarehouseSchedule +``` + +--- + +## Forklift Types + +A forklift type describes the technical category of a forklift. + +It can define properties such as: + +- model name +- maximum load capacity +- physical or operational characteristics + +In the current MVP, forklift types are useful because not every forklift should be treated as identical. Different forklifts may have different capacities and therefore may not be able to execute every transport order. + +Example: + +- Small electric forklift + - lower capacity + - useful for lighter load units + +- Heavy forklift + - higher capacity + - useful for heavier load units + + +--- + +## Forklifts + +A forklift represents a vehicle that can execute transport orders inside the warehouse. + +A Forklift has: + +- a [forklift type](#forklift-types-) +- a current [storage bin](#storage-bins-) (current location of the Forklift inside the warehouse) +- an availability state (e.g., available, busy) +- a list of assigned transport orders during planning + +In the optimization model, forklifts are part of the planning problem and receive ordered transport assignments. + +The order of assigned transport orders matters because the forklift's next travel distance depends on where the previous order ended. + +```mermaid +flowchart LR + CurrentLocation[Current Forklift Location] + OrderA[Transport Order A] + OrderB[Transport Order B] + OrderC[Transport Order C] + + CurrentLocation --> OrderA + OrderA --> OrderB + OrderB --> OrderC +``` + +!!! note "Why order matters" + The system does not only decide which forklift handles an order. It also considers the sequence of orders assigned to a forklift, because each completed order changes the forklift's next starting location. + +--- + +## Storage Bins + +A storage bin represents a location inside the warehouse. + +In the current MVP, storage bins have coordinates. These coordinates are used to estimate distances between locations. + +A storage bin can be used as: + +- the current location of a forklift +- the current location of a load unit +- the source location of a transport order +- the target location of a transport order + +The current distance model is simplified and based on warehouse coordinates. It does not yet model real warehouse paths, aisles, blocked zones, or traffic rules. + +See [Known Limitations](limitations.md#simplified-warehouse-topology) for more detail. + + +## Load Units + +A load unit represents a physical item, pallet, container, or unit that can be moved inside the warehouse. + +A load unit can have: + +- weight +- current storage bin +- identifying information + +Transport orders usually refer to a load unit that needs to be moved from one storage bin to another. + +The weight of a load unit is important because the assigned forklift must be able to carry it. + +--- + + +## Transport Orders + +A transport order describes a movement that should happen inside the warehouse. + +A transport order connects: + +- a load unit +- a source storage bin +- a target storage bin + +In the current MVP, transport orders are the main work items that need to be assigned to forklifts. + +A simplified transport order answers: +``` +Move this load unit from this source bin to this target bin. +``` + +During optimization, transport orders are assigned to forklifts and arranged in a sequence. + +## Dispatch Jobs + +A dispatch job represents one optimization run. + +The current MVP uses a static dispatching workflow: + +1. The current warehouse state is available. +2. A dispatch job is created. +3. The solver receives the planning problem. +4. Timefold calculates a solution. +5. The resulting assignments are stored. + +```mermaid +flowchart LR + WarehouseState[Warehouse State] + DispatchJob[Dispatch Job] + Solver[Timefold Solver] + Result[Warehouse Schedule] + + WarehouseState --> DispatchJob + DispatchJob --> Solver + Solver --> Result +``` + +Dispatch jobs make the optimization workflow explicit. Instead of directly assigning orders inside a controller, the system treats solving as a separate process with its own status and result. + +--- + +## Warehouse Schedule + +The warehouse schedule represents the assignment result of a dispatch job. + +It describes: + +- which transport orders were assigned +- which forklift should execute them +- in which sequence they should be executed +- what score or planning result was produced by the solver + +The current result should be understood as a planning recommendation for the simplified MVP, not as a real-world executable warehouse schedule. + +--- + +## Simplified relationship summary + +```mermaid +classDiagram + direction LR + + class ForkliftType { + modelName + maxLoadCapacity + } + + class Forklift { + currentLocation + availabilityState + } + + class StorageBin { + coordinate + } + + class LoadUnit { + weight + currentStorageBin + } + + class TransportOrder { + sourceBin + targetBin + loadUnit + } + + class DispatchJob { + status + } + + class WarehouseSchedule { + score + assignments + } + + ForkliftType "1" --> "*" Forklift : defines + StorageBin "1" --> "*" Forklift : current location + StorageBin "1" --> "*" LoadUnit : stores + LoadUnit "1" --> "*" TransportOrder : moved by + StorageBin "1" --> "*" TransportOrder : source + StorageBin "1" --> "*" TransportOrder : target + DispatchJob "1" --> "1" WarehouseSchedule : produces + WarehouseSchedule "1" --> "*" TransportOrder : sequences + WarehouseSchedule "1" --> "*" Forklift : assigns work to +``` + +!!! warning "Simplified model" + This diagram is meant to explain the current domain concepts. It is not a complete production warehouse data model. + +--- + +## Current model boundaries + +The current domain model intentionally does not include every possible warehouse concept. + +Not yet modeled in detail: + +- users and roles +- real warehouse topology +- traffic rules +- charging stations +- battery-aware dispatching +- operator shifts +- real-time execution tracking +- external warehouse systems + +These boundaries keep Milestone 1 focused on the core dispatching problem. diff --git a/docs/mkdocs/index.md b/docs/mkdocs/index.md new file mode 100644 index 00000000..71dfac1f --- /dev/null +++ b/docs/mkdocs/index.md @@ -0,0 +1,91 @@ +# Lift Nexus API Documentation + +[Lift Nexus API](../) is a Spring Boot backend MVP for warehouse dispatch optimization. + +It models a simplified warehouse scenario with [Forklifts](domain-model.md#forklifts-), [Load Units](domain-model.md#load-units-), [Storage Bins](domain-model.md#storage-bins-), and [Transport Orders](domain-model.md#transport-orders). The current MVP focuses on [static dispatching](roadmap.md): creating a warehouse state, starting a one-shot optimization job, and assigning transport orders to forklifts using [Timefold](https://docs.timefold.ai/) Solver. + +!!! info "Project status" + This is a portfolio / learning project, not a production warehouse management system yet. + + +## What this documentation covers + +
+ +- :material-map-marker-path:{ .lg .middle } **Project Overview** + + --- + + Why the project exists, what problem it models, and what is currently implemented. + + [:octicons-arrow-right-24: Read overview](project-overview.md) + +- :material-sitemap:{ .lg .middle } **Architecture** + + --- + + Current architecture decisions, module boundaries, and why the project uses a modular monolith. + + [:octicons-arrow-right-24: Read architecture](architecture.md) + +- :material-package-variant:{ .lg .middle } **Domain Model** + + --- + + Explanation of Forklifts, Load Units, Storage Bins, Transport Orders, and Dispatch Jobs. + + [:octicons-arrow-right-24: Read domain model](domain-model.md) + +- :material-brain:{ .lg .middle } **Optimization Approach** + + --- + + How Timefold is used, what gets optimized, and how hard and soft constraints are structured. + + [:octicons-arrow-right-24: Read optimization approach](optimization.md) + +- :material-api:{ .lg .middle } **API Usage** + + --- + + How to run the application, create data, start a dispatch job, and inspect the result. + + [:octicons-arrow-right-24: Read API usage](api-usage.md) + +- :material-test-tube:{ .lg .middle } **Testing Strategy** + + --- + + Unit tests, integration tests with PostgreSQL/Testcontainers, coverage, and code quality checks. + + [:octicons-arrow-right-24: Read testing strategy](testing.md) + +- :material-alert-circle-outline:{ .lg .middle } **Known Limitations** + + --- + + Current MVP limitations, simplifications, and technical areas that still need improvement. + + [:octicons-arrow-right-24: Read limitations](limitations.md) + +- :material-map-clock:{ .lg .middle } **Roadmap** + + --- + + Milestones from static dispatching toward dynamic dispatching, stronger constraints, and deployment readiness. + + [:octicons-arrow-right-24: Read roadmap](roadmap.md) + +
+ +### Generated reports + +These reports are generated from the build pipeline: + +- [Interactive API Reference](../api.html) +- [Jacoco Test Coverage](../coverage/) +- [Generated Javadoc](../javadoc/) + +### Repository + +[View the project on GitHub](https://github.com/v1rex/lift-nexus-api) \ No newline at end of file diff --git a/docs/mkdocs/limitations.md b/docs/mkdocs/limitations.md new file mode 100644 index 00000000..c72a1275 --- /dev/null +++ b/docs/mkdocs/limitations.md @@ -0,0 +1,193 @@ +# Known Limitations + +Lift Nexus API is currently a portfolio / learning project and not a production warehouse management system. + +This page documents the current limitations intentionally. The goal is to keep the project scope honest and make future improvements easier to reason about. + +--- + +## Current MVP scope + +The current implementation focuses on a **static dispatching workflow**. + +That means the system takes the current warehouse state as a snapshot, creates a dispatch job, runs the solver, and stores the resulting assignments. + +The system does not yet continuously react to warehouse changes while a plan is being executed. + +```mermaid +flowchart LR + Snapshot[Warehouse State Snapshot] + Job[Dispatch Job] + Solver[Timefold Solver] + Result[Assignment Result] + + Snapshot --> Job + Job --> Solver + Solver --> Result +``` + +--- + +## No authentication or authorization yet + +The current API does not include user accounts, authentication, authorization, or role-based access control. + +In a production system, this would be required to protect endpoints for: + +- warehouse operators +- dispatch planners +- administrators + +For the current MVP, security is intentionally out of scope because the focus is on the domain model, persistence, dispatch workflow, and solver integration. + +--- + +## Simplified warehouse topology + +The current system uses warehouse coordinates to estimate distances using [Manhattan distances](https://en.wikipedia.org/wiki/Taxicab_geometry). + +This keeps the MVP understandable, but it does not yet model a full warehouse topology with: + +- aisles +- blocked paths +- one-way routes +- traffic rules +- restricted zones +- charging areas +- real pathfinding between locations + +This means the current distance calculation is useful for a simplified planning model, but it is not yet equivalent to real warehouse navigation. + +Future versions should focus on a better warehouse topology model before trying to optimize more realistic routes. + +--- + +## Limited optimization constraints + +??? dev-note "Developer note" + One lesson I want to keep in mind for this MVP: more constraints do not automatically make the model better. + + A smaller constraint model is easier to understand, test, and improve step by step. + +The current solver model focuses on a small set of planning constraints. + +The MVP already considers important dispatching aspects such as forklift capacity, transport orders, and travel distance. However, a real warehouse dispatching system would need more constraints, for example: + +- order deadlines +- order priorities +- forklift battery level +- charging requirements +- driver or shift availability +- richer equipment compatibility rules +- blocked or unavailable storage bins +- congestion and traffic inside the warehouse +- service times for pickup and drop-off + +The current constraint model is intentionally limited so the first version stays understandable and testable. + + +--- + +## Static dispatching only + +The current dispatching workflow is static. + +A dispatch job is created from the warehouse state at one point in time. If new transport orders are created, forklifts become unavailable, or the warehouse state changes during execution, the current plan is not automatically updated. + +A more advanced version could support dynamic dispatching, where the system reacts to new events and recalculates assignments when needed. + +Examples of events that could trigger replanning: + +- new transport order created +- forklift becomes unavailable +- load unit changes location +- storage bin becomes blocked +- high-priority order arrives +- forklift battery becomes low + + +??? note "Future research focus" + Before implementing dynamic dispatching, I need to better understand how Timefold supports problem changes, replanning, and long-running solver workflows. + +--- + +## No production deployment setup yet +The project currently focuses on local development and CI-generated documentation. + +It does not yet include a production deployment setup with: + + +- secrets management +- production database configuration +- system monitoring +- alerting +- log aggregation + +The current GitHub Pages deployment is only for project documentation and reports. It is not an application deployment. + +--- + +## Basic observability only + +The current system does not yet include production-grade observability. + +A real backend system would need better visibility into: + +- dispatch job execution time +- failed jobs +- database errors +- solver score and constraint explanations +- system health +- logs and traces + +Future versions could add structured logging, metrics, health checks, and tracing. + +--- + +## Limited test coverage + +The project includes unit and integration tests, including PostgreSQL/Testcontainers-based testing. + +However, the test suite is still evolving. Areas that need stronger test coverage include: + +- Error Handling + - What happens when the solver fails? +- Edge Cases + - What if there are no available forklifts? + - What if transport orders have conflicting requirements? + +The current goal is not only to increase coverage numbers, but to make the tests more meaningful around the most important business behavior. + +--- + +## No real-world validation yet + +The current model is based on a simplified warehouse scenario. + +It has not yet been validated against: + +- real warehouse layouts +- real forklift movement data +- real transport order history +- real operational constraints +- production traffic patterns +- warehouse operator feedback + +Because of that, the project should be understood as a technical MVP and learning project, not as a validated industrial optimization product. + +--- + +## Summary + +The current limitations are intentional for Milestone 1. + +The goal of the MVP is to build a clean foundation: + +- understandable domain model +- modular backend structure +- persistence with PostgreSQL/Flyway +- dispatch job workflow +- Timefold solver integration +- tests, documentation, and CI reports + +Future milestones can build on this foundation by adding dynamic dispatching, richer constraints, better topology modeling, energy-aware planning, and production-readiness improvements. \ No newline at end of file diff --git a/docs/mkdocs/optimization.md b/docs/mkdocs/optimization.md new file mode 100644 index 00000000..494f0338 --- /dev/null +++ b/docs/mkdocs/optimization.md @@ -0,0 +1,303 @@ +# Optimization Approach + +Lift Nexus API uses Timefold Solver to experiment with constraint-based warehouse dispatching. + +The current MVP focuses on **static dispatching**. The system takes the current warehouse state, creates a dispatch job, builds a warehouse schedule, sends it to the solver, and stores the solved schedule after optimization. + +!!! info "MVP scope" + The optimization model is intentionally small. The goal of Milestone 1 is to understand how an optimization solver can be integrated into a Spring Boot backend, not to model every real warehouse constraint yet. + +--- + +## Why Timefold? + +Timefold is used because the problem is constraint-based. + +The project does not only need to store warehouse data. It needs to search for a good assignment and sequence of transport orders under constraints. + +Using a solver allows the project to express planning rules as constraints and let the solver search for a better schedule. + +For this MVP, Timefold helps explore how optimization can be integrated into a backend application with: + +- REST APIs +- persistence +- asynchronous dispatch jobs +- stored planning results +- tests and documentation + +--- + +## What are hard and soft constraints in Timefold? + +The project uses score-based optimization. + +A useful way to think about the model is: + +- **hard constraints** define rules that should not be violated +- **soft constraints** define preferences that should be optimized + +For example: + +```text +Hard constraint: +A forklift should not receive work it cannot physically handle. + +Soft constraint: +Prefer schedules with shorter travel distance. +``` + +This separation is useful because not every rule has the same importance. + +Some rules are required for a valid solution. Other rules improve the quality of the solution. + +--- + +## What gets optimized? + +The current problem is a simplified vehicle routing and dispatching problem. + +Given: + +- a set of forklifts +- a set of open transport orders +- current forklift locations +- source and target storage bins +- load units and their weights + +the solver should produce a warehouse schedule that answers: + +> Which forklift should execute which transport order, and in which sequence? + +This is more than a simple assignment problem because the order of transport orders matters. After a forklift completes one transport order, its next starting location changes. + +```mermaid +flowchart LR + ForkliftLocation[Current Forklift Location] + OrderASource[Order A Source] + OrderATarget[Order A Target] + OrderBSource[Order B Source] + OrderBTarget[Order B Target] + + ForkliftLocation --> OrderASource + OrderASource --> OrderATarget + OrderATarget --> OrderBSource + OrderBSource --> OrderBTarget +``` + +!!! note "Why sequence matters" + If a forklift executes Order A before Order B, the travel distance can be different than executing Order B before Order A. The route sequence therefore affects the quality of the solution. + +--- + +## Static dispatching workflow + +The current optimization workflow is static. + +```mermaid +flowchart LR + WarehouseState[Warehouse State Snapshot] + DispatchJob[Dispatch Job] + WarehouseSchedule[Warehouse Schedule / Planning Solution] + Timefold[Timefold Solver] + SolvedSchedule[Solved Warehouse Schedule] + Database[(Database)] + + WarehouseState --> DispatchJob + DispatchJob --> WarehouseSchedule + WarehouseSchedule --> Timefold + Timefold --> SolvedSchedule + SolvedSchedule --> Database +``` + +A dispatch job represents one optimization run. + +The application builds a `WarehouseSchedule` from the current warehouse state. This schedule acts as the planning solution that is passed to Timefold Solver. + +Before solving, the warehouse schedule contains the planning data. After solving, it contains the optimized assignment and sequence of transport orders. + +The solver does not directly update the database. The application receives the solved schedule and then persists the result or updates the relevant database state. + +The current workflow is static. If the warehouse state changes later, the current MVP does not automatically update the plan. + +See [Known Limitations](limitations.md#static-dispatching-only) for more detail. + +--- + +## Planning model + +The planning model is centered around the `WarehouseSchedule`. + +In the current MVP, the warehouse schedule acts as the planning solution. It collects the data needed by the solver and later contains the optimized result. + +The important planning concepts are: + +- **Warehouse Schedule**: the planning solution passed to Timefold +- **Forklifts**: vehicles that can execute transport orders +- **Transport Orders**: work items that need to be assigned and sequenced +- **Storage Bins**: source, target, and current locations +- **Load Units**: physical units moved by transport orders +- **Score**: the optimization result that describes solution quality + +At a high level, Timefold receives a warehouse schedule, assigns and sequences transport orders for forklifts, and returns a solved warehouse schedule. + +```mermaid +flowchart TD + Forklifts[Forklifts] + Orders[Transport Orders] + WarehouseSchedule[Warehouse Schedule] + Constraints[Constraints] + Timefold[Timefold Solver] + SolvedSchedule[Solved Warehouse Schedule] + + Forklifts --> WarehouseSchedule + Orders --> WarehouseSchedule + WarehouseSchedule --> Timefold + Constraints --> Timefold + Timefold --> SolvedSchedule +``` + +--- + +## Current constraints + +The current constraint model is intentionally limited. + +The MVP focuses on a small set of constraints that are enough to make the first dispatching workflow meaningful and understandable. + +Current constraints include: + +- **Forklift capacity limit** + A forklift should not receive a transport order if the load unit is heavier than the forklift type's maximum capacity. + +- **Transport order equipment requirement** + A transport order that requires a specific equipment type should be assigned to a forklift with a matching equipment type. + +- **Forklift travel distance** + The solver should prefer schedules where forklifts travel less total distance. + +The first two constraints are hard constraints. They describe rules that should not be violated. + +The travel distance constraint is a soft constraint. It improves the quality of the solution by preferring shorter routes. + +!!! dev-note "Developer note" + More constraints do not automatically make the model better. + + For the current MVP, I prefer a smaller constraint model that I can understand, test, and improve step by step. + +--- + +### Forklift capacity constraint + +The forklift capacity constraint checks whether a forklift can physically carry the load unit of a transport order. + +If a transport order is assigned to a forklift, the load unit weight should not exceed the forklift type's maximum capacity. + +Simplified: + +```text +load unit weight +must be less than or equal to +assigned forklift maximum capacity +``` + +If the load unit is too heavy for the assigned forklift, the solution receives a hard penalty. + +This prevents the solver from creating schedules where a forklift receives work that it cannot physically handle. + + +--- + +### Equipment requirement constraint + +One current hard constraint checks whether a transport order requires a specific equipment type. + +If a transport order has a required equipment type, the assigned forklift should have a matching equipment type through its forklift type. + +Simplified: + +```text +transport order required equipment +must match +assigned forklift equipment type +``` +If the required equipment does not match the assigned forklift's equipment type, the solution receives a hard penalty. + +This keeps the solver from treating all forklifts as interchangeable. Some transport orders may require equipment that only certain forklift types can provide. + +### Travel distance constraint + +One important soft constraint is forklift travel distance. + +The idea is to penalize schedules where forklifts travel more than necessary. + +For each forklift, the distance calculation follows the sequence of assigned transport orders: + +1. Start at the forklift's current location. +2. Travel to the source bin of the first transport order. +3. Move from the source bin to the target bin. +4. Use the target bin as the next starting location. +5. Repeat this for the next assigned transport order. + +Simplified: + +```mermaid +flowchart LR + ForkliftLocation[Current Forklift Location] + OrderSource[Order Source Location] + OrderTarget[Order Target Location] + NextOrderSource[Next Order Source Location] + NextOrderTarget[Next Order Target Location] + + ForkliftLocation --> OrderSource + OrderSource --> OrderTarget + OrderTarget --> NextOrderSource + NextOrderSource --> NextOrderTarget +``` + +This makes the sequence of transport orders important. A different order can produce a different total travel distance. + +!!! warning "Simplified distance model" + The current MVP uses warehouse coordinates to estimate travel distance. It does not yet use a full warehouse topology with aisles, blocked paths, one-way routes, or real pathfinding. + +See [Known Limitations](limitations.md#simplified-warehouse-topology) for more detail. + +--- + +## What the current optimization does not do yet + +The current optimization model is not a complete real-world warehouse optimizer. + +It does not yet include: + +- real warehouse pathfinding +- dynamic replanning while orders are being executed +- forklift battery level +- charging decisions +- operator shifts +- traffic or congestion inside the warehouse +- real-time telemetry +- validated real-world warehouse data + +These limitations are intentional for the current milestone. + +The goal is to first build a working and understandable optimization foundation before adding more realistic planning behavior. + +See [Known Limitations](limitations.md) and [Roadmap](roadmap.md) for more detail. + +--- + +## Future optimization directions + +Future milestones may improve the optimization model with: + +- dynamic dispatching and replanning +- richer transport order priorities +- better warehouse topology modeling +- more realistic travel-distance calculations +- energy-aware forklift charging decisions +- benchmark scenarios with larger datasets +- comparison against simple baseline assignment strategies + +The long-term goal is to make optimization quality visible and measurable instead of only saying that the solver works. + +See [Roadmap](roadmap.md) for more detail. \ No newline at end of file diff --git a/docs/mkdocs/project-overview.md b/docs/mkdocs/project-overview.md new file mode 100644 index 00000000..941ac296 --- /dev/null +++ b/docs/mkdocs/project-overview.md @@ -0,0 +1,149 @@ +# Project Overview + +[Lift Nexus API](../../) is a Spring Boot backend MVP for warehouse dispatch optimization. + +The project models a simplified warehouse environment where forklifts move load units between storage bins based on transport orders. The current goal is to support a static dispatching workflow: create the warehouse state, start an optimization job, and assign transport orders to forklifts using Timefold Solver. + +!!! info "Project status" + Lift Nexus API is a portfolio / learning project. It is not a production warehouse management system yet. + +--- + + +## Why this project exists + +I built [Lift Nexus API](../../) to practice backend architecture beyond simple CRUD applications. + +The project combines two areas I am interested in: + +- backend systems and software architecture +- operations research / constraint-based optimization + +Coming from an academic optimization background, I have often seen models implemented as standalone scripts or notebooks. + +With this project, I wanted to explore how an optimization model can be integrated into a backend application with APIs, persistence, asynchronous jobs, tests, documentation, and CI-generated reports. + +--- + +## Problem domain + +In a warehouse, transport orders describe movements that need to happen, for example moving a load unit from one storage bin to another. + +A simplified dispatching problem is: + +> Given a set of forklifts and open transport orders, decide which forklift should handle which order and in which sequence. + +This is a simplified vehicle routing and dispatching problem inspired by [classical Vehicle Routing Problem](https://en.wikipedia.org/wiki/Vehicle_routing_problem) variants in logistics. In the current MVP, the route model is still simplified: distances are calculated from warehouse coordinates instead of a full pathfinding/topology model. + + +Even in a simplified MVP, this decision can depend on several factors: + +- forklift availability +- load unit weight +- forklift capacity +- source and target locations +- estimated travel distance +- compatibility between forklifts and tasks + +The current MVP does not try to model a full warehouse management system. +Instead, it focuses on a smaller optimization problem that is easier to understand and extend. The goal is to have a basis for future milestones that is extensible. + +--- + +## Current MVP scope + +The current implementation focuses on **static dispatching**. + +That means the system takes the current warehouse state as a snapshot, creates a dispatch job, runs the solver, and stores the result. + +```mermaid +flowchart LR + WarehouseState[Warehouse State] + DispatchJob[Dispatch Job] + Solver[Timefold Solver] + Assignment[Order Assignments] + + WarehouseState --> DispatchJob + DispatchJob --> Solver + Solver --> Assignment +``` + +The MVP currently includes: + +- warehouse domain entities such as forklifts, load units, storage bins, and transport orders +- REST APIs for managing the warehouse state +- PostgreSQL persistence with Flyway migrations +- asynchronous dispatch job handling +- Timefold-based assignment optimization +- generated OpenAPI reference +- integration tests with PostgreSQL/Testcontainers +- CI-generated reports for coverage, Javadoc, and test results + +--- + +## What the project is not yet + +Lift Nexus API is not production-ready yet. + +The current version intentionally does not include every concern that a real warehouse system would need. + +Current limitations include: + +- no authentication or authorization yet +- simplified warehouse topology and pathfinding +- limited solver constraints +- basic observability only +- no production deployment setup yet +- dispatch behavior is currently static, not continuously reactive +- not tested in a production-like deployment environment + +These limitations are documented openly because they are part of the current MVP scope and should be taken into account when developing future versions. + +See [Known Limitations](limitations.md) for more detail. + + +--- + +## How the system is used + +A typical local usage flow is: + +1. Start the application and PostgreSQL database. +2. Create warehouse data, such as forklift types, forklifts, storage bins, and load units +3. Create transport orders. +4. Start a dispatch job. +5. Poll the job status. +6. Inspect the resulting assignments. + +The detailed API flow is documented in [API Usage](api-usage.md). + +--- + +## Documentation map +The documentation is split into focused pages: + +- [Architecture](architecture.md) explains the modular monolith structure and module boundaries. +- [Domain Model](domain-model.md) explains the main warehouse concepts. +- [Optimization Approach](optimization.md) explains how Timefold is used. +- [API Usage](api-usage.md) shows how to run and use the API. +- [Testing Strategy](testing.md) explains the test setup and CI reports. +- [Known Limitations](limitations.md) documents current simplifications. +- [Roadmap](roadmap.md) describes planned milestones. + +--- + + +## Long-term direction + +The current MVP is the first step toward a more realistic warehouse dispatching engine. + +Future work may build on this foundation with: + +- dynamic dispatching when warehouse state changes +- richer planning constraints +- better pathfinding and topology modeling +- energy-aware forklift charging decisions +- stronger observability and deployment readiness +- benchmarking with larger scenarios + +The long-term direction is to explore how backend systems can integrate operations research models into usable software applications that can optimize real warehouse operations. \ No newline at end of file diff --git a/docs/mkdocs/roadmap.md b/docs/mkdocs/roadmap.md new file mode 100644 index 00000000..0397a6f7 --- /dev/null +++ b/docs/mkdocs/roadmap.md @@ -0,0 +1,155 @@ +# Roadmap + +This roadmap describes the planned evolution of Lift Nexus API. + +The project is developed step by step. The current priority is to keep the MVP understandable, working, and well documented before adding more advanced warehouse behavior. + +!!! info "Roadmap scope" + This roadmap is not a fixed product commitment. It is a planning document for the current learning and portfolio project. + +--- + +## Current focus + +The current focus is to finish **Milestone 1: Core MVP - Static Dispatching**. + +The goal of this milestone is to build a clean backend foundation that can model a simplified warehouse state and run a one-shot optimization job with a limited constraint set. + +[View Milestone 1 on GitHub](https://github.com/V1rex/lift-nexus-api/milestone/1) + +--- + +## Milestone 1: Core MVP - Static Dispatching + +Milestone 1 focuses on the first working version of the project. + +The system should be able to: + +- model forklifts, forklift types, storage bins, load units, and transport orders +- persist warehouse data with PostgreSQL and Flyway +- expose REST APIs for the current warehouse model +- create and execute dispatch jobs +- use Timefold Solver for static transport order assignment +- store and inspect the resulting warehouse schedule +- generate OpenAPI documentation +- provide CI-generated reports for tests, coverage, and Javadoc +- document the current architecture, limitations, and API usage + +The dispatching workflow is static. The solver receives a snapshot of the current warehouse state, calculates a solution, and stores the result. + + +```mermaid +flowchart LR + WarehouseState[Warehouse State] + DispatchJob[Dispatch Job] + Solver[Timefold Solver] + Schedule[Warehouse Schedule] + + WarehouseState --> DispatchJob + DispatchJob --> Solver + Solver --> Schedule +``` + +### Completion goal + +Milestone 1 should be considered complete when the project can be run locally, the static dispatching flow works, the documentation is usable, and the project can be presented as a stable MVP. + +--- + +## Milestone 2: Dynamic Dispatching + + +Milestone 2 moves the project from a one-shot optimization tool toward a more reactive dispatching engine. + +[View Milestone 2 on GitHub](https://github.com/V1rex/lift-nexus-api/milestone/2) + +The goal is to explore how the system should react when the warehouse state changes. + +Examples of future changes: + +- new transport orders are created +- forklifts become unavailable +- solver jobs fail or need status updates +- transport priorities change +- forklift telemetry becomes relevant +- the system needs stronger orchestration around solver execution + +!!! note "Research focus" + This milestone is intentionally more difficult than Milestone 1 because it introduces dynamic behavior. Before implementing too much, the project needs research around Timefold problem changes, replanning, and long-running solver workflows. + +!!! note "Developer note" + Milestone 2 should not be rushed. Dynamic dispatching adds complexity, so the first goal is to understand the problem before adding too much infrastructure. + +--- + +## Milestone 3: Richer Planning Constraints + +Milestone 3 focuses on making the optimization model more realistic. + +[View Milestone 3 on GitHub](https://github.com/V1rex/lift-nexus-api/milestone/7) + +Possible directions: + +- richer transport order priorities +- stronger capacity and compatibility constraints +- better warehouse topology assumptions +- more realistic travel-distance calculation +- blocked or unavailable warehouse locations +- better handling of solver scores and planning trade-offs + +The goal is not to add constraints blindly. The goal is to add constraints that make the model more useful while keeping it understandable and testable. + +--- + +## Milestone 4: Application Hardening + +Milestone 4 focuses on technical hardening around the backend application. + +[View Milestone 4 on GitHub](https://github.com/V1rex/lift-nexus-api/milestone/5) + +Possible directions: + +- authentication and authorization +- better application configuration +- production-oriented Docker setup +- container image publishing +- health checks +- structured logging +- monitoring and basic metrics +- deployment documentation + +The current GitHub Pages deployment is only for documentation and reports. This milestone would move the application closer to a production-like backend setup. + +--- + +## Milestone 5: Performance and Benchmarking + +Milestone 5 focuses on measuring how the system behaves with larger scenarios. + +[View Milestone 5 on GitHub](https://github.com/V1rex/lift-nexus-api/milestone/6) + +Possible directions: + +- generate larger warehouse scenarios +- compare solver results against simple baseline assignment strategies +- measure solver duration +- measure total travel distance +- document benchmark scenarios +- improve test data generation + +The goal is to make optimization quality and performance more visible instead of only saying that the solver works. + +--- + +## Long-term direction + +The long-term direction is to explore how backend systems can integrate operations research models into usable software applications. + +Future ideas may include: + +- energy-aware forklift charging decisions +- better warehouse topology modeling +- integration with external systems +- more realistic operational constraints + +These ideas are intentionally kept for later. The current priority is to finish the static dispatching MVP first. \ No newline at end of file diff --git a/docs/mkdocs/stylesheets/extra.css b/docs/mkdocs/stylesheets/extra.css new file mode 100644 index 00000000..a96c7003 --- /dev/null +++ b/docs/mkdocs/stylesheets/extra.css @@ -0,0 +1,14 @@ +.md-typeset .admonition.dev-note, +.md-typeset details.dev-note { + border-color: #f97316; +} + +.md-typeset .dev-note > .admonition-title, +.md-typeset .dev-note > summary { + background-color: rgba(249, 115, 22, 0.12); +} + +.md-typeset .dev-note > .admonition-title::before, +.md-typeset .dev-note > summary::before { + background-color: #f97316; +} \ No newline at end of file diff --git a/docs/mkdocs/testing.md b/docs/mkdocs/testing.md new file mode 100644 index 00000000..7698fa71 --- /dev/null +++ b/docs/mkdocs/testing.md @@ -0,0 +1,149 @@ +# Testing Strategy + +Lift Nexus API uses automated tests to keep the current MVP stable while the domain model, REST API, persistence layer, and optimization logic evolve. + +The goal is not only to increase the coverage percentage. The more important goal is to test the behavior that matters for the current warehouse dispatching workflow. + +!!! info "MVP scope" + The test suite is still evolving. Current tests focus on the most important backend, persistence, API, and solver behavior for Milestone 1. + +--- + +## Test structure + +The test source tree follows the main project modules. + +Current test areas include: + +- application and configuration tests +- forklift tests +- load unit tests +- planning tests +- storage bin tests +- transport order tests + +This mirrors the modular structure of the application and makes it easier to understand which part of the system a test belongs to. + +--- + +## What is tested? + +The current test suite covers several layers of the application: + +- application context startup +- controller behavior +- service behavior +- mapper behavior +- repository and persistence behavior +- OpenAPI generation +- Timefold solver constraint behavior + +The goal is to test the project as a backend system, not only as isolated Java classes. + +--- + +## Repository and persistence tests + +Repository tests are used to verify persistence behavior against the database layer. + +These tests are important because Lift Nexus API uses PostgreSQL and Flyway migrations. The persistence layer is part of the real backend behavior and should not only be tested with mocks. + +The project uses PostgreSQL/Testcontainers test infrastructure so persistence tests can run against a database setup that is closer to the real application environment than an in-memory database. + +!!! note "Why this matters" + Database behavior is part of the system. Testing repositories with PostgreSQL helps catch persistence and migration issues earlier. + +--- + +## Controller and service tests + +Controller and service tests check important application behavior around the domain modules. + +These tests help verify that: + +- API endpoints behave as expected +- service methods apply the intended business logic +- errors and edge cases are handled consistently +- module behavior does not break during refactoring + +For the current MVP, these tests are useful because the domain model is still evolving. + +--- + +## Mapper tests + +Mapper tests verify that data is translated correctly between domain objects and DTOs. + +This matters because the API layer should not expose internal domain objects directly. Mapper tests help protect the boundary between the REST API and the internal model. + +--- + +## Solver constraint tests + +Planning constraints are tested directly. + +The current solver-related tests cover constraints such as: + +- forklift capacity limit +- forklift travel distance +- transport order equipment requirement +- constraint provider behavior + +This is one of the most important parts of the test suite because the optimization model is a core part of the project. + +Testing constraints directly makes it easier to verify that the solver rules behave as intended before they are used inside a larger dispatching workflow. + +--- + +## Coverage reports + +The CI pipeline generates a JaCoCo coverage report. + +[View coverage report](../coverage/) + +Coverage is useful as a visibility tool, but it is not the only measure of test quality. + +A high coverage number does not automatically mean the most important behavior is tested. For this project, meaningful tests around dispatching, persistence, constraints, and domain rules matter more than only increasing the percentage. + +--- + +## Current testing focus + +The current test focus is: + +- keeping the application context stable +- testing repository behavior with PostgreSQL/Testcontainers +- testing controller and service behavior +- checking mapper behavior +- testing Timefold constraints directly +- generating OpenAPI and CI reports + +This is enough for the current static dispatching MVP. + +--- + +## Future testing improvements + +Future milestones should add stronger tests around: + +- full dispatch job workflows +- solver failure handling +- edge cases with no available forklifts +- edge cases with incompatible transport orders +- larger dispatching scenarios +- dynamic dispatching and replanning behavior +- benchmark scenarios for optimization quality + +The goal is to grow the test suite together with the project instead of adding unnecessary complexity too early. + +--- + +## Testing philosophy + +The testing strategy follows the same philosophy as the project architecture: + +> Keep the system understandable first, then improve it step by step. + +For Milestone 1, the test suite should protect the current static dispatching MVP and make future refactoring safer. + +--- \ No newline at end of file diff --git a/docs/postman/lift-nexus-api-v0.1.0.postman_collection.json b/docs/postman/lift-nexus-api-v0.1.0.postman_collection.json new file mode 100644 index 00000000..4bd3ab38 --- /dev/null +++ b/docs/postman/lift-nexus-api-v0.1.0.postman_collection.json @@ -0,0 +1,210 @@ +{ + "info": { + "name": "Lift Nexus API v0.1.0", + "description": "Demo collection for the static dispatching MVP. Start the app with `docker compose up -d`, then run the requests in order: inspect seeded data, submit a dispatch job, poll status, and inspect the result.\n\nAll data is pre-seeded β€” no manual setup required.\n\nSwagger UI: http://localhost:8080/swagger-ui.html", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080" + } + ], + "item": [ + { + "name": "Demo β€” Static Dispatching Workflow", + "description": "Run these requests in order to see the end-to-end dispatch flow.", + "item": [ + { + "name": "01 List forklifts", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "pm.test(\"Has forklifts\", () => pm.expect(pm.response.json().content.length).to.be.greaterThan(0));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/forklifts", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "forklifts"] + }, + "description": "Lists all forklifts in the fleet. Seeded data includes FLEET-HEAVY-01 and FLEET-REACH-01." + } + }, + { + "name": "02 List open transport orders", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "pm.test(\"Has open orders\", () => pm.expect(pm.response.json().content.length).to.be.greaterThan(0));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/transport-orders/search?status=OPEN", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "transport-orders", "search"], + "query": [ + { "key": "status", "value": "OPEN" } + ] + }, + "description": "Finds transport orders with status OPEN. The seeded data has 2 open orders ready for dispatch." + } + }, + { + "name": "03 Submit dispatch job", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 202\", () => pm.response.to.have.status(202));", + "const jobId = pm.response.json().jobId;", + "pm.expect(jobId).to.not.be.undefined;", + "pm.collectionVariables.set(\"jobId\", jobId);", + "pm.test(\"jobId captured\", () => pm.expect(jobId).to.match(/^[0-9a-f-]+$/));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/v1/dispatcher/jobs", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "dispatcher", "jobs"] + }, + "description": "Submits a new optimization job. The solver runs asynchronously. The response contains a jobId used in the next requests. No request body needed." + } + }, + { + "name": "04 Poll job status", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "const status = pm.response.json().status;", + "pm.test(\"Job has status\", () => pm.expect([\"QUEUED\",\"SOLVING\",\"COMPLETED\",\"FAILED\"]).to.include(status));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/dispatcher/jobs/{{jobId}}", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "dispatcher", "jobs", "{{jobId}}"] + }, + "description": "Polls the status of the dispatch job. Re-run this request until status is COMPLETED. The seeded dataset typically solves in a few seconds." + } + }, + { + "name": "05 Inspect transport orders after solving", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status 200\", () => pm.response.to.have.status(200));", + "const assigned = pm.response.json().content.filter(o => o.assignedForklift);", + "pm.test(\"Orders have assignments\", () => pm.expect(assigned.length).to.be.greaterThan(0));" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/transport-orders/search", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "transport-orders", "search"] + }, + "description": "Checks transport orders after dispatch. Previously OPEN orders should now show status ASSIGNED and include an assignedForklift field." + } + } + ] + }, + { + "name": "Reference β€” Read Endpoints", + "description": "Additional GET endpoints for exploring the warehouse state.", + "item": [ + { + "name": "Get forklift by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/forklifts/1", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "forklifts", "1"] + } + } + }, + { + "name": "List forklift types", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/forklift-types", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "forklift-types"] + } + } + }, + { + "name": "List storage bins", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/storage-bins", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "storage-bins"] + } + } + }, + { + "name": "List load units", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/load-units", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "load-units"] + } + } + }, + { + "name": "Get transport order by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/v1/transport-orders/1", + "host": ["{{baseUrl}}"], + "path": ["api", "v1", "transport-orders", "1"] + } + } + } + ] + } + ] +} diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt new file mode 100644 index 00000000..898468cb --- /dev/null +++ b/docs/requirements-docs.txt @@ -0,0 +1 @@ +mkdocs-material \ No newline at end of file diff --git a/http-requests/test-requests.http b/http-requests/test-requests.http deleted file mode 100644 index c92a47d3..00000000 --- a/http-requests/test-requests.http +++ /dev/null @@ -1,11 +0,0 @@ -### 1. Trigger the Solver -# This calls the method that runs solverManager.solveAndListen() -POST http://localhost:8080/api/dispatcher/solve -Content-Type: application/json - -### - -### 2. Get All Pick Tasks -# Use this to check if "forklift_id" is still NULL or has been assigned -GET http://localhost:8080/api/dispatcher/solution -Accept: application/json \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..4873c37a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,56 @@ +site_name: Lift Nexus API +site_description: Spring Boot backend MVP for warehouse dispatch optimization +site_url: https://lift-nexus.amine-bahij.dev/ +repo_url: https://github.com/v1rex/lift-nexus-api +repo_name: v1rex/lift-nexus-api + +docs_dir: docs/mkdocs +site_dir: build/site + +theme: + name: material + logo: assets/logo_without_text_white.png + favicon: assets/logo_without_text_black.png + palette: + scheme: default + primary: blue + accent: blue + features: + - navigation.sections + - navigation.top + - content.code.copy + - search.suggest + - search.highlight + + +nav: + - Overview: index.md + - Project Overview: project-overview.md + - Architecture: architecture.md + - Domain Model: domain-model.md + - Optimization Approach: optimization.md + - Demo Guide: demo.md + - API Usage: api-usage.md + - Testing Strategy: testing.md + - Known Limitations: limitations.md + - Roadmap: roadmap.md + +markdown_extensions: + - admonition + - attr_list + - md_in_html + - tables + - toc: + permalink: true + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.blocks.details +extra_css: + - stylesheets/extra.css \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100644 index 00000000..f4f0fd00 --- /dev/null +++ b/mvnw @@ -0,0 +1,88 @@ +#!/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 script, version 3.3.1 +# ---------------------------------------------------------------------------- + +# 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 +# ---------------------------------------------------------------------------- + +# Resolve links - $0 may be a softlink +PRG="$0" +while [ -h "$PRG" ]; do + ls=$(ls -ld "$PRG") + link=$(expr "$ls" : '.*-> \(.*\)$') + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=$(dirname "$PRG")/"$link" + fi +done + +# Find the project base directory +SAVED="$(pwd)" +cd "$(dirname "$PRG")/" >/dev/null +APP_HOME="$(pwd -P)" +cd "$SAVED" >/dev/null + +# Locate Java +if [ -n "$JAVA_HOME" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi +else + JAVACMD="java" +fi + +if ! command -v "$JAVACMD" >/dev/null 2>&1 && [ "$JAVACMD" = "java" ]; then + echo "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH." + echo "" + echo "Please set the JAVA_HOME variable in your environment to match the" + echo "location of your Java installation." + exit 1 +fi + +# Maven Wrapper configuration +CLASSWORLDS_JAR="$APP_HOME/.mvn/wrapper/maven-wrapper.jar" +CLASSWORLDS_CONF="$APP_HOME/.mvn/wrapper/maven-wrapper.properties" + +if [ ! -f "$CLASSWORLDS_JAR" ]; then + echo "ERROR: Could not find .mvn/wrapper/maven-wrapper.jar" + exit 1 +fi + +# Run the wrapper +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$CLASSWORLDS_JAR" \ + "-Dclassworlds.conf=$CLASSWORLDS_CONF" \ + "-Dmaven.home=\${M2_HOME}" \ + "-Dlibrary.jansi.path=$APP_HOME/.mvn/wrapper/jansi" \ + "-Dmaven.multiModuleProjectDirectory=$APP_HOME" \ + org.apache.maven.wrapper.MavenWrapperMain "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 00000000..86115719 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,182 @@ +@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 Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml index d9cc29da..87b38647 100644 --- a/pom.xml +++ b/pom.xml @@ -9,94 +9,39 @@ com.v1rex - Warehouse_Dispatcher - 0.0.1-SNAPSHOT - Warehouse Dispatcher - Warehouse Dispatcher - + lift-nexus-api + 0.1.0 + LiftNexus API + High-performance asynchronous optimization and dispatching engine for intralogistics assets using Timefold and Spring Boot. + https://github.com/v1rex/lift-nexus-api - + + Apache License 2.0 + http://www.apache.org/licenses/LICENSE-2.0 + repo + - + + v1rex + Mohamed Amine Bahij + contact@amine-bahij.dev + + Developer + Architect + + Europe/Berlin + - - - - + scm:git:https://github.com/v1rex/lift-nexus-api.git + scm:git:git@github.com:v1rex/lift-nexus-api.git + https://github.com/v1rex/lift-nexus-api + HEAD 21 - - - org.springframework.boot - spring-boot-h2console - - - - - org.springframework.boot - spring-boot-starter-webmvc - - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 3.0.2 - - - - org.springframework.boot - spring-boot-starter-validation - - - - - org.springframework.boot - spring-boot-docker-compose - runtime - true - - - - com.h2database - h2 - runtime - - - - org.projectlombok - lombok - true - - - - org.springframework.boot - spring-boot-starter-test - test - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - - org.springframework.boot - spring-boot-starter-webmvc-test - test - - - - ai.timefold.solver - timefold-solver-spring-boot-starter - 2.0.0-beta-2 - - - @@ -111,6 +56,7 @@ + org.apache.maven.plugins maven-compiler-plugin @@ -147,7 +93,224 @@ + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + pre-unit-test + + prepare-agent + + + + + post-unit-test + test + + report + + + + **/config/** + **/common/exception/** + + + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.43.0 + + + + 1.19.2 + + + + + + + spotless-check + validate + + check + + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.1 + + sun_checks.xml + true + false + false + + **/service/**/*,**/*Service.java + + + + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + org.springframework.boot + spring-boot-starter-restclient-test + test + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.boot + spring-boot-testcontainers + 4.0.6 + test + + + + + + org.testcontainers + postgresql + 1.21.4 + test + + + + + + org.testcontainers + junit-jupiter + 1.21.4 + test + + + + + + + org.flywaydb + flyway-database-postgresql + + + + org.springframework.boot + spring-boot-starter-flyway + + + + + org.postgresql + postgresql + runtime + + + + + + org.springframework.boot + spring-boot-docker-compose + true + + + + + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.2 + + + + + + + + org.projectlombok + lombok + true + + + + + + + + ai.timefold.solver + timefold-solver-spring-boot-starter + 2.0.0-beta-2 + + + + + + + diff --git a/scripts/ci/badge_utils.py b/scripts/ci/badge_utils.py new file mode 100644 index 00000000..3edda4b0 --- /dev/null +++ b/scripts/ci/badge_utils.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +""" +Shared helper functions for CI badge generation. + +This file does not read project reports directly. +It only contains reusable logic used by other CI scripts. +""" + + +def color_for_percentage(value: int) -> str: + """ + Return a shields.io badge color based on a percentage value. + + Args: + value: Percentage between 0 and 100. + + Returns: + A shields.io color name. + """ + if value >= 85: + return "brightgreen" + if value >= 70: + return "green" + if value >= 50: + return "yellow" + return "red" + + +def normalize_percentage(value: int) -> int: + """ + Keep a percentage value inside the 0-100 range. + """ + return max(0, min(100, value)) + + +if __name__ == "__main__": + # Small manual test when running: + # python scripts/ci/badge_utils.py + + examples = [95, 80, 65, 40, -5, 120] + + for raw_value in examples: + value = normalize_percentage(raw_value) + color = color_for_percentage(value) + print(f"{raw_value} -> {value}% -> {color}") \ No newline at end of file diff --git a/scripts/ci/jacoco_coverage.py b/scripts/ci/jacoco_coverage.py new file mode 100644 index 00000000..03fb73cc --- /dev/null +++ b/scripts/ci/jacoco_coverage.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 + +""" +Parse JaCoCo CSV coverage and expose values for GitHub Actions. + +Expected input: +- target/site/jacoco/jacoco.csv + +GitHub Actions outputs: +- test_percentage +- test_color +""" + +import csv +import os +import sys +from pathlib import Path + +from badge_utils import color_for_percentage, normalize_percentage + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +JACOCO_CSV = PROJECT_ROOT / "target" / "site" / "jacoco" / "jacoco.csv" + + +def write_github_output(key: str, value: str) -> None: + github_output = os.environ.get("GITHUB_OUTPUT") + + if github_output: + with Path(github_output).open("a", encoding="utf-8") as file: + file.write(f"{key}={value}\n") + else: + print(f"{key}={value}") + + +def calculate_line_coverage(jacoco_csv: Path) -> int: + if not jacoco_csv.exists(): + print(f"JaCoCo CSV report not found: {jacoco_csv}", file=sys.stderr) + return 0 + + line_missed = 0 + line_covered = 0 + + with jacoco_csv.open("r", encoding="utf-8", newline="") as file: + reader = csv.DictReader(file) + + required_columns = {"LINE_MISSED", "LINE_COVERED"} + missing_columns = required_columns - set(reader.fieldnames or []) + + if missing_columns: + print( + f"JaCoCo CSV is missing columns: {', '.join(sorted(missing_columns))}", + file=sys.stderr, + ) + return 0 + + for row in reader: + line_missed += int(row["LINE_MISSED"]) + line_covered += int(row["LINE_COVERED"]) + + total = line_missed + line_covered + + if total == 0: + return 0 + + return round((line_covered * 100) / total) + + +def main() -> None: + percentage = normalize_percentage(calculate_line_coverage(JACOCO_CSV)) + color = color_for_percentage(percentage) + + print(f"JaCoCo line coverage: {percentage}%") + print(f"Badge color: {color}") + + write_github_output("test_percentage", str(percentage)) + write_github_output("test_color", color) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/ci/javadoc_coverage.py b/scripts/ci/javadoc_coverage.py new file mode 100644 index 00000000..191d05fb --- /dev/null +++ b/scripts/ci/javadoc_coverage.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +""" +Calculate service Javadoc coverage from Java service files and Checkstyle output. + +Expected inputs: +- src/main/java/**/*.java +- target/checkstyle-result.xml + +GitHub Actions outputs: +- doc_percentage +- doc_color +""" + +import os +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +from badge_utils import color_for_percentage, normalize_percentage + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_MAIN_JAVA = PROJECT_ROOT / "src" / "main" / "java" +CHECKSTYLE_XML = PROJECT_ROOT / "target" / "checkstyle-result.xml" + + +PUBLIC_ELEMENT_PATTERN = re.compile( + r"\bpublic\s+(class|interface|enum|record|void|[A-Z][A-Za-z0-9_<>, ?]*)\b" +) + + +def write_github_output(key: str, value: str) -> None: + github_output = os.environ.get("GITHUB_OUTPUT") + + if github_output: + with Path(github_output).open("a", encoding="utf-8") as file: + file.write(f"{key}={value}\n") + else: + print(f"{key}={value}") + + +def is_service_file(path: Path) -> bool: + normalized_parts = {part.lower() for part in path.parts} + + return "service" in normalized_parts or path.name.endswith("Service.java") + + +def find_service_files() -> list[Path]: + if not SRC_MAIN_JAVA.exists(): + print(f"Source directory not found: {SRC_MAIN_JAVA}", file=sys.stderr) + return [] + + return [ + path + for path in SRC_MAIN_JAVA.rglob("*.java") + if is_service_file(path) + ] + + +def count_public_service_elements(service_files: list[Path]) -> int: + total = 0 + + for file_path in service_files: + text = file_path.read_text(encoding="utf-8", errors="ignore") + total += len(PUBLIC_ELEMENT_PATTERN.findall(text)) + + return total + + +def count_missing_javadocs(checkstyle_xml: Path) -> int: + if not checkstyle_xml.exists(): + print(f"Checkstyle report not found: {checkstyle_xml}", file=sys.stderr) + return 0 + + tree = ET.parse(checkstyle_xml) + root = tree.getroot() + + missing_docs = 0 + + for error in root.iter("error"): + source = error.attrib.get("source", "") + + if "MissingJavadocMethod" in source or "JavadocVariable" in source: + missing_docs += 1 + + return missing_docs + + +def calculate_doc_coverage(total_elements: int, missing_docs: int) -> int: + if total_elements == 0: + return 100 + + if missing_docs >= total_elements: + return 0 + + covered = total_elements - missing_docs + return round((covered * 100) / total_elements) + + +def main() -> None: + service_files = find_service_files() + total_elements = count_public_service_elements(service_files) + missing_docs = count_missing_javadocs(CHECKSTYLE_XML) + + percentage = normalize_percentage( + calculate_doc_coverage(total_elements, missing_docs) + ) + color = color_for_percentage(percentage) + + print(f"Service files: {len(service_files)}") + print(f"Public service elements: {total_elements}") + print(f"Missing Javadocs: {missing_docs}") + print(f"Service Javadoc coverage: {percentage}%") + print(f"Badge color: {color}") + + write_github_output("doc_percentage", str(percentage)) + write_github_output("doc_color", color) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/ci/prepare_pages.sh b/scripts/ci/prepare_pages.sh new file mode 100644 index 00000000..c88ca80b --- /dev/null +++ b/scripts/ci/prepare_pages.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +PAGES_DIR="gh-pages" + +echo "Preparing GitHub Pages output..." + +rm -rf "$PAGES_DIR" +mkdir -p "$PAGES_DIR/coverage" "$PAGES_DIR/javadoc" "$PAGES_DIR/site" + +# Landing page + API docs +cp docs/index.html "$PAGES_DIR/" +cp docs/api.html "$PAGES_DIR/" +cp target/openapi.json "$PAGES_DIR/" + +# Static assets for landing page +if [ -d docs/assets ]; then + cp -r docs/assets "$PAGES_DIR/assets" +else + echo "Assets directory missing: docs/assets" +fi + +# MkDocs generated documentation +if [ -d build/site ]; then + cp -r build/site/* "$PAGES_DIR/site/" +else + echo "MkDocs directory missing: build/site" + exit 1 +fi + +# JaCoCo coverage report +if [ -d target/site/jacoco ]; then + cp -r target/site/jacoco/* "$PAGES_DIR/coverage/" +else + echo "JaCoCo directory missing: target/site/jacoco" + exit 1 +fi + +# Javadoc +if [ -d target/reports/apidocs ]; then + cp -r target/reports/apidocs/* "$PAGES_DIR/javadoc/" +else + echo "Javadoc directory missing: target/reports/apidocs" + exit 1 +fi + + +echo "GitHub Pages output prepared in $PAGES_DIR/" \ No newline at end of file diff --git a/src/main/java/com/v1rex/liftnexus/LiftNexusApplication.java b/src/main/java/com/v1rex/liftnexus/LiftNexusApplication.java new file mode 100644 index 00000000..a7d1c34d --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/LiftNexusApplication.java @@ -0,0 +1,12 @@ +package com.v1rex.liftnexus; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LiftNexusApplication { + + public static void main(String[] args) { + SpringApplication.run(LiftNexusApplication.class, args); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/common/exception/DomainException.java b/src/main/java/com/v1rex/liftnexus/common/exception/DomainException.java new file mode 100644 index 00000000..7479555b --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/common/exception/DomainException.java @@ -0,0 +1,14 @@ +package com.v1rex.liftnexus.common.exception; + +import lombok.Getter; + +@Getter +public abstract class DomainException extends RuntimeException { + + private final ErrorCode errorCode; + + protected DomainException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/v1rex/liftnexus/common/exception/ErrorCode.java b/src/main/java/com/v1rex/liftnexus/common/exception/ErrorCode.java new file mode 100644 index 00000000..f7aee475 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/common/exception/ErrorCode.java @@ -0,0 +1,11 @@ +package com.v1rex.liftnexus.common.exception; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + String getCode(); + + String getDefaultTitle(); + + HttpStatus getStatus(); +} diff --git a/src/main/java/com/v1rex/liftnexus/common/exception/GlobalErrorCode.java b/src/main/java/com/v1rex/liftnexus/common/exception/GlobalErrorCode.java new file mode 100644 index 00000000..93bfe639 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/common/exception/GlobalErrorCode.java @@ -0,0 +1,36 @@ +package com.v1rex.liftnexus.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum GlobalErrorCode implements ErrorCode { + VALIDATION_FAILED("validation_failed", "Validation failed", HttpStatus.BAD_REQUEST), + CONSTRAINT_VIOLATION("constraint_violation", "Validation failed", HttpStatus.BAD_REQUEST), + TYPE_MISMATCH("type_mismatch", "Invalid parameter type", HttpStatus.BAD_REQUEST), + MISSING_PARAMETER( + "missing_required_parameter", "Missing required parameter", HttpStatus.BAD_REQUEST), + PAYLOAD_TOO_LARGE("payload_too_large", "Payload too large", HttpStatus.PAYLOAD_TOO_LARGE), + + METHOD_NOT_ALLOWED( + "method_not_allowed", "HTTP method not supported", HttpStatus.METHOD_NOT_ALLOWED), + UNSUPPORTED_MEDIA_TYPE( + "unsupported_media_type", "Content type not supported", HttpStatus.UNSUPPORTED_MEDIA_TYPE), + MEDIA_TYPE_NOT_ACCEPTABLE( + "media_type_not_acceptable", "Requested format not acceptable", HttpStatus.NOT_ACCEPTABLE), + MALFORMED_REQUEST_BODY( + "malformed_request_body", "Malformed request body", HttpStatus.BAD_REQUEST), + SERIALIZATION_ERROR( + "serialization_failed", "Response generation failed", HttpStatus.INTERNAL_SERVER_ERROR), + + DATABASE_CONFLICT("database_state_conflict", "Database state conflict", HttpStatus.CONFLICT), + + INTERNAL_SERVER_ERROR( + "internal_server_error", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String code; + private final String defaultTitle; + private final HttpStatus status; +} diff --git a/src/main/java/com/v1rex/liftnexus/common/exception/GlobalExceptionHandler.java b/src/main/java/com/v1rex/liftnexus/common/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..e977efc5 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,225 @@ +package com.v1rex.liftnexus.common.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +@RestControllerAdvice +@Order(Ordered.LOWEST_PRECEDENCE) // Resolves system framework-level failures as a fallback +@Slf4j +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final ProblemDetailFactory errorFactory; + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException( + MethodArgumentNotValidException ex, HttpServletRequest request) { + + List errors = + ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .filter(msg -> !msg.isBlank()) + .sorted() + .toList(); + + log.debug("Payload validation failed at {}: {}", request.getRequestURI(), errors); + + return errorFactory.createErrorResponse( + GlobalErrorCode.VALIDATION_FAILED, + "One or more request fields failed structural validation criteria.", + request, + errors); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException( + ConstraintViolationException ex, HttpServletRequest request) { + + List errors = + ex.getConstraintViolations().stream() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .sorted() + .toList(); + + log.warn("Parameter constraint violation at {}: {}", request.getRequestURI(), errors); + + return errorFactory.createErrorResponse( + GlobalErrorCode.CONSTRAINT_VIOLATION, + "One or more request parameters are semantically invalid.", + request, + errors); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch( + MethodArgumentTypeMismatchException ex, HttpServletRequest request) { + + String message = + String.format( + "Parameter '%s' must be of data type %s.", + ex.getName(), + ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); + + log.warn("Type mismatch triggered at {}: {}", request.getRequestURI(), message); + + return errorFactory.createErrorResponse( + GlobalErrorCode.TYPE_MISMATCH, message, request, List.of()); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParameter( + MissingServletRequestParameterException ex, HttpServletRequest request) { + + String message = + String.format( + "Required query parameter '%s' (%s) is missing.", + ex.getParameterName(), ex.getParameterType()); + + log.warn("Missing expected parameter at {}: {}", request.getRequestURI(), message); + + return errorFactory.createErrorResponse( + GlobalErrorCode.MISSING_PARAMETER, message, request, List.of()); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceeded( + MaxUploadSizeExceededException ex, HttpServletRequest request) { + + log.warn("Payload size threshold breached at {}", request.getRequestURI()); + + return errorFactory.createErrorResponse( + GlobalErrorCode.PAYLOAD_TOO_LARGE, + "The uploaded attachment size exceeds the configured max limit.", + request, + List.of()); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported( + HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { + + String message = + String.format( + "HTTP verb '%s' is invalid for this route. Supported verbs: %s", + ex.getMethod(), ex.getSupportedHttpMethods()); + + log.warn("HTTP method mismatch at {}: {}", request.getRequestURI(), message); + + return errorFactory.createErrorResponse( + GlobalErrorCode.METHOD_NOT_ALLOWED, message, request, List.of()); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleMediaTypeNotSupported( + HttpMediaTypeNotSupportedException ex, HttpServletRequest request) { + + String message = + String.format( + "Content type '%s' is unacceptable. Supported formats: %s", + ex.getContentType(), ex.getSupportedMediaTypes()); + + log.warn("Unsupported incoming media type request at {}: {}", request.getRequestURI(), message); + + return errorFactory.createErrorResponse( + GlobalErrorCode.UNSUPPORTED_MEDIA_TYPE, message, request, List.of()); + } + + @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) + public ResponseEntity handleMediaTypeNotAcceptable( + HttpMediaTypeNotAcceptableException ex, HttpServletRequest request) { + + log.warn("Client Accept header negotiation failed at {}", request.getRequestURI()); + + return errorFactory.createErrorResponse( + GlobalErrorCode.MEDIA_TYPE_NOT_ACCEPTABLE, + "Could not generate a response matching the format specified in the client Accept header.", + request, + List.of()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadableMessage( + HttpMessageNotReadableException ex, HttpServletRequest request) { + + log.warn( + "Unparseable payload stream detected at {}: {}", request.getRequestURI(), ex.getMessage()); + + return errorFactory.createErrorResponse( + GlobalErrorCode.MALFORMED_REQUEST_BODY, + "The incoming JSON request body contains malformed syntax and cannot be parsed.", + request, + List.of()); + } + + @ExceptionHandler(HttpMessageNotWritableException.class) + public ResponseEntity handleWritableException( + HttpMessageNotWritableException ex, HttpServletRequest request) { + + log.error("Outbound JSON serialization failed at {}", request.getRequestURI(), ex); + + return errorFactory.createErrorResponse( + GlobalErrorCode.SERIALIZATION_ERROR, + "An error occurred while serializing the response payload.", + request, + List.of()); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolation( + DataIntegrityViolationException ex, HttpServletRequest request) { + String detail = "Database constraint violation."; + // Try to extract constraint name + String message = ex.getMessage(); + if (message != null && message.contains("Detail:")) { + detail = message.substring(message.indexOf("Detail:")); + } + log.warn("Database constraint triggered at {}: {}", request.getRequestURI(), message); + return errorFactory.createErrorResponse( + GlobalErrorCode.DATABASE_CONFLICT, detail, request, List.of()); + } + + @ExceptionHandler(DomainException.class) + public ResponseEntity handleDomainException( + DomainException ex, HttpServletRequest request) { + + log.warn( + "Unhandled domain exception [{}] at {}: {}", + ex.getErrorCode().getCode(), + request.getRequestURI(), + ex.getMessage()); + + return errorFactory.createErrorResponse(ex.getErrorCode(), ex.getMessage(), request, List.of()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException( + Exception ex, HttpServletRequest request) { + + log.error("Critical unhandled system anomaly logged at {}: ", request.getRequestURI(), ex); + + return errorFactory.createErrorResponse( + GlobalErrorCode.INTERNAL_SERVER_ERROR, + "An unexpected software execution anomaly has occurred on the server.", + request, + List.of()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/common/exception/ProblemDetailFactory.java b/src/main/java/com/v1rex/liftnexus/common/exception/ProblemDetailFactory.java new file mode 100644 index 00000000..b9fad713 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/common/exception/ProblemDetailFactory.java @@ -0,0 +1,30 @@ +package com.v1rex.liftnexus.common.exception; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.time.Instant; +import java.util.List; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +@Component +public class ProblemDetailFactory { + + public ResponseEntity createErrorResponse( + ErrorCode errorCode, String detail, HttpServletRequest request, List errors) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(errorCode.getStatus(), detail); + problemDetail.setTitle(errorCode.getDefaultTitle()); + problemDetail.setType(URI.create("urn:liftnexus:problem:" + errorCode.getCode())); + problemDetail.setInstance(URI.create(request.getRequestURI())); + problemDetail.setProperty("errorCode", errorCode.getCode()); + problemDetail.setProperty("timestamp", Instant.now().toString()); + + if (errors != null && !errors.isEmpty()) { + problemDetail.setProperty("errors", errors); + } + + return ResponseEntity.status(errorCode.getStatus()).body(problemDetail); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/config/OpenApiConfig.java b/src/main/java/com/v1rex/liftnexus/config/OpenApiConfig.java new file mode 100644 index 00000000..792ec23e --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/config/OpenApiConfig.java @@ -0,0 +1,59 @@ +package com.v1rex.liftnexus.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.tags.Tag; +import java.util.List; +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("LiftNexus API") + .version("0.1.0") + .description( + "Asynchronous optimization and dispatching engine for" + + " intralogistics assets using Timefold.") + .contact( + new Contact() + .name("Mohamed Amine Bahij") + .email("contact@amine-bahij.dev") + .url("https://github.com/v1rex")) + .license( + new License() + .name("Apache License 2.0") + .url("http://www.apache.org/licenses/LICENSE-2.0"))) + .servers( + List.of( + new Server().url("http://localhost:8080").description("Local Development Server"))) + .tags( + List.of( + new Tag() + .name("Forklifts") + .description("Manage forklift assets and their operational status"), + new Tag() + .name("Forklift Types") + .description("Manage forklift type catalog (models, capacity, equipment)"), + new Tag() + .name("Storage Bins") + .description("Manage warehouse storage bin locations and coordinates"), + new Tag() + .name("Load Units") + .description("Manage load units (pallets, containers) and their tracking"), + new Tag() + .name("Transport Orders") + .description("Manage transport orders for moving loads across the warehouse"), + new Tag() + .name("Dispatcher") + .description("Submit and manage Timefold optimization jobs"))); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/config/dev/DataSeeder.java b/src/main/java/com/v1rex/liftnexus/config/dev/DataSeeder.java new file mode 100644 index 00000000..86222dfc --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/config/dev/DataSeeder.java @@ -0,0 +1,189 @@ +package com.v1rex.liftnexus.config.dev; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.repository.ForkliftRepository; +import com.v1rex.liftnexus.forklift.repository.ForkliftTypeRepository; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.loadunit.repository.LoadUnitRepository; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.storagebin.repository.StorageBinRepository; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.repository.TransportOrderRepository; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@Profile("dev") +@RequiredArgsConstructor +public class DataSeeder implements CommandLineRunner { + private final StorageBinRepository storageBinRepository; + private final ForkliftRepository forkliftRepository; + private final ForkliftTypeRepository forkliftTypeRepository; + private final TransportOrderRepository transportOrderRepository; + private final LoadUnitRepository loadUnitRepository; + + @Override + @Transactional + public void run(String... args) { + if (storageBinRepository.count() > 0) { + log.info("Warehouse already has data. Skipping seed."); + return; + } + + log.info("-------- Seeding Warehouse Dispatcher data---------- "); + + ForkliftType sideLoaderType = + ForkliftType.builder() + .modelName("SIDEL-2000") + .equipmentType(EquipmentType.SIDE_LOADER) + .maxCapacityKg(5000) + .totalBatteryCapacitykWh(150.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + ForkliftType reachType = + ForkliftType.builder() + .modelName("REACH-1000") + .equipmentType(EquipmentType.REACH_TRUCK) + .maxCapacityKg(2000) + .totalBatteryCapacitykWh(80.0) + .baseEnergyConsumptionPerMeter(0.3) + .build(); + + forkliftTypeRepository.saveAll(List.of(sideLoaderType, reachType)); + + StorageBin dock = + StorageBin.builder() + .binCode("DOCK-01") + .coordinate(new Coordinate3D(0, 0, 0)) + .zoneType(ZoneType.STAGING_OUT) + .maxWeightCapacityKg(10000) + .build(); + + StorageBin zoneA = + StorageBin.builder() + .binCode("A-01") + .coordinate(new Coordinate3D(10, 5, 0)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(2000) + .build(); + + StorageBin zoneB = + StorageBin.builder() + .binCode("B-01") + .coordinate(new Coordinate3D(-5, 15, 0)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(2000) + .build(); + + StorageBin shipping = + StorageBin.builder() + .binCode("SHIP-01") + .coordinate(new Coordinate3D(20, 20, 0)) + .zoneType(ZoneType.STAGING_IN) + .maxWeightCapacityKg(5000) + .build(); + + storageBinRepository.saveAll(List.of(dock, zoneA, zoneB, shipping)); + + LoadUnit lu1 = + LoadUnit.builder() + .trackingCode("LU-1000") + .weightKg(500) + .status(LoadUnitStatus.STAGED) + .currentBin(zoneB) + .build(); + LoadUnit lu2 = + LoadUnit.builder() + .trackingCode("LU-2000") + .weightKg(4000) + .status(LoadUnitStatus.STAGED) + .currentBin(zoneA) + .build(); + LoadUnit lu3 = + LoadUnit.builder() + .trackingCode("LU-3000") + .weightKg(100) + .status(LoadUnitStatus.STAGED) + .currentBin(dock) + .build(); + + loadUnitRepository.saveAll(List.of(lu1, lu2, lu3)); + + Forklift heavyTruck = + Forklift.builder() + .fleetNumber("FLEET-HEAVY-01") + .forkliftType(sideLoaderType) + .currentStorageBin(dock) + .status(OperationalStatus.ACTIVE) + .currentBatteryPercentage(85.0) + .transportOrders(new ArrayList<>()) + .build(); + + Forklift reachTruck = + Forklift.builder() + .fleetNumber("FLEET-REACH-01") + .forkliftType(reachType) + .currentStorageBin(zoneA) + .status(OperationalStatus.ACTIVE) + .currentBatteryPercentage(60.0) + .transportOrders(new ArrayList<>()) + .build(); + + forkliftRepository.saveAll(List.of(heavyTruck, reachTruck)); + + TransportOrder activeOrder = + TransportOrder.builder() + .sourceBin(zoneB) + .targetBin(shipping) + .targetLoadUnit(lu1) + .status(TransportOrderStatus.IN_PROGRESS) + .requiredEquipment(EquipmentType.REACH_TRUCK) + .assignedForklift(reachTruck) + .build(); + + reachTruck.getTransportOrders().add(activeOrder); + + TransportOrder heavyOrder = + TransportOrder.builder() + .sourceBin(zoneA) + .targetBin(shipping) + .targetLoadUnit(lu2) + .status(TransportOrderStatus.OPEN) + .requiredEquipment(EquipmentType.SIDE_LOADER) + .build(); + + TransportOrder openOrder = + TransportOrder.builder() + .sourceBin(dock) + .targetBin(zoneB) + .targetLoadUnit(lu3) + .status(TransportOrderStatus.OPEN) + .requiredEquipment(EquipmentType.REACH_TRUCK) + .build(); + + transportOrderRepository.saveAll(List.of(activeOrder, heavyOrder, openOrder)); + + log.info( + "Seeding complete: {} Locations, {} ForkliftTypes, {} Forklifts, {} LoadUnits, {} Orders.", + storageBinRepository.count(), + forkliftTypeRepository.count(), + forkliftRepository.count(), + loadUnitRepository.count(), + transportOrderRepository.count()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftController.java b/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftController.java new file mode 100644 index 00000000..44cc2cf8 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftController.java @@ -0,0 +1,138 @@ +package com.v1rex.liftnexus.forklift.controller; + +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.dto.ForkliftLocationUpdateRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftResponse; +import com.v1rex.liftnexus.forklift.service.ForkliftService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/forklifts") +@Validated +@RequiredArgsConstructor +@Tag(name = "Forklifts", description = "Manage forklift assets and their operational status") +public class ForkliftController { + + private final ForkliftService forkliftService; + + @Operation( + summary = "Get a forklift by ID", + description = + "Retrieves details of a specific forklift, including its current status, battery level, and assigned transport orders.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Forklift found"), + @ApiResponse(responseCode = "404", description = "Forklift not found", content = @Content) + }) + @GetMapping("/{id}") + public ResponseEntity getForkliftById(@PathVariable Long id) { + return ResponseEntity.ok(forkliftService.findById(id)); + } + + @Operation( + summary = "List all forklifts", + description = "Returns a paginated list of all forklifts in the fleet.") + @ApiResponse(responseCode = "200", description = "Paginated list of forklifts") + @GetMapping + public ResponseEntity> findAllForklifts( + @PageableDefault(size = 15, sort = "id", direction = Sort.Direction.ASC) Pageable pageable) { + return ResponseEntity.ok(forkliftService.findAll(pageable)); + } + + @Operation( + summary = "Search forklifts by capacity or status", + description = + "Filters forklifts by minimum capacity or operational status. If no filter is provided, returns all forklifts.") + @ApiResponse(responseCode = "200", description = "Matching forklifts") + @GetMapping("/search") + public ResponseEntity> findWithCapacity( + @Parameter(description = "Minimum capacity in kg") @RequestParam(required = false) @Min(1) + Integer minCapacity, + @Parameter(description = "Filter by operational status") @RequestParam(required = false) + OperationalStatus status, + @PageableDefault(size = 10, sort = "fleetNumber") Pageable pageable) { + + if (minCapacity != null) { + return ResponseEntity.ok(forkliftService.findWithCapacityGreaterThan(minCapacity, pageable)); + } else if (status != null) { + return ResponseEntity.ok(forkliftService.findByStatus(status, pageable)); + } + return ResponseEntity.ok(forkliftService.findAll(pageable)); + } + + @Operation( + summary = "Register a new forklift", + description = "Creates a new forklift asset in the fleet.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Forklift created"), + @ApiResponse( + responseCode = "400", + description = "Invalid input or business rule violation", + content = @Content), + @ApiResponse( + responseCode = "409", + description = "Fleet number already exists", + content = @Content) + }) + @PostMapping + public ResponseEntity createForklift( + @RequestBody @Valid ForkliftRequest request) { + ForkliftResponse savedForklift = forkliftService.createForklift(request); + + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(savedForklift.id()) + .toUri(); + + return ResponseEntity.created(location).body(savedForklift); + } + + @Operation( + summary = "Update forklift location", + description = "Moves a forklift to a different storage bin location.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Location updated"), + @ApiResponse( + responseCode = "404", + description = "Forklift or storage bin not found", + content = @Content) + }) + @PutMapping("/{id}/location") + public ResponseEntity updateForkliftLocation( + @PathVariable Long id, @Valid @RequestBody ForkliftLocationUpdateRequest updateRequest) { + return ResponseEntity.ok( + forkliftService.updateForkliftLocation(id, updateRequest.locationId())); + } + + @Operation( + summary = "Update forklift operational status", + description = + "Changes the operational status of a forklift (e.g., ACTIVE, MAINTENANCE, CHARGING, OFFLINE).") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Status updated"), + @ApiResponse(responseCode = "404", description = "Forklift not found", content = @Content) + }) + @PatchMapping("/{id}/status") + public ResponseEntity updateOperationalStatus( + @PathVariable Long id, @RequestParam OperationalStatus status) { + return ResponseEntity.ok(forkliftService.updateOperationalStatus(id, status)); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftExceptionHandler.java b/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftExceptionHandler.java new file mode 100644 index 00000000..95f2c72e --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftExceptionHandler.java @@ -0,0 +1,33 @@ +package com.v1rex.liftnexus.forklift.controller; + +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.forklift.exception.ForkliftDomainException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "com.v1rex.liftnexus.forklift") +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +@RequiredArgsConstructor +public class ForkliftExceptionHandler { + + private final ProblemDetailFactory errorFactory; + + @ExceptionHandler(ForkliftDomainException.class) + public ResponseEntity handleForkliftDomainException( + ForkliftDomainException ex, HttpServletRequest request) { + + log.warn( + "Domain anomaly tracked [{}] | Context: {}", ex.getErrorCode().getCode(), ex.getMessage()); + + return errorFactory.createErrorResponse(ex.getErrorCode(), ex.getMessage(), request, List.of()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftTypeController.java b/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftTypeController.java new file mode 100644 index 00000000..d38658dd --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/controller/ForkliftTypeController.java @@ -0,0 +1,80 @@ +package com.v1rex.liftnexus.forklift.controller; + +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeResponse; +import com.v1rex.liftnexus.forklift.service.ForkliftTypeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/forklift-types") +@Validated +@RequiredArgsConstructor +@Tag( + name = "Forklift Types", + description = "Manage forklift type catalog (models, capacity, equipment)") +public class ForkliftTypeController { + + private final ForkliftTypeService forkliftTypeService; + + @Operation( + summary = "Create a new forklift type", + description = + "Registers a new forklift model/type in the catalog with its capacity and energy specifications.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Forklift type created"), + @ApiResponse(responseCode = "400", description = "Invalid input", content = @Content), + @ApiResponse( + responseCode = "409", + description = "Model name already exists", + content = @Content) + }) + @PostMapping + public ResponseEntity createForkliftType( + @RequestBody @Valid ForkliftTypeRequest request) { + ForkliftTypeResponse savedType = forkliftTypeService.createForkliftType(request); + + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(savedType.id()) + .toUri(); + + return ResponseEntity.created(location).body(savedType); + } + + @Operation( + summary = "Get a forklift type by ID", + description = "Retrieves details of a specific forklift type/model.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Forklift type found"), + @ApiResponse(responseCode = "404", description = "Forklift type not found", content = @Content) + }) + @GetMapping("/{id}") + public ResponseEntity getForkliftTypeById(@PathVariable Long id) { + return ResponseEntity.ok(forkliftTypeService.findById(id)); + } + + @Operation( + summary = "List all forklift types", + description = "Returns a paginated list of all available forklift types in the catalog.") + @ApiResponse(responseCode = "200", description = "Paginated list of forklift types") + @GetMapping + public ResponseEntity> getAllForkliftTypes( + @PageableDefault(size = 10) Pageable pageable) { + return ResponseEntity.ok(forkliftTypeService.findAll(pageable)); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/domain/EquipmentType.java b/src/main/java/com/v1rex/liftnexus/forklift/domain/EquipmentType.java new file mode 100644 index 00000000..c503e790 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/domain/EquipmentType.java @@ -0,0 +1,15 @@ +package com.v1rex.liftnexus.forklift.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Category of material handling equipment") +public enum EquipmentType { + @Schema(description = "Manual or electric pallet jack for horizontal transport") + PALLET_JACK, + @Schema(description = "Standard counterbalance forklift") + STANDARD, + @Schema(description = "Reach truck for narrow-aisle high-bay operations") + REACH_TRUCK, + @Schema(description = "Side loader for long/oversized loads") + SIDE_LOADER +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/domain/Forklift.java b/src/main/java/com/v1rex/liftnexus/forklift/domain/Forklift.java new file mode 100644 index 00000000..9d7cfc32 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/domain/Forklift.java @@ -0,0 +1,54 @@ +package com.v1rex.liftnexus.forklift.domain; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; +import lombok.*; + +@PlanningEntity +@Entity +@Table(name = "forklifts") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Forklift { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(name = "fleet_number", nullable = false, unique = true) + private String fleetNumber; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "forklift_type_id", nullable = false) + private ForkliftType forkliftType; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "current_storage_bin_id") + private StorageBin currentStorageBin; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "operational_status", nullable = false) + private OperationalStatus status = OperationalStatus.OFFLINE; + + @Builder.Default + @Column(name = "current_battery_percentage", nullable = false) + private Double currentBatteryPercentage = 100.0; + + @PlanningListVariable(valueRangeProviderRefs = "taskPoolRange") + @OneToMany(mappedBy = "assignedForklift", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @Builder.Default + private List transportOrders = new ArrayList<>(); +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/domain/ForkliftType.java b/src/main/java/com/v1rex/liftnexus/forklift/domain/ForkliftType.java new file mode 100644 index 00000000..3898dc02 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/domain/ForkliftType.java @@ -0,0 +1,42 @@ +package com.v1rex.liftnexus.forklift.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.*; + +@Entity +@Table(name = "forklift_types") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ForkliftType { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(name = "model_name", nullable = false, unique = true) + private String modelName; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(name = "equipment_type", nullable = false) + private EquipmentType equipmentType; + + @Positive + @Column(name = "max_capacity_kg", nullable = false) + private Integer maxCapacityKg; + + @Positive + @Column(name = "total_battery_capacity_kwh", nullable = false) + private Double totalBatteryCapacitykWh; + + @Positive + @Column(name = "base_energy_consumption_per_meter", nullable = false) + private Double baseEnergyConsumptionPerMeter; +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/domain/OperationalStatus.java b/src/main/java/com/v1rex/liftnexus/forklift/domain/OperationalStatus.java new file mode 100644 index 00000000..fcded440 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/domain/OperationalStatus.java @@ -0,0 +1,15 @@ +package com.v1rex.liftnexus.forklift.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Operational state of a forklift") +public enum OperationalStatus { + @Schema(description = "Forklift is operational and available for tasks") + ACTIVE, + @Schema(description = "Forklift is undergoing maintenance") + MAINTENANCE, + @Schema(description = "Forklift is currently charging") + CHARGING, + @Schema(description = "Forklift is offline or decommissioned") + OFFLINE +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftLocationUpdateRequest.java b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftLocationUpdateRequest.java new file mode 100644 index 00000000..9b6dbcef --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftLocationUpdateRequest.java @@ -0,0 +1,9 @@ +package com.v1rex.liftnexus.forklift.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request payload for updating a forklift's location") +public record ForkliftLocationUpdateRequest( + @NotNull @Schema(description = "ID of the destination storage bin", example = "7") + Long locationId) {} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftRequest.java b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftRequest.java new file mode 100644 index 00000000..6475a4fd --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftRequest.java @@ -0,0 +1,26 @@ +package com.v1rex.liftnexus.forklift.dto; + +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request payload for registering a new forklift") +public record ForkliftRequest( + @NotBlank + @Schema(description = "Unique fleet number identifying the forklift", example = "FL-0042") + String fleetNumber, + @NotNull @Schema(description = "ID of the forklift type/model", example = "1") + Long forkliftTypeId, + @Schema( + description = "ID of the storage bin where the forklift is currently located", + example = "5", + nullable = true) + Long currentStorageBinId, + @Schema(description = "Initial operational status", example = "ACTIVE", nullable = true) + OperationalStatus status, + @Schema( + description = "Current battery level as a percentage (0-100)", + example = "85.5", + nullable = true) + Double currentBatteryPercentage) {} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftResponse.java b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftResponse.java new file mode 100644 index 00000000..a0767648 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftResponse.java @@ -0,0 +1,26 @@ +package com.v1rex.liftnexus.forklift.dto; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "Detailed view of a forklift asset") +public record ForkliftResponse( + @Schema(description = "Unique identifier", example = "1") Long id, + @Schema(description = "Unique fleet number", example = "FL-0042") String fleetNumber, + @Schema(description = "ID of the assigned forklift type", example = "1") Long forkliftTypeId, + @Schema( + description = "Human-readable model name from the forklift type", + example = "Toyota BT Staxio") + String modelName, + @Schema(description = "Type of material handling equipment") EquipmentType equipmentType, + @Schema(description = "Maximum carrying capacity in kg", example = "1500") + Integer maxCapacityKg, + @Schema(description = "ID of the current storage bin location", example = "3", nullable = true) + Long currentStorageBinId, + @Schema(description = "Current operational status") OperationalStatus status, + @Schema(description = "Current battery level percentage", example = "72.3", nullable = true) + Double currentBatteryPercentage, + @Schema(description = "IDs of currently assigned transport orders", example = "[10, 11]") + List transportOrderIds) {} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftTypeRequest.java b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftTypeRequest.java new file mode 100644 index 00000000..f454bc5d --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftTypeRequest.java @@ -0,0 +1,30 @@ +package com.v1rex.liftnexus.forklift.dto; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@Schema(description = "Request payload for creating a new forklift type/model in the catalog") +public record ForkliftTypeRequest( + @NotBlank(message = "Model name is mandatory") + @Schema(description = "Manufacturer model name", example = "Toyota BT Staxio") + String modelName, + @NotNull(message = "Equipment type is mandatory") + @Schema(description = "Category of material handling equipment") + EquipmentType equipmentType, + @NotNull(message = "Max capacity is mandatory") + @Positive(message = "Max capacity must be greater than zero") + @Schema(description = "Maximum safe load capacity in kg", example = "1500") + Integer maxCapacityKg, + @NotNull(message = "Total battery capacity is mandatory") + @Positive(message = "Battery capacity must be greater than zero") + @Schema(description = "Total battery capacity in kWh", example = "48.0") + Double totalBatteryCapacitykWh, + @NotNull(message = "Base energy consumption is mandatory") + @Positive(message = "Energy consumption must be greater than zero") + @Schema( + description = "Base energy consumption per meter travelled in kWh", + example = "0.05") + Double baseEnergyConsumptionPerMeter) {} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftTypeResponse.java b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftTypeResponse.java new file mode 100644 index 00000000..76198a5b --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/dto/ForkliftTypeResponse.java @@ -0,0 +1,16 @@ +package com.v1rex.liftnexus.forklift.dto; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Detailed view of a forklift type/model in the catalog") +public record ForkliftTypeResponse( + @Schema(description = "Unique identifier", example = "1") Long id, + @Schema(description = "Manufacturer model name", example = "Toyota BT Staxio") String modelName, + @Schema(description = "Category of material handling equipment") EquipmentType equipmentType, + @Schema(description = "Maximum safe load capacity in kg", example = "1500") + Integer maxCapacityKg, + @Schema(description = "Total battery capacity in kWh", example = "48.0") + Double totalBatteryCapacitykWh, + @Schema(description = "Base energy consumption per meter in kWh", example = "0.05") + Double baseEnergyConsumptionPerMeter) {} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftDomainException.java b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftDomainException.java new file mode 100644 index 00000000..f6cff002 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftDomainException.java @@ -0,0 +1,15 @@ +package com.v1rex.liftnexus.forklift.exception; + +import com.v1rex.liftnexus.common.exception.DomainException; +import com.v1rex.liftnexus.common.exception.ErrorCode; + +public abstract sealed class ForkliftDomainException extends DomainException + permits ForkliftFleetNumberExistsException, + ForkliftNotFoundException, + ForkliftTypeNameExistsException, + ForkliftTypeNotFoundException { + + protected ForkliftDomainException(ErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftErrorCode.java b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftErrorCode.java new file mode 100644 index 00000000..f4435b6b --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftErrorCode.java @@ -0,0 +1,29 @@ +package com.v1rex.liftnexus.forklift.exception; + +import com.v1rex.liftnexus.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ForkliftErrorCode implements ErrorCode { + FORKLIFT_NOT_FOUND("forklift_not_found", "Forklift Not Found", HttpStatus.NOT_FOUND), + + FORKLIFT_TYPE_NOT_FOUND( + "forklift_type_not_found", "Forklift Type Not Found", HttpStatus.NOT_FOUND), + + FORKLIFT_FLEET_NUMBER_EXISTS( + "forklift_fleet_number_already_exists", + "Forklift Fleet Number already exists", + HttpStatus.CONFLICT), + + FORKLIFT_TYPE_NAME_EXISTS( + "forklift_type_name_already_exists", + "Forklift Type Name already exists", + HttpStatus.CONFLICT); + + private final String code; + private final String defaultTitle; + private final HttpStatus status; +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftFleetNumberExistsException.java b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftFleetNumberExistsException.java new file mode 100644 index 00000000..0f0e8519 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftFleetNumberExistsException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.forklift.exception; + +public final class ForkliftFleetNumberExistsException extends ForkliftDomainException { + + public ForkliftFleetNumberExistsException(String fleetNumber) { + super( + ForkliftErrorCode.FORKLIFT_FLEET_NUMBER_EXISTS, + "A forklift type with fleet number'" + fleetNumber + "' already exists."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftNotFoundException.java b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftNotFoundException.java new file mode 100644 index 00000000..9bb8a3f9 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftNotFoundException.java @@ -0,0 +1,8 @@ +package com.v1rex.liftnexus.forklift.exception; + +public final class ForkliftNotFoundException extends ForkliftDomainException { + + public ForkliftNotFoundException(Long id) { + super(ForkliftErrorCode.FORKLIFT_NOT_FOUND, "Forklift with ID " + id + " does not exist."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftTypeNameExistsException.java b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftTypeNameExistsException.java new file mode 100644 index 00000000..0fedfb34 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftTypeNameExistsException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.forklift.exception; + +public final class ForkliftTypeNameExistsException extends ForkliftDomainException { + + public ForkliftTypeNameExistsException(String modelName) { + super( + ForkliftErrorCode.FORKLIFT_TYPE_NAME_EXISTS, + "A forklift type model named '" + modelName + "' already exists."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftTypeNotFoundException.java b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftTypeNotFoundException.java new file mode 100644 index 00000000..1507856f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/exception/ForkliftTypeNotFoundException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.forklift.exception; + +public final class ForkliftTypeNotFoundException extends ForkliftDomainException { + + public ForkliftTypeNotFoundException(Long id) { + super( + ForkliftErrorCode.FORKLIFT_TYPE_NOT_FOUND, + "Forklift Type with ID " + id + " does not exist."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/mapper/ForkliftMapper.java b/src/main/java/com/v1rex/liftnexus/forklift/mapper/ForkliftMapper.java new file mode 100644 index 00000000..340a0b4f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/mapper/ForkliftMapper.java @@ -0,0 +1,41 @@ +package com.v1rex.liftnexus.forklift.mapper; + +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.dto.ForkliftRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftResponse; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class ForkliftMapper { + + public Forklift toEntity(ForkliftRequest request) { + if (request == null) return null; + return Forklift.builder() + .fleetNumber(request.fleetNumber()) + .status(request.status() != null ? request.status() : OperationalStatus.OFFLINE) + .currentBatteryPercentage( + request.currentBatteryPercentage() != null ? request.currentBatteryPercentage() : 100.0) + .build(); + } + + public ForkliftResponse toResponse(Forklift entity) { + if (entity == null) return null; + return new ForkliftResponse( + entity.getId(), + entity.getFleetNumber(), + entity.getForkliftType() != null ? entity.getForkliftType().getId() : null, + entity.getForkliftType() != null ? entity.getForkliftType().getModelName() : null, + entity.getForkliftType() != null ? entity.getForkliftType().getEquipmentType() : null, + entity.getForkliftType() != null ? entity.getForkliftType().getMaxCapacityKg() : null, + entity.getCurrentStorageBin() != null ? entity.getCurrentStorageBin().getId() : null, + entity.getStatus(), + entity.getCurrentBatteryPercentage(), + entity.getTransportOrders() != null + ? entity.getTransportOrders().stream().map(TransportOrder::getId).toList() + : List.of() // TODO: fix later as this can cause the N+1 Query Problem + ); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/mapper/ForkliftTypeMapper.java b/src/main/java/com/v1rex/liftnexus/forklift/mapper/ForkliftTypeMapper.java new file mode 100644 index 00000000..5ee2cf8f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/mapper/ForkliftTypeMapper.java @@ -0,0 +1,32 @@ +package com.v1rex.liftnexus.forklift.mapper; + +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeResponse; +import org.springframework.stereotype.Component; + +@Component +public class ForkliftTypeMapper { + + public ForkliftType toEntity(ForkliftTypeRequest request) { + if (request == null) return null; + return ForkliftType.builder() + .modelName(request.modelName()) + .equipmentType(request.equipmentType()) + .maxCapacityKg(request.maxCapacityKg()) + .totalBatteryCapacitykWh(request.totalBatteryCapacitykWh()) + .baseEnergyConsumptionPerMeter(request.baseEnergyConsumptionPerMeter()) + .build(); + } + + public ForkliftTypeResponse toResponse(ForkliftType entity) { + if (entity == null) return null; + return new ForkliftTypeResponse( + entity.getId(), + entity.getModelName(), + entity.getEquipmentType(), + entity.getMaxCapacityKg(), + entity.getTotalBatteryCapacitykWh(), + entity.getBaseEnergyConsumptionPerMeter()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/repository/ForkliftRepository.java b/src/main/java/com/v1rex/liftnexus/forklift/repository/ForkliftRepository.java new file mode 100644 index 00000000..0781f8c2 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/repository/ForkliftRepository.java @@ -0,0 +1,55 @@ +package com.v1rex.liftnexus.forklift.repository; + +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +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; + +@Repository +public interface ForkliftRepository extends JpaRepository { + + boolean existsByFleetNumber(String fleetNumber); + + @EntityGraph(attributePaths = {"forkliftType", "currentStorageBin"}) + Optional findById(Long id); + + @Override + @EntityGraph(attributePaths = {"forkliftType", "currentStorageBin"}) + Page findAll(Pageable pageable); + + @Override + @EntityGraph(attributePaths = {"forkliftType", "currentStorageBin"}) + List findAll(); + + @EntityGraph(attributePaths = {"forkliftType", "currentStorageBin"}) + Page findByStatus(OperationalStatus status, Pageable pageable); + + @EntityGraph(attributePaths = {"forkliftType", "currentStorageBin"}) + @Query( + """ + SELECT f FROM Forklift f + JOIN f.forkliftType ft + WHERE ft.maxCapacityKg >= :minCapacity + """) + Page findByForkliftType_MaxCapacityKgGreaterThanEqual( + @Param("minCapacity") Integer minCapacity, Pageable pageable); + + @EntityGraph( + attributePaths = { + "forkliftType", + "currentStorageBin", + "transportOrders", + "transportOrders.targetLoadUnit", + "transportOrders.sourceBin", + "transportOrders.targetBin" + }) + @Query("SELECT DISTINCT f FROM Forklift f") + List findAllForPlanning(); +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/repository/ForkliftTypeRepository.java b/src/main/java/com/v1rex/liftnexus/forklift/repository/ForkliftTypeRepository.java new file mode 100644 index 00000000..1452149f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/repository/ForkliftTypeRepository.java @@ -0,0 +1,13 @@ +package com.v1rex.liftnexus.forklift.repository; + +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ForkliftTypeRepository extends JpaRepository { + Optional findByModelName(String modelName); + + boolean existsByModelName(String modelName); +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/service/ForkliftService.java b/src/main/java/com/v1rex/liftnexus/forklift/service/ForkliftService.java new file mode 100644 index 00000000..41341449 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/service/ForkliftService.java @@ -0,0 +1,277 @@ +package com.v1rex.liftnexus.forklift.service; + +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.dto.ForkliftRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftResponse; +import com.v1rex.liftnexus.forklift.exception.ForkliftFleetNumberExistsException; +import com.v1rex.liftnexus.forklift.exception.ForkliftNotFoundException; +import com.v1rex.liftnexus.forklift.mapper.ForkliftMapper; +import com.v1rex.liftnexus.forklift.repository.ForkliftRepository; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service layer for the Forklift bounded context. + * + *

Manages the full lifecycle of warehouse forklifts: registration, location tracking, + * operational status updates, and assignment of transport orders during solver solution + * persistence. Cross-domain communication follows the anti-corruption rule β€” all external entity + * lookups go through the respective service interfaces, never directly through repositories. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class ForkliftService { + + private final ForkliftRepository forkliftRepository; + private final ForkliftMapper forkliftMapper; + private final ForkliftTypeService forkliftTypeService; + private final StorageBinService storageBinService; + + /** + * Registers a new forklift in the warehouse fleet. + * + *

Validates that the fleet number is unique, resolves the forklift type (archetype) and + * optional initial storage bin from their respective domains, persists the aggregate, and returns + * a {@link ForkliftResponse} DTO. + * + * @param request the inbound payload containing fleet number, type ID, and optional initial + * storage bin ID (must not be {@code null}, must pass validation) + * @return a DTO representing the newly created forklift + * @throws ForkliftFleetNumberExistsException if a forklift with the given fleet number already + * exists in the system + */ + @Transactional + public ForkliftResponse createForklift(ForkliftRequest request) { + log.info("Provisioning new warehouse asset with fleet number: {}", request.fleetNumber()); + + if (forkliftRepository.existsByFleetNumber(request.fleetNumber())) { + throw new ForkliftFleetNumberExistsException(request.fleetNumber()); + } + + ForkliftType forkliftType = forkliftTypeService.findEntityById(request.forkliftTypeId()); + + StorageBin initialBin = + request.currentStorageBinId() != null + ? storageBinService.findEntityById(request.currentStorageBinId()) + : null; + + Forklift forklift = forkliftMapper.toEntity(request); + forklift.setForkliftType(forkliftType); + forklift.setCurrentStorageBin(initialBin); + + Forklift savedForklift = forkliftRepository.save(forklift); + log.debug("Successfully registered asset ID {}", savedForklift.getId()); + return forkliftMapper.toResponse(savedForklift); + } + + /** + * Retrieves a single forklift by its unique identifier. + * + * @param id the forklift's database identifier (must be positive and exist) + * @return a DTO representing the forklift + * @throws ForkliftNotFoundException if no forklift with that ID is found + */ + @Transactional(readOnly = true) + public ForkliftResponse findById(Long id) { + return forkliftMapper.toResponse(findEntityById(id)); + } + + /** + * Returns a paginated list of all forklifts in the system. + * + * @param pageable pagination and sorting parameters (default sort: {@code id} ascending) + * @return a page of forklift DTOs + */ + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return findAllEntities(pageable).map(forkliftMapper::toResponse); + } + + /** + * Searches for forklifts by minimum lifting capacity or operational status. + * + *

If {@code minCapacity} is provided, results are filtered by the forklift type's max + * capacity. + * + * @param minCapacity the minimum weight capacity in kilograms (optional, {@code >= 1}) + * @param pageable pagination parameters (default size: 10, sort: fleetNumber) + * @return a page of matching forklift DTOs + */ + @Transactional(readOnly = true) + public Page findWithCapacityGreaterThan( + Integer minCapacity, Pageable pageable) { + log.info("Searching assets matching minimum operational lifting capacity: {}kg", minCapacity); + return forkliftRepository + .findByForkliftType_MaxCapacityKgGreaterThanEqual(minCapacity, pageable) + .map(forkliftMapper::toResponse); + } + + /** + * Searches for forklifts by operational status. + * + * @param status the operational status to filter by (e.g. {@code ACTIVE}, {@code OFFLINE}) + * @param pageable pagination parameters + * @return a page of forklift DTOs matching the given status + */ + @Transactional(readOnly = true) + public Page findByStatus(OperationalStatus status, Pageable pageable) { + log.info("Filtering active assets by operational status: {}", status); + return forkliftRepository.findByStatus(status, pageable).map(forkliftMapper::toResponse); + } + + /** + * Moves a forklift to a new storage bin location. + * + *

This operation updates the physical location of the forklift within the warehouse. Both the + * forklift and the target bin must exist. + * + * @param forkliftId the identifier of the forklift to relocate + * @param locationId the identifier of the destination storage bin + * @return a DTO representing the updated forklift + * @throws ForkliftNotFoundException if no forklift with the given ID exists + */ + @Transactional + public ForkliftResponse updateForkliftLocation(Long forkliftId, Long locationId) { + log.info("Moving Forklift ID {} to StorageBin ID {}", forkliftId, locationId); + Forklift forklift = findEntityById(forkliftId); + StorageBin newStorageBin = storageBinService.findEntityById(locationId); + + forklift.setCurrentStorageBin(newStorageBin); + Forklift updatedForklift = forkliftRepository.save(forklift); + + log.debug("Update successful for Forklift ID {}", forkliftId); + return forkliftMapper.toResponse(updatedForklift); + } + + /** + * Transitions a forklift's operational status (e.g. from {@code OFFLINE} to {@code ACTIVE}). + * + * @param forkliftId the identifier of the target forklift + * @param status the new operational status + * @return a DTO representing the updated forklift + * @throws ForkliftNotFoundException if no forklift with the given ID exists + */ + @Transactional + public ForkliftResponse updateOperationalStatus(Long forkliftId, OperationalStatus status) { + log.info("Transitioning Forklift ID {} state to: {}", forkliftId, status); + Forklift forklift = findEntityById(forkliftId); + + forklift.setStatus(status); + Forklift updatedForklift = forkliftRepository.save(forklift); + + return forkliftMapper.toResponse(updatedForklift); + } + + /** + * Internal domain-level lookup: retrieves the managed {@link Forklift} entity by its ID. + * + *

This method is exposed to other services within the same domain and to the planning service + * for solution persistence. External consumers receive a DTO; only domain-internal callers should + * use the entity directly. + * + * @param id the forklift's database identifier + * @return the persistent Forklift entity + * @throws ForkliftNotFoundException if no entity with that ID exists + */ + @Transactional(readOnly = true) + public Forklift findEntityById(Long id) { + log.info("Fetching Forklift entity with id: {}", id); + return forkliftRepository + .findById(id) + .orElseThrow( + () -> { + log.warn("Lookup failed: Forklift ID {} not found", id); + return new ForkliftNotFoundException(id); + }); + } + + /** + * Returns a paginated list of raw {@link Forklift} entities for internal domain usage. + * + * @param pageable pagination and sorting parameters + * @return a page of Forklift entities + */ + @Transactional(readOnly = true) + public Page findAllEntities(Pageable pageable) { + log.info("Fetching all managed forklift entities and returning a page"); + return forkliftRepository.findAll(pageable); + } + + /** + * Returns all {@link Forklift} entities without pagination for solver consumption. + * + *

Performance note: This method loads the entire fleet into memory and is called during + * {@code WarehouseDispatcherService.buildCurrentState()}. For large warehouses, consider + * paginated or selective fetching (addressed in Milestone 2). + * + * @return a list of all Forklift entities in the database + */ + @Transactional(readOnly = true) + public List findAllEntities() { + log.info("Fetching all managed forklift entities without pagination"); + return forkliftRepository.findAll(); + } + + /** + * Persists the solver's assignment results back to the database. + * + *

Called by {@code WarehouseDispatcherService.saveFinalSolution()} after the Timefold solver + * produces a solution. This method performs a bulk ID lookup to ensure all referenced forklifts + * exist before applying any assignment changes. If a forklift was deleted between solver + * completion and persistence, the operation fails with an {@link IllegalStateException} to + * prevent partial updates. + * + *

Each database forklift's transport order list is cleared and repopulated with the + * solver-determined assignments. + * + * @param forklifts the list of solver-state forklifts containing updated order assignments + * @throws IllegalStateException if one or more forklift IDs from the solver solution no longer + * exist in the database (stale data) + */ + @Deprecated + @Transactional + public void updateAssignedOrders(List forklifts) { + log.info("Updating assigned transport orders for Forklifts"); + List ids = forklifts.stream().map(Forklift::getId).toList(); + List databaseForklifts = forkliftRepository.findAllById(ids); + if (databaseForklifts.size() != ids.size()) { + throw new IllegalStateException("One or more forklifts not found during assignment update"); + } + + for (Forklift newForklift : forklifts) { + Forklift databaseForklift = findEntityById(newForklift.getId()); + + databaseForklift.getTransportOrders().clear(); + if (newForklift.getTransportOrders() != null) { + databaseForklift.getTransportOrders().addAll(newForklift.getTransportOrders()); + } + } + } + + /** + * Retrieves all {@link Forklift} entities with the object graph required by the Timefold solver. + * + *

This is an internal method for planning use cases. It intentionally loads the full + * planning-relevant graph, including forklift type, current location and assigned transport + * orders, because the solver runs asynchronously outside the Hibernate session. + * + *

Do not use this method for normal paginated REST API access. + * + * @return an unmodifiable list of all planning-ready {@link Forklift} entities + */ + @Transactional(readOnly = true) + public List findAllEntitiesForPlanning() { + log.info("Fetching all forklift entities with planning graph"); + return List.copyOf(forkliftRepository.findAllForPlanning()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/forklift/service/ForkliftTypeService.java b/src/main/java/com/v1rex/liftnexus/forklift/service/ForkliftTypeService.java new file mode 100644 index 00000000..88d07bb6 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/forklift/service/ForkliftTypeService.java @@ -0,0 +1,91 @@ +package com.v1rex.liftnexus.forklift.service; + +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeResponse; +import com.v1rex.liftnexus.forklift.exception.ForkliftTypeNameExistsException; +import com.v1rex.liftnexus.forklift.exception.ForkliftTypeNotFoundException; +import com.v1rex.liftnexus.forklift.mapper.ForkliftTypeMapper; +import com.v1rex.liftnexus.forklift.repository.ForkliftTypeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service layer for managing {@link ForkliftType} entities. + * + *

Provides transactional operations for creating, retrieving, and querying forklift types. + * Enforces uniqueness constraints on model names and uses a mapper for entity-to-DTO conversion. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class ForkliftTypeService { + + private final ForkliftTypeRepository forkliftTypeRepository; + private final ForkliftTypeMapper forkliftTypeMapper; + + /** + * Creates a new forklift type after validating that the model name is unique. + * + * @param request the DTO containing forklift type details (model name, etc.) + * @return a {@link ForkliftTypeResponse} representing the persisted entity + * @throws ForkliftTypeNameExistsException if a type with the given model name already exists + */ + @Transactional + public ForkliftTypeResponse createForkliftType(ForkliftTypeRequest request) { + log.info("Registering new forklift archetype blueprint: {}", request.modelName()); + + if (forkliftTypeRepository.existsByModelName(request.modelName())) { + throw new ForkliftTypeNameExistsException(request.modelName()); + } + + ForkliftType forkliftType = forkliftTypeMapper.toEntity(request); + ForkliftType savedType = forkliftTypeRepository.save(forkliftType); + + return forkliftTypeMapper.toResponse(savedType); + } + + /** + * Retrieves a forklift type by its unique identifier. + * + * @param id the primary key of the forklift type + * @return a {@link ForkliftTypeResponse} representing the found entity + * @throws ForkliftTypeNotFoundException if no type exists with the given id + */ + @Transactional(readOnly = true) + public ForkliftTypeResponse findById(Long id) { + return forkliftTypeMapper.toResponse(findEntityById(id)); + } + + /** + * Retrieves a paginated list of all forklift types. + * + * @param pageable pagination and sorting parameters + * @return a {@link Page} of {@link ForkliftTypeResponse} DTOs + */ + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return forkliftTypeRepository.findAll(pageable).map(forkliftTypeMapper::toResponse); + } + + /** + * Finds the underlying {@link ForkliftType} entity by its id, throwing if not found. + * + *

This is an internal helper used by other service methods that need access to the entity + * object rather than its DTO representation. + * + * @param id the primary key of the forklift type + * @return the {@link ForkliftType} entity + * @throws ForkliftTypeNotFoundException if no type exists with the given id + */ + @Transactional(readOnly = true) + public ForkliftType findEntityById(Long id) { + return forkliftTypeRepository + .findById(id) + .orElseThrow(() -> new ForkliftTypeNotFoundException(id)); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitController.java b/src/main/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitController.java new file mode 100644 index 00000000..ac657229 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitController.java @@ -0,0 +1,125 @@ +package com.v1rex.liftnexus.loadunit.controller; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitRequest; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitResponse; +import com.v1rex.liftnexus.loadunit.service.LoadUnitService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/load-units") +@Validated +@Slf4j +@RequiredArgsConstructor +@Tag( + name = "Load Units", + description = "Manage load units (pallets, containers) and their tracking") +public class LoadUnitController { + + private final LoadUnitService loadUnitService; + + @Operation( + summary = "Register a new load unit", + description = + "Creates a new load unit with a unique tracking code, weight, initial status, and optional storage bin assignment.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Load unit created"), + @ApiResponse( + responseCode = "400", + description = "Invalid input", + content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse( + responseCode = "409", + description = "Tracking code already exists", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @PostMapping + public ResponseEntity createLoadUnit( + @RequestBody @Valid LoadUnitRequest request) { + log.info("REST request to create Load Unit with tracking code: {}", request.trackingCode()); + LoadUnitResponse savedUnit = loadUnitService.createLoadUnit(request); + + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(savedUnit.id()) + .toUri(); + + return ResponseEntity.created(location).body(savedUnit); + } + + @Operation( + summary = "Get a load unit by ID", + description = "Retrieves details of a specific load unit.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Load unit found"), + @ApiResponse( + responseCode = "404", + description = "Load unit not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @GetMapping("/{id}") + public ResponseEntity findById(@PathVariable Long id) { + log.info("REST request to get Load Unit by ID: {}", id); + return ResponseEntity.ok(loadUnitService.findById(id)); + } + + @Operation( + summary = "Find a load unit by tracking code", + description = "Retrieves a load unit using its unique tracking code.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Load unit found"), + @ApiResponse( + responseCode = "404", + description = "Load unit not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @GetMapping("/tracking/{trackingCode}") + public ResponseEntity findByTrackingCode(@PathVariable String trackingCode) { + log.info("REST request to get Load Unit by tracking code: {}", trackingCode); + return ResponseEntity.ok(loadUnitService.findByTrackingCode(trackingCode)); + } + + @Operation( + summary = "List all load units", + description = "Returns a paginated list of all load units in the system.") + @ApiResponse(responseCode = "200", description = "Paginated list of load units") + @GetMapping + public ResponseEntity> findAll( + @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.ASC) Pageable pageable) { + log.info( + "REST request to get all Load Units (Page size: {}, Page number: {})", + pageable.getPageSize(), + pageable.getPageNumber()); + return ResponseEntity.ok(loadUnitService.findAll(pageable)); + } + + @Operation( + summary = "Filter load units by status", + description = + "Returns a paginated list of load units filtered by their current status (e.g., STORED, IN_TRANSIT).") + @ApiResponse(responseCode = "200", description = "Matching load units") + @GetMapping("/status/{status}") + public ResponseEntity> findByStatus( + @Parameter(description = "Load unit status to filter by") @PathVariable LoadUnitStatus status, + @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.ASC) Pageable pageable) { + log.info("REST request to get Load Units by status: {}", status); + return ResponseEntity.ok(loadUnitService.findByStatus(status, pageable)); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitExceptionHandler.java b/src/main/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitExceptionHandler.java new file mode 100644 index 00000000..0d85c9e6 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitExceptionHandler.java @@ -0,0 +1,33 @@ +package com.v1rex.liftnexus.loadunit.controller; + +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.loadunit.exception.LoadUnitDomainException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "com.v1rex.liftnexus.loadunit") +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +@RequiredArgsConstructor +public class LoadUnitExceptionHandler { + + private final ProblemDetailFactory errorFactory; + + @ExceptionHandler(LoadUnitDomainException.class) + public ResponseEntity handleLoadUnitDomainException( + LoadUnitDomainException ex, HttpServletRequest request) { + + log.warn( + "Domain anomaly tracked [{}] | Context: {}", ex.getErrorCode().getCode(), ex.getMessage()); + + return errorFactory.createErrorResponse(ex.getErrorCode(), ex.getMessage(), request, List.of()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/domain/LoadUnit.java b/src/main/java/com/v1rex/liftnexus/loadunit/domain/LoadUnit.java new file mode 100644 index 00000000..fb77c3d9 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/domain/LoadUnit.java @@ -0,0 +1,41 @@ +package com.v1rex.liftnexus.loadunit.domain; + +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import jakarta.persistence.*; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Entity +@Table(name = "load_units") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoadUnit { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank(message = "Tracking code must be provided") + @Column(nullable = false, updatable = false, unique = true) + private String trackingCode; + + @Min(value = 0, message = "Weight cannot be negative") + @Column(nullable = false, updatable = false) + private int weightKg; + + @NotNull + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LoadUnitStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "current_storage_bin_id") + private StorageBin currentBin; + + @Version private Long version; +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/domain/LoadUnitStatus.java b/src/main/java/com/v1rex/liftnexus/loadunit/domain/LoadUnitStatus.java new file mode 100644 index 00000000..eec5e5ab --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/domain/LoadUnitStatus.java @@ -0,0 +1,17 @@ +package com.v1rex.liftnexus.loadunit.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Lifecycle status of a load unit") +public enum LoadUnitStatus { + @Schema(description = "Load unit is expected but not yet physically received") + EXPECTED, + @Schema(description = "Load unit is staged at a staging area awaiting put-away") + STAGED, + @Schema(description = "Load unit is stored in a storage bin") + STORED, + @Schema(description = "Load unit is being moved by a forklift") + IN_TRANSIT, + @Schema(description = "Load unit has been shipped out of the warehouse") + SHIPPED +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/dto/LoadUnitRequest.java b/src/main/java/com/v1rex/liftnexus/loadunit/dto/LoadUnitRequest.java new file mode 100644 index 00000000..f7075dcb --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/dto/LoadUnitRequest.java @@ -0,0 +1,24 @@ +package com.v1rex.liftnexus.loadunit.dto; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request payload for registering a new load unit") +public record LoadUnitRequest( + @NotBlank(message = "Tracking code is required") + @Schema(description = "Unique tracking code for the load unit", example = "LU-2024-001") + String trackingCode, + @Min(value = 0, message = "Weight cannot be negative") + @Schema(description = "Weight of the load unit in kg", example = "450") + int weightKg, + @NotNull(message = "Initial status is required") + @Schema(description = "Initial status of the load unit") + LoadUnitStatus status, + @Schema( + description = "ID of the storage bin where the unit is placed (nullable)", + example = "3", + nullable = true) + Long currentStorageBinId) {} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/dto/LoadUnitResponse.java b/src/main/java/com/v1rex/liftnexus/loadunit/dto/LoadUnitResponse.java new file mode 100644 index 00000000..c402d7d2 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/dto/LoadUnitResponse.java @@ -0,0 +1,17 @@ +package com.v1rex.liftnexus.loadunit.dto; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Detailed view of a load unit") +public record LoadUnitResponse( + @Schema(description = "Unique identifier", example = "1") Long id, + @Schema(description = "Unique tracking code", example = "LU-2024-001") String trackingCode, + @Schema(description = "Weight in kg", example = "450") int weightKg, + @Schema(description = "Current lifecycle status of the load unit") LoadUnitStatus status, + @Schema( + description = "ID of the storage bin where the unit is located", + example = "3", + nullable = true) + Long currentStorageBinId, + @Schema(description = "Optimistic locking version number", example = "0") Long version) {} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitDomainException.java b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitDomainException.java new file mode 100644 index 00000000..39f0bc8a --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitDomainException.java @@ -0,0 +1,12 @@ +package com.v1rex.liftnexus.loadunit.exception; + +import com.v1rex.liftnexus.common.exception.DomainException; +import com.v1rex.liftnexus.common.exception.ErrorCode; + +public abstract sealed class LoadUnitDomainException extends DomainException + permits LoadUnitNotFoundException, LoadUnitTrackingCodeExistsException { + + protected LoadUnitDomainException(ErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitErrorCode.java b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitErrorCode.java new file mode 100644 index 00000000..807f9a8d --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitErrorCode.java @@ -0,0 +1,21 @@ +package com.v1rex.liftnexus.loadunit.exception; + +import com.v1rex.liftnexus.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum LoadUnitErrorCode implements ErrorCode { + LOAD_UNIT_NOT_FOUND("load_unit_not_found", "Load Unit Not Found", HttpStatus.NOT_FOUND), + + LOAD_UNIT_TRACKING_CODE_EXISTS( + "load_unit_tracking_code_already_exists", + "Load Unit Tracking Code Already Exists", + HttpStatus.CONFLICT); + + private final String code; + private final String defaultTitle; + private final HttpStatus status; +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitNotFoundException.java b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitNotFoundException.java new file mode 100644 index 00000000..a347ea69 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitNotFoundException.java @@ -0,0 +1,14 @@ +package com.v1rex.liftnexus.loadunit.exception; + +public final class LoadUnitNotFoundException extends LoadUnitDomainException { + + public LoadUnitNotFoundException(Long id) { + super(LoadUnitErrorCode.LOAD_UNIT_NOT_FOUND, "Load unit with ID " + id + " does not exist."); + } + + public LoadUnitNotFoundException(String trackingCode) { + super( + LoadUnitErrorCode.LOAD_UNIT_NOT_FOUND, + "Load unit with tracking code '" + trackingCode + "' does not exist."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitTrackingCodeExistsException.java b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitTrackingCodeExistsException.java new file mode 100644 index 00000000..d0d9955c --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/exception/LoadUnitTrackingCodeExistsException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.loadunit.exception; + +public final class LoadUnitTrackingCodeExistsException extends LoadUnitDomainException { + + public LoadUnitTrackingCodeExistsException(String trackingCode) { + super( + LoadUnitErrorCode.LOAD_UNIT_TRACKING_CODE_EXISTS, + "Load unit with tracking code '" + trackingCode + "' already exists."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/mapper/LoadUnitMapper.java b/src/main/java/com/v1rex/liftnexus/loadunit/mapper/LoadUnitMapper.java new file mode 100644 index 00000000..f1f42d73 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/mapper/LoadUnitMapper.java @@ -0,0 +1,38 @@ +package com.v1rex.liftnexus.loadunit.mapper; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitRequest; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitResponse; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import org.springframework.stereotype.Component; + +@Component +public class LoadUnitMapper { + + public LoadUnit toEntity(LoadUnitRequest request, StorageBin currentBin) { + if (request == null) { + return null; + } + return LoadUnit.builder() + .trackingCode(request.trackingCode()) + .weightKg(request.weightKg()) + .status(request.status()) + .currentBin(currentBin) + .build(); + } + + public LoadUnitResponse toResponse(LoadUnit entity) { + if (entity == null) { + return null; + } + Long binId = entity.getCurrentBin() != null ? entity.getCurrentBin().getId() : null; + + return new LoadUnitResponse( + entity.getId(), + entity.getTrackingCode(), + entity.getWeightKg(), + entity.getStatus(), + binId, + entity.getVersion()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/repository/LoadUnitRepository.java b/src/main/java/com/v1rex/liftnexus/loadunit/repository/LoadUnitRepository.java new file mode 100644 index 00000000..8505f287 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/repository/LoadUnitRepository.java @@ -0,0 +1,19 @@ +package com.v1rex.liftnexus.loadunit.repository; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface LoadUnitRepository extends JpaRepository { + + boolean existsByTrackingCode(String trackingCode); + + Optional findByTrackingCode(String trackingCode); + + Page findByStatus(LoadUnitStatus status, Pageable pageable); +} diff --git a/src/main/java/com/v1rex/liftnexus/loadunit/service/LoadUnitService.java b/src/main/java/com/v1rex/liftnexus/loadunit/service/LoadUnitService.java new file mode 100644 index 00000000..4d3b8478 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/loadunit/service/LoadUnitService.java @@ -0,0 +1,207 @@ +package com.v1rex.liftnexus.loadunit.service; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitRequest; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitResponse; +import com.v1rex.liftnexus.loadunit.exception.LoadUnitNotFoundException; +import com.v1rex.liftnexus.loadunit.exception.LoadUnitTrackingCodeExistsException; +import com.v1rex.liftnexus.loadunit.mapper.LoadUnitMapper; +import com.v1rex.liftnexus.loadunit.repository.LoadUnitRepository; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service layer for managing {@link LoadUnit} entities. + * + *

This class provides two tiers of access: + * + *

    + *
  • External API Boundary – Methods that return DTOs ({@link LoadUnitResponse}) to + * controllers and external callers. These methods handle validation, business logic, and + * mapping. + *
  • Internal Domain Boundary – Methods that return domain entities ({@link LoadUnit}) + * for use by other services, internal components, or the Timefold solver, bypassing DTO + * conversion. + *
+ */ +@Service +@Slf4j +@RequiredArgsConstructor +public class LoadUnitService { + + private final LoadUnitRepository loadUnitRepository; + private final LoadUnitMapper loadUnitMapper; + + private final StorageBinService storageBinService; + + // ===================================================================== + // EXTERNAL API BOUNDARY (Returns DTOs to Controllers) + // ===================================================================== + + /** + * Creates a new load unit and persists it to the database. + * + *

Before persisting, this method validates that the provided {@code trackingCode} is unique. + * If a storage bin ID is supplied, the load unit will be assigned to the referenced bin (the bin + * must already exist). + * + * @param request the DTO containing the load unit details (tracking code, weight, optional + * storage bin ID, etc.) + * @return a {@link LoadUnitResponse} representing the newly created and saved load unit + * @throws LoadUnitTrackingCodeExistsException if a load unit with the same tracking code already + * exists + */ + @Transactional + public LoadUnitResponse createLoadUnit(LoadUnitRequest request) { + log.info( + "Creating load unit with tracking code: {} and weight: {}kg", + request.trackingCode(), + request.weightKg()); + + if (loadUnitRepository.existsByTrackingCode(request.trackingCode())) { + log.warn( + "Creation failed: Load unit with tracking code {} already exists", + request.trackingCode()); + throw new LoadUnitTrackingCodeExistsException(request.trackingCode()); + } + + StorageBin assignedBin = null; + if (request.currentStorageBinId() != null) { + assignedBin = storageBinService.findEntityById(request.currentStorageBinId()); + } + + LoadUnit loadUnit = loadUnitMapper.toEntity(request, assignedBin); + LoadUnit savedUnit = loadUnitRepository.save(loadUnit); + + log.info( + "Successfully created load unit with Id: {}, tracking code: {}", + savedUnit.getId(), + savedUnit.getTrackingCode()); + return loadUnitMapper.toResponse(savedUnit); + } + + /** + * Retrieves a load unit by its database ID and returns it as a DTO. + * + * @param id the primary key of the load unit + * @return a {@link LoadUnitResponse} for the matching load unit + * @throws LoadUnitNotFoundException if no load unit exists with the given {@code id} + */ + @Transactional(readOnly = true) + public LoadUnitResponse findById(Long id) { + return loadUnitMapper.toResponse(findEntityById(id)); + } + + /** + * Retrieves a load unit by its unique tracking code and returns it as a DTO. + * + * @param trackingCode the unique tracking code to search for + * @return a {@link LoadUnitResponse} for the matching load unit + * @throws LoadUnitNotFoundException if no load unit exists with the given {@code trackingCode} + */ + @Transactional(readOnly = true) + public LoadUnitResponse findByTrackingCode(String trackingCode) { + return loadUnitMapper.toResponse(findEntityByTrackingCode(trackingCode)); + } + + /** + * Returns a paginated list of all load units as DTOs. + * + * @param pageable pagination and sorting configuration + * @return a {@link Page} of {@link LoadUnitResponse} + */ + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return findAllEntities(pageable).map(loadUnitMapper::toResponse); + } + + /** + * Returns a paginated list of load units filtered by the given {@link LoadUnitStatus} as DTOs. + * + * @param status the status to filter by (e.g. {@code AVAILABLE}, {@code RESERVED}, etc.) + * @param pageable pagination and sorting configuration + * @return a {@link Page} of {@link LoadUnitResponse} matching the specified status + */ + @Transactional(readOnly = true) + public Page findByStatus(LoadUnitStatus status, Pageable pageable) { + return findEntitiesByStatus(status, pageable).map(loadUnitMapper::toResponse); + } + + // ===================================================================== + // INTERNAL DOMAIN BOUNDARY (Returns Entities to other Services/Timefold) + // ===================================================================== + + /** + * Finds a load unit entity by its database ID. + * + *

This method is intended for internal use by other services, domain components, or the + * Timefold solver that require direct access to the domain entity rather than a DTO. + * + * @param id the primary key of the load unit + * @return the {@link LoadUnit} entity + * @throws LoadUnitNotFoundException if no load unit exists with the given {@code id} + */ + public LoadUnit findEntityById(Long id) { + return loadUnitRepository + .findById(id) + .orElseThrow( + () -> { + log.warn("Load unit with id: {} not found.", id); + return new LoadUnitNotFoundException(id); + }); + } + + /** + * Finds a load unit entity by its unique tracking code. + * + *

This method is intended for internal use by other services, domain components, or the + * Timefold solver that require direct access to the domain entity rather than a DTO. + * + * @param trackingCode the unique tracking code to search for + * @return the {@link LoadUnit} entity + * @throws LoadUnitNotFoundException if no load unit exists with the given {@code trackingCode} + */ + public LoadUnit findEntityByTrackingCode(String trackingCode) { + return loadUnitRepository + .findByTrackingCode(trackingCode) + .orElseThrow( + () -> { + log.warn("Load unit with tracking code: {} not found.", trackingCode); + return new LoadUnitNotFoundException(trackingCode); + }); + } + + /** + * Returns a paginated list of all load unit entities. + * + *

This method is intended for internal use by other services, domain components, or the + * Timefold solver that require direct access to the domain entity rather than a DTO. + * + * @param pageable pagination and sorting configuration + * @return a {@link Page} of {@link LoadUnit} entities + */ + public Page findAllEntities(Pageable pageable) { + return loadUnitRepository.findAll(pageable); + } + + /** + * Returns a paginated list of load unit entities filtered by the given {@link LoadUnitStatus}. + * + *

This method is intended for internal use by other services, domain components, or the + * Timefold solver that require direct access to the domain entity rather than a DTO. + * + * @param status the status to filter by + * @param pageable pagination and sorting configuration + * @return a {@link Page} of {@link LoadUnit} entities matching the specified status + */ + public Page findEntitiesByStatus(LoadUnitStatus status, Pageable pageable) { + return loadUnitRepository.findByStatus(status, pageable); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/constraints/ForkliftCapacityConstraint.java b/src/main/java/com/v1rex/liftnexus/planning/constraints/ForkliftCapacityConstraint.java new file mode 100644 index 00000000..d8d35b1e --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/constraints/ForkliftCapacityConstraint.java @@ -0,0 +1,20 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.HardSoftScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; + +public class ForkliftCapacityConstraint { + public static Constraint forkliftCapacity(ConstraintFactory factory) { + return factory + .forEach(TransportOrder.class) + .filter(transportorder -> transportorder.getAssignedForklift() != null) + .filter( + transportorder -> + transportorder.getTargetLoadUnit().getWeightKg() + > transportorder.getAssignedForklift().getForkliftType().getMaxCapacityKg()) + .penalize(HardSoftScore.ONE_HARD) + .asConstraint("Forklift capacity limit"); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/constraints/ForkliftTravelDistanceConstraint.java b/src/main/java/com/v1rex/liftnexus/planning/constraints/ForkliftTravelDistanceConstraint.java new file mode 100644 index 00000000..8b27b99f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/constraints/ForkliftTravelDistanceConstraint.java @@ -0,0 +1,43 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.HardSoftScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; + +public class ForkliftTravelDistanceConstraint { + + // TODO: [PERFORMANCE OPTIMIZATION - MILESTONE 2 PREPARATION] + // This O(N) loop on Forklift.class works perfectly for the MVP's small datasets. + // However, it triggers a full chain recalculation on every micro-move + public static Constraint forkliftTravelDistance(ConstraintFactory factory) { + return factory + .forEach(Forklift.class) + .filter( + forklift -> + forklift.getTransportOrders() != null && !forklift.getTransportOrders().isEmpty()) + .penalize( + HardSoftScore.ONE_SOFT, ForkliftTravelDistanceConstraint::calculateTotalTravelDistance) + .asConstraint("Forklift travel distance"); + } + + private static int calculateTotalTravelDistance(Forklift forklift) { + int totalTraveledDistance = 0; + + Coordinate3D currentLocationForklift = forklift.getCurrentStorageBin().getCoordinate(); + + for (TransportOrder order : forklift.getTransportOrders()) { + Coordinate3D sourceLocation = order.getSourceBin().getCoordinate(); + Coordinate3D targetLocation = order.getTargetBin().getCoordinate(); + + totalTraveledDistance += (int) currentLocationForklift.calculateDistance(sourceLocation); + + totalTraveledDistance += (int) sourceLocation.calculateDistance(targetLocation); + + currentLocationForklift = targetLocation; + } + return totalTraveledDistance; + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/constraints/TransportOrderEquipmentRequirementConstraint.java b/src/main/java/com/v1rex/liftnexus/planning/constraints/TransportOrderEquipmentRequirementConstraint.java new file mode 100644 index 00000000..ffde80f0 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/constraints/TransportOrderEquipmentRequirementConstraint.java @@ -0,0 +1,23 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.HardSoftScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; + +public class TransportOrderEquipmentRequirementConstraint { + + public static Constraint equipmentType(ConstraintFactory factory) { + return factory + .forEach(TransportOrder.class) + .filter(order -> order.getAssignedForklift() != null) + .filter(order -> order.getRequiredEquipment() != null) + .filter( + order -> + !order + .getRequiredEquipment() + .equals(order.getAssignedForklift().getForkliftType().getEquipmentType())) + .penalize(HardSoftScore.ONE_HARD) + .asConstraint("Transport order equipment requirement"); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/constraints/WarehouseConstraintProvider.java b/src/main/java/com/v1rex/liftnexus/planning/constraints/WarehouseConstraintProvider.java new file mode 100644 index 00000000..1d37fd59 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/constraints/WarehouseConstraintProvider.java @@ -0,0 +1,29 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; + +public class WarehouseConstraintProvider implements ConstraintProvider { + + @Override + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] { + forkliftCapacity(constraintFactory), + travelDistance(constraintFactory), + equipmentType(constraintFactory) + }; + } + + public Constraint forkliftCapacity(ConstraintFactory factory) { + return ForkliftCapacityConstraint.forkliftCapacity(factory); + } + + public Constraint travelDistance(ConstraintFactory factory) { + return ForkliftTravelDistanceConstraint.forkliftTravelDistance(factory); + } + + public Constraint equipmentType(ConstraintFactory factory) { + return TransportOrderEquipmentRequirementConstraint.equipmentType(factory); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/controller/DispatchJobExceptionHandler.java b/src/main/java/com/v1rex/liftnexus/planning/controller/DispatchJobExceptionHandler.java new file mode 100644 index 00000000..ff951a8f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/controller/DispatchJobExceptionHandler.java @@ -0,0 +1,33 @@ +package com.v1rex.liftnexus.planning.controller; + +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.planning.exception.DispatchJobDomainException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "com.v1rex.liftnexus.planning") +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +@RequiredArgsConstructor +public class DispatchJobExceptionHandler { + + private final ProblemDetailFactory errorFactory; + + @ExceptionHandler(DispatchJobDomainException.class) + public ResponseEntity handleDispatchJobDomainException( + DispatchJobDomainException ex, HttpServletRequest request) { + + log.warn( + "Domain anomaly tracked [{}] | Context: {}", ex.getErrorCode().getCode(), ex.getMessage()); + + return errorFactory.createErrorResponse(ex.getErrorCode(), ex.getMessage(), request, List.of()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/controller/WarehouseDispatcherController.java b/src/main/java/com/v1rex/liftnexus/planning/controller/WarehouseDispatcherController.java new file mode 100644 index 00000000..392882a2 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/controller/WarehouseDispatcherController.java @@ -0,0 +1,78 @@ +package com.v1rex.liftnexus.planning.controller; + +import com.v1rex.liftnexus.planning.dto.DispatchJobResponse; +import com.v1rex.liftnexus.planning.service.WarehouseDispatcherService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/dispatcher/jobs") +@Slf4j +@RequiredArgsConstructor +@Tag(name = "Dispatcher", description = "Submit and manage Timefold optimization jobs") +public class WarehouseDispatcherController { + + private final WarehouseDispatcherService dispatcherService; + + @Operation( + summary = "Get optimization job status", + description = + "Retrieves the current status and result of a previously submitted optimization job.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Job status retrieved"), + @ApiResponse( + responseCode = "404", + description = "Job not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @GetMapping("/{jobId}") + public ResponseEntity getJobStatus(@PathVariable UUID jobId) { + log.debug("API request received to fetch status for job: {}", jobId); + + DispatchJobResponse job = dispatcherService.getJobStatusAndReconcile(jobId); + + return ResponseEntity.ok(job); + } + + @Operation( + summary = "Submit a new optimization job", + description = + "Triggers the Timefold optimization engine to compute optimal forklift routing and task assignments asynchronously. Returns a job ID for tracking progress.") + @ApiResponse(responseCode = "202", description = "Optimization job accepted and queued") + @PostMapping + public ResponseEntity> submitJob() { + log.info("API request received to trigger warehouse optimization engine."); + UUID jobId = dispatcherService.submitOptimizationJob(); + + // Returning 202 Accepted with a structured JSON body + return ResponseEntity.status(HttpStatus.ACCEPTED).body(Map.of("jobId", jobId)); + } + + @Operation( + summary = "Terminate an optimization job", + description = + "Aborts a running or queued optimization job. Jobs that have already completed will remain in COMPLETED state.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Job terminated"), + @ApiResponse( + responseCode = "404", + description = "Job not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @DeleteMapping("/{jobId}") + public ResponseEntity terminateJob(@PathVariable UUID jobId) { + log.info("API request received to manually abort optimization job: {}", jobId); + dispatcherService.terminateOptimizationJob(jobId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/domain/DispatchJob.java b/src/main/java/com/v1rex/liftnexus/planning/domain/DispatchJob.java new file mode 100644 index 00000000..0d3fa207 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/domain/DispatchJob.java @@ -0,0 +1,28 @@ +package com.v1rex.liftnexus.planning.domain; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; +import lombok.*; + +@Entity +@Table(name = "dispatch_jobs") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DispatchJob { + + @Id private UUID id; + + @NonNull + @Enumerated(EnumType.STRING) + private JobStatus status; + + @NonNull private Instant createdAt; + + private Instant completedAt; + + private String finalScore; +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/domain/JobStatus.java b/src/main/java/com/v1rex/liftnexus/planning/domain/JobStatus.java new file mode 100644 index 00000000..332c8bec --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/domain/JobStatus.java @@ -0,0 +1,17 @@ +package com.v1rex.liftnexus.planning.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Processing state of a Timefold optimization job") +public enum JobStatus { + @Schema(description = "Job is queued and waiting for an available solver thread") + QUEUED, + @Schema(description = "Timefold is actively calculating optimal routes and assignments") + SOLVING, + @Schema(description = "Job was manually terminated by the user") + ABORTED, + @Schema(description = "Solver finished gracefully with a valid solution") + COMPLETED, + @Schema(description = "An exception occurred during optimization") + FAILED +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/domain/WarehouseSchedule.java b/src/main/java/com/v1rex/liftnexus/planning/domain/WarehouseSchedule.java new file mode 100644 index 00000000..672ea477 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/domain/WarehouseSchedule.java @@ -0,0 +1,31 @@ +package com.v1rex.liftnexus.planning.domain; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.HardSoftScore; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import java.util.List; +import lombok.*; + +@PlanningSolution +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class WarehouseSchedule { + + @ProblemFactCollectionProperty private List storageBins; + + @ValueRangeProvider(id = "taskPoolRange") + @PlanningEntityCollectionProperty + private List transportOrderPool; + + @PlanningEntityCollectionProperty private List forklifts; + + @PlanningScore private HardSoftScore score; +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/dto/DispatchJobResponse.java b/src/main/java/com/v1rex/liftnexus/planning/dto/DispatchJobResponse.java new file mode 100644 index 00000000..06d7d41f --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/dto/DispatchJobResponse.java @@ -0,0 +1,20 @@ +package com.v1rex.liftnexus.planning.dto; + +import com.v1rex.liftnexus.planning.domain.JobStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; + +@Schema(description = "View of a Timefold optimization job with its current state and results") +public record DispatchJobResponse( + @Schema(description = "Unique job identifier", example = "a1b2c3d4-e5f6-7890-abcd-ef1234567890") + UUID id, + @Schema(description = "Current processing status of the optimization job") JobStatus status, + @Schema(description = "Timestamp when the job was created") Instant createdAt, + @Schema(description = "Timestamp when the job completed or failed (nullable)", nullable = true) + Instant completedAt, + @Schema( + description = "Final score from the Timefold solver (nullable before completion)", + example = "0hard/0soft", + nullable = true) + String finalScore) {} diff --git a/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobDomainException.java b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobDomainException.java new file mode 100644 index 00000000..944e45b1 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobDomainException.java @@ -0,0 +1,12 @@ +package com.v1rex.liftnexus.planning.exception; + +import com.v1rex.liftnexus.common.exception.DomainException; +import com.v1rex.liftnexus.common.exception.ErrorCode; + +public abstract sealed class DispatchJobDomainException extends DomainException + permits DispatchJobNotFoundException, DispatchJobInvalidStateException { + + protected DispatchJobDomainException(ErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobErrorCode.java b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobErrorCode.java new file mode 100644 index 00000000..8b7a5c54 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobErrorCode.java @@ -0,0 +1,19 @@ +package com.v1rex.liftnexus.planning.exception; + +import com.v1rex.liftnexus.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum DispatchJobErrorCode implements ErrorCode { + DISPATCH_JOB_NOT_FOUND("dispatch_job_not_found", "Dispatch Job Not Found", HttpStatus.NOT_FOUND), + + DISPATCH_JOB_INVALID_STATE( + "dispatch_job_invalid_state", "Dispatch Job Invalid State", HttpStatus.CONFLICT); + + private final String code; + private final String defaultTitle; + private final HttpStatus status; +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobInvalidStateException.java b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobInvalidStateException.java new file mode 100644 index 00000000..359946de --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobInvalidStateException.java @@ -0,0 +1,8 @@ +package com.v1rex.liftnexus.planning.exception; + +public final class DispatchJobInvalidStateException extends DispatchJobDomainException { + + public DispatchJobInvalidStateException(String message) { + super(DispatchJobErrorCode.DISPATCH_JOB_INVALID_STATE, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobNotFoundException.java b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobNotFoundException.java new file mode 100644 index 00000000..ed2bf477 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/exception/DispatchJobNotFoundException.java @@ -0,0 +1,12 @@ +package com.v1rex.liftnexus.planning.exception; + +import java.util.UUID; + +public final class DispatchJobNotFoundException extends DispatchJobDomainException { + + public DispatchJobNotFoundException(UUID jobId) { + super( + DispatchJobErrorCode.DISPATCH_JOB_NOT_FOUND, + "Dispatch job with ID " + jobId + " does not exist."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/mapper/DispatchJobMapper.java b/src/main/java/com/v1rex/liftnexus/planning/mapper/DispatchJobMapper.java new file mode 100644 index 00000000..be3d3a70 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/mapper/DispatchJobMapper.java @@ -0,0 +1,21 @@ +package com.v1rex.liftnexus.planning.mapper; + +import com.v1rex.liftnexus.planning.domain.DispatchJob; +import com.v1rex.liftnexus.planning.dto.DispatchJobResponse; +import org.springframework.stereotype.Component; + +@Component +public class DispatchJobMapper { + + public DispatchJobResponse toResponse(DispatchJob job) { + if (job == null) { + return null; + } + return new DispatchJobResponse( + job.getId(), + job.getStatus(), + job.getCreatedAt(), + job.getCompletedAt(), + job.getFinalScore()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/planning/repository/DispatchJobRepository.java b/src/main/java/com/v1rex/liftnexus/planning/repository/DispatchJobRepository.java new file mode 100644 index 00000000..47d9788e --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/repository/DispatchJobRepository.java @@ -0,0 +1,9 @@ +package com.v1rex.liftnexus.planning.repository; + +import com.v1rex.liftnexus.planning.domain.DispatchJob; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface DispatchJobRepository extends JpaRepository {} diff --git a/src/main/java/com/v1rex/liftnexus/planning/service/WarehouseDispatcherService.java b/src/main/java/com/v1rex/liftnexus/planning/service/WarehouseDispatcherService.java new file mode 100644 index 00000000..53c50e80 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/planning/service/WarehouseDispatcherService.java @@ -0,0 +1,298 @@ +package com.v1rex.liftnexus.planning.service; + +import ai.timefold.solver.core.api.solver.SolverManager; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.service.ForkliftService; +import com.v1rex.liftnexus.planning.domain.DispatchJob; +import com.v1rex.liftnexus.planning.domain.JobStatus; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.planning.dto.DispatchJobResponse; +import com.v1rex.liftnexus.planning.exception.DispatchJobInvalidStateException; +import com.v1rex.liftnexus.planning.exception.DispatchJobNotFoundException; +import com.v1rex.liftnexus.planning.mapper.DispatchJobMapper; +import com.v1rex.liftnexus.planning.repository.DispatchJobRepository; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.service.TransportOrderService; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service responsible for orchestrating warehouse optimisation using Timefold Solver. + * + *

Manages the full lifecycle of a dispatch-optimisation job: submitting new jobs, monitoring + * their status, terminating running jobs, and persisting the final forklift-to-transport-order + * assignments. Acts as the bridge between the warehouse domain (bins, forklifts, transport orders) + * and the constraint-solving engine. + * + *

Job lifecycle: {@code QUEUED β†’ SOLVING β†’ COMPLETED / FAILED / ABORTED}. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class WarehouseDispatcherService { + + private final StorageBinService storageBinService; + private final ForkliftService forkliftService; + private final TransportOrderService transportOrderService; + + private final DispatchJobRepository jobRepository; + private final DispatchJobMapper dispatchJobMapper; + + /** Timefold solver manager that runs optimisation in a separate thread pool. */ + private final SolverManager solverManager; + + /** + * Returns the current status of a dispatch job. + * + *

Intended to be extended with reconciliation logic that detects when Timefold has silently + * stopped solving (e.g., due to an internal error) while the job is still marked as {@code + * SOLVING} in the database, and marks it as {@code FAILED} accordingly. + * + * @param jobId The unique identifier of the dispatch job. + * @return A response DTO with the current job state. + * @throws DispatchJobNotFoundException if no job exists for the given ID. + */ + public DispatchJobResponse getJobStatusAndReconcile(UUID jobId) { + DispatchJob job = findJobEntityById(jobId); + // TODO: look for a better way to update the job status in case of TimeFold failure + /* + SolverStatus timefoldStatus = solverManager.getSolverStatus(jobId); + if (job.getStatus() == JobStatus.SOLVING && timefoldStatus == SolverStatus.NOT_SOLVING) { + log.error("Reconciliation Alert: Job {} is {} in DB, " + + "but Timefold is NOT_SOLVING. Marking job as FAILED.", + jobId, job.getStatus()); + + job.setStatus(JobStatus.FAILED); + job.setCompletedAt(Instant.now()); + jobRepository.save(job); + + }*/ + + return dispatchJobMapper.toResponse(job); + } + + /** + * Assembles the current warehouse snapshot that the solver will optimise. + * + *

Loads all storage bins, forklifts, and pending transport orders from the database into an + * in-memory {@link WarehouseSchedule} object. This serves as the problem fact set for the + * Timefold constraint solver. + * + *

Known issue: Loading all entities without pagination is a performance + * bottleneck for large datasets. A more selective fetching strategy (cursor-based pagination, + * lazy fields, caching) should be implemented. + * + * @return A fully populated {@link WarehouseSchedule} representing the current state of the + * warehouse. + */ + public WarehouseSchedule buildCurrentState() { + log.info("Building current warehouse state for optimization..."); + + // TODO: this is a really great performance bottleneck. + // If the database contains thousands of storage bins, + // forklifts, and transport orders, this method will take a long + // time to execute and may cause the worker thread to time out before optimization can even + // begin. + // Action: Create in Milestone 2 an issue to fix the fetching of storageBins, forklifts, and + // transportOrders + // by implementing a more efficient data retrieval strategy (e.g., pagination, selective field + // fetching, or caching). + List storageBins = storageBinService.findAllEntitiesForPlanning(); + List forklifts = forkliftService.findAllEntitiesForPlanning(); + List transportOrders = transportOrderService.findAllEntitiesForPlanning(); + + log.debug( + "Found {} storageBins, {} forklifts, and {} transportOrders in DB.", + storageBins.size(), + forklifts.size(), + transportOrders.size()); + + WarehouseSchedule schedule = new WarehouseSchedule(); + schedule.setStorageBins(storageBins); + schedule.setForklifts(forklifts); + schedule.setTransportOrderPool(transportOrders); + + return schedule; + } + + /** + * Submits a new asynchronous optimisation job to Timefold. + * + *

Creates a {@link DispatchJob} record in state {@code QUEUED}, then delegates to {@link + * SolverManager#solveAndListen} with: + * + *

    + *
  1. A problem-fetcher that transitions the job to {@code SOLVING} and builds the current + * warehouse state. + *
  2. A best-solution consumer that persists the final assignments and marks the job as {@code + * COMPLETED} (or {@code FAILED}). + *
+ * + * @return The UUID assigned to the newly created optimisation job. The caller can use this ID to + * poll status ({@link #getJobStatusAndReconcile}) or to terminate the job ({@link + * #terminateOptimizationJob}). + */ + public UUID submitOptimizationJob() { + UUID ticketId = UUID.randomUUID(); + DispatchJob job = + DispatchJob.builder() + .id(ticketId) + .status(JobStatus.QUEUED) + .createdAt(Instant.now()) + .build(); + jobRepository.save(job); + + solverManager.solveAndListen( + ticketId, + buildCurrentProblemAndSetSolvingStatus(ticketId), + solution -> saveFinalSolution(solution, ticketId)); + + return ticketId; + } + + /** + * Attempts to gracefully terminate a running or queued optimisation job. + * + *

Only jobs in state {@code QUEUED} or {@code SOLVING} can be terminated. Once terminated, the + * job is marked as {@code ABORTED} and any partial results are discarded (the best-solution + * consumer checks for this flag). + * + * @param jobId The unique identifier of the job to terminate. + * @throws DispatchJobInvalidStateException if the job is already in a terminal state ({@code + * COMPLETED}, {@code FAILED}, or {@code ABORTED}). + */ + @Transactional + public void terminateOptimizationJob(UUID jobId) { + log.info("Request received to manually terminate optimization Job: {}", jobId); + + DispatchJob job = findJobEntityById(jobId); + + if (job.getStatus() != JobStatus.QUEUED && job.getStatus() != JobStatus.SOLVING) { + throw new DispatchJobInvalidStateException( + "Cannot terminate job " + jobId + " because it is already in status: " + job.getStatus()); + } + + // Ask Timefold to stop solving as soon as possible + solverManager.terminateEarly(jobId); + + job.setStatus(JobStatus.ABORTED); + job.setCompletedAt(Instant.now()); + jobRepository.save(job); + + log.info("Job {} has been successfully halted and marked as ABORTED.", jobId); + } + + /** + * Fetcher callback used by Timefold at the start of solving. + * + *

Transitions the job from {@code QUEUED} to {@code SOLVING} in the database and then builds + * the current warehouse snapshot. If building the snapshot fails, the job is marked as {@code + * FAILED} and the exception is rethrown to Timefold. + * + *

Note: This method is annotated {@code @Transactional} so that the status update and + * snapshot build happen within the same persistence context. + * + * @param jobId The unique identifier of the job being started. + * @return A fully populated {@link WarehouseSchedule} for the solver. + */ + @Transactional + public WarehouseSchedule buildCurrentProblemAndSetSolvingStatus(UUID jobId) { + log.info("Worker thread starting optimization for Job: {}", jobId); + + DispatchJob job = findJobEntityById(jobId); + + job.setStatus(JobStatus.SOLVING); + jobRepository.save(job); + + try { + return buildCurrentState(); + } catch (Exception e) { + log.error("Failed to build current state for job {}: {}", jobId, e.getMessage(), e); + job.setStatus(JobStatus.FAILED); + job.setCompletedAt(Instant.now()); + jobRepository.save(job); + throw e; + } + } + + /** + * Best-solution consumer callback invoked by Timefold when a solution is available (either the + * final optimal solution or an intermediate best-effort one). + * + *

Persists the solver's assignments back to the database: + * + *

    + *
  • Updates forklift assignments on transport orders. + *
  • Records assigned orders on forklifts. + *
+ * + *

If the job was {@code ABORTED} during solving, the solution is silently discarded. Otherwise + * the job is marked as {@code COMPLETED} (or {@code FAILED} if persistence fails). Even + * infeasible solutions are saved as a best-effort fallback. + * + * @param solution The {@link WarehouseSchedule} produced by Timefold, which contains the + * optimised assignments. + * @param jobId The unique identifier of the job that produced this solution. + */ + @Transactional + public void saveFinalSolution(WarehouseSchedule solution, UUID jobId) { + log.info("Optimization completed for Job: {}", jobId); + + DispatchJob job = findJobEntityById(jobId); + + // Discard the result if the job was externally aborted while solving + if (job.getStatus() == JobStatus.ABORTED) { + log.warn("Job {} was aborted during optimization. Final solution will not be saved.", jobId); + return; + } + + if (solution.getScore() != null && solution.getScore().isFeasible()) { + log.info("Solution is feasible (Score: {}). Saving assignments to DB.", solution.getScore()); + } else { + log.warn( + "Solution is INFEASIBLE (Score: {}). Saving best-effort assignments anyway.", + solution.getScore()); + } + + // TODO: Wrap data updates in a try-catch block. If transportOrderService or forkliftService + // throws an exception here, the job status will remain stuck in 'SOLVING'. + // Catch exceptions and mark the job status as JobStatus.FAILED. + + try { + // Persist the optimised assignments to the underlying domain entities + transportOrderService.updateForkliftAssignments(solution.getTransportOrderPool()); + // forkliftService.updateAssignedOrders(solution.getForklifts()); + + job.setStatus(JobStatus.COMPLETED); + if (solution.getScore() != null) { + job.setFinalScore(solution.getScore().toString()); + } + } catch (Exception e) { + log.error("Failed to persist solution for job {}: {}", jobId, e.getMessage(), e); + job.setStatus(JobStatus.FAILED); + } finally { + job.setCompletedAt(Instant.now()); + jobRepository.save(job); + } + + log.info("Job {} successfully wrapped and saved.", jobId); + } + + /** + * Retrieves the raw {@link DispatchJob} entity by its ID. + * + * @param jobId The unique identifier of the dispatch job. + * @return The managed {@link DispatchJob} entity. + * @throws DispatchJobNotFoundException if no job exists for the given ID. + */ + public DispatchJob findJobEntityById(UUID jobId) { + return jobRepository.findById(jobId).orElseThrow(() -> new DispatchJobNotFoundException(jobId)); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/controller/StorageBinController.java b/src/main/java/com/v1rex/liftnexus/storagebin/controller/StorageBinController.java new file mode 100644 index 00000000..d5e10c09 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/controller/StorageBinController.java @@ -0,0 +1,85 @@ +package com.v1rex.liftnexus.storagebin.controller; + +import com.v1rex.liftnexus.storagebin.dto.StorageBinRequest; +import com.v1rex.liftnexus.storagebin.dto.StorageBinResponse; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/storage-bins") +@Validated +@RequiredArgsConstructor +@Tag(name = "Storage Bins", description = "Manage warehouse storage bin locations and coordinates") +public class StorageBinController { + + private final StorageBinService storageBinService; + + @Operation( + summary = "Get a storage bin by ID", + description = "Retrieves a storage bin with its 3D coordinate and zone type.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Storage bin found"), + @ApiResponse( + responseCode = "404", + description = "Storage bin not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @GetMapping("/{id}") + public ResponseEntity findById(@PathVariable Long id) { + return ResponseEntity.ok(storageBinService.findById(id)); + } + + @Operation( + summary = "List all storage bins", + description = "Returns a paginated list of all storage bins in the warehouse.") + @ApiResponse(responseCode = "200", description = "Paginated list of storage bins") + @GetMapping + public ResponseEntity> findAllStorageBins( + @PageableDefault(size = 15, sort = "id", direction = Sort.Direction.ASC) Pageable pageable) { + return ResponseEntity.ok(storageBinService.findAll(pageable)); + } + + @Operation( + summary = "Create a new storage bin", + description = + "Registers a new storage bin at a specific 3D coordinate with a zone type and weight capacity.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Storage bin created"), + @ApiResponse( + responseCode = "400", + description = "Invalid input or coordinate already in use", + content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse( + responseCode = "409", + description = "Bin code already exists", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @PostMapping + public ResponseEntity createStorageBin( + @RequestBody @Valid StorageBinRequest request) { + + StorageBinResponse savedBin = storageBinService.createStorageBin(request); + + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(savedBin.id()) + .toUri(); + + return ResponseEntity.created(location).body(savedBin); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/controller/StorageBinExceptionHandler.java b/src/main/java/com/v1rex/liftnexus/storagebin/controller/StorageBinExceptionHandler.java new file mode 100644 index 00000000..9d050f21 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/controller/StorageBinExceptionHandler.java @@ -0,0 +1,33 @@ +package com.v1rex.liftnexus.storagebin.controller; + +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.storagebin.exception.StorageBinDomainException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "com.v1rex.liftnexus.storagebin") +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +@RequiredArgsConstructor +public class StorageBinExceptionHandler { + + private final ProblemDetailFactory errorFactory; + + @ExceptionHandler(StorageBinDomainException.class) + public ResponseEntity handleStorageBinDomainException( + StorageBinDomainException ex, HttpServletRequest request) { + + log.warn( + "Domain anomaly tracked [{}] | Context: {}", ex.getErrorCode().getCode(), ex.getMessage()); + + return errorFactory.createErrorResponse(ex.getErrorCode(), ex.getMessage(), request, List.of()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/domain/Coordinate3D.java b/src/main/java/com/v1rex/liftnexus/storagebin/domain/Coordinate3D.java new file mode 100644 index 00000000..807566f3 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/domain/Coordinate3D.java @@ -0,0 +1,38 @@ +package com.v1rex.liftnexus.storagebin.domain; + +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Coordinate3D { + // Represents the Aisle + private int x; + + // Represents the Bay + private int y; + + // Represents the Tier (Height) + private int z; + + /** Calculates the Manhattan distance using the default vertical penalty factor of 2.5. */ + public double calculateDistance(Coordinate3D other) { + return calculateDistance(other, 2.5); + } + + /** + * Calculates the Manhattan distance with a custom vertical penalty factor. Useful if certain + * warehouse areas have faster/slower vertical lifts. + */ + public double calculateDistance(Coordinate3D other, double zWeightPenalty) { + int dx = Math.abs(this.x - other.x); + int dy = Math.abs(this.y - other.y); + int dz = Math.abs(this.z - other.z); + + return dx + dy + (dz * zWeightPenalty); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/domain/StorageBin.java b/src/main/java/com/v1rex/liftnexus/storagebin/domain/StorageBin.java new file mode 100644 index 00000000..181c8474 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/domain/StorageBin.java @@ -0,0 +1,39 @@ +package com.v1rex.liftnexus.storagebin.domain; + +import jakarta.persistence.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Entity +@Table(name = "storage_bin") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class StorageBin { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull(message = "Storage bin should have a bin code") + @Column(name = "bin_code", nullable = false, unique = true) + private String binCode; + + @NotNull(message = "Storage bin must have a physical coordinate location") + @Valid + @Embedded + private Coordinate3D coordinate; + + @NotNull(message = "Storage bin must have a zone type") + @Enumerated(EnumType.STRING) + @Column(name = "zone_type", nullable = false) + private ZoneType zoneType; + + @NotNull(message = "Storage bin must have a maximum weight capacity defined") + @Min(value = 0) + @Column(name = "max_weight_capacity_kg", nullable = false) + private Integer maxWeightCapacityKg; +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/domain/ZoneType.java b/src/main/java/com/v1rex/liftnexus/storagebin/domain/ZoneType.java new file mode 100644 index 00000000..a558b6f4 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/domain/ZoneType.java @@ -0,0 +1,17 @@ +package com.v1rex.liftnexus.storagebin.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Functional zone classification of a storage bin location") +public enum ZoneType { + @Schema(description = "General storage area") + STORAGE, + @Schema(description = "Inbound staging area for incoming goods") + STAGING_IN, + @Schema(description = "Outbound staging area for outgoing goods") + STAGING_OUT, + @Schema(description = "Battery charging station") + CHARGING_STATION, + @Schema(description = "Hazardous materials storage area") + HAZMAT +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/dto/CoordinateDto.java b/src/main/java/com/v1rex/liftnexus/storagebin/dto/CoordinateDto.java new file mode 100644 index 00000000..98cf2080 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/dto/CoordinateDto.java @@ -0,0 +1,16 @@ +package com.v1rex.liftnexus.storagebin.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "3D coordinate representing a location in the warehouse (aisle, bay, tier)") +public record CoordinateDto( + @NotNull(message = "X coordinate (aisle) is required") + @Schema(description = "Aisle number", example = "1") + Integer x, + @NotNull(message = "Y coordinate (bay) is required") + @Schema(description = "Bay/column number within the aisle", example = "2") + Integer y, + @NotNull(message = "Z coordinate (tier) is required") + @Schema(description = "Tier/level number", example = "3") + Integer z) {} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/dto/StorageBinRequest.java b/src/main/java/com/v1rex/liftnexus/storagebin/dto/StorageBinRequest.java new file mode 100644 index 00000000..570730da --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/dto/StorageBinRequest.java @@ -0,0 +1,26 @@ +package com.v1rex.liftnexus.storagebin.dto; + +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request payload for creating a new storage bin") +public record StorageBinRequest( + @NotNull(message = "Bin code is required") + @Schema( + description = "Human-readable bin identifier (e.g., A-01-02-03)", + example = "A-01-02-03") + String binCode, + @NotNull(message = "Coordinates are required") + @Valid + @Schema(description = "3D warehouse coordinates (aisle, bay, tier)") + CoordinateDto coordinate, + @NotNull(message = "Zone type is required") + @Schema(description = "Functional zone of the storage bin") + ZoneType zoneType, + @NotNull(message = "Max weight capacity is required") + @Min(value = 0, message = "Weight capacity cannot be negative") + @Schema(description = "Maximum weight capacity in kg", example = "2000") + Integer maxWeightCapacityKg) {} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/dto/StorageBinResponse.java b/src/main/java/com/v1rex/liftnexus/storagebin/dto/StorageBinResponse.java new file mode 100644 index 00000000..5e1781af --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/dto/StorageBinResponse.java @@ -0,0 +1,13 @@ +package com.v1rex.liftnexus.storagebin.dto; + +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Detailed view of a storage bin location") +public record StorageBinResponse( + @Schema(description = "Unique identifier", example = "1") Long id, + @Schema(description = "Human-readable bin identifier", example = "A-01-02-03") String binCode, + @Schema(description = "3D warehouse coordinates") CoordinateDto coordinate, + @Schema(description = "Functional zone of the storage bin") ZoneType zoneType, + @Schema(description = "Maximum weight capacity in kg", example = "2000") + Integer maxWeightCapacityKg) {} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinCodeExistsException.java b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinCodeExistsException.java new file mode 100644 index 00000000..72f5d8f6 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinCodeExistsException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.storagebin.exception; + +public final class StorageBinCodeExistsException extends StorageBinDomainException { + + public StorageBinCodeExistsException(String binCode) { + super( + StorageBinErrorCode.STORAGE_BIN_CODE_EXISTS, + "Storage bin with code '" + binCode + "' already exists."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinDomainException.java b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinDomainException.java new file mode 100644 index 00000000..2b182f68 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinDomainException.java @@ -0,0 +1,12 @@ +package com.v1rex.liftnexus.storagebin.exception; + +import com.v1rex.liftnexus.common.exception.DomainException; +import com.v1rex.liftnexus.common.exception.ErrorCode; + +public abstract sealed class StorageBinDomainException extends DomainException + permits StorageBinNotFoundException, StorageBinCodeExistsException { + + protected StorageBinDomainException(ErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinErrorCode.java b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinErrorCode.java new file mode 100644 index 00000000..b55d4804 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinErrorCode.java @@ -0,0 +1,19 @@ +package com.v1rex.liftnexus.storagebin.exception; + +import com.v1rex.liftnexus.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum StorageBinErrorCode implements ErrorCode { + STORAGE_BIN_NOT_FOUND("storage_bin_not_found", "Storage Bin Not Found", HttpStatus.NOT_FOUND), + + STORAGE_BIN_CODE_EXISTS( + "storage_bin_code_already_exists", "Storage Bin Code Already Exists", HttpStatus.CONFLICT); + + private final String code; + private final String defaultTitle; + private final HttpStatus status; +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinNotFoundException.java b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinNotFoundException.java new file mode 100644 index 00000000..114dc394 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/exception/StorageBinNotFoundException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.storagebin.exception; + +public final class StorageBinNotFoundException extends StorageBinDomainException { + + public StorageBinNotFoundException(Long id) { + super( + StorageBinErrorCode.STORAGE_BIN_NOT_FOUND, + "Storage bin with ID " + id + " does not exist."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/mapper/StorageBinMapper.java b/src/main/java/com/v1rex/liftnexus/storagebin/mapper/StorageBinMapper.java new file mode 100644 index 00000000..d26ef0a1 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/mapper/StorageBinMapper.java @@ -0,0 +1,50 @@ +package com.v1rex.liftnexus.storagebin.mapper; + +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.dto.CoordinateDto; +import com.v1rex.liftnexus.storagebin.dto.StorageBinRequest; +import com.v1rex.liftnexus.storagebin.dto.StorageBinResponse; +import org.springframework.stereotype.Component; + +@Component +public class StorageBinMapper { + + public StorageBin toEntity(StorageBinRequest request) { + if (request == null) return null; + + Coordinate3D domainCoordinate = null; + if (request.coordinate() != null) { + domainCoordinate = + new Coordinate3D( + request.coordinate().x(), request.coordinate().y(), request.coordinate().z()); + } + + return StorageBin.builder() + .binCode(request.binCode()) + .coordinate(domainCoordinate) + .zoneType(request.zoneType()) + .maxWeightCapacityKg(request.maxWeightCapacityKg()) + .build(); + } + + public StorageBinResponse toResponse(StorageBin entity) { + if (entity == null) return null; + + CoordinateDto dtoCoordinate = null; + if (entity.getCoordinate() != null) { + dtoCoordinate = + new CoordinateDto( + entity.getCoordinate().getX(), + entity.getCoordinate().getY(), + entity.getCoordinate().getZ()); + } + + return new StorageBinResponse( + entity.getId(), + entity.getBinCode(), + dtoCoordinate, + entity.getZoneType(), + entity.getMaxWeightCapacityKg()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/repository/StorageBinRepository.java b/src/main/java/com/v1rex/liftnexus/storagebin/repository/StorageBinRepository.java new file mode 100644 index 00000000..3dae1c81 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/repository/StorageBinRepository.java @@ -0,0 +1,13 @@ +package com.v1rex.liftnexus.storagebin.repository; + +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface StorageBinRepository extends JpaRepository { + boolean existsByBinCode(String binCode); + + @Query("SELECT s FROM StorageBin s") + List findAllForPlanning(); +} diff --git a/src/main/java/com/v1rex/liftnexus/storagebin/service/StorageBinService.java b/src/main/java/com/v1rex/liftnexus/storagebin/service/StorageBinService.java new file mode 100644 index 00000000..a4e5b553 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/storagebin/service/StorageBinService.java @@ -0,0 +1,180 @@ +package com.v1rex.liftnexus.storagebin.service; + +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.dto.StorageBinRequest; +import com.v1rex.liftnexus.storagebin.dto.StorageBinResponse; +import com.v1rex.liftnexus.storagebin.exception.StorageBinCodeExistsException; +import com.v1rex.liftnexus.storagebin.exception.StorageBinNotFoundException; +import com.v1rex.liftnexus.storagebin.mapper.StorageBinMapper; +import com.v1rex.liftnexus.storagebin.repository.StorageBinRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service layer for {@link StorageBin} domain operations. + * + *

This class provides a clear separation between external API methods (returning DTOs to + * controllers) and internal domain methods (returning entities to other services or the + * Timefold solver). This dual-boundary pattern ensures that external clients receive decoupled + * response objects, while internal consumers have full access to the domain model for complex + * operations like constraint-based optimisation. + * + *

All public methods are {@link Transactional @Transactional} to guarantee data consistency. + * + * @see StorageBinRepository + * @see StorageBinMapper + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class StorageBinService { + + private final StorageBinRepository storageBinRepository; + private final StorageBinMapper storageBinMapper; + + // ===================================================================== + // EXTERNAL API BOUNDARY (Returns DTOs to Controllers) + // ===================================================================== + + /** + * Creates a new storage bin and returns its DTO representation. + * + *

Before persisting, this method validates that the supplied {@code binCode} is unique. If a + * storage bin with the same code already exists, a {@link StorageBinCodeExistsException} is + * thrown. + * + * @param request the input data containing the bin code and its spatial coordinates + * @return a {@link StorageBinResponse} representing the newly persisted storage bin + * @throws StorageBinCodeExistsException if a storage bin with the given {@code binCode} already + * exists in the database + */ + @Transactional + public StorageBinResponse createStorageBin(StorageBinRequest request) { + log.info( + "Creating storage bin with code: {} at [X:{}, Y:{}, Z:{}]", + request.binCode(), + request.coordinate().x(), + request.coordinate().y(), + request.coordinate().z()); + + if (storageBinRepository.existsByBinCode(request.binCode())) { + throw new StorageBinCodeExistsException(request.binCode()); + } + + StorageBin storageBin = storageBinMapper.toEntity(request); + StorageBin savedBin = storageBinRepository.save(storageBin); + + log.info( + "Successfully created storage bin with Id: {}, code: {}", + savedBin.getId(), + savedBin.getBinCode()); + return storageBinMapper.toResponse(savedBin); + } + + /** + * Retrieves a storage bin by its unique identifier and returns its DTO representation. + * + *

If no storage bin exists with the given {@code id}, a {@link StorageBinNotFoundException} is + * thrown. + * + * @param id the storage bin's primary key + * @return a {@link StorageBinResponse} for the matching storage bin + * @throws StorageBinNotFoundException if no storage bin is found for the given {@code id} + * @see #findEntityById(Long) + */ + @Transactional(readOnly = true) + public StorageBinResponse findById(Long id) { + return storageBinMapper.toResponse(findEntityById(id)); + } + + /** + * Retrieves a paginated list of all storage bins, mapped to their DTO representations. + * + * @param pageable pagination and sorting parameters + * @return a {@link Page} of {@link StorageBinResponse} objects + * @see #findAllEntities(Pageable) + */ + @Transactional(readOnly = true) + public Page findAll(Pageable pageable) { + return findAllEntities(pageable).map(storageBinMapper::toResponse); + } + + // ===================================================================== + // INTERNAL DOMAIN BOUNDARY (Returns Entities to other Services/Timefold) + // ===================================================================== + + /** + * Finds a {@link StorageBin} entity by its identifier. + * + *

This is an internal method intended for use by other services or the Timefold solver + * that require access to the full domain model. It throws a {@link StorageBinNotFoundException} + * when the entity is not found. + * + * @param id the storage bin's primary key + * @return the {@link StorageBin} entity + * @throws StorageBinNotFoundException if no storage bin exists for the given {@code id} + */ + @Transactional(readOnly = true) + public StorageBin findEntityById(Long id) { + log.info("Fetching storage bin entity with id: {}", id); + return storageBinRepository + .findById(id) + .orElseThrow( + () -> { + log.warn("Storage bin with id: {} not found.", id); + return new StorageBinNotFoundException(id); + }); + } + + /** + * Retrieves a paginated list of all {@link StorageBin} entities. + * + *

This is an internal method that returns full domain objects, suitable for batch + * processing or solver input. + * + * @param pageable pagination and sorting parameters + * @return a {@link Page} of {@link StorageBin} entities + */ + @Transactional(readOnly = true) + public Page findAllEntities(Pageable pageable) { + log.info("Fetching all managed storage bin entities with pagination"); + return storageBinRepository.findAll(pageable); + } + + /** + * Retrieves all {@link StorageBin} entities without pagination. + * + *

This is an internal method that should be used with care when the total number of + * bins is expected to be small, or when the caller intentionally loads the full collection (e.g., + * seeding the Timefold solver). + * + * @return an unmodifiable-style {@link List} of all {@link StorageBin} entities + */ + @Transactional(readOnly = true) + public List findAllEntities() { + log.info("Fetching all managed storage bin entities without pagination"); + return storageBinRepository.findAll(); + } + + /** + * Retrieves all {@link StorageBin} entities without pagination. + * + *

This is an internal method that should be used with care when the total number of + * bins is expected to be small, or when the caller intentionally loads the full collection, for + * example when building the Timefold planning problem. + * + *

Do not use this method for normal paginated REST API access. + * + * @return an unmodifiable list of all {@link StorageBin} entities + */ + @Transactional(readOnly = true) + public List findAllEntitiesForPlanning() { + log.info("Fetching all storage bin entities for planning without pagination"); + return List.copyOf(storageBinRepository.findAllForPlanning()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderController.java b/src/main/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderController.java new file mode 100644 index 00000000..1c62e936 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderController.java @@ -0,0 +1,112 @@ +package com.v1rex.liftnexus.transportorder.controller; + +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderRequest; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderResponse; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderStatusUpdateRequest; +import com.v1rex.liftnexus.transportorder.service.TransportOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import java.net.URI; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +@RestController +@RequestMapping("/api/v1/transport-orders") +@Validated +@RequiredArgsConstructor +@Tag( + name = "Transport Orders", + description = "Manage transport orders for moving loads across the warehouse") +public class TransportOrderController { + + private final TransportOrderService transportOrderService; + + @Operation( + summary = "Get a transport order by ID", + description = + "Retrieves details of a specific transport order, including its source/destination bins and assigned forklift.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Transport order found"), + @ApiResponse( + responseCode = "404", + description = "Transport order not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @GetMapping("/{id}") + public ResponseEntity getOrderById(@PathVariable Long id) { + return ResponseEntity.ok(transportOrderService.findById(id)); + } + + @Operation( + summary = "Search transport orders", + description = + "Search transport orders by status and/or minimum weight. Returns a paginated result set.") + @ApiResponse(responseCode = "200", description = "Matching transport orders") + @GetMapping("/search") + public ResponseEntity> searchOrders( + @Parameter(description = "Filter by order status") @RequestParam(required = false) + TransportOrderStatus status, + @Parameter(description = "Minimum weight in kg") @RequestParam(required = false) @Min(1) + Integer minWeight, + @PageableDefault(size = 20, sort = "id") Pageable pageable) { + return ResponseEntity.ok(transportOrderService.searchOrders(status, minWeight, pageable)); + } + + @Operation( + summary = "Create a new transport order", + description = + "Creates a transport order to move a load unit from a source bin to a destination bin.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Transport order created"), + @ApiResponse( + responseCode = "400", + description = "Invalid input", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @PostMapping + public ResponseEntity createOrder( + @RequestBody @Valid TransportOrderRequest request) { + TransportOrderResponse savedOrder = transportOrderService.createTransportOrder(request); + + URI location = + ServletUriComponentsBuilder.fromCurrentRequest() + .path("/{id}") + .buildAndExpand(savedOrder.id()) + .toUri(); + + return ResponseEntity.created(location).body(savedOrder); + } + + @Operation( + summary = "Update transport order status", + description = + "Updates the status of a transport order along the lifecycle: OPEN β†’ ASSIGNED β†’ IN_PROGRESS β†’ COMPLETED/FAILED.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Status updated"), + @ApiResponse( + responseCode = "400", + description = "Invalid state transition", + content = @io.swagger.v3.oas.annotations.media.Content), + @ApiResponse( + responseCode = "404", + description = "Transport order not found", + content = @io.swagger.v3.oas.annotations.media.Content) + }) + @PutMapping("/{id}/status") + public ResponseEntity updateOrderStatus( + @PathVariable Long id, @RequestBody @Valid TransportOrderStatusUpdateRequest request) { + return ResponseEntity.ok(transportOrderService.updateOrderStatus(id, request)); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderExceptionHandler.java b/src/main/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderExceptionHandler.java new file mode 100644 index 00000000..206f2d4a --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderExceptionHandler.java @@ -0,0 +1,33 @@ +package com.v1rex.liftnexus.transportorder.controller; + +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.transportorder.exception.TransportOrderDomainException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "com.v1rex.liftnexus.transportorder") +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +@RequiredArgsConstructor +public class TransportOrderExceptionHandler { + + private final ProblemDetailFactory errorFactory; + + @ExceptionHandler(TransportOrderDomainException.class) + public ResponseEntity handleTransportOrderDomainException( + TransportOrderDomainException ex, HttpServletRequest request) { + + log.warn( + "Domain anomaly tracked [{}] | Context: {}", ex.getErrorCode().getCode(), ex.getMessage()); + + return errorFactory.createErrorResponse(ex.getErrorCode(), ex.getMessage(), request, List.of()); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/domain/TransportOrder.java b/src/main/java/com/v1rex/liftnexus/transportorder/domain/TransportOrder.java new file mode 100644 index 00000000..287a8d7c --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/domain/TransportOrder.java @@ -0,0 +1,66 @@ +package com.v1rex.liftnexus.transportorder.domain; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.entity.PlanningPin; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@PlanningEntity +@Entity +@Table(name = "transport_orders") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TransportOrder { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "load_unit_id", nullable = false) + private LoadUnit targetLoadUnit; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "target_bin_id", nullable = false) + private StorageBin targetBin; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "source_bin_id", nullable = false) + private StorageBin sourceBin; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "required_equipment", nullable = false) + private EquipmentType requiredEquipment = EquipmentType.STANDARD; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private TransportOrderStatus status = TransportOrderStatus.OPEN; + + @InverseRelationShadowVariable(sourceVariableName = "transportOrders") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "forklift_id") + @JsonIgnore + private Forklift assignedForklift; + + @PlanningPin + public boolean isPinned() { + return status == TransportOrderStatus.IN_PROGRESS + || status == TransportOrderStatus.COMPLETED + || status == TransportOrderStatus.FAILED; + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/domain/TransportOrderStatus.java b/src/main/java/com/v1rex/liftnexus/transportorder/domain/TransportOrderStatus.java new file mode 100644 index 00000000..220cfa91 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/domain/TransportOrderStatus.java @@ -0,0 +1,17 @@ +package com.v1rex.liftnexus.transportorder.domain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Lifecycle status of a transport order") +public enum TransportOrderStatus { + @Schema(description = "Order has been created and is awaiting assignment") + OPEN, + @Schema(description = "Order has been assigned to a forklift") + ASSIGNED, + @Schema(description = "Order is actively being executed") + IN_PROGRESS, + @Schema(description = "Order has been successfully completed") + COMPLETED, + @Schema(description = "Order execution failed") + FAILED +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderRequest.java b/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderRequest.java new file mode 100644 index 00000000..536b05ba --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderRequest.java @@ -0,0 +1,16 @@ +package com.v1rex.liftnexus.transportorder.dto; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request payload for creating a new transport order") +public record TransportOrderRequest( + @NotNull @Schema(description = "ID of the load unit to transport", example = "1") + Long targetLoadUnitId, + @NotNull @Schema(description = "ID of the source/pickup storage bin", example = "5") + Long sourceBinId, + @NotNull @Schema(description = "ID of the destination/drop-off storage bin", example = "12") + Long destinationBinId, + @Schema(description = "Required equipment type for this transport (nullable)", nullable = true) + EquipmentType requiredEquipment) {} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderResponse.java b/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderResponse.java new file mode 100644 index 00000000..37e3fb15 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderResponse.java @@ -0,0 +1,23 @@ +package com.v1rex.liftnexus.transportorder.dto; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Detailed view of a transport order") +public record TransportOrderResponse( + @Schema(description = "Unique identifier", example = "1") Long id, + @Schema(description = "Tracking code of the referenced load unit", example = "LU-2024-001") + String trackingCode, + @Schema(description = "ID of the load unit being transported", example = "1") + Long targetLoadUnitId, + @Schema(description = "ID of the destination storage bin", example = "12") Long targetBinId, + @Schema(description = "ID of the source/pickup storage bin", example = "5") Long sourceBinId, + @Schema(description = "Required equipment type", nullable = true) + EquipmentType requiredEquipment, + @Schema(description = "Current status of the transport order") TransportOrderStatus status, + @Schema( + description = "ID of the forklift assigned to this order", + example = "2", + nullable = true) + Long assignedForkliftId) {} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderStatusUpdateRequest.java b/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderStatusUpdateRequest.java new file mode 100644 index 00000000..9d58c076 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/dto/TransportOrderStatusUpdateRequest.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.transportorder.dto; + +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request payload for updating the status of a transport order") +public record TransportOrderStatusUpdateRequest( + @NotNull @Schema(description = "New status for the transport order", example = "IN_PROGRESS") + TransportOrderStatus status) {} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderDomainException.java b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderDomainException.java new file mode 100644 index 00000000..64213067 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderDomainException.java @@ -0,0 +1,12 @@ +package com.v1rex.liftnexus.transportorder.exception; + +import com.v1rex.liftnexus.common.exception.DomainException; +import com.v1rex.liftnexus.common.exception.ErrorCode; + +public abstract sealed class TransportOrderDomainException extends DomainException + permits TransportOrderNotFoundException, TransportOrderInvalidStateException { + + protected TransportOrderDomainException(ErrorCode errorCode, String message) { + super(errorCode, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderErrorCode.java b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderErrorCode.java new file mode 100644 index 00000000..0a8b12f4 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderErrorCode.java @@ -0,0 +1,20 @@ +package com.v1rex.liftnexus.transportorder.exception; + +import com.v1rex.liftnexus.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TransportOrderErrorCode implements ErrorCode { + TRANSPORT_ORDER_NOT_FOUND( + "transport_order_not_found", "Transport Order Not Found", HttpStatus.NOT_FOUND), + + TRANSPORT_ORDER_INVALID_STATE( + "transport_order_invalid_state", "Transport Order Invalid State", HttpStatus.CONFLICT); + + private final String code; + private final String defaultTitle; + private final HttpStatus status; +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderInvalidStateException.java b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderInvalidStateException.java new file mode 100644 index 00000000..6821e08b --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderInvalidStateException.java @@ -0,0 +1,8 @@ +package com.v1rex.liftnexus.transportorder.exception; + +public final class TransportOrderInvalidStateException extends TransportOrderDomainException { + + public TransportOrderInvalidStateException(String message) { + super(TransportOrderErrorCode.TRANSPORT_ORDER_INVALID_STATE, message); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderNotFoundException.java b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderNotFoundException.java new file mode 100644 index 00000000..20eca3ee --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/exception/TransportOrderNotFoundException.java @@ -0,0 +1,10 @@ +package com.v1rex.liftnexus.transportorder.exception; + +public final class TransportOrderNotFoundException extends TransportOrderDomainException { + + public TransportOrderNotFoundException(Long id) { + super( + TransportOrderErrorCode.TRANSPORT_ORDER_NOT_FOUND, + "Transport order with ID " + id + " does not exist."); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/mapper/TransportOrderMapper.java b/src/main/java/com/v1rex/liftnexus/transportorder/mapper/TransportOrderMapper.java new file mode 100644 index 00000000..7068647a --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/mapper/TransportOrderMapper.java @@ -0,0 +1,36 @@ +package com.v1rex.liftnexus.transportorder.mapper; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderRequest; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderResponse; +import org.springframework.stereotype.Component; + +@Component +public class TransportOrderMapper { + + public TransportOrder toEntity(TransportOrderRequest request) { + if (request == null) return null; + return TransportOrder.builder() + .status(TransportOrderStatus.OPEN) + .requiredEquipment( + request.requiredEquipment() != null + ? request.requiredEquipment() + : EquipmentType.STANDARD) + .build(); + } + + public TransportOrderResponse toResponse(TransportOrder entity) { + if (entity == null) return null; + return new TransportOrderResponse( + entity.getId(), + entity.getTargetLoadUnit() != null ? entity.getTargetLoadUnit().getTrackingCode() : null, + entity.getTargetLoadUnit() != null ? entity.getTargetLoadUnit().getId() : null, + entity.getTargetBin() != null ? entity.getTargetBin().getId() : null, + entity.getSourceBin() != null ? entity.getSourceBin().getId() : null, + entity.getRequiredEquipment(), + entity.getStatus(), + entity.getAssignedForklift() != null ? entity.getAssignedForklift().getId() : null); + } +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/repository/TransportOrderRepository.java b/src/main/java/com/v1rex/liftnexus/transportorder/repository/TransportOrderRepository.java new file mode 100644 index 00000000..e62848c3 --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/repository/TransportOrderRepository.java @@ -0,0 +1,44 @@ +package com.v1rex.liftnexus.transportorder.repository; + +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TransportOrderRepository extends JpaRepository { + + List findByAssignedForkliftIsNull(); + + Page findByStatus(TransportOrderStatus status, Pageable pageable); + + Page findByTargetLoadUnit_WeightKgGreaterThan(Integer weight, Pageable pageable); + + @Query( + """ + SELECT to FROM TransportOrder to + JOIN to.targetLoadUnit lu + WHERE (:status IS NULL OR to.status = :status) + AND (:minWeight IS NULL OR lu.weightKg >= :minWeight) + """) + Page searchOrders( + @Param("status") TransportOrderStatus status, + @Param("minWeight") Integer minWeight, + Pageable pageable); + + @EntityGraph( + attributePaths = { + "targetLoadUnit", + "sourceBin", + "targetBin", + "assignedForklift", + "assignedForklift.forkliftType", + "assignedForklift.currentStorageBin" + }) + @Query("SELECT DISTINCT t FROM TransportOrder t") + List findAllForPlanning(); +} diff --git a/src/main/java/com/v1rex/liftnexus/transportorder/service/TransportOrderService.java b/src/main/java/com/v1rex/liftnexus/transportorder/service/TransportOrderService.java new file mode 100644 index 00000000..e9c7e59a --- /dev/null +++ b/src/main/java/com/v1rex/liftnexus/transportorder/service/TransportOrderService.java @@ -0,0 +1,252 @@ +package com.v1rex.liftnexus.transportorder.service; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.service.LoadUnitService; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderRequest; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderResponse; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderStatusUpdateRequest; +import com.v1rex.liftnexus.transportorder.exception.TransportOrderInvalidStateException; +import com.v1rex.liftnexus.transportorder.exception.TransportOrderNotFoundException; +import com.v1rex.liftnexus.transportorder.mapper.TransportOrderMapper; +import com.v1rex.liftnexus.transportorder.repository.TransportOrderRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Service layer responsible for managing transport orders within the warehouse system. + * + *

Handles the full lifecycle of a transport order: creation, status transitions, assignment to + * forklifts, and retrieval. All public methods enforce business rules such as ensuring the load + * unit is physically present at the source bin before a transport order can be created. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class TransportOrderService { + + private final TransportOrderRepository transportOrderRepository; + private final TransportOrderMapper transportOrderMapper; + + private final StorageBinService storageBinService; + private final LoadUnitService loadUnitService; + + /** + * Creates a new transport order that moves a load unit from a source storage bin to a destination + * storage bin. + * + *

Validates that the specified load unit is currently located in the requested source bin + * before proceeding. Throws an exception if the load unit is not present at the source location. + * + * @param request DTO containing the target load unit ID, source bin ID, and destination bin ID. + * @return The persisted transport order wrapped in a response DTO. + * @throws TransportOrderInvalidStateException if the load unit is not in the specified source + * bin. + */ + @Transactional + public TransportOrderResponse createTransportOrder(TransportOrderRequest request) { + log.info( + "Creating TransportOrder for LoadUnit: {} from Bin: {} to Bin: {}", + request.targetLoadUnitId(), + request.sourceBinId(), + request.destinationBinId()); + + // Resolve referenced entities + LoadUnit loadUnit = loadUnitService.findEntityById(request.targetLoadUnitId()); + StorageBin sourceBin = storageBinService.findEntityById(request.sourceBinId()); + StorageBin destinationBin = storageBinService.findEntityById(request.destinationBinId()); + + // Business rule: the load unit must physically reside in the source bin + if (loadUnit.getCurrentBin() == null + || !loadUnit.getCurrentBin().getId().equals(sourceBin.getId())) { + throw new TransportOrderInvalidStateException( + "LoadUnit " + + loadUnit.getTrackingCode() + + " is not located in the requested source bin."); + } + + // Map request to entity and wire up the resolved domain objects + TransportOrder order = transportOrderMapper.toEntity(request); + order.setTargetLoadUnit(loadUnit); + order.setSourceBin(sourceBin); + order.setTargetBin(destinationBin); + + TransportOrder savedOrder = transportOrderRepository.save(order); + + log.info("Successfully created TransportOrder ID: {}", savedOrder.getId()); + return transportOrderMapper.toResponse(savedOrder); + } + + /** + * Advances (or changes) the status of an existing transport order. + * + *

Applies state-machine rules defined in {@link #checkStatusBeforeUpdate} to prevent invalid + * transitions such as rolling back from {@code IN_PROGRESS} to {@code OPEN} or mutating a + * completed order. + * + * @param id The unique identifier of the transport order to update. + * @param request DTO carrying the desired new status. + * @return The updated transport order wrapped in a response DTO. + * @throws TransportOrderNotFoundException if no order exists for the given ID. + * @throws TransportOrderInvalidStateException if the requested transition is not allowed. + */ + @Transactional + public TransportOrderResponse updateOrderStatus( + Long id, TransportOrderStatusUpdateRequest request) { + TransportOrder order = findEntityById(id); + TransportOrderStatus currentStatus = order.getStatus(); + TransportOrderStatus newStatus = request.status(); + + checkStatusBeforeUpdate(id, currentStatus, newStatus); + + order.setStatus(newStatus); + + log.info("TransportOrder {} transitioned: {} -> {}", id, currentStatus, newStatus); + return transportOrderMapper.toResponse(order); + } + + /** + * Looks up a transport order by its ID and returns the response DTO. + * + * @param id The unique identifier of the transport order. + * @return The transport order response DTO. + * @throws TransportOrderNotFoundException if no order exists for the given ID. + */ + @Transactional(readOnly = true) + public TransportOrderResponse findById(Long id) { + return transportOrderMapper.toResponse(findEntityById(id)); + } + + /** + * Searches for transport orders with optional filters and pagination. + * + * @param status Optional status filter (may be null). + * @param minWeight Optional minimum weight filter (may be null). + * @param pageable Pagination and sorting information. + * @return A page of matching transport order response DTOs. + */ + @Transactional(readOnly = true) + public Page searchOrders( + TransportOrderStatus status, Integer minWeight, Pageable pageable) { + return transportOrderRepository + .searchOrders(status, minWeight, pageable) + .map(transportOrderMapper::toResponse); + } + + /** + * Retrieves the raw {@link TransportOrder} entity by its ID. + * + *

This is used internally by other service methods that need to work with the managed JPA + * entity rather than the response DTO. + * + * @param id The unique identifier of the transport order. + * @return The managed {@link TransportOrder} entity. + * @throws TransportOrderNotFoundException if no order exists for the given ID. + */ + @Transactional(readOnly = true) + public TransportOrder findEntityById(Long id) { + return transportOrderRepository + .findById(id) + .orElseThrow(() -> new TransportOrderNotFoundException(id)); + } + + /** + * Returns all transport order entities without pagination. + * + *

Caution: This method should only be used in batch/background operations where + * fetching the full dataset is acceptable (e.g., scheduled forklift-assignment jobs). Prefer the + * paginated {@link #searchOrders} method for user-facing features. + * + * @return A list of every {@link TransportOrder} in the database. + */ + public List findAllEntities() { + log.info("Fetching all managed transport order entities without pagination"); + return transportOrderRepository.findAll(); + } + + /** + * Assigns forklifts to a list of transport orders and transitions them to the {@link + * TransportOrderStatus#ASSIGNED} state. + * + *

Each order in the provided list is fetched from the database to obtain the managed entity, + * then updated with the assigned forklift reference. The status is automatically advanced to + * {@code ASSIGNED}. + * + * @param orders List of transport order entities carrying at least the ID and the desired + * forklift assignment. + */ + @Transactional + public void updateForkliftAssignments(List orders) { + for (TransportOrder order : orders) { + // Re-fetch to work with the managed entity within this persistence context + TransportOrder databaseOrder = findEntityById(order.getId()); + + // Update the forklift reference + databaseOrder.setAssignedForklift(order.getAssignedForklift()); + + // Automatically transition the order to ASSIGNED status + updateOrderStatus( + databaseOrder.getId(), + new TransportOrderStatusUpdateRequest(TransportOrderStatus.ASSIGNED)); + } + } + + /** + * Retrieves all {@link TransportOrder} entities with the object graph required by the Timefold + * solver. + * + *

This is an internal method for planning use cases. It intentionally loads load units, + * source bins, target bins and assigned forklifts because the solver evaluates constraints + * outside the Hibernate session. + * + *

Do not use this method for normal paginated REST API access. + * + * @return an unmodifiable list of all planning-ready {@link TransportOrder} entities + */ + @Transactional(readOnly = true) + public List findAllEntitiesForPlanning() { + log.info("Fetching all transport order entities with planning graph"); + return List.copyOf(transportOrderRepository.findAllForPlanning()); + } + + /** + * Enforces the transport-order state machine rules to prevent invalid status transitions. + * + *

Currently enforced rules: + * + *

    + *
  • A {@code COMPLETED} order can never be changed. + *
  • An {@code IN_PROGRESS} order cannot be rolled back to {@code OPEN}. + *
  • An {@code ASSIGNED} order cannot be rolled back to {@code OPEN}. + *
+ * + * @param id The transport order ID (used only in error messages). + * @param currentStatus The current status of the order. + * @param newStatus The desired new status. + * @throws TransportOrderInvalidStateException if the transition is forbidden. + */ + private void checkStatusBeforeUpdate( + Long id, TransportOrderStatus currentStatus, TransportOrderStatus newStatus) { + if (currentStatus == TransportOrderStatus.COMPLETED) { + throw new TransportOrderInvalidStateException( + "Cannot update TransportOrder " + id + " because it is already COMPLETED."); + } + if (currentStatus == TransportOrderStatus.IN_PROGRESS + && newStatus == TransportOrderStatus.OPEN) { + throw new TransportOrderInvalidStateException( + "Cannot roll back TransportOrder " + id + " from ACTIVE to OPEN."); + } + if (currentStatus == TransportOrderStatus.ASSIGNED && newStatus == TransportOrderStatus.OPEN) { + throw new TransportOrderInvalidStateException( + "Cannot roll back TransportOrder " + id + " from ASSIGNED to OPEN."); + } + } +} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/WarehouseDispatcherApplication.java b/src/main/java/com/v1rex/warehouse_dispatcher/WarehouseDispatcherApplication.java deleted file mode 100644 index a0eaa510..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/WarehouseDispatcherApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.v1rex.warehouse_dispatcher; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class WarehouseDispatcherApplication { - - public static void main(String[] args) { - SpringApplication.run(WarehouseDispatcherApplication.class, args); - } - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/ApiError.java b/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/ApiError.java deleted file mode 100644 index 23c62317..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/ApiError.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.v1rex.warehouse_dispatcher.common.exception; - - -import java.time.LocalDateTime; - -public record ApiError( - String message, - int status, - LocalDateTime timestamp, - String path -) {} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/GlobalExceptionHandler.java b/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/GlobalExceptionHandler.java deleted file mode 100644 index f89e95d6..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.v1rex.warehouse_dispatcher.common.exception; - - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.time.LocalDateTime; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity handleResourceNotFound( - ResourceNotFoundException ex, - HttpServletRequest request) { - - ApiError error = new ApiError( - ex.getMessage(), - HttpStatus.NOT_FOUND.value(), - LocalDateTime.now(), - request.getRequestURI() - ); - - return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); - } - - - - - @ExceptionHandler(Exception.class) - public ResponseEntity handleGeneralException( - Exception ex, - HttpServletRequest request) { - - ApiError error = new ApiError( - "An unexpected error occurred. Please try again later.", - HttpStatus.INTERNAL_SERVER_ERROR.value(), - LocalDateTime.now(), - request.getRequestURI() - ); - - return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR); - } - -@ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class) -public ResponseEntity handleValidationException( - org.springframework.web.bind.MethodArgumentNotValidException ex, - HttpServletRequest request) { - - String errorMessage = ex.getBindingResult().getFieldErrors().stream() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .collect(java.util.stream.Collectors.joining(", ")); - - ApiError error = new ApiError( - "Validation Failed: " + errorMessage, - HttpStatus.BAD_REQUEST.value(), - LocalDateTime.now(), - request.getRequestURI() - ); - - return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); -} -} - - diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/ResourceNotFoundException.java b/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/ResourceNotFoundException.java deleted file mode 100644 index c4435b10..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/common/exception/ResourceNotFoundException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.v1rex.warehouse_dispatcher.common.exception; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.NOT_FOUND) -public class ResourceNotFoundException extends RuntimeException { - public ResourceNotFoundException(String message) { - super(message); - } -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/controller/ForkliftController.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/controller/ForkliftController.java deleted file mode 100644 index b3644d8e..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/controller/ForkliftController.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.controller; - - -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftLocationUpdateRequest; -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftRequest; -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftResponse; -import com.v1rex.warehouse_dispatcher.forklift.service.ForkliftService; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -import java.net.URI; - -@RestController -@RequestMapping("/api/v1/forklifts") -@Validated -@RequiredArgsConstructor -public class ForkliftController { - private final ForkliftService forkliftService; - - @PostMapping - public ResponseEntity createForklift( - @RequestBody @Valid ForkliftRequest request - ) { - ForkliftResponse savedForklift = forkliftService.createForklift(request); - - URI location = ServletUriComponentsBuilder - .fromCurrentRequest() - .path("/{id}") - .buildAndExpand(savedForklift.id()) - .toUri(); - - return ResponseEntity.created(location).body(savedForklift); - } - - @PutMapping("/{id}/location") - public ResponseEntity updateForkliftLocation( - @PathVariable Long id, - @Valid @RequestBody ForkliftLocationUpdateRequest updateRequest - ){ - - ForkliftResponse updatedForklift = forkliftService.updateForkliftLocation(id, - updateRequest.locationId()); - - return ResponseEntity.ok(updatedForklift); - - } - - - @GetMapping - public ResponseEntity> findAllForklifts( - @PageableDefault(size = 15, sort = "id", direction = Sort.Direction.ASC) Pageable pageable - ) { - return ResponseEntity.ok(forkliftService.findAll(pageable)); - } - - - @GetMapping("/search") - public ResponseEntity> findWithCapacity( - @RequestParam @Min(1) Integer minCapacity, - @PageableDefault(size = 10, sort = "weightCapacity") Pageable pageable - ) { - return ResponseEntity.ok(forkliftService.findWithCapacityGreaterThan(minCapacity, pageable)); - } - - @GetMapping("/{id}") - public ResponseEntity getForkliftById(@PathVariable Long id) { - return ResponseEntity.ok(forkliftService.findById(id)); - } - - - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/domain/EquipmentType.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/domain/EquipmentType.java deleted file mode 100644 index d26ecf3a..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/domain/EquipmentType.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.domain; - -public enum EquipmentType { - PALLET_JACK, - STANDARD, - REACH_TRUCK, - SIDE_LOADER -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/domain/Forklift.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/domain/Forklift.java deleted file mode 100644 index 8ab99cbe..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/domain/Forklift.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.domain; - -import ai.timefold.solver.core.api.domain.entity.PlanningEntity; -import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import jakarta.persistence.*; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import lombok.*; - -import java.util.ArrayList; -import java.util.List; - -@PlanningEntity -@Entity -@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder -public class Forklift { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @NotNull - @Min(value = 1, message = "Weight must be greater than 0") - @Column(name = "weight_capacity", nullable = false, columnDefinition = "integer check (weight_capacity >0)") - private Integer weightCapacity; - - @PlanningListVariable(valueRangeProviderRefs = "taskPoolRange") - @OneToMany(mappedBy = "forklift", - cascade = CascadeType.ALL, - fetch = FetchType.EAGER) - private List tasks = new ArrayList<>(); - - @Builder.Default - @Enumerated(EnumType.STRING) - @Column(name = "equipment_type", nullable = false) - private EquipmentType equipmentType = EquipmentType.STANDARD; - - - @ManyToOne - @JoinColumn(name = "current_location_id") - private Location currentLocation; - -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftLocationUpdateRequest.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftLocationUpdateRequest.java deleted file mode 100644 index 96d6e516..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftLocationUpdateRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.dto; - -import jakarta.validation.constraints.NotNull; - -public record ForkliftLocationUpdateRequest( - @NotNull Long locationId) { -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftRequest.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftRequest.java deleted file mode 100644 index 22f9ec03..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.dto; - -import com.v1rex.warehouse_dispatcher.forklift.domain.EquipmentType; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Min; - - -public record ForkliftRequest( - @NotNull @Min(1) Integer weightCapacity, - @NotNull EquipmentType equipmentType -) {} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftResponse.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftResponse.java deleted file mode 100644 index a13ef76f..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/dto/ForkliftResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.dto; - -import com.v1rex.warehouse_dispatcher.forklift.domain.EquipmentType; -import com.v1rex.warehouse_dispatcher.location.dto.LocationResponse; -import com.v1rex.warehouse_dispatcher.task.dto.TaskResponse; - -import java.util.List; - -public record ForkliftResponse( - Long id, - Integer weightCapacity, - EquipmentType equipmentType, - List tasks, - LocationResponse currentLocation -) {} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/mapper/ForkliftMapper.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/mapper/ForkliftMapper.java deleted file mode 100644 index 3c759423..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/mapper/ForkliftMapper.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.mapper; - -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftRequest; -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftResponse; -import com.v1rex.warehouse_dispatcher.location.mapper.LocationMapper; -import com.v1rex.warehouse_dispatcher.task.mapper.TaskMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ForkliftMapper { - - private final TaskMapper taskMapper; - private final LocationMapper locationMapper; - - public ForkliftResponse toResponse(Forklift entity) { - if (entity == null) return null; - return new ForkliftResponse( - entity.getId(), - entity.getWeightCapacity(), - entity.getEquipmentType(), - entity.getTasks().stream() - .map(taskMapper::toResponse) - .toList(), - locationMapper.toResponse(entity.getCurrentLocation()) - ); - } - - public Forklift toEntity(ForkliftRequest request) { - if (request == null) return null; - return Forklift.builder() - .weightCapacity(request.weightCapacity()) - .equipmentType(request.equipmentType()) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/repository/ForkliftRepository.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/repository/ForkliftRepository.java deleted file mode 100644 index d1e0a4ea..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/repository/ForkliftRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.repository; - -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ForkliftRepository extends JpaRepository { - - Page findByWeightCapacityGreaterThan(Integer weight, Pageable pageable); -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/service/ForkliftService.java b/src/main/java/com/v1rex/warehouse_dispatcher/forklift/service/ForkliftService.java deleted file mode 100644 index 7bfba220..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/forklift/service/ForkliftService.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.v1rex.warehouse_dispatcher.forklift.service; - - -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftRequest; -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftResponse; -import com.v1rex.warehouse_dispatcher.common.exception.ResourceNotFoundException; -import com.v1rex.warehouse_dispatcher.location.service.LocationService; -import com.v1rex.warehouse_dispatcher.forklift.mapper.ForkliftMapper; -import com.v1rex.warehouse_dispatcher.forklift.repository.ForkliftRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - - - -@Service -@RequiredArgsConstructor -public class ForkliftService { - private final ForkliftRepository forkliftRepository; - private final ForkliftMapper forkliftMapper; - private final LocationService locationService; - - @Transactional - public ForkliftResponse createForklift(ForkliftRequest request){ - Forklift forklift = forkliftMapper.toEntity(request); - - Forklift savedForklift = forkliftRepository.save(forklift); - - return forkliftMapper.toResponse(savedForklift); - } - - @Transactional(readOnly = true) - public ForkliftResponse findById(Long id) { - return forkliftMapper.toResponse(findEntityById(id)); - } - - @Transactional(readOnly = true) - public Page findAll(Pageable pageable) { - return findAllEntities(pageable).map(forkliftMapper::toResponse); - } - - @Transactional(readOnly = true) - public Page findWithCapacityGreaterThan( - Integer weightCapacity, - Pageable pageable - ){ - return findEntitiesWithCapacityGreaterThan(weightCapacity, pageable).map(forkliftMapper::toResponse); - } - - @Transactional - public ForkliftResponse updateForkliftLocation(Long forkLiftId, Long locationId){ - Forklift forklift = findEntityById(forkLiftId); - Location newLocation = locationService.findEntityById(locationId); - - forklift.setCurrentLocation(newLocation); - - Forklift updatedForklift = forkliftRepository.save(forklift); - - return forkliftMapper.toResponse(updatedForklift); - } - - public Forklift findEntityById(Long id) { - return forkliftRepository.findById(id) - .orElseThrow(() ->new ResourceNotFoundException("Forklift with " + id + " not found.") ); - } - - public Page findAllEntities(Pageable pageable){ - return forkliftRepository.findAll(pageable); - } - - public Page findEntitiesWithCapacityGreaterThan( - Integer weightCapacity, - Pageable pageable){ - - return forkliftRepository.findByWeightCapacityGreaterThan(weightCapacity, pageable); - - } -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/controller/LocationController.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/controller/LocationController.java deleted file mode 100644 index 09bf0b0c..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/controller/LocationController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.controller; - - -import com.v1rex.warehouse_dispatcher.location.dto.LocationRequest; -import com.v1rex.warehouse_dispatcher.location.dto.LocationResponse; -import com.v1rex.warehouse_dispatcher.location.service.LocationService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -import java.net.URI; - -@RestController -@RequestMapping("/api/v1/locations") -@Validated -@RequiredArgsConstructor -public class LocationController { - - private final LocationService locationService; - - @PostMapping - public ResponseEntity createLocation( - @RequestBody @Valid LocationRequest request - ){ - - LocationResponse savedLocation = locationService.createLocation(request); - - - URI location = ServletUriComponentsBuilder - .fromCurrentRequest() - .path("/{id}") - .buildAndExpand(savedLocation.id()) - .toUri(); - - return ResponseEntity.created(location).body(savedLocation); - } - - @GetMapping - public ResponseEntity> findAllLocations( - @PageableDefault(size = 15, sort = "id", direction = Sort.Direction.ASC) Pageable pageable - ){ - return ResponseEntity.ok(locationService.findAll(pageable)); - } - - @GetMapping("/{id}") - public ResponseEntity findById( - @PathVariable Long id - ){ - return ResponseEntity.ok(locationService.findById(id)); - } -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/domain/Location.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/domain/Location.java deleted file mode 100644 index f1093c94..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/domain/Location.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.domain; - -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.*; - -@Entity -@Builder -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -public class Location { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private Float latitude; - - private Float longitude; - - public double distanceTo(Location other) { - double dx = this.latitude - other.latitude; - double dy = this.longitude - other.longitude; - return Math.sqrt(dx * dx + dy * dy); - } -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/dto/LocationRequest.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/dto/LocationRequest.java deleted file mode 100644 index 3732f98b..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/dto/LocationRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.dto; - -import io.smallrye.common.constraint.NotNull; - -public record LocationRequest( - @NotNull Float latitude, - @NotNull Float longitude -) { -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/dto/LocationResponse.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/dto/LocationResponse.java deleted file mode 100644 index 114bdf98..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/dto/LocationResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.dto; - -public record LocationResponse( - Long id, - Float latitude, - Float longitude -) { - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/mapper/LocationMapper.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/mapper/LocationMapper.java deleted file mode 100644 index a956ff44..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/mapper/LocationMapper.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.mapper; - -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.location.dto.LocationRequest; -import com.v1rex.warehouse_dispatcher.location.dto.LocationResponse; -import org.springframework.stereotype.Component; - -@Component -public class LocationMapper { - - public Location toEntity(LocationRequest request){ - if (request == null ) return null; - return Location.builder() - .longitude(request.longitude()) - .latitude(request.latitude()) - .build(); - } - - public LocationResponse toResponse(Location entity) { - if (entity == null) return null; - return new LocationResponse( - entity.getId(), - entity.getLatitude(), - entity.getLongitude() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/repository/LocationRepository.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/repository/LocationRepository.java deleted file mode 100644 index 2cc97a1a..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/repository/LocationRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.repository; - -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface LocationRepository extends JpaRepository {} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/location/service/LocationService.java b/src/main/java/com/v1rex/warehouse_dispatcher/location/service/LocationService.java deleted file mode 100644 index 9c77d7db..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/location/service/LocationService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.v1rex.warehouse_dispatcher.location.service; - -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.location.dto.LocationRequest; -import com.v1rex.warehouse_dispatcher.location.dto.LocationResponse; -import com.v1rex.warehouse_dispatcher.common.exception.ResourceNotFoundException; -import com.v1rex.warehouse_dispatcher.location.mapper.LocationMapper; -import com.v1rex.warehouse_dispatcher.location.repository.LocationRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class LocationService { - - private final LocationRepository locationRepository; - - private final LocationMapper locationMapper; - - @Transactional - public LocationResponse createLocation(LocationRequest request){ - Location location = locationMapper.toEntity(request); - - Location savedForklift = locationRepository.save(location); - - return locationMapper.toResponse(savedForklift); - } - - @Transactional(readOnly=true ) - public LocationResponse findById(Long id) { - return locationMapper.toResponse(findEntityById(id)); - } - - @Transactional(readOnly = true) - public Page findAll(Pageable pageable) { - return findAllEntities(pageable).map(locationMapper::toResponse); - } - - - public Location findEntityById(Long id) { - return locationRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Location with " - + id + " not found.")); - } - - public Page findAllEntities(Pageable pageable){ - return locationRepository.findAll(pageable); - } - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/planning/controller/DispatcherController.java b/src/main/java/com/v1rex/warehouse_dispatcher/planning/controller/DispatcherController.java deleted file mode 100644 index 0ce3e932..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/planning/controller/DispatcherController.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.v1rex.warehouse_dispatcher.planning.controller; - -import com.v1rex.warehouse_dispatcher.planning.domain.WarehouseSchedule; -import com.v1rex.warehouse_dispatcher.planning.service.WarehouseDispatcherService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/dispatcher") -@RequiredArgsConstructor -public class DispatcherController { - - private final WarehouseDispatcherService dispatcherService; - - @PostMapping("/solve") - public String solve() { - dispatcherService.startSolving(); - return "Solver started in the background. Optimization is running."; - } - - @GetMapping("/solution") - public WarehouseSchedule getSolution() { - return dispatcherService.buildCurrentState(); - } -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/planning/domain/WarehouseSchedule.java b/src/main/java/com/v1rex/warehouse_dispatcher/planning/domain/WarehouseSchedule.java deleted file mode 100644 index 2ec7e0b5..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/planning/domain/WarehouseSchedule.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.v1rex.warehouse_dispatcher.planning.domain; - -import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; -import ai.timefold.solver.core.api.domain.solution.PlanningScore; -import ai.timefold.solver.core.api.domain.solution.PlanningSolution; -import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty; -import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; -import ai.timefold.solver.core.api.score.HardSoftScore; -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import lombok.*; - -import java.util.List; - -@PlanningSolution -@Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class WarehouseSchedule { - - @ProblemFactCollectionProperty - private List locations; - - @ValueRangeProvider(id = "taskPoolRange") - @PlanningEntityCollectionProperty - private List taskPool; - - @PlanningEntityCollectionProperty - private List forklifts; - - @PlanningScore - private HardSoftScore score; -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/planning/dto/WarehouseScheduleResponse.java b/src/main/java/com/v1rex/warehouse_dispatcher/planning/dto/WarehouseScheduleResponse.java deleted file mode 100644 index 5a7fdcd2..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/planning/dto/WarehouseScheduleResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.v1rex.warehouse_dispatcher.planning.dto; - -import com.v1rex.warehouse_dispatcher.forklift.dto.ForkliftResponse; -import com.v1rex.warehouse_dispatcher.location.dto.LocationResponse; -import com.v1rex.warehouse_dispatcher.task.dto.TaskResponse; - -import java.util.List; - -public record WarehouseScheduleResponse( - List locations, - List forkLifts, - List unassignedTasks) { - - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/planning/logic/WarehouseConstraintProvider.java b/src/main/java/com/v1rex/warehouse_dispatcher/planning/logic/WarehouseConstraintProvider.java deleted file mode 100644 index 429762b2..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/planning/logic/WarehouseConstraintProvider.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.v1rex.warehouse_dispatcher.planning.logic; - -import ai.timefold.solver.core.api.score.HardSoftScore; -import ai.timefold.solver.core.api.score.stream.Constraint; -import ai.timefold.solver.core.api.score.stream.ConstraintFactory; -import ai.timefold.solver.core.api.score.stream.ConstraintProvider; -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.task.domain.Task; - -import java.util.List; - -public class WarehouseConstraintProvider implements ConstraintProvider { - - @Override - public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { - return new Constraint[]{ - forkliftCapacity(constraintFactory), - minimizeTravelDistance(constraintFactory), - taskEquipmentRequirement(constraintFactory) - }; - } - - - // Hard constraint: check if all the assigned tasks to a Forklift does - // not exceed the capacity of the forklift - private Constraint forkliftCapacity(ConstraintFactory factory) { - return factory.forEach(Task.class) // Start with the Task - .filter(task -> task.getForklift() != null) - .filter(task -> task.getWeight() > task.getForklift().getWeightCapacity()) - .penalize(HardSoftScore.ONE_HARD) - .asConstraint("Forklift capacity limit"); - } - - // Hard constraint: check if the assigned tasks to a Forklift is - // compatible with the requirement equipment type of the task - private Constraint taskEquipmentRequirement(ConstraintFactory factory) { - return factory.forEach(Task.class) - .filter(task -> task.getForklift() != null) - .filter(task -> task.getRequiredEquipment() != task.getForklift().getEquipmentType()) - .penalize(HardSoftScore.ONE_HARD) - .asConstraint("Task equipment type requirement"); - } - - - // Soft constraint: sum complete travel distance of the forklift - private Constraint minimizeTravelDistance(ConstraintFactory factory) { - return factory.forEach(Forklift.class) - .filter(forklift -> !forklift.getTasks().isEmpty()) - .penalize(HardSoftScore.ONE_SOFT, forklift ->{ - int totalTraveledDistance = 0; - - List tasks = forklift.getTasks(); - // initial drive to the first task - totalTraveledDistance += (int) forklift.getCurrentLocation() - .distanceTo(tasks.get(0).getPickLocation()); - - for (int i = 0; i < tasks.size(); i++) { - Task current = tasks.get(i); - - // We calculate the travel distance from currentTask - // to the Delivery Location - totalTraveledDistance += (int) current.getPickLocation() - .distanceTo(current.getDeliveryLocation()); - - // if there is a next task, we calculate the travel distance - // from the delivery location to the pick location - // of the next task - if (i < tasks.size() - 1) { - Task next = tasks.get(i + 1); - totalTraveledDistance += (int) current.getDeliveryLocation() - .distanceTo(next.getPickLocation()); - } - } - // todo: think about the metrics!! - return totalTraveledDistance; - - }) - .asConstraint("Minimize travel distance"); - } -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/planning/mapper/WarehouseScheduleMapper.java b/src/main/java/com/v1rex/warehouse_dispatcher/planning/mapper/WarehouseScheduleMapper.java deleted file mode 100644 index c56815ef..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/planning/mapper/WarehouseScheduleMapper.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.v1rex.warehouse_dispatcher.planning.mapper; - -import com.v1rex.warehouse_dispatcher.forklift.mapper.ForkliftMapper; -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import com.v1rex.warehouse_dispatcher.planning.dto.WarehouseScheduleResponse; -import com.v1rex.warehouse_dispatcher.location.mapper.LocationMapper; -import com.v1rex.warehouse_dispatcher.task.mapper.TaskMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -@RequiredArgsConstructor -public class WarehouseScheduleMapper { - - private final LocationMapper locationMapper; - private final ForkliftMapper forkliftMapper; - private final TaskMapper taskMapper; - - public WarehouseScheduleResponse toResponse( - List locations, - List forklifts, - List unassignedTasks) { - - return new WarehouseScheduleResponse( - locations.stream().map(locationMapper::toResponse).toList(), - forklifts.stream().map(forkliftMapper::toResponse).toList(), - unassignedTasks.stream().map(taskMapper::toResponse).toList() - ); - } -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/planning/service/WarehouseDispatcherService.java b/src/main/java/com/v1rex/warehouse_dispatcher/planning/service/WarehouseDispatcherService.java deleted file mode 100644 index b173bebc..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/planning/service/WarehouseDispatcherService.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.v1rex.warehouse_dispatcher.planning.service; - - -import ai.timefold.solver.core.api.solver.SolverManager; -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import com.v1rex.warehouse_dispatcher.planning.domain.WarehouseSchedule; -import com.v1rex.warehouse_dispatcher.forklift.repository.ForkliftRepository; -import com.v1rex.warehouse_dispatcher.location.repository.LocationRepository; -import com.v1rex.warehouse_dispatcher.task.repository.TaskRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class WarehouseDispatcherService { - private final LocationRepository locationRepository; - private final ForkliftRepository forkliftRepository; - private final TaskRepository taskRepository; - - private final SolverManager solverManager; - - private WarehouseSchedule bestSolution; - - public WarehouseSchedule buildCurrentState() { - // 1. Fetch data from the database - List locations = locationRepository.findAll(); - List forklifts = forkliftRepository.findAll(); - List unassignedTasks = taskRepository.findByForkliftIsNull(); - - // 2. Assemble the "Whiteboard" (The Planning Solution) - WarehouseSchedule schedule = new WarehouseSchedule(); - schedule.setLocations(locations); - schedule.setForklifts(forklifts); - schedule.setTaskPool(unassignedTasks); - - // 3. Return the fully loaded state ready for optimization - return schedule; - } - - public void startSolving() { - WarehouseSchedule problem = buildCurrentState(); - // Update the bestSolution as the solver finds better ones - // Explicitly define the ID and the lambda - Long problemId = 1L; - solverManager.solveAndListen(problemId, - problem, - this::saveSolution); - } - - public WarehouseSchedule getSolution() { - return bestSolution != null ? bestSolution : buildCurrentState(); - } - - @Transactional - public void saveSolution(WarehouseSchedule solution) { - for (Forklift forklift : solution.getForklifts()) { - for (Task task : forklift.getTasks()) { - // MANUALLY sync the relationship before saving - task.setForklift(forklift); - taskRepository.save(task); - } - forkliftRepository.save(forklift); - } - } -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/controller/TaskController.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/controller/TaskController.java deleted file mode 100644 index 35cbb4ea..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/controller/TaskController.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.controller; - - -import com.v1rex.warehouse_dispatcher.task.dto.TaskRequest; -import com.v1rex.warehouse_dispatcher.task.dto.TaskResponse; -import com.v1rex.warehouse_dispatcher.task.dto.TaskStatusUpdateRequest; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import com.v1rex.warehouse_dispatcher.task.service.TaskService; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -import java.net.URI; - -@RestController -@RequestMapping("/api/v1/tasks") -@Validated -@RequiredArgsConstructor -public class TaskController { - private final TaskService taskService; - - @PostMapping - public ResponseEntity createTask( - @RequestBody @Valid TaskRequest taskRequest - ){ - TaskResponse savedPickTask = - taskService.createTask(taskRequest); - - - URI location = ServletUriComponentsBuilder - .fromCurrentRequest() - .path("/{id}") - .buildAndExpand(savedPickTask.id()) - .toUri(); - - return ResponseEntity.created(location).body(savedPickTask); - } - - @PutMapping("/{id}") - public ResponseEntity updateTaskStatus( - @PathVariable Long id, - @RequestBody TaskStatusUpdateRequest newStatusRequest - ){ - TaskResponse updatedTask = - taskService.updateTask(id, newStatusRequest); - - - return ResponseEntity.ok(updatedTask); - } - - - @GetMapping("/search") - public ResponseEntity> searchTasks( - @RequestParam(required = false) TaskStatus status, - @RequestParam(required = false) @Min(1) Integer minWeight, - @PageableDefault(size = 10, sort = "weight") Pageable pageable - ) { - return ResponseEntity.ok(taskService.searchTasks(status, - minWeight, - pageable)); - } - - @GetMapping("/{id}") - public ResponseEntity getTaskById(@PathVariable Long id) { - return ResponseEntity.ok(taskService.findById(id)); - } - - - - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/domain/Task.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/domain/Task.java deleted file mode 100644 index f0663630..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/domain/Task.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.domain; - -import ai.timefold.solver.core.api.domain.entity.PlanningEntity; -import ai.timefold.solver.core.api.domain.entity.PlanningPin; -import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.v1rex.warehouse_dispatcher.forklift.domain.Forklift; -import com.v1rex.warehouse_dispatcher.forklift.domain.EquipmentType; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import jakarta.persistence.*; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import lombok.*; - -@PlanningEntity -@Entity -@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder -public class Task { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "pick_location_id", nullable = false) - private Location pickLocation; - - @ManyToOne - @JoinColumn(name = "delivery_location_id", nullable = false) - private Location deliveryLocation; - - - @NotNull @Min(value = 1, message = "Weight must be greater than 0") - @Column(name = "weight", nullable = false, columnDefinition = "integer check (weight > 0)") - private Integer weight; - - @Builder.Default - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private TaskStatus status = TaskStatus.OPEN; - - - @Builder.Default - @Enumerated(EnumType.STRING) - @Column(name = "required_equipment", nullable = false) - private EquipmentType requiredEquipment = EquipmentType.STANDARD; - - - @InverseRelationShadowVariable(sourceVariableName = "tasks") - @ManyToOne - @JoinColumn(name = "forklift_id") - @JsonIgnore - private Forklift forklift; - - @PlanningPin - public boolean isPinned() { - return status == TaskStatus.IN_PROGRESS || status == TaskStatus.COMPLETED; - } -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskRequest.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskRequest.java deleted file mode 100644 index 8e055b5a..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.dto; - -import com.v1rex.warehouse_dispatcher.forklift.domain.EquipmentType; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; - -public record TaskRequest( - @NotNull Long pickLocationId, - @NotNull Long deliveryLocationId, - TaskStatus status, - EquipmentType requiredEquipment, - @NotNull @Min(1) Integer weight -) {} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskResponse.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskResponse.java deleted file mode 100644 index 1c15a3b4..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.dto; - -import com.v1rex.warehouse_dispatcher.forklift.domain.EquipmentType; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import com.v1rex.warehouse_dispatcher.location.dto.LocationResponse; - -public record TaskResponse( - Long id, - LocationResponse pickLocation, - LocationResponse deliveryLocation, - Integer weight, - EquipmentType requiredEquipment, - TaskStatus status, - Long forkliftId -) { - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskStatusUpdateRequest.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskStatusUpdateRequest.java deleted file mode 100644 index 3d212320..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/dto/TaskStatusUpdateRequest.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.dto; - -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; - -public record TaskStatusUpdateRequest(TaskStatus status) { -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/enums/TaskStatus.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/enums/TaskStatus.java deleted file mode 100644 index 866286d3..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/enums/TaskStatus.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.enums; - -public enum TaskStatus { - OPEN, - ASSIGNED, - IN_PROGRESS, - COMPLETED -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/mapper/TaskMapper.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/mapper/TaskMapper.java deleted file mode 100644 index efca9230..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/mapper/TaskMapper.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.mapper; - -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import com.v1rex.warehouse_dispatcher.task.dto.TaskRequest; -import com.v1rex.warehouse_dispatcher.task.dto.TaskResponse; -import com.v1rex.warehouse_dispatcher.forklift.domain.EquipmentType; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import com.v1rex.warehouse_dispatcher.location.mapper.LocationMapper; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor // Automatically injects the LocationMapper -public class TaskMapper { - - private final LocationMapper locationMapper; - - public Task toEntity(TaskRequest request){ - if (request == null) return null; - return Task.builder() - .weight(request.weight()) - .status(request.status() != null ? - request.status() : - TaskStatus.OPEN) - .requiredEquipment(request.requiredEquipment() != null ? - request.requiredEquipment() : - EquipmentType.STANDARD) - .build(); - - } - - public TaskResponse toResponse(Task entity) { - if (entity == null) return null; - return new TaskResponse( - entity.getId(), - locationMapper.toResponse(entity.getPickLocation()) , - locationMapper.toResponse(entity.getDeliveryLocation()), - entity.getWeight(), - entity.getRequiredEquipment(), - entity.getStatus(), - entity.getForklift() != null ? entity.getForklift().getId() : null - ); - } - -} \ No newline at end of file diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/repository/TaskRepository.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/repository/TaskRepository.java deleted file mode 100644 index 054c88dd..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/repository/TaskRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.repository; - -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Page; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.List; - -public interface TaskRepository extends JpaRepository { - List findByForkliftIsNull(); - - Page findByStatus(TaskStatus status, Pageable pageable); - - Page findByWeightGreaterThan( - @NotNull - @Min(value = 1, message = "Weight must be greater than 0") Integer weightIsGreaterThan, - Pageable pageable); - - @Query(""" - SELECT t FROM Task t - WHERE (:status IS NULL OR t.status = :status) - AND (:minWeight IS NULL OR t.weight >= :minWeight) - """) - Page searchTasks( - @Param("status") TaskStatus status, - @Param("minWeight") Integer minWeight, - Pageable pageable - ); - - -} diff --git a/src/main/java/com/v1rex/warehouse_dispatcher/task/service/TaskService.java b/src/main/java/com/v1rex/warehouse_dispatcher/task/service/TaskService.java deleted file mode 100644 index 81e7267d..00000000 --- a/src/main/java/com/v1rex/warehouse_dispatcher/task/service/TaskService.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.v1rex.warehouse_dispatcher.task.service; - - -import com.v1rex.warehouse_dispatcher.location.domain.Location; -import com.v1rex.warehouse_dispatcher.task.dto.TaskRequest; -import com.v1rex.warehouse_dispatcher.task.dto.TaskStatusUpdateRequest; -import com.v1rex.warehouse_dispatcher.task.enums.TaskStatus; -import com.v1rex.warehouse_dispatcher.common.exception.ResourceNotFoundException; -import com.v1rex.warehouse_dispatcher.location.service.LocationService; -import com.v1rex.warehouse_dispatcher.task.mapper.TaskMapper; -import com.v1rex.warehouse_dispatcher.task.repository.TaskRepository; -import com.v1rex.warehouse_dispatcher.task.dto.TaskResponse; -import com.v1rex.warehouse_dispatcher.task.domain.Task; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class TaskService { - private final TaskRepository taskRepository; - private final TaskMapper taskMapper; - - private final LocationService locationService; - - @Transactional - public TaskResponse createTask(TaskRequest request){ - Location pickLocation = locationService.findEntityById(request.pickLocationId()); - Location deliveryLocation = locationService.findEntityById(request.deliveryLocationId()); - - Task task = taskMapper.toEntity(request); - - // we set always new tasks to OPEN - task.setStatus(TaskStatus.OPEN); - task.setPickLocation(pickLocation); - task.setDeliveryLocation(deliveryLocation); - - - Task savedTask = taskRepository.save(task); - - return taskMapper.toResponse(savedTask); - } - - @Transactional - public TaskResponse updateTask(Long id, TaskStatusUpdateRequest newStatusRequest){ - Task task = findEntityById(id); - - TaskStatus currentStatus = task.getStatus(); - TaskStatus newStatus = newStatusRequest.status(); - checkStatusBeforeUpdate(currentStatus, newStatus ); - // update the status of the task - task.setStatus(newStatus); - - return taskMapper.toResponse(task); - } - - @Transactional(readOnly = true) - public TaskResponse findById(Long id) { - return taskMapper.toResponse(findEntityById(id)); - } - - - @Transactional(readOnly = true) - public Page searchTasks(TaskStatus status, - Integer minWeight, - Pageable pageable) { - return taskRepository.searchTasks(status, minWeight, pageable) - .map(taskMapper::toResponse); - } - - - public Task findEntityById(Long id) { - return taskRepository.findById(id) - .orElseThrow(() ->new ResourceNotFoundException("Task with " + id + " not found.") ); - } - - private void checkStatusBeforeUpdate(TaskStatus currentStatus, TaskStatus newStatus){ - if (currentStatus == TaskStatus.COMPLETED) { - throw new IllegalStateException("Cannot update a" + - " task that is already completed."); - } - - if (currentStatus == TaskStatus.IN_PROGRESS && newStatus == TaskStatus.OPEN) { - throw new IllegalStateException("Cannot un-assign a " + - "task that is already in progress."); - } - - if (currentStatus == TaskStatus.ASSIGNED && newStatus == TaskStatus.OPEN) { - throw new IllegalStateException("Cannot un-assign a " + - "task that is already assigned."); - } - } - -} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 00000000..f84cd982 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,14 @@ +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/warehouse_db} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:postgres} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:your_password} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=validate +spring.flyway.enabled=true + +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.properties.hibernate.default_schema=public + +spring.sql.init.mode=always +spring.flyway.fail-on-missing-locations=true diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 00000000..f255087c --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,7 @@ + + +# Deactivate SQL logging in prod +spring.jpa.show-sql=false + +# Disable automatic data seeding in prod +spring.sql.init.mode=never \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 123507b4..640862d7 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,6 +1,17 @@ spring.application.name=Warehouse Dispatcher +spring.profiles.active=dev -spring.jpa.defer-datasource-initialization=true +# Shared Timefold configuration timefold.solver.termination.spent-limit=10s -spring.devtools.restart.enabled=false -spring.jpa.hibernate.ddl-auto=update + +# Hibernate / Flyway global defaults +spring.flyway.baseline-on-migrate=true + +# Keep this to allow your Java Seeder to run after Flyway creates tables +spring.jpa.defer-datasource-initialization=false + +# Springdoc OpenAPI +springdoc.api-docs.path=/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.tryItOut.enabled=true +springdoc.swagger-ui.display-request-duration=true diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..2f7e0867 --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,65 @@ +CREATE TABLE storage_bin ( + id BIGSERIAL PRIMARY KEY, + bin_code VARCHAR(255) NOT NULL UNIQUE, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + z INTEGER NOT NULL, + zone_type VARCHAR(100) NOT NULL, + max_weight_capacity_kg INTEGER NOT NULL +); + +CREATE TABLE forklift_types ( + id BIGSERIAL PRIMARY KEY, + model_name VARCHAR(255) NOT NULL UNIQUE, + equipment_type VARCHAR(50) NOT NULL, + max_capacity_kg INTEGER NOT NULL, + total_battery_capacity_kwh DOUBLE PRECISION NOT NULL, + base_energy_consumption_per_meter DOUBLE PRECISION NOT NULL +); + +CREATE TABLE forklifts ( + id BIGSERIAL PRIMARY KEY, + fleet_number VARCHAR(255) NOT NULL UNIQUE, + forklift_type_id BIGINT NOT NULL, + current_storage_bin_id BIGINT, + operational_status VARCHAR(50) NOT NULL, + current_battery_percentage DOUBLE PRECISION NOT NULL, + CONSTRAINT fk_forklift_type FOREIGN KEY (forklift_type_id) REFERENCES forklift_types (id), + CONSTRAINT fk_forklift_current_bin FOREIGN KEY (current_storage_bin_id) REFERENCES storage_bin (id) +); + +CREATE TABLE load_units ( + id BIGSERIAL PRIMARY KEY, + tracking_code VARCHAR(255) NOT NULL UNIQUE, + weight_kg INTEGER NOT NULL, + status VARCHAR(50) NOT NULL, + current_storage_bin_id BIGINT, + version BIGINT, + CONSTRAINT fk_loadunit_current_bin FOREIGN KEY (current_storage_bin_id) REFERENCES storage_bin (id) +); + +CREATE TABLE transport_orders ( + id BIGSERIAL PRIMARY KEY, + load_unit_id BIGINT NOT NULL, + target_bin_id BIGINT NOT NULL, + source_bin_id BIGINT NOT NULL, + required_equipment VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL, + forklift_id BIGINT, + CONSTRAINT fk_transport_loadunit FOREIGN KEY (load_unit_id) REFERENCES load_units (id), + CONSTRAINT fk_transport_target_bin FOREIGN KEY (target_bin_id) REFERENCES storage_bin (id), + CONSTRAINT fk_transport_source_bin FOREIGN KEY (source_bin_id) REFERENCES storage_bin (id), + CONSTRAINT fk_transport_forklift FOREIGN KEY (forklift_id) REFERENCES forklifts (id) +); + +CREATE TABLE dispatch_jobs ( + id UUID PRIMARY KEY, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ, + final_score VARCHAR(255) +); + +CREATE INDEX idx_transport_forklift ON transport_orders(forklift_id); +CREATE INDEX idx_forklift_current_bin ON forklifts(current_storage_bin_id); +CREATE INDEX idx_transport_loadunit ON transport_orders(load_unit_id); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..217b8716 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,19 @@ + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sun_checks.xml b/src/main/resources/sun_checks.xml new file mode 100644 index 00000000..b89a3263 --- /dev/null +++ b/src/main/resources/sun_checks.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/v1rex/liftnexus/LiftNexusApplicationTests.java b/src/test/java/com/v1rex/liftnexus/LiftNexusApplicationTests.java new file mode 100644 index 00000000..a37e1418 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/LiftNexusApplicationTests.java @@ -0,0 +1,17 @@ +package com.v1rex.liftnexus; + +import com.v1rex.liftnexus.config.TestContainersConfiguration; +import com.v1rex.liftnexus.config.TimefoldTestConfig; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Import({TestContainersConfiguration.class, TimefoldTestConfig.class}) +@ActiveProfiles("test") +class LiftNexusApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/src/test/java/com/v1rex/liftnexus/config/OpenApiGeneratorTest.java b/src/test/java/com/v1rex/liftnexus/config/OpenApiGeneratorTest.java new file mode 100644 index 00000000..e429f69e --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/config/OpenApiGeneratorTest.java @@ -0,0 +1,49 @@ +package com.v1rex.liftnexus.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + properties = { + "springdoc.api-docs.enabled=true", + "springdoc.use-management-port=false", // Forces it onto the main server context + "springdoc.api-docs.path=/v3/api-docs" // Forces the default path + }) +@AutoConfigureMockMvc +@Import({ + TestContainersConfiguration.class, + TimefoldTestConfig.class, +}) +@ActiveProfiles("test") +@DisplayName("OpenAPI Spec Generator") +class OpenApiGeneratorTest { + + @Autowired private MockMvc mockMvc; + + @Test + void generateOpenApiSpec() throws Exception { + String json = + mockMvc + .perform(get("/v3/api-docs")) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + Path output = Path.of("target", "openapi.json"); + Files.writeString(output, json); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/config/SharedPostgresContainer.java b/src/test/java/com/v1rex/liftnexus/config/SharedPostgresContainer.java new file mode 100644 index 00000000..3a7c1f0c --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/config/SharedPostgresContainer.java @@ -0,0 +1,29 @@ +package com.v1rex.liftnexus.config; + +import org.testcontainers.containers.PostgreSQLContainer; + +public final class SharedPostgresContainer { + + private static final boolean IS_CI = "true".equalsIgnoreCase(System.getenv("CI")); + + private static final PostgreSQLContainer INSTANCE; + + static { + PostgreSQLContainer container = + new PostgreSQLContainer<>("postgres:16") + .withDatabaseName("warehouse_testdb") + .withUsername("test_user") + .withPassword("test_pass"); + + if (!IS_CI) { + container.withReuse(true); + } + + INSTANCE = container; + INSTANCE.start(); + } + + public static PostgreSQLContainer getInstance() { + return INSTANCE; + } +} diff --git a/src/test/java/com/v1rex/liftnexus/config/TestContainersConfiguration.java b/src/test/java/com/v1rex/liftnexus/config/TestContainersConfiguration.java new file mode 100644 index 00000000..984593fc --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/config/TestContainersConfiguration.java @@ -0,0 +1,16 @@ +package com.v1rex.liftnexus.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class TestContainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return SharedPostgresContainer.getInstance(); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/config/TimefoldTestConfig.java b/src/test/java/com/v1rex/liftnexus/config/TimefoldTestConfig.java new file mode 100644 index 00000000..cfe20a53 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/config/TimefoldTestConfig.java @@ -0,0 +1,21 @@ +package com.v1rex.liftnexus.config; + +import ai.timefold.solver.core.config.solver.SolverConfig; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.planning.constraints.WarehouseConstraintProvider; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration(proxyBeanMethods = false) +public class TimefoldTestConfig { + + @Bean + SolverConfig solverConfig() { + return new SolverConfig() + .withSolutionClass(WarehouseSchedule.class) + .withEntityClasses(Forklift.class, TransportOrder.class) + .withConstraintProviderClass(WarehouseConstraintProvider.class); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/controller/ForkliftControllerTest.java b/src/test/java/com/v1rex/liftnexus/forklift/controller/ForkliftControllerTest.java new file mode 100644 index 00000000..e7656f0e --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/controller/ForkliftControllerTest.java @@ -0,0 +1,204 @@ +package com.v1rex.liftnexus.forklift.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.v1rex.liftnexus.common.exception.GlobalExceptionHandler; +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.dto.ForkliftLocationUpdateRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftResponse; +import com.v1rex.liftnexus.forklift.service.ForkliftService; +import java.util.List; +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.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ForkliftController.class) +@Import({GlobalExceptionHandler.class, ForkliftExceptionHandler.class, ProblemDetailFactory.class}) +public class ForkliftControllerTest { + + @Autowired private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean private ForkliftService forkliftService; + + private ForkliftResponse createMockResponse() { + return new ForkliftResponse( + 1L, + "FL-01", + 10L, + "Toyota X", + EquipmentType.STANDARD, + 2000, + 5L, + OperationalStatus.ACTIVE, + 100.0, + List.of()); + } + + @Nested + @DisplayName("Tests - GET /api/v1/forklifts/{id}") + class GetForkliftByIdTest { + + @Test + @DisplayName("Should return 200 OK with requested forklift") + void shouldReturnById() throws Exception { + ForkliftResponse response = createMockResponse(); + + when(forkliftService.findById(1L)).thenReturn(response); + + mockMvc + .perform(get("/api/v1/forklifts/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.fleetNumber").value("FL-01")); + } + } + + @Nested + @DisplayName("Tests - GET /api/v1/forklifts") + class GetAllForkliftsTest { + + @Test + @DisplayName("Should return 200 OK with paginated list of all forklifts") + void shouldReturnPaginatedList() throws Exception { + Page page = new PageImpl<>(List.of(createMockResponse())); + + when(forkliftService.findAll(any(Pageable.class))).thenReturn(page); + + mockMvc + .perform(get("/api/v1/forklifts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(1)) + .andExpect(jsonPath("$.content[0].fleetNumber").value("FL-01")); + } + } + + @Nested + @DisplayName("Tests - GET /api/v1/forklifts/search") + class SearchForkliftsTest { + + @Test + @DisplayName("Branch 1: Should search by minCapacity and return 200 OK") + void shouldSearchByMinCapacity() throws Exception { + Page page = new PageImpl<>(List.of(createMockResponse())); + + when(forkliftService.findWithCapacityGreaterThan(eq(2000), any(Pageable.class))) + .thenReturn(page); + + mockMvc + .perform(get("/api/v1/forklifts/search").param("minCapacity", "2000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].maxCapacityKg").value(2000)); + } + + @Test + @DisplayName("Branch 2: Should search by operational status and return 200 OK") + void shouldSearchByStatus() throws Exception { + Page page = new PageImpl<>(List.of(createMockResponse())); + + when(forkliftService.findByStatus(eq(OperationalStatus.ACTIVE), any(Pageable.class))) + .thenReturn(page); + + mockMvc + .perform(get("/api/v1/forklifts/search").param("status", "ACTIVE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].status").value("ACTIVE")); + } + + @Test + @DisplayName("Branch 3: Should fallback to findAll if no search params provided") + void shouldFallbackToFindAll() throws Exception { + Page page = new PageImpl<>(List.of(createMockResponse())); + + when(forkliftService.findAll(any(Pageable.class))).thenReturn(page); + + mockMvc + .perform(get("/api/v1/forklifts/search")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(1)); + } + } + + @Nested + @DisplayName("Tests - POST /api/v1/forklifts") + class CreateForkliftTest { + + @Test + @DisplayName("Should create forklift, return 201 Created and Location header") + void shouldCreateAndReturn201() throws Exception { + ForkliftRequest request = + new ForkliftRequest("FL-01", 10L, 5L, OperationalStatus.ACTIVE, 100.0); + ForkliftResponse response = createMockResponse(); + + when(forkliftService.createForklift(any(ForkliftRequest.class))).thenReturn(response); + + mockMvc + .perform( + post("/api/v1/forklifts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/api/v1/forklifts/1")) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.fleetNumber").value("FL-01")); + } + } + + @Nested + @DisplayName("Tests - PUT /api/v1/forklifts/{id}/location") + class UpdateForkliftLocationTest { + + @Test + @DisplayName("Should update forklift location and return 200 OK") + void shouldUpdateLocation() throws Exception { + ForkliftLocationUpdateRequest request = new ForkliftLocationUpdateRequest(99L); + ForkliftResponse response = createMockResponse(); + + when(forkliftService.updateForkliftLocation(eq(1L), eq(99L))).thenReturn(response); + + mockMvc + .perform( + put("/api/v1/forklifts/1/location") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)); + } + } + + @Nested + @DisplayName("Tests - PATCH /api/v1/forklifts/{id}/status") + class UpdateOperationalStatusTest { + + @Test + @DisplayName("Should update operational status and return 200 OK") + void shouldUpdateStatus() throws Exception { + ForkliftResponse response = createMockResponse(); + + when(forkliftService.updateOperationalStatus(eq(1L), eq(OperationalStatus.MAINTENANCE))) + .thenReturn(response); + + mockMvc + .perform(patch("/api/v1/forklifts/1/status").param("status", "MAINTENANCE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/controller/ForkliftTypeControllerTest.java b/src/test/java/com/v1rex/liftnexus/forklift/controller/ForkliftTypeControllerTest.java new file mode 100644 index 00000000..aaa4046d --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/controller/ForkliftTypeControllerTest.java @@ -0,0 +1,105 @@ +package com.v1rex.liftnexus.forklift.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.v1rex.liftnexus.common.exception.GlobalExceptionHandler; +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeResponse; +import com.v1rex.liftnexus.forklift.service.ForkliftTypeService; +import java.util.List; +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.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ForkliftTypeController.class) +@Import({GlobalExceptionHandler.class, ProblemDetailFactory.class}) +public class ForkliftTypeControllerTest { + + @Autowired private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean private ForkliftTypeService forkliftTypeService; + + @Nested + @DisplayName("Tests - POST /api/v1/forklift-types") + class CreateForkliftTypeTest { + + @Test + @DisplayName("Should return 201 Created and Location header") + void shouldCreateAndReturn201() throws Exception { + ForkliftTypeRequest request = + new ForkliftTypeRequest("Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + ForkliftTypeResponse response = + new ForkliftTypeResponse(10L, "Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + + when(forkliftTypeService.createForkliftType(any(ForkliftTypeRequest.class))) + .thenReturn(response); + + mockMvc + .perform( + post("/api/v1/forklift-types") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/api/v1/forklift-types/10")) + .andExpect(jsonPath("$.id").value(10)) + .andExpect(jsonPath("$.modelName").value("Toyota X")); + } + } + + @Nested + @DisplayName("Tests - GET /api/v1/forklift-types/{id}") + class GetForkliftTypeByIdTest { + + @Test + @DisplayName("Should return 200 OK with requested blueprint") + void shouldReturnById() throws Exception { + ForkliftTypeResponse response = + new ForkliftTypeResponse(1L, "Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + + when(forkliftTypeService.findById(1L)).thenReturn(response); + + mockMvc + .perform(get("/api/v1/forklift-types/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.modelName").value("Toyota X")); + } + } + + @Nested + @DisplayName("Tests - GET /api/v1/forklift-types") + class GetAllForkliftTypesTest { + + @Test + @DisplayName("Should return 200 OK with paginated list") + void shouldReturnPaginatedList() throws Exception { + ForkliftTypeResponse response = + new ForkliftTypeResponse(1L, "Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + Page page = new PageImpl<>(List.of(response)); + + when(forkliftTypeService.findAll(any(Pageable.class))).thenReturn(page); + + mockMvc + .perform(get("/api/v1/forklift-types")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(1)) + .andExpect(jsonPath("$.content[0].modelName").value("Toyota X")); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/mapper/ForkliftMapperTest.java b/src/test/java/com/v1rex/liftnexus/forklift/mapper/ForkliftMapperTest.java new file mode 100644 index 00000000..fc465a67 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/mapper/ForkliftMapperTest.java @@ -0,0 +1,120 @@ +package com.v1rex.liftnexus.forklift.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.dto.ForkliftRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftResponse; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class ForkliftMapperTest { + + private final ForkliftMapper mapper = new ForkliftMapper(); + + @Nested + @DisplayName("Tests - toEntity(ForkliftRequest)") + class ToEntityTest { + + @Test + @DisplayName("Should map request to entity with default fallback values") + void shouldMapToEntityWithDefaults() { + ForkliftRequest request = new ForkliftRequest("FL-01", 1L, null, null, null); + + Forklift entity = mapper.toEntity(request); + + assertNotNull(entity); + assertEquals("FL-01", entity.getFleetNumber()); + assertEquals(OperationalStatus.OFFLINE, entity.getStatus()); // Default branch hit + assertEquals(100.0, entity.getCurrentBatteryPercentage()); // Default branch hit + } + + @Test + @DisplayName("Should map request to entity with explicit values") + void shouldMapToEntityWithExplicitValues() { + ForkliftRequest request = + new ForkliftRequest("FL-02", 1L, 5L, OperationalStatus.ACTIVE, 85.5); + + Forklift entity = mapper.toEntity(request); + + assertEquals("FL-02", entity.getFleetNumber()); + assertEquals(OperationalStatus.ACTIVE, entity.getStatus()); + assertEquals(85.5, entity.getCurrentBatteryPercentage()); + } + + @Test + @DisplayName("Should return null when request is null") + void shouldReturnNullWhenRequestIsNull() { + assertNull(mapper.toEntity(null)); + } + } + + @Nested + @DisplayName("Tests - toResponse(Forklift)") + class ToResponseTest { + + @Test + @DisplayName("Should map fully populated entity to response") + void shouldMapPopulatedEntity() { + ForkliftType type = + ForkliftType.builder() + .id(10L) + .modelName("Toyota X") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(2000) + .build(); + StorageBin bin = StorageBin.builder().id(55L).build(); + TransportOrder order = TransportOrder.builder().id(100L).build(); + + Forklift entity = + Forklift.builder() + .id(1L) + .fleetNumber("FL-01") + .forkliftType(type) + .currentStorageBin(bin) + .status(OperationalStatus.ACTIVE) + .currentBatteryPercentage(90.0) + .transportOrders(List.of(order)) + .build(); + + ForkliftResponse response = mapper.toResponse(entity); + + assertEquals(1L, response.id()); + assertEquals("FL-01", response.fleetNumber()); + assertEquals(10L, response.forkliftTypeId()); + assertEquals("Toyota X", response.modelName()); + assertEquals(EquipmentType.STANDARD, response.equipmentType()); + assertEquals(2000, response.maxCapacityKg()); + assertEquals(55L, response.currentStorageBinId()); + assertEquals(OperationalStatus.ACTIVE, response.status()); + assertEquals(90.0, response.currentBatteryPercentage()); + assertTrue(response.transportOrderIds().contains(100L)); + } + + @Test + @DisplayName("Should handle entity with null relationships safely") + void shouldHandleNullRelationships() { + Forklift entity = + Forklift.builder().id(2L).fleetNumber("FL-02").build(); // No relationships attached + + ForkliftResponse response = mapper.toResponse(entity); + + assertNull(response.forkliftTypeId()); + assertNull(response.currentStorageBinId()); + assertTrue(response.transportOrderIds().isEmpty()); // Null safe list check + } + + @Test + @DisplayName("Should return null when entity is null") + void shouldReturnNullWhenEntityIsNull() { + assertNull(mapper.toResponse(null)); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/mapper/ForkliftTypeMapperTest.java b/src/test/java/com/v1rex/liftnexus/forklift/mapper/ForkliftTypeMapperTest.java new file mode 100644 index 00000000..95e84073 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/mapper/ForkliftTypeMapperTest.java @@ -0,0 +1,78 @@ +package com.v1rex.liftnexus.forklift.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class ForkliftTypeMapperTest { + + private final ForkliftTypeMapper mapper = new ForkliftTypeMapper(); + + @Nested + @DisplayName("Tests - toEntity(ForkliftTypeRequest)") + class ToEntityTest { + + @Test + @DisplayName("Should map request to entity successfully") + void shouldMapToEntity() { + ForkliftTypeRequest request = + new ForkliftTypeRequest("Toyota Traigo 48", EquipmentType.STANDARD, 2000, 50.0, 0.5); + + ForkliftType entity = mapper.toEntity(request); + + assertNotNull(entity); + assertEquals("Toyota Traigo 48", entity.getModelName()); + assertEquals(EquipmentType.STANDARD, entity.getEquipmentType()); + assertEquals(2000, entity.getMaxCapacityKg()); + assertEquals(50.0, entity.getTotalBatteryCapacitykWh()); + assertEquals(0.5, entity.getBaseEnergyConsumptionPerMeter()); + } + + @Test + @DisplayName("Should return null when request is null") + void shouldReturnNullWhenRequestIsNull() { + assertNull(mapper.toEntity(null)); + } + } + + @Nested + @DisplayName("Tests - toResponse(ForkliftType)") + class ToResponseTest { + + @Test + @DisplayName("Should map entity to response successfully") + void shouldMapToResponse() { + ForkliftType entity = + ForkliftType.builder() + .id(1L) + .modelName("Toyota Traigo 48") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(2000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + ForkliftTypeResponse response = mapper.toResponse(entity); + + assertNotNull(response); + assertEquals(1L, response.id()); + assertEquals("Toyota Traigo 48", response.modelName()); + assertEquals(EquipmentType.STANDARD, response.equipmentType()); + assertEquals(2000, response.maxCapacityKg()); + assertEquals(50.0, response.totalBatteryCapacitykWh()); + assertEquals(0.5, response.baseEnergyConsumptionPerMeter()); + } + + @Test + @DisplayName("Should return null when entity is null") + void shouldReturnNullWhenEntityIsNull() { + assertNull(mapper.toResponse(null)); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/repository/ForkliftRepositoryTest.java b/src/test/java/com/v1rex/liftnexus/forklift/repository/ForkliftRepositoryTest.java new file mode 100644 index 00000000..b725b196 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/repository/ForkliftRepositoryTest.java @@ -0,0 +1,261 @@ +package com.v1rex.liftnexus.forklift.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +import com.v1rex.liftnexus.config.TestContainersConfiguration; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import java.util.Optional; +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.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +public class ForkliftRepositoryTest { + + @Autowired private ForkliftRepository forkliftRepository; + + @Autowired private ForkliftTypeRepository forkliftTypeRepository; + + private ForkliftType lightType; + private ForkliftType heavyType; + + @BeforeEach + void setUp() { + lightType = + forkliftTypeRepository.save( + ForkliftType.builder() + .modelName("Light Model") + .equipmentType(EquipmentType.PALLET_JACK) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build()); + + heavyType = + forkliftTypeRepository.save( + ForkliftType.builder() + .modelName("Heavy Model") + .equipmentType(EquipmentType.REACH_TRUCK) + .maxCapacityKg(3000) + .totalBatteryCapacitykWh(150.0) + .baseEnergyConsumptionPerMeter(2.0) + .build()); + } + + @Nested + @DisplayName("Database Constraint & Validation Tests") + class ConstraintTests { + + @Test + @DisplayName("Should throw exception when fleet number is null (@NotBlank)") + void shouldThrowException_WhenFleetNumberIsNull() { + Forklift invalidForklift = + Forklift.builder().fleetNumber(null).forkliftType(lightType).build(); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(invalidForklift)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when fleet number is empty string (@NotBlank)") + void shouldThrowException_WhenFleetNumberIsEmpty() { + Forklift invalidForklift = Forklift.builder().fleetNumber("").forkliftType(lightType).build(); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(invalidForklift)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when fleet number is blank string (@NotBlank)") + void shouldThrowException_WhenFleetNumberIsBlank() { + Forklift invalidForklift = + Forklift.builder().fleetNumber(" ").forkliftType(lightType).build(); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(invalidForklift)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when forklift type reference is null (@NotNull)") + void shouldThrowException_WhenForkliftTypeIsNull() { + Forklift invalidForklift = + Forklift.builder().fleetNumber("FL-VALID-ID").forkliftType(null).build(); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(invalidForklift)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when fleet number is duplicated (Unique Constraint)") + void shouldThrowException_WhenFleetNumberIsDuplicated() { + Forklift forklift1 = Forklift.builder().fleetNumber("FL-01").forkliftType(lightType).build(); + Forklift forklift2 = Forklift.builder().fleetNumber("FL-01").forkliftType(heavyType).build(); + + forkliftRepository.saveAndFlush(forklift1); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(forklift2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("Should apply default builder values for status and battery percentage") + void shouldApplyDefaultValues() { + Forklift forklift = + Forklift.builder().fleetNumber("FL-DEFAULTS").forkliftType(lightType).build(); + + Forklift saved = forkliftRepository.saveAndFlush(forklift); + + assertThat(saved.getStatus()).isEqualTo(OperationalStatus.OFFLINE); + assertThat(saved.getCurrentBatteryPercentage()).isEqualTo(100.0); + assertThat(saved.getTransportOrders()).isNotNull(); + } + + @Test + @DisplayName("Should throw exception when operational status is explicitly set to null") + void shouldThrowException_WhenStatusIsNull() { + Forklift invalidForklift = + Forklift.builder() + .fleetNumber("FL-NULL-STATUS") + .forkliftType(lightType) + .status(null) + .build(); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(invalidForklift)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("Should throw exception when current battery percentage is explicitly set to null") + void shouldThrowException_WhenBatteryPercentageIsNull() { + Forklift invalidForklift = + Forklift.builder() + .fleetNumber("FL-NULL-BATTERY") + .forkliftType(lightType) + .currentBatteryPercentage(null) + .build(); + + assertThatThrownBy(() -> forkliftRepository.saveAndFlush(invalidForklift)) + .isInstanceOf(DataIntegrityViolationException.class); + } + } + + @Nested + @DisplayName("Custom Query Method Tests") + class QueryTests { + + @BeforeEach + void seedData() { + Forklift activeForklift = + Forklift.builder() + .fleetNumber("FL-01") + .forkliftType(lightType) + .status(OperationalStatus.ACTIVE) + .build(); + + Forklift maintenanceForklift = + Forklift.builder() + .fleetNumber("FL-02") + .forkliftType(heavyType) + .status(OperationalStatus.MAINTENANCE) + .build(); + + forkliftRepository.saveAll(List.of(activeForklift, maintenanceForklift)); + forkliftRepository.flush(); + } + + @Test + @DisplayName("existsByFleetNumber should return true if exists, false otherwise") + void shouldCheckExistenceByFleetNumber() { + assertTrue(forkliftRepository.existsByFleetNumber("FL-01")); + assertFalse(forkliftRepository.existsByFleetNumber("FL-NON-EXISTENT")); + } + + @Test + @DisplayName("findById should fetch forklift with initialized EntityGraph targets") + void shouldFindByIdWithEntityGraph() { + Forklift target = forkliftRepository.findAll().get(0); + + Optional resultOpt = forkliftRepository.findById(target.getId()); + + assertTrue(resultOpt.isPresent()); + Forklift result = resultOpt.get(); + assertEquals(target.getFleetNumber(), result.getFleetNumber()); + assertNotNull(result.getForkliftType()); + assertEquals( + target.getForkliftType().getModelName(), result.getForkliftType().getModelName()); + } + + @Test + @DisplayName("findAll() without arguments should utilize EntityGraph successfully") + void shouldExecuteFindAllListWithEntityGraph() { + List results = forkliftRepository.findAll(); + + assertThat(results.size()).isEqualTo(2); + assertThat(results.get(0).getForkliftType()).isNotNull(); + } + + @Test + @DisplayName("findAll(Pageable) should return a paginated slice utilizing EntityGraph elements") + void shouldExecuteFindAllPagedWithEntityGraph() { + Page pageResult = forkliftRepository.findAll(PageRequest.of(0, 1)); + + assertNotNull(pageResult); + assertEquals(2, pageResult.getTotalElements()); + assertEquals(1, pageResult.getContent().size()); + assertNotNull(pageResult.getContent().get(0).getForkliftType()); + } + + @Test + @DisplayName("Should correctly filter and paginate forklifts by Operational Status") + void shouldFindForkliftsByStatus() { + Page result = + forkliftRepository.findByStatus(OperationalStatus.ACTIVE, PageRequest.of(0, 10)); + + assertEquals(1, result.getTotalElements()); + assertEquals("FL-01", result.getContent().get(0).getFleetNumber()); + } + + @Test + @DisplayName("Should return empty page configuration if no forklift matches status filter") + void shouldReturnEmptyPageWhenNoStatusMatches() { + Page result = + forkliftRepository.findByStatus(OperationalStatus.OFFLINE, PageRequest.of(0, 10)); + assertEquals(0, result.getTotalElements()); + assertTrue(result.getContent().isEmpty()); + } + + @Test + @DisplayName( + "Should correctly filter and paginate forklifts by joined ForkliftType max capacity") + void shouldFindForkliftsWithCapacityGreaterThanEqual() { + Page result = + forkliftRepository.findByForkliftType_MaxCapacityKgGreaterThanEqual( + 2000, PageRequest.of(0, 10)); + + assertNotNull(result); + assertEquals( + 1, result.getTotalElements(), "Should match exactly 1 forklift with archetype >= 2000kg"); + + Forklift found = result.getContent().get(0); + assertEquals("FL-02", found.getFleetNumber()); + assertEquals(3000, found.getForkliftType().getMaxCapacityKg()); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/repository/ForkliftTypeRepositoryTest.java b/src/test/java/com/v1rex/liftnexus/forklift/repository/ForkliftTypeRepositoryTest.java new file mode 100644 index 00000000..cf03c915 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/repository/ForkliftTypeRepositoryTest.java @@ -0,0 +1,272 @@ +package com.v1rex.liftnexus.forklift.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.v1rex.liftnexus.config.TestContainersConfiguration; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import jakarta.validation.ConstraintViolationException; +import java.util.Optional; +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.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +public class ForkliftTypeRepositoryTest { + + @Autowired private ForkliftTypeRepository forkliftTypeRepository; + + @Nested + @DisplayName("Database Constraint & Validation Tests") + class ConstraintTests { + + @Test + @DisplayName("Should throw exception when model name is duplicated (Unique Constraint)") + void shouldThrowException_WhenModelNameIsDuplicated() { + ForkliftType type1 = + ForkliftType.builder() + .modelName("Model-X") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + ForkliftType type2 = + ForkliftType.builder() + .modelName("Model-X") + .equipmentType(EquipmentType.REACH_TRUCK) + .maxCapacityKg(2000) + .totalBatteryCapacitykWh(60.0) + .baseEnergyConsumptionPerMeter(0.6) + .build(); + + forkliftTypeRepository.saveAndFlush(type1); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(type2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("Should throw exception when model name is null (@NotBlank)") + void shouldThrowException_WhenModelNameIsNull() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName(null) + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when model name is empty string (@NotBlank)") + void shouldThrowException_WhenModelNameIsEmpty() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when model name is only spaces (@NotBlank)") + void shouldThrowException_WhenModelNameIsBlank() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName(" ") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when equipment type is null (@NotNull)") + void shouldThrowException_WhenEquipmentTypeIsNull() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-Valid") + .equipmentType(null) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when max capacity is zero (@Positive)") + void shouldThrowException_WhenCapacityIsZero() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-Y") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(0) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when max capacity is negative (@Positive)") + void shouldThrowException_WhenCapacityIsNegative() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-Y") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(-500) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when battery capacity is zero (@Positive)") + void shouldThrowException_WhenBatteryCapacityIsZero() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-B") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(0.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when battery capacity is negative (@Positive)") + void shouldThrowException_WhenBatteryCapacityIsNegative() { + + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-B") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(-12.5) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when base energy consumption is zero (@Positive)") + void shouldThrowException_WhenEnergyConsumptionIsZero() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-E") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.0) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + + @Test + @DisplayName("Should throw exception when base energy consumption is negative (@Positive)") + void shouldThrowException_WhenEnergyConsumptionIsNegative() { + ForkliftType invalidType = + ForkliftType.builder() + .modelName("Model-E") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(-0.1) + .build(); + + assertThatThrownBy(() -> forkliftTypeRepository.saveAndFlush(invalidType)) + .isInstanceOf(ConstraintViolationException.class); + } + } + + @Nested + @DisplayName("Custom Query Method Tests") + class QueryTests { + + @Test + @DisplayName("existsByModelName should return true if exists, false otherwise") + void shouldCheckExistenceByModelName() { + ForkliftType type = + ForkliftType.builder() + .modelName("Model-Z") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + forkliftTypeRepository.saveAndFlush(type); + + assertTrue(forkliftTypeRepository.existsByModelName("Model-Z")); + assertFalse(forkliftTypeRepository.existsByModelName("Model-None")); + } + + @Test + @DisplayName("findByModelName should find structural data object by exact match name string") + void shouldFindByModelName() { + ForkliftType type = + ForkliftType.builder() + .modelName("Model-A") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.5) + .build(); + + forkliftTypeRepository.saveAndFlush(type); + + Optional found = forkliftTypeRepository.findByModelName("Model-A"); + + assertTrue(found.isPresent()); + assertThat(found.get().getModelName()).isEqualTo("Model-A"); + } + + @Test + @DisplayName( + "findByModelName should return empty optional container if no database matches exist") + void shouldReturnEmptyOptionalWhenModelDoesNotExist() { + Optional found = forkliftTypeRepository.findByModelName("Model-NonExistent"); + assertFalse(found.isPresent()); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/service/ForkliftServiceTest.java b/src/test/java/com/v1rex/liftnexus/forklift/service/ForkliftServiceTest.java new file mode 100644 index 00000000..2a160c11 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/service/ForkliftServiceTest.java @@ -0,0 +1,292 @@ +package com.v1rex.liftnexus.forklift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.domain.OperationalStatus; +import com.v1rex.liftnexus.forklift.dto.ForkliftRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftResponse; +import com.v1rex.liftnexus.forklift.exception.ForkliftFleetNumberExistsException; +import com.v1rex.liftnexus.forklift.exception.ForkliftNotFoundException; +import com.v1rex.liftnexus.forklift.mapper.ForkliftMapper; +import com.v1rex.liftnexus.forklift.repository.ForkliftRepository; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +public class ForkliftServiceTest { + + @Mock private ForkliftRepository forkliftRepository; + @Mock private ForkliftTypeService forkliftTypeService; + @Mock private StorageBinService storageBinService; + @Mock private ForkliftMapper forkliftMapper; + + @InjectMocks private ForkliftService forkliftService; + + @Nested + @DisplayName("Tests - createForklift()") + class CreateForkliftTests { + + @Test + @DisplayName("Should provision a new forklift successfully with a storage bin") + void shouldCreateForkliftWithStorageBin() { + ForkliftRequest request = + new ForkliftRequest("FL-01", 1L, 2L, OperationalStatus.ACTIVE, 100.0); + + ForkliftType mockType = new ForkliftType(); + StorageBin mockBin = new StorageBin(); + + Forklift mappedEntity = new Forklift(); + Forklift savedEntity = new Forklift(); + savedEntity.setId(99L); + + ForkliftResponse expectedResponse = + new ForkliftResponse( + 99L, "FL-01", 1L, "Model", null, 1000, 2L, OperationalStatus.ACTIVE, 100.0, null); + + when(forkliftRepository.existsByFleetNumber("FL-01")).thenReturn(false); + + when(forkliftTypeService.findEntityById(1L)).thenReturn(mockType); + + when(storageBinService.findEntityById(2L)).thenReturn(mockBin); + + when(forkliftMapper.toEntity(request)).thenReturn(mappedEntity); + + when(forkliftRepository.save(mappedEntity)).thenReturn(savedEntity); + + when(forkliftMapper.toResponse(savedEntity)).thenReturn(expectedResponse); + + ForkliftResponse actualResponse = forkliftService.createForklift(request); + + assertThat(actualResponse.id()).isEqualTo(99L); + assertThat(mappedEntity.getForkliftType()).isEqualTo(mockType); + assertThat(mappedEntity.getCurrentStorageBin()).isEqualTo(mockBin); + } + + @Test + @DisplayName("Should provision a new forklift successfully without a storage bin (null check)") + void shouldCreateForkliftWithoutStorageBin() { + ForkliftRequest request = + new ForkliftRequest("FL-02", 1L, null, OperationalStatus.ACTIVE, 100.0); + + ForkliftType mockType = new ForkliftType(); + + Forklift mappedEntity = new Forklift(); + Forklift savedEntity = new Forklift(); + savedEntity.setId(100L); + + ForkliftResponse expectedResponse = + new ForkliftResponse( + 100L, "FL-02", 1L, "Model", null, 1000, null, OperationalStatus.ACTIVE, 100.0, null); + + when(forkliftRepository.existsByFleetNumber("FL-02")).thenReturn(false); + when(forkliftTypeService.findEntityById(1L)).thenReturn(mockType); + when(forkliftMapper.toEntity(request)).thenReturn(mappedEntity); + when(forkliftRepository.save(mappedEntity)).thenReturn(savedEntity); + when(forkliftMapper.toResponse(savedEntity)).thenReturn(expectedResponse); + + ForkliftResponse actualResponse = forkliftService.createForklift(request); + + assertThat(actualResponse.id()).isEqualTo(100L); + assertThat(mappedEntity.getForkliftType()).isEqualTo(mockType); + assertThat(mappedEntity.getCurrentStorageBin()).isNull(); + verifyNoInteractions(storageBinService); + } + + @Test + @DisplayName("Should throw ForkliftFleetNumberExistsException if fleet number exists") + void shouldThrowIfFleetNumberExists() { + ForkliftRequest request = new ForkliftRequest("FL-DUP", 1L, null, null, null); + when(forkliftRepository.existsByFleetNumber("FL-DUP")).thenReturn(true); + + assertThatThrownBy(() -> forkliftService.createForklift(request)) + .isInstanceOf(ForkliftFleetNumberExistsException.class); + + verifyNoInteractions(forkliftTypeService, storageBinService, forkliftMapper); + } + } + + @Nested + @DisplayName("Tests - findById()") + class FindByIdTests { + + @Test + @DisplayName("Should find response by ID") + void shouldFindById() { + Forklift entity = new Forklift(); + ForkliftResponse expectedResponse = + new ForkliftResponse(1L, "FL-01", null, null, null, null, null, null, null, null); + + when(forkliftRepository.findById(1L)).thenReturn(Optional.of(entity)); + when(forkliftMapper.toResponse(entity)).thenReturn(expectedResponse); + + ForkliftResponse response = forkliftService.findById(1L); + + assertThat(response).isEqualTo(expectedResponse); + } + } + + @Nested + @DisplayName("Tests - Retrieval & Pagination Methods") + class RetrievalTests { + + private final Pageable pageable = PageRequest.of(0, 10); + private final Forklift entity = new Forklift(); + private final ForkliftResponse responseDto = + new ForkliftResponse(1L, "FL-01", null, null, null, null, null, null, null, null); + + @Test + @DisplayName("Should return all forklifts paginated") + void shouldFindAll() { + Page page = new PageImpl<>(List.of(entity)); + + when(forkliftRepository.findAll(pageable)).thenReturn(page); + when(forkliftMapper.toResponse(entity)).thenReturn(responseDto); + + Page result = forkliftService.findAll(pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst()).isEqualTo(responseDto); + } + + @Test + @DisplayName("Should return forklifts filtered by capacity") + void shouldFindWithCapacityGreaterThan() { + Page page = new PageImpl<>(List.of(entity)); + + when(forkliftRepository.findByForkliftType_MaxCapacityKgGreaterThanEqual(2000, pageable)) + .thenReturn(page); + when(forkliftMapper.toResponse(entity)).thenReturn(responseDto); + + Page result = forkliftService.findWithCapacityGreaterThan(2000, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst()).isEqualTo(responseDto); + } + + @Test + @DisplayName("Should return forklifts filtered by status") + void shouldFindByStatus() { + Page page = new PageImpl<>(List.of(entity)); + + when(forkliftRepository.findByStatus(OperationalStatus.ACTIVE, pageable)).thenReturn(page); + when(forkliftMapper.toResponse(entity)).thenReturn(responseDto); + + Page result = + forkliftService.findByStatus(OperationalStatus.ACTIVE, pageable); + + assertThat(result.getContent()).hasSize(1); + + assertThat(result.getContent().getFirst()).isEqualTo(responseDto); + } + } + + @Nested + @DisplayName("Tests - State Transitions (Updates)") + class UpdateTests { + + @Test + @DisplayName("Should update forklift location and save") + void shouldUpdateLocation() { + Forklift forklift = new Forklift(); + StorageBin newBin = new StorageBin(); + ForkliftResponse responseDto = + new ForkliftResponse(1L, null, null, null, null, null, 5L, null, null, null); + + when(forkliftRepository.findById(1L)).thenReturn(Optional.of(forklift)); + when(storageBinService.findEntityById(5L)).thenReturn(newBin); + when(forkliftRepository.save(forklift)).thenReturn(forklift); + when(forkliftMapper.toResponse(forklift)).thenReturn(responseDto); + + ForkliftResponse result = forkliftService.updateForkliftLocation(1L, 5L); + + assertThat(forklift.getCurrentStorageBin()).isEqualTo(newBin); + assertThat(result).isEqualTo(responseDto); + verify(forkliftRepository).save(forklift); + } + + @Test + @DisplayName("Should update operational status and save") + void shouldUpdateOperationalStatus() { + Forklift forklift = new Forklift(); + ForkliftResponse responseDto = + new ForkliftResponse( + 1L, null, null, null, null, null, null, OperationalStatus.MAINTENANCE, null, null); + + when(forkliftRepository.findById(1L)).thenReturn(Optional.of(forklift)); + + when(forkliftRepository.save(forklift)).thenReturn(forklift); + + when(forkliftMapper.toResponse(forklift)).thenReturn(responseDto); + + ForkliftResponse result = + forkliftService.updateOperationalStatus(1L, OperationalStatus.MAINTENANCE); + + assertThat(forklift.getStatus()).isEqualTo(OperationalStatus.MAINTENANCE); + assertThat(result).isEqualTo(responseDto); + verify(forkliftRepository).save(forklift); + } + } + + @Nested + @DisplayName("Tests - findEntityById()") + class FindEntityByIdTests { + + @Test + @DisplayName("Should return entity if found") + void shouldReturnEntity() { + Forklift forklift = new Forklift(); + when(forkliftRepository.findById(1L)).thenReturn(Optional.of(forklift)); + + Forklift result = forkliftService.findEntityById(1L); + + assertThat(result).isEqualTo(forklift); + } + + @Test + @DisplayName("Should throw ForkliftNotFoundException if forklift not found") + void shouldThrowIfForkliftNotFound() { + when(forkliftRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> forkliftService.findEntityById(99L)) + .isInstanceOf(ForkliftNotFoundException.class) + .hasMessageContaining("Forklift with ID 99 does not exist."); + } + } + + @Nested + @DisplayName("Tests - findAllEntitiesForPlanning()") + class FindAllEntitiesForPlanningTests { + + @Test + @DisplayName("Should return raw list of entities for solver") + void shouldReturnEntityList() { + Forklift forklift1 = new Forklift(); + Forklift forklift2 = new Forklift(); + List mockList = List.of(forklift1, forklift2); + + when(forkliftRepository.findAll()).thenReturn(mockList); + + List result = forkliftService.findAllEntities(); + + assertThat(result).hasSize(2); + assertThat(result).containsExactly(forklift1, forklift2); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/forklift/service/ForkliftTypeServiceTest.java b/src/test/java/com/v1rex/liftnexus/forklift/service/ForkliftTypeServiceTest.java new file mode 100644 index 00000000..3ecf3bb0 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/forklift/service/ForkliftTypeServiceTest.java @@ -0,0 +1,143 @@ +package com.v1rex.liftnexus.forklift.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeRequest; +import com.v1rex.liftnexus.forklift.dto.ForkliftTypeResponse; +import com.v1rex.liftnexus.forklift.exception.ForkliftTypeNameExistsException; +import com.v1rex.liftnexus.forklift.exception.ForkliftTypeNotFoundException; +import com.v1rex.liftnexus.forklift.mapper.ForkliftTypeMapper; +import com.v1rex.liftnexus.forklift.repository.ForkliftTypeRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +public class ForkliftTypeServiceTest { + + @Mock private ForkliftTypeRepository forkliftTypeRepository; + @Mock private ForkliftTypeMapper forkliftTypeMapper; + + @InjectMocks private ForkliftTypeService forkliftTypeService; + + @Nested + @DisplayName("Tests - createForkliftType()") + class CreateForkliftTypeTests { + + @Test + @DisplayName("Should create and return new blueprint") + void shouldCreateForkliftType() { + ForkliftTypeRequest request = + new ForkliftTypeRequest("Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + + ForkliftType mappedEntity = new ForkliftType(); + ForkliftType savedEntity = new ForkliftType(); + savedEntity.setId(1L); + ForkliftTypeResponse expectedResponse = + new ForkliftTypeResponse(1L, "Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + + when(forkliftTypeRepository.existsByModelName("Toyota X")).thenReturn(false); + when(forkliftTypeMapper.toEntity(request)).thenReturn(mappedEntity); + when(forkliftTypeRepository.save(mappedEntity)).thenReturn(savedEntity); + when(forkliftTypeMapper.toResponse(savedEntity)).thenReturn(expectedResponse); + + ForkliftTypeResponse actualResponse = forkliftTypeService.createForkliftType(request); + + assertThat(actualResponse).isEqualTo(expectedResponse); + // State based verify because mappedEntity is a real object + verify(forkliftTypeRepository).save(mappedEntity); + } + + @Test + @DisplayName("Should throw exception if model name already exists") + void shouldThrowIfModelNameExists() { + ForkliftTypeRequest request = + new ForkliftTypeRequest("Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + when(forkliftTypeRepository.existsByModelName("Toyota X")).thenReturn(true); + + assertThatThrownBy(() -> forkliftTypeService.createForkliftType(request)) + .isInstanceOf(ForkliftTypeNameExistsException.class) + .hasMessageContaining("already exists"); + + verifyNoInteractions(forkliftTypeMapper); + verify(forkliftTypeRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("Tests - findEntityById() & findById()") + class FindByIdTests { + + @Test + @DisplayName("Should return entity when ID exists") + void shouldReturnEntity() { + ForkliftType entity = new ForkliftType(); + entity.setId(1L); + when(forkliftTypeRepository.findById(1L)).thenReturn(Optional.of(entity)); + + ForkliftType result = forkliftTypeService.findEntityById(1L); + + assertThat(result).isEqualTo(entity); + } + + @Test + @DisplayName("Should throw ForkliftTypeNotFoundException when ID does not exist") + void shouldThrowWhenNotFound() { + when(forkliftTypeRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> forkliftTypeService.findEntityById(99L)) + .isInstanceOf(ForkliftTypeNotFoundException.class); + } + + @Test + @DisplayName("Should return response DTO when ID exists") + void shouldReturnResponseDto() { + ForkliftType entity = new ForkliftType(); + ForkliftTypeResponse response = + new ForkliftTypeResponse(1L, "Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + + when(forkliftTypeRepository.findById(1L)).thenReturn(Optional.of(entity)); + when(forkliftTypeMapper.toResponse(entity)).thenReturn(response); + + ForkliftTypeResponse result = forkliftTypeService.findById(1L); + + assertThat(result).isEqualTo(response); + } + } + + @Nested + @DisplayName("Tests - findAll()") + class FindAllTests { + + @Test + @DisplayName("Should return paginated list of forklift types") + void shouldReturnPaginatedList() { + ForkliftType entity = new ForkliftType(); + ForkliftTypeResponse responseDto = + new ForkliftTypeResponse(1L, "Toyota X", EquipmentType.STANDARD, 2000, 50.0, 0.5); + PageRequest pageRequest = PageRequest.of(0, 10); + Page page = new PageImpl<>(List.of(entity)); + + when(forkliftTypeRepository.findAll(pageRequest)).thenReturn(page); + when(forkliftTypeMapper.toResponse(entity)).thenReturn(responseDto); + + Page result = forkliftTypeService.findAll(pageRequest); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst()).isEqualTo(responseDto); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitControllerTest.java b/src/test/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitControllerTest.java new file mode 100644 index 00000000..ba54c527 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/loadunit/controller/LoadUnitControllerTest.java @@ -0,0 +1,148 @@ +package com.v1rex.liftnexus.loadunit.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.v1rex.liftnexus.common.exception.GlobalExceptionHandler; +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitRequest; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitResponse; +import com.v1rex.liftnexus.loadunit.exception.LoadUnitNotFoundException; +import com.v1rex.liftnexus.loadunit.service.LoadUnitService; +import java.util.List; +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.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(LoadUnitController.class) +@Import({GlobalExceptionHandler.class, LoadUnitExceptionHandler.class, ProblemDetailFactory.class}) +@DisplayName("LoadUnitController Gateway Endpoint Tests") +class LoadUnitControllerTest { + + @Autowired private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean private LoadUnitService loadUnitService; + + @Nested + @DisplayName("Endpoint: POST /api/v1/load-units") + class CreateLoadUnitEndpoint { + + @Test + @DisplayName( + "Should accept correct JSON configurations and return 201 Created status with accurate context locations") + void shouldCreateLoadUnitAndReturnCreated() throws Exception { + LoadUnitRequest validRequest = + new LoadUnitRequest("LU-CTRL-01", 620, LoadUnitStatus.STORED, 5L); + LoadUnitResponse mockResponse = + new LoadUnitResponse(42L, "LU-CTRL-01", 620, LoadUnitStatus.STORED, 5L, 1L); + + when(loadUnitService.createLoadUnit(any(LoadUnitRequest.class))).thenReturn(mockResponse); + + mockMvc + .perform( + post("/api/v1/load-units") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validRequest))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/api/v1/load-units/42")) + .andExpect(jsonPath("$.id").value(42)) + .andExpect(jsonPath("$.trackingCode").value("LU-CTRL-01")) + .andExpect(jsonPath("$.weightKg").value(620)) + .andExpect(jsonPath("$.currentStorageBinId").value(5)); + } + + @Test + @DisplayName( + "Should capture validation payload defects and decline request processing early with 400 Bad Request status") + void shouldReturnBadRequestOnValidationFailure() throws Exception { + // Defect: Empty tracking code, invalid negative mass weight parameter values + LoadUnitRequest structuralDefectPayload = + new LoadUnitRequest("", -50, LoadUnitStatus.EXPECTED, null); + + mockMvc + .perform( + post("/api/v1/load-units") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(structuralDefectPayload))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Endpoint: GET /api/v1/load-units/{id}") + class GetLoadUnitEndpoint { + + @Test + @DisplayName( + "Should serialize response accurately with 200 OK status if database element match is confirmed") + void shouldReturnLoadUnitWhenFound() throws Exception { + LoadUnitResponse activeResponse = + new LoadUnitResponse(12L, "LU-FOUND", 15, LoadUnitStatus.SHIPPED, null, 0L); + when(loadUnitService.findById(12L)).thenReturn(activeResponse); + + mockMvc + .perform(get("/api/v1/load-units/12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.trackingCode").value("LU-FOUND")) + .andExpect(jsonPath("$.status").value("SHIPPED")); + } + + @Test + @DisplayName( + "Should transform infrastructure exception maps to standard 404 Not Found returns securely") + void shouldReturnNotFoundOnMissingElement() throws Exception { + when(loadUnitService.findById(404L)).thenThrow(new LoadUnitNotFoundException(404L)); + + mockMvc.perform(get("/api/v1/load-units/404")).andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("Endpoint: GET /api/v1/load-units/status/{status}") + class FilterLoadUnitsEndpoint { + + @Test + @DisplayName( + "Should accept requests and pass correct sorting parameter schemas out to consumer loops") + void shouldReturnPaginatedListFilteredByStatus() throws Exception { + LoadUnitResponse response = + new LoadUnitResponse(1L, "LU-PAGED", 80, LoadUnitStatus.STAGED, 2L, 0L); + + Pageable expectedPageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "id")); + + PageImpl responsePage = + new PageImpl<>(List.of(response), expectedPageable, 1); + + when(loadUnitService.findByStatus(LoadUnitStatus.STAGED, expectedPageable)) + .thenReturn(responsePage); + + mockMvc + .perform( + get("/api/v1/load-units/status/STAGED") + .param("page", "0") + .param( + "size", + "20")) // Spring automagically appends sort="id,asc" based on @PageableDefault + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].trackingCode").value("LU-PAGED")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/loadunit/mapper/LoadUnitMapperTest.java b/src/test/java/com/v1rex/liftnexus/loadunit/mapper/LoadUnitMapperTest.java new file mode 100644 index 00000000..ad07d936 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/loadunit/mapper/LoadUnitMapperTest.java @@ -0,0 +1,79 @@ +package com.v1rex.liftnexus.loadunit.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitRequest; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitResponse; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("LoadUnitMapper Unit Tests") +class LoadUnitMapperTest { + + private final LoadUnitMapper mapper = new LoadUnitMapper(); + + @Test + @DisplayName("Should map complete Request payload to Entity structure cleanly") + void shouldMapRequestToEntity() { + LoadUnitRequest request = new LoadUnitRequest("LU-MAPPED-1", 450, LoadUnitStatus.EXPECTED, 10L); + StorageBin mockBin = StorageBin.builder().id(10L).binCode("BIN-A-01").build(); + + LoadUnit result = mapper.toEntity(request, mockBin); + + assertThat(result).isNotNull(); + assertThat(result.getTrackingCode()).isEqualTo("LU-MAPPED-1"); + assertThat(result.getWeightKg()).isEqualTo(450); + assertThat(result.getStatus()).isEqualTo(LoadUnitStatus.EXPECTED); + assertThat(result.getCurrentBin()).isEqualTo(mockBin); + assertThat(result.getId()).isNull(); + } + + @Test + @DisplayName("Should map entity instance fields to lean flat Response record payload") + void shouldMapEntityToResponse() { + StorageBin mockBin = StorageBin.builder().id(25L).binCode("BIN-C-05").build(); + LoadUnit entity = + LoadUnit.builder() + .id(1L) + .trackingCode("LU-ENTITY-1") + .weightKg(1200) + .status(LoadUnitStatus.STORED) + .currentBin(mockBin) + .version(3L) + .build(); + + LoadUnitResponse response = mapper.toResponse(entity); + + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(1L); + assertThat(response.trackingCode()).isEqualTo("LU-ENTITY-1"); + assertThat(response.weightKg()).isEqualTo(1200); + assertThat(response.status()).isEqualTo(LoadUnitStatus.STORED); + assertThat(response.currentStorageBinId()).isEqualTo(25L); + assertThat(response.version()).isEqualTo(3L); + } + + @Test + @DisplayName( + "Should gracefully map null inputs or null references without throwing NullPointerExceptions") + void shouldHandleNullReferencesGracefully() { + assertThat(mapper.toEntity(null, null)).isNull(); + assertThat(mapper.toResponse(null)).isNull(); + + LoadUnit partialEntity = + LoadUnit.builder() + .id(5L) + .trackingCode("LU-ORPHAN") + .weightKg(10) + .status(LoadUnitStatus.EXPECTED) + .currentBin(null) // Not placed in a physical grid yet + .version(0L) + .build(); + + LoadUnitResponse response = mapper.toResponse(partialEntity); + assertThat(response.currentStorageBinId()).isNull(); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/loadunit/repository/LoadUnitRepositoryTest.java b/src/test/java/com/v1rex/liftnexus/loadunit/repository/LoadUnitRepositoryTest.java new file mode 100644 index 00000000..bad054aa --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/loadunit/repository/LoadUnitRepositoryTest.java @@ -0,0 +1,161 @@ +package com.v1rex.liftnexus.loadunit.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.v1rex.liftnexus.config.TestContainersConfiguration; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import jakarta.persistence.PersistenceException; +import jakarta.validation.ConstraintViolationException; +import java.util.Optional; +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.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@DisplayName("LoadUnitRepository Integration Tests") +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +class LoadUnitRepositoryTest { + + @Autowired private LoadUnitRepository loadUnitRepository; + + @Autowired private TestEntityManager entityManager; + + @Nested + @DisplayName("Validation Constraints Tests") + class ValidationConstraints { + + @Test + @DisplayName("Should fail when tracking code is blank") + void shouldFailWhenTrackingCodeIsBlank() { + LoadUnit invalidUnit = + LoadUnit.builder() + .trackingCode(" ") + .weightKg(500) + .status(LoadUnitStatus.EXPECTED) + .build(); + + assertThatThrownBy(() -> entityManager.persistAndFlush(invalidUnit)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("Tracking code must be provided"); + } + + @Test + @DisplayName("Should fail when weight is negative") + void shouldFailWhenWeightIsNegative() { + LoadUnit invalidUnit = + LoadUnit.builder() + .trackingCode("LU-NEGATIVE") + .weightKg(-1) + .status(LoadUnitStatus.EXPECTED) + .build(); + + assertThatThrownBy(() -> entityManager.persistAndFlush(invalidUnit)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("Weight cannot be negative"); + } + + @Test + @DisplayName("Should enforce global database uniqueness constraint on tracking code") + void shouldEnforceUniquenessOnTrackingCode() { + LoadUnit continuousUnit1 = + LoadUnit.builder() + .trackingCode("LU-DUPLICATE-123") + .weightKg(350) + .status(LoadUnitStatus.STAGED) + .build(); + entityManager.persistAndFlush(continuousUnit1); + + LoadUnit continuousUnit2 = + LoadUnit.builder() + .trackingCode("LU-DUPLICATE-123") + .weightKg(400) + .status(LoadUnitStatus.STORED) + .build(); + + assertThatThrownBy(() -> entityManager.persistAndFlush(continuousUnit2)) + .isInstanceOf(PersistenceException.class); + } + } + + @Nested + @DisplayName("Custom Domain Queries Tests") + class CustomQueries { + + @Test + @DisplayName("Should properly verify existence by tracking code") + void shouldVerifyExistenceByTrackingCode() { + LoadUnit unit = + LoadUnit.builder() + .trackingCode("LU-EXISTS-999") + .weightKg(120) + .status(LoadUnitStatus.STORED) + .build(); + entityManager.persistAndFlush(unit); + + assertThat(loadUnitRepository.existsByTrackingCode("LU-EXISTS-999")).isTrue(); + assertThat(loadUnitRepository.existsByTrackingCode("LU-NON-EXISTENT")).isFalse(); + } + + @Test + @DisplayName("Should retrieve correct optional entity structure by tracking code") + void shouldRetrieveOptionalByTrackingCode() { + LoadUnit unit = + LoadUnit.builder() + .trackingCode("LU-FIND-777") + .weightKg(850) + .status(LoadUnitStatus.IN_TRANSIT) + .build(); + entityManager.persistAndFlush(unit); + + Optional found = loadUnitRepository.findByTrackingCode("LU-FIND-777"); + assertThat(found).isPresent(); + assertThat(found.get().getWeightKg()).isEqualTo(850); + assertThat(found.get().getStatus()).isEqualTo(LoadUnitStatus.IN_TRANSIT); + + Optional notFound = loadUnitRepository.findByTrackingCode("LU-ABSENT"); + assertThat(notFound).isEmpty(); + } + + @Test + @DisplayName("Should fetch paginated elements matching specific lifecycle statuses") + void shouldFetchPaginatedByStatus() { + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-STAT-1") + .weightKg(100) + .status(LoadUnitStatus.STAGED) + .build()); + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-STAT-2") + .weightKg(200) + .status(LoadUnitStatus.STAGED) + .build()); + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-STAT-3") + .weightKg(300) + .status(LoadUnitStatus.SHIPPED) + .build()); + entityManager.flush(); + + Page stagedPage = + loadUnitRepository.findByStatus(LoadUnitStatus.STAGED, PageRequest.of(0, 10)); + + assertThat(stagedPage.getContent()).hasSize(2); + assertThat(stagedPage.getContent()) + .extracting(LoadUnit::getTrackingCode) + .containsExactlyInAnyOrder("LU-STAT-1", "LU-STAT-2"); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/loadunit/service/LoadUnitServiceTest.java b/src/test/java/com/v1rex/liftnexus/loadunit/service/LoadUnitServiceTest.java new file mode 100644 index 00000000..632ac16d --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/loadunit/service/LoadUnitServiceTest.java @@ -0,0 +1,155 @@ +package com.v1rex.liftnexus.loadunit.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitRequest; +import com.v1rex.liftnexus.loadunit.dto.LoadUnitResponse; +import com.v1rex.liftnexus.loadunit.exception.LoadUnitNotFoundException; +import com.v1rex.liftnexus.loadunit.exception.LoadUnitTrackingCodeExistsException; +import com.v1rex.liftnexus.loadunit.mapper.LoadUnitMapper; +import com.v1rex.liftnexus.loadunit.repository.LoadUnitRepository; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LoadUnitService Business Logic Tests") +class LoadUnitServiceTest { + + @Mock private LoadUnitRepository loadUnitRepository; + @Mock private LoadUnitMapper loadUnitMapper; + @Mock private StorageBinService storageBinService; + + @InjectMocks private LoadUnitService loadUnitService; + + @Nested + @DisplayName("Feature: Create LoadUnit Workflow") + class CreateLoadUnit { + + @Test + @DisplayName( + "Should successfully create load unit when payload constraints are valid and distinct") + void shouldCreateLoadUnitSuccessfully() { + // Arrange + LoadUnitRequest request = new LoadUnitRequest("LU-NEW", 500, LoadUnitStatus.STAGED, 1L); + + StorageBin resolvedBin = StorageBin.builder().id(1L).build(); + LoadUnit mockEntity = new LoadUnit(); + LoadUnit savedEntity = new LoadUnit(); + LoadUnitResponse expectedResponse = + new LoadUnitResponse(100L, "LU-NEW", 500, LoadUnitStatus.STAGED, 1L, 0L); + + when(loadUnitRepository.existsByTrackingCode("LU-NEW")).thenReturn(false); + when(storageBinService.findEntityById(1L)).thenReturn(resolvedBin); + when(loadUnitMapper.toEntity(request, resolvedBin)).thenReturn(mockEntity); + when(loadUnitRepository.save(mockEntity)).thenReturn(savedEntity); + when(loadUnitMapper.toResponse(savedEntity)).thenReturn(expectedResponse); + + // Act + LoadUnitResponse operationalResult = loadUnitService.createLoadUnit(request); + + // Assert + assertThat(operationalResult).isEqualTo(expectedResponse); + verify(loadUnitRepository).save(mockEntity); + } + + @Test + @DisplayName( + "Should abort creation and throw LoadUnitTrackingCodeExistsException on business-key code duplication") + void shouldThrowExceptionOnDuplicateTrackingCode() { + // Arrange + LoadUnitRequest request = + new LoadUnitRequest("LU-CONFLICT", 200, LoadUnitStatus.EXPECTED, null); + + when(loadUnitRepository.existsByTrackingCode("LU-CONFLICT")).thenReturn(true); + + // Act and Assert + assertThatThrownBy(() -> loadUnitService.createLoadUnit(request)) + .isInstanceOf(LoadUnitTrackingCodeExistsException.class); + + verify(loadUnitRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("Feature: Retrieve Single LoadUnit Entity Context") + class FindSingleUnit { + + @Test + @DisplayName( + "Should map entity directly to Response record if technical internal identifier exists") + void shouldFindByIdWhenExists() { + // Arrange + LoadUnit entity = LoadUnit.builder().id(1L).trackingCode("LU-1").build(); + LoadUnitResponse response = + new LoadUnitResponse(1L, "LU-1", 100, LoadUnitStatus.EXPECTED, null, 0L); + + when(loadUnitRepository.findById(1L)).thenReturn(Optional.of(entity)); + when(loadUnitMapper.toResponse(entity)).thenReturn(response); + + // Act + LoadUnitResponse result = loadUnitService.findById(1L); + + // Assert + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName( + "Should capture failure and throw LoadUnitNotFoundException if technical ID does not match any entry") + void shouldThrowNotFoundOnMissingId() { + // Arrange + when(loadUnitRepository.findById(99L)).thenReturn(Optional.empty()); + + // Act and Assert + assertThatThrownBy(() -> loadUnitService.findById(99L)) + .isInstanceOf(LoadUnitNotFoundException.class); + } + } + + @Nested + @DisplayName("Feature: Paginated Retrieval Filter Loops") + class PaginatedQueries { + + @Test + @DisplayName("Should route clean execution data straight to mapped status pages") + void shouldFilterByStatusCorrectly() { + // Arrange + Pageable pageable = PageRequest.of(0, 10); + LoadUnit unit = + LoadUnit.builder().trackingCode("LU-ACTIVE").status(LoadUnitStatus.IN_TRANSIT).build(); + Page entityPage = new PageImpl<>(List.of(unit)); + LoadUnitResponse mappedResponse = + new LoadUnitResponse(2L, "LU-ACTIVE", 400, LoadUnitStatus.IN_TRANSIT, null, 0L); + + when(loadUnitRepository.findByStatus(LoadUnitStatus.IN_TRANSIT, pageable)) + .thenReturn(entityPage); + when(loadUnitMapper.toResponse(unit)).thenReturn(mappedResponse); + + // Act + Page finalPage = + loadUnitService.findByStatus(LoadUnitStatus.IN_TRANSIT, pageable); + + // Assert + assertThat(finalPage.getContent()).hasSize(1); + assertThat(finalPage.getContent().get(0)).isEqualTo(mappedResponse); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/constraints/ForkliftCapacityConstraintTest.java b/src/test/java/com/v1rex/liftnexus/planning/constraints/ForkliftCapacityConstraintTest.java new file mode 100644 index 00000000..7e81e8ae --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/constraints/ForkliftCapacityConstraintTest.java @@ -0,0 +1,190 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ForkliftCapacityConstraintTest { + + private ConstraintVerifier + constraintVerifier; + + private StorageBin loadingDock; + private ForkliftType smallType; + private ForkliftType bigType; + + @BeforeEach + void setUp() { + this.constraintVerifier = + ConstraintVerifier.build( + new ForkliftCapacityTestConstraintProvider(), + WarehouseSchedule.class, + TransportOrder.class, + Forklift.class); + + loadingDock = + StorageBin.builder() + .id(1L) + .binCode("DOCK-01") + .coordinate(new Coordinate3D(0, 0, 0)) + .zoneType(ZoneType.STAGING_OUT) + .maxWeightCapacityKg(10_000) + .build(); + + smallType = + ForkliftType.builder() + .id(1L) + .modelName("SMALL-01") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1_000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.25) + .build(); + + bigType = + ForkliftType.builder() + .id(2L) + .modelName("BIG-01") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(3_000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.25) + .build(); + } + + @Test + @DisplayName( + "Forklift capacity should penalize when assigned load exceeds forklift's max capacity") + void forkliftCapacity_shouldPenalize_whenOverloaded() { + + Forklift smallForklift = + Forklift.builder() + .id(1L) + .fleetNumber("FLEET-001") + .forkliftType(smallType) + .currentStorageBin(loadingDock) + .currentBatteryPercentage(100.0) + .build(); + + LoadUnit heavyLoad = + LoadUnit.builder() + .id(1L) + .trackingCode("LU-HEAVY-01") + .weightKg(2_000) + .status(LoadUnitStatus.STAGED) + .currentBin(loadingDock) + .build(); + + TransportOrder order = + TransportOrder.builder() + .id(1L) + .sourceBin(loadingDock) + .targetBin(loadingDock) + .targetLoadUnit(heavyLoad) + .assignedForklift(smallForklift) + .build(); + + constraintVerifier + .verifyThat(ForkliftCapacityTestConstraintProvider::forkliftCapacity) + .given(order) + .penalizesBy(1); + } + + @Test + @DisplayName( + "Forklift capacity should not penalize when assigned load is within forklift's max capacity") + void forkliftCapacity_shouldNotPenalize_whenNotOverloaded() { + Forklift bigForklift = + Forklift.builder() + .id(1L) + .fleetNumber("FLEET-001") + .forkliftType(bigType) + .currentStorageBin(loadingDock) + .currentBatteryPercentage(100.0) + .build(); + + LoadUnit heavyLoad = + LoadUnit.builder() + .id(1L) + .trackingCode("LU-HEAVY-01") + .weightKg(2_000) + .status(LoadUnitStatus.STAGED) + .currentBin(loadingDock) + .build(); + + TransportOrder order = + TransportOrder.builder() + .id(1L) + .sourceBin(loadingDock) + .targetBin(loadingDock) + .targetLoadUnit(heavyLoad) + .assignedForklift(bigForklift) + .build(); + + constraintVerifier + .verifyThat(ForkliftCapacityTestConstraintProvider::forkliftCapacity) + .given(order) + .penalizesBy(0); + } + + @Test + @DisplayName("Forklift capacity should not penalize when the task is not assigned") + void forkliftCapacity_shouldNotPenalize_whenNotAssigned() { + Forklift bigForklift = + Forklift.builder() + .id(1L) + .fleetNumber("FLEET-001") + .forkliftType(bigType) + .currentStorageBin(loadingDock) + .currentBatteryPercentage(100.0) + .build(); + + LoadUnit heavyLoad = + LoadUnit.builder() + .id(1L) + .trackingCode("LU-HEAVY-01") + .weightKg(2_000) + .status(LoadUnitStatus.STAGED) + .currentBin(loadingDock) + .build(); + + TransportOrder order = + TransportOrder.builder() + .id(1L) + .sourceBin(loadingDock) + .targetBin(loadingDock) + .targetLoadUnit(heavyLoad) + // .assignedForklift(bigForklift) - we do not assign the order to a forklift + .build(); + + constraintVerifier + .verifyThat(ForkliftCapacityTestConstraintProvider::forkliftCapacity) + .given(order) + .penalizesBy(0); + } + + private static final class ForkliftCapacityTestConstraintProvider implements ConstraintProvider { + @Override + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] {forkliftCapacity(constraintFactory)}; + } + + public Constraint forkliftCapacity(ConstraintFactory factory) { + return ForkliftCapacityConstraint.forkliftCapacity(factory); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/constraints/ForkliftTravelDistanceConstraintTest.java b/src/test/java/com/v1rex/liftnexus/planning/constraints/ForkliftTravelDistanceConstraintTest.java new file mode 100644 index 00000000..78aeaab5 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/constraints/ForkliftTravelDistanceConstraintTest.java @@ -0,0 +1,213 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class ForkliftTravelDistanceConstraintTest { + private ConstraintVerifier + constraintVerifier; + + private ForkliftType mockForkliftType; + private Forklift mockForklift; + + @BeforeEach + void setUp() { + constraintVerifier = + ConstraintVerifier.build( + new ForkliftTravelDistanceTestConstraintProvider(), + WarehouseSchedule.class, + TransportOrder.class, + Forklift.class); + + StorageBin dock = + StorageBin.builder() + .id(1L) + .binCode("DOCK-01") + .coordinate(new Coordinate3D(0, 0, 0)) + .zoneType(ZoneType.STAGING_OUT) + .maxWeightCapacityKg(10_000) + .build(); + + mockForkliftType = + ForkliftType.builder() + .id(1L) + .modelName("MOCK-Forklift-Type") + .equipmentType(EquipmentType.STANDARD) + .maxCapacityKg(1_000) + .totalBatteryCapacitykWh(50.0) + .baseEnergyConsumptionPerMeter(0.25) + .build(); + + mockForklift = + Forklift.builder() + .id(1L) + .fleetNumber("FLEET-001") + .forkliftType(mockForkliftType) + .currentStorageBin(dock) + .currentBatteryPercentage(100.0) + .transportOrders(new ArrayList()) + .build(); + } + + @Test + @DisplayName("Forklift travel distance should not penalize when no transport orders are assigned") + void forkliftTravelDistance_shouldNotPenalize_WhenEmptyTransportOrders() { + constraintVerifier + .verifyThat(ForkliftTravelDistanceTestConstraintProvider::forkliftTravelDistance) + .given(mockForklift) + .penalizesBy(0); + } + + @Test + @DisplayName("Forklift travel distance should penalize when one transport order is assigned") + void forkliftTravelDistance_shouldPenalize_WhenOneTransportOrder() { + + LoadUnit mockLoad = + LoadUnit.builder() + .id(1L) + .trackingCode("MOCK-LOAD-001") + .weightKg(500) + .status(LoadUnitStatus.STAGED) + .currentBin(mockForklift.getCurrentStorageBin()) + .build(); + + StorageBin targetDock = + StorageBin.builder() + .id(1L) + .binCode("DOCK-01") + .coordinate(new Coordinate3D(10, 0, 0)) + .zoneType(ZoneType.STAGING_OUT) + .maxWeightCapacityKg(10_000) + .build(); + + TransportOrder order = + TransportOrder.builder() + .id(1L) + .sourceBin(mockForklift.getCurrentStorageBin()) + // we use the current Storage Bin with the following coordinates + // (0,0,0) + .targetBin(targetDock) + .targetLoadUnit(mockLoad) + .assignedForklift(mockForklift) + .build(); + + mockForklift.getTransportOrders().add(order); + + constraintVerifier + .verifyThat(ForkliftTravelDistanceTestConstraintProvider::forkliftTravelDistance) + .given(mockForklift) + .penalizesBy(10); // the travel distance should be penalized by 10 + } + + @Test + @DisplayName( + "Forklift travel distance should penalize sequentially when more than one transport order is assigned") + void forkliftTravelDistance_shouldPenalize_WhenMoreThanOneTransportOrder() { + + LoadUnit mockLoad1 = + LoadUnit.builder() + .id(1L) + .trackingCode("MOCK-LOAD-001") + .weightKg(500) + .status(LoadUnitStatus.STAGED) + .currentBin(mockForklift.getCurrentStorageBin()) // (0,0,0) + .build(); + + StorageBin targetDock1 = + StorageBin.builder() + .id(2L) + .binCode("DOCK-02") + .coordinate(new Coordinate3D(10, 0, 0)) + .zoneType(ZoneType.STAGING_OUT) + .maxWeightCapacityKg(10_000) + .build(); + + TransportOrder order1 = + TransportOrder.builder() + .id(1L) + .sourceBin(mockForklift.getCurrentStorageBin()) + .targetBin(targetDock1) + .targetLoadUnit(mockLoad1) + .assignedForklift(mockForklift) + .build(); + + StorageBin sourceBin2 = + StorageBin.builder() + .id(3L) + .binCode("AISLE-A-01") + .coordinate(new Coordinate3D(15, 0, 0)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(5_000) + .build(); + + StorageBin targetBin2 = + StorageBin.builder() + .id(4L) + .binCode("AISLE-A-12") + .coordinate(new Coordinate3D(25, 0, 0)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(5_000) + .build(); + + LoadUnit mockLoad2 = + LoadUnit.builder() + .id(2L) + .trackingCode("MOCK-LOAD-002") + .weightKg(300) + .status(LoadUnitStatus.STAGED) + .currentBin(sourceBin2) + .build(); + + TransportOrder order2 = + TransportOrder.builder() + .id(2L) + .sourceBin(sourceBin2) + .targetBin(targetBin2) + .targetLoadUnit(mockLoad2) + .assignedForklift(mockForklift) + .build(); + + mockForklift.getTransportOrders().add(order1); + mockForklift.getTransportOrders().add(order2); + + // Total calculated distance verification: + // Order 1 loaded trip: |0 - 10| = 10 + // Deadhead trip to Order 2: |10 - 15| = 5 + // Order 2 loaded trip: |15 - 25| = 10 + // Total = 25 + constraintVerifier + .verifyThat(ForkliftTravelDistanceTestConstraintProvider::forkliftTravelDistance) + .given(mockForklift) + .penalizesBy(25); + } + + private static final class ForkliftTravelDistanceTestConstraintProvider + implements ConstraintProvider { + @Override + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] { + ForkliftTravelDistanceConstraint.forkliftTravelDistance(constraintFactory) + }; + } + + public Constraint forkliftTravelDistance(ConstraintFactory factory) { + return ForkliftTravelDistanceConstraint.forkliftTravelDistance(factory); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/constraints/TransportOrderEquipmentRequirementConstraintTest.java b/src/test/java/com/v1rex/liftnexus/planning/constraints/TransportOrderEquipmentRequirementConstraintTest.java new file mode 100644 index 00000000..f9842250 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/constraints/TransportOrderEquipmentRequirementConstraintTest.java @@ -0,0 +1,143 @@ +package com.v1rex.liftnexus.planning.constraints; + +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.forklift.domain.ForkliftType; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class TransportOrderEquipmentRequirementConstraintTest { + + private ConstraintVerifier + constraintVerifier; + private StorageBin mockBin; + + @BeforeEach + void setUp() { + constraintVerifier = + ConstraintVerifier.build( + new EquipmentRequirementTestConstraintProvider(), + WarehouseSchedule.class, + TransportOrder.class, + Forklift.class); + + mockBin = + StorageBin.builder() + .id(1L) + .binCode("BIN-01") + .coordinate(new Coordinate3D(0, 0, 0)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(5_000) + .build(); + } + + @Test + @DisplayName( + "Equipment requirement should NOT penalize when forklift type matches required equipment") + void equipmentType_shouldNotPenalize_WhenEquipmentMatches() { + ForkliftType clampType = + ForkliftType.builder() + .id(1L) + .modelName("Standard-Forklift-Typ") + .equipmentType(EquipmentType.STANDARD) + .build(); + + Forklift forklift = + Forklift.builder().id(1L).fleetNumber("Forklift-01").forkliftType(clampType).build(); + + TransportOrder order = + TransportOrder.builder() + .id(1L) + .sourceBin(mockBin) + .targetBin(mockBin) + .requiredEquipment(EquipmentType.STANDARD) // Matches forklift! + .assignedForklift(forklift) + .build(); + + constraintVerifier + .verifyThat(EquipmentRequirementTestConstraintProvider::equipmentType) + .given(order) + .penalizesBy(0); + } + + @Test + @DisplayName( + "Equipment requirement should penalize with 1 HARD when forklift type mis-matches required equipment") + void equipmentType_shouldPenalize_WhenEquipmentMismatches() { + ForkliftType standardType = + ForkliftType.builder() + .id(2L) + .modelName("Standard-Forklift") + .equipmentType(EquipmentType.STANDARD) + .build(); + + Forklift forklift = + Forklift.builder() + .id(2L) + .fleetNumber("FLEET-STANDARD-01") + .forkliftType(standardType) + .build(); + + TransportOrder order = + TransportOrder.builder() + .id(2L) + .sourceBin(mockBin) + .targetBin(mockBin) + .requiredEquipment(EquipmentType.REACH_TRUCK) + .assignedForklift(forklift) + .build(); + + constraintVerifier + .verifyThat(EquipmentRequirementTestConstraintProvider::equipmentType) + .given(order) + .penalizesBy(1); + } + + @Test + @DisplayName( + "Equipment requirement should NOT penalize when order has no specific equipment requirements") + void equipmentType_shouldNotPenalize_WhenNoEquipmentRequired() { + ForkliftType standardType = + ForkliftType.builder().id(2L).equipmentType(EquipmentType.STANDARD).build(); + + Forklift forklift = Forklift.builder().id(2L).forkliftType(standardType).build(); + + TransportOrder order = + TransportOrder.builder() + .id(3L) + .sourceBin(mockBin) + .targetBin(mockBin) + .requiredEquipment(null) + .assignedForklift(forklift) + .build(); + + constraintVerifier + .verifyThat(EquipmentRequirementTestConstraintProvider::equipmentType) + .given(order) + .penalizesBy(0); + } + + private static final class EquipmentRequirementTestConstraintProvider + implements ConstraintProvider { + @Override + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] { + TransportOrderEquipmentRequirementConstraint.equipmentType(constraintFactory) + }; + } + + public Constraint equipmentType(ConstraintFactory factory) { + return TransportOrderEquipmentRequirementConstraint.equipmentType(factory); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/constraints/WarehouseConstraintProviderTest.java b/src/test/java/com/v1rex/liftnexus/planning/constraints/WarehouseConstraintProviderTest.java new file mode 100644 index 00000000..38e2f8d5 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/constraints/WarehouseConstraintProviderTest.java @@ -0,0 +1,44 @@ +package com.v1rex.liftnexus.planning.constraints; + +import static org.assertj.core.api.Assertions.assertThat; + +import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("WarehouseConstraintProvider Integration Tests") +class WarehouseConstraintProviderTest { + + private final ConstraintVerifier + constraintVerifier = + ConstraintVerifier.build( + new WarehouseConstraintProvider(), + WarehouseSchedule.class, + TransportOrder.class, + Forklift.class); + + @Test + @DisplayName("All constraints should be registered and return zero penalty for an empty schedule") + void shouldRegisterAllConstraintsCleanly() { + + assertThat(constraintVerifier).isNotNull(); + + constraintVerifier + .verifyThat(WarehouseConstraintProvider::forkliftCapacity) + .given() + .penalizesBy(0); + + constraintVerifier + .verifyThat(WarehouseConstraintProvider::travelDistance) + .given() + .penalizesBy(0); + + constraintVerifier + .verifyThat(WarehouseConstraintProvider::equipmentType) + .given() + .penalizesBy(0); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/controller/WarehouseDispatcherControllerTest.java b/src/test/java/com/v1rex/liftnexus/planning/controller/WarehouseDispatcherControllerTest.java new file mode 100644 index 00000000..93c571e1 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/controller/WarehouseDispatcherControllerTest.java @@ -0,0 +1,77 @@ +package com.v1rex.liftnexus.planning.controller; + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.v1rex.liftnexus.common.exception.GlobalExceptionHandler; +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.planning.domain.JobStatus; +import com.v1rex.liftnexus.planning.dto.DispatchJobResponse; +import com.v1rex.liftnexus.planning.service.WarehouseDispatcherService; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(WarehouseDispatcherController.class) +@Import({ + GlobalExceptionHandler.class, + DispatchJobExceptionHandler.class, + ProblemDetailFactory.class +}) +@DisplayName("WarehouseDispatcherController API Tests") +class WarehouseDispatcherControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private WarehouseDispatcherService dispatcherService; + + @Test + @DisplayName("POST /api/v1/dispatcher/jobs should return 202 Accepted with Job ID") + void shouldSubmitJobAndReturnAccepted() throws Exception { + UUID mockJobId = UUID.randomUUID(); + when(dispatcherService.submitOptimizationJob()).thenReturn(mockJobId); + + mockMvc + .perform(post("/api/v1/dispatcher/jobs")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.jobId").value(mockJobId.toString())); + } + + @Test + @DisplayName("GET /api/v1/dispatcher/jobs/{jobId} should return 200 OK with job details") + void shouldGetJobStatusAndReturnOk() throws Exception { + UUID mockJobId = UUID.randomUUID(); + DispatchJobResponse mockResponse = + new DispatchJobResponse(mockJobId, JobStatus.SOLVING, Instant.now(), null, null); + + when(dispatcherService.getJobStatusAndReconcile(mockJobId)).thenReturn(mockResponse); + + mockMvc + .perform(get("/api/v1/dispatcher/jobs/{jobId}", mockJobId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(mockJobId.toString())) + .andExpect(jsonPath("$.status").value("SOLVING")); + } + + @Test + @DisplayName("DELETE /api/v1/dispatcher/jobs/{jobId} should return 204 No Content") + void shouldTerminateJobAndReturnNoContent() throws Exception { + UUID mockJobId = UUID.randomUUID(); + doNothing().when(dispatcherService).terminateOptimizationJob(mockJobId); + + mockMvc + .perform(delete("/api/v1/dispatcher/jobs/{jobId}", mockJobId)) + .andExpect(status().isNoContent()); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/mapper/DispatchJobMapperTest.java b/src/test/java/com/v1rex/liftnexus/planning/mapper/DispatchJobMapperTest.java new file mode 100644 index 00000000..e29e1446 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/mapper/DispatchJobMapperTest.java @@ -0,0 +1,86 @@ +package com.v1rex.liftnexus.planning.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.v1rex.liftnexus.planning.domain.DispatchJob; +import com.v1rex.liftnexus.planning.domain.JobStatus; +import com.v1rex.liftnexus.planning.dto.DispatchJobResponse; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("DispatchJobMapper Unit Tests") +public class DispatchJobMapperTest { + + private final DispatchJobMapper mapper = new DispatchJobMapper(); + + @Nested + @DisplayName("Feature: Map Entity to Response DTO") + class ToResponse { + + @Test + @DisplayName("Should return null when the source entity is null") + void shouldReturnNullWhenEntityIsNull() { + + DispatchJobResponse response = mapper.toResponse(null); + + assertThat(response).isNull(); + } + + @Test + @DisplayName("Should completely copy all fields from Entity to Record DTO") + void shouldMapAllFieldsCorrectly() { + + UUID expectedId = UUID.randomUUID(); + Instant expectedCreatedAt = Instant.now().minusSeconds(60); + Instant expectedCompletedAt = Instant.now(); + String expectedScore = "0hard/-120soft"; + + DispatchJob entity = + DispatchJob.builder() + .id(expectedId) + .status(JobStatus.COMPLETED) + .createdAt(expectedCreatedAt) + .completedAt(expectedCompletedAt) + .finalScore(expectedScore) + .build(); + + DispatchJobResponse response = mapper.toResponse(entity); + + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(expectedId); + assertThat(response.status()).isEqualTo(JobStatus.COMPLETED); + assertThat(response.createdAt()).isEqualTo(expectedCreatedAt); + assertThat(response.completedAt()).isEqualTo(expectedCompletedAt); + assertThat(response.finalScore()).isEqualTo(expectedScore); + } + + @Test + @DisplayName("Should successfully map partial entities with missing optional fields") + void shouldMapPartialEntityWithNullOptionalFields() { + + UUID expectedId = UUID.randomUUID(); + Instant expectedCreatedAt = Instant.now(); + + DispatchJob queuedEntity = + DispatchJob.builder() + .id(expectedId) + .status(JobStatus.QUEUED) + .createdAt(expectedCreatedAt) + .completedAt(null) + .finalScore(null) + .build(); + + DispatchJobResponse response = mapper.toResponse(queuedEntity); + + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(expectedId); + assertThat(response.status()).isEqualTo(JobStatus.QUEUED); + assertThat(response.createdAt()).isEqualTo(expectedCreatedAt); + assertThat(response.completedAt()).isNull(); + assertThat(response.finalScore()).isNull(); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/planning/service/WarehouseDispatcherServiceTest.java b/src/test/java/com/v1rex/liftnexus/planning/service/WarehouseDispatcherServiceTest.java new file mode 100644 index 00000000..172a32d2 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/planning/service/WarehouseDispatcherServiceTest.java @@ -0,0 +1,272 @@ +package com.v1rex.liftnexus.planning.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import ai.timefold.solver.core.api.score.HardSoftScore; +import ai.timefold.solver.core.api.solver.SolverManager; +import com.v1rex.liftnexus.forklift.service.ForkliftService; +import com.v1rex.liftnexus.planning.domain.DispatchJob; +import com.v1rex.liftnexus.planning.domain.JobStatus; +import com.v1rex.liftnexus.planning.domain.WarehouseSchedule; +import com.v1rex.liftnexus.planning.exception.DispatchJobInvalidStateException; +import com.v1rex.liftnexus.planning.exception.DispatchJobNotFoundException; +import com.v1rex.liftnexus.planning.repository.DispatchJobRepository; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import com.v1rex.liftnexus.transportorder.service.TransportOrderService; +import java.time.Instant; +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WarehouseDispatcherService Logic Tests") +public class WarehouseDispatcherServiceTest { + + @Mock private StorageBinService storageBinService; + + @Mock private ForkliftService forkliftService; + + @Mock private TransportOrderService transportOrderService; + + @Mock private DispatchJobRepository jobRepository; + + @Mock private SolverManager solverManager; + + @InjectMocks private WarehouseDispatcherService warehouseDispatcherService; + + @Nested + @DisplayName("Feature: Build Current State for Optimization") + class BuildCurrentState { + + @Test + void shouldReturnCorrectLoadedState() { + when(storageBinService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + when(forkliftService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + when(transportOrderService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + + WarehouseSchedule schedule = warehouseDispatcherService.buildCurrentState(); + + assertThat(schedule).isNotNull(); + assertThat(schedule.getStorageBins()).isEmpty(); + assertThat(schedule.getForklifts()).isEmpty(); + assertThat(schedule.getTransportOrderPool()).isEmpty(); + } + } + + @Nested + @DisplayName("Feature: Terminate Optimization Job") + class TerminateOptimizationJob { + + @Test + void shouldSuccessfullyTerminateRunningJob() { + + UUID runningJobId = UUID.randomUUID(); + DispatchJob activeJob = + DispatchJob.builder() + .id(runningJobId) + .status(JobStatus.SOLVING) + .createdAt(Instant.now()) + .build(); + + when(jobRepository.findById(runningJobId)).thenReturn(Optional.of(activeJob)); + when(jobRepository.save(any(DispatchJob.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + warehouseDispatcherService.terminateOptimizationJob(runningJobId); + + verify(solverManager, times(1)).terminateEarly(runningJobId); + + ArgumentCaptor finalJobCaptor = ArgumentCaptor.forClass(DispatchJob.class); + verify(jobRepository, times(1)).save(finalJobCaptor.capture()); + + assertThat(finalJobCaptor.getValue().getStatus()).isEqualTo(JobStatus.ABORTED); + assertThat(finalJobCaptor.getValue().getCompletedAt()).isNotNull(); + } + + @Test + void shouldThrowExceptionWhenTryingToTerminateAnAlreadyCompletedJob() { + + UUID completedJobId = UUID.randomUUID(); + DispatchJob historicalJob = + DispatchJob.builder() + .id(completedJobId) + .status(JobStatus.COMPLETED) + .createdAt(Instant.now().minusSeconds(60)) + .completedAt(Instant.now()) + .build(); + + when(jobRepository.findById(completedJobId)).thenReturn(Optional.of(historicalJob)); + + assertThatThrownBy(() -> warehouseDispatcherService.terminateOptimizationJob(completedJobId)) + .isInstanceOf(DispatchJobInvalidStateException.class); + + verify(solverManager, never()).terminateEarly(any()); + verify(jobRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("Feature: Submit Optimization Job") + class SubmitOptimizationJob { + + @Test + void shouldCreateTicketInQueuedStatusAndStartSolver() { + ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(DispatchJob.class); + + when(jobRepository.save(any(DispatchJob.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + when(jobRepository.findById(any(UUID.class))) + .thenAnswer( + invocation -> { + UUID id = invocation.getArgument(0); + return Optional.of( + DispatchJob.builder() + .id(id) + .status(JobStatus.QUEUED) + .createdAt(Instant.now()) + .build()); + }); + + when(storageBinService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + when(forkliftService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + when(transportOrderService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + + UUID returnedTicketId = warehouseDispatcherService.submitOptimizationJob(); + + verify(jobRepository, times(2)).save(jobCaptor.capture()); + + DispatchJob initialSavedJob = jobCaptor.getAllValues().get(0); + assertThat(returnedTicketId).isNotNull(); + assertThat(initialSavedJob.getId()).isEqualTo(returnedTicketId); + assertThat(initialSavedJob.getStatus()).isEqualTo(JobStatus.QUEUED); + + verify(solverManager, times(1)) + .solveAndListen(eq(returnedTicketId), any(WarehouseSchedule.class), any()); + } + } + + @Nested + @DisplayName("Feature: Background Worker Initialization") + class BackgroundWorkerInitialization { + + @Test + void shouldTransitionJobToSolvingAndReturnCurrentProblemState() { + + UUID targetJobId = UUID.randomUUID(); + DispatchJob existingQueuedJob = + DispatchJob.builder() + .id(targetJobId) + .status(JobStatus.QUEUED) + .createdAt(Instant.now()) + .build(); + + when(jobRepository.findById(targetJobId)).thenReturn(Optional.of(existingQueuedJob)); + when(jobRepository.save(any(DispatchJob.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + when(storageBinService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + when(forkliftService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + when(transportOrderService.findAllEntitiesForPlanning()).thenReturn(Collections.emptyList()); + + WarehouseSchedule resultProblemState = + warehouseDispatcherService.buildCurrentProblemAndSetSolvingStatus(targetJobId); + + ArgumentCaptor updatedJobCaptor = ArgumentCaptor.forClass(DispatchJob.class); + verify(jobRepository, times(1)).save(updatedJobCaptor.capture()); + + assertThat(updatedJobCaptor.getValue().getStatus()).isEqualTo(JobStatus.SOLVING); + + assertThat(resultProblemState).isNotNull(); + assertThat(resultProblemState.getStorageBins()).isEmpty(); + } + + @Test + void shouldThrowExceptionWhenJobTicketDoesNotExistInDatabase() { + // Given + UUID nonExistentJobId = UUID.randomUUID(); + when(jobRepository.findById(nonExistentJobId)).thenReturn(Optional.empty()); + + // When / Then + assertThatThrownBy( + () -> + warehouseDispatcherService.buildCurrentProblemAndSetSolvingStatus( + nonExistentJobId)) + .isInstanceOf(DispatchJobNotFoundException.class); + + verify(jobRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("Feature: Save Final Solution Callback") + class SaveFinalSolution { + + @Test + void shouldDiscardResultsAndNotUpdateDatabaseIfJobWasAborted() { + UUID jobId = UUID.randomUUID(); + DispatchJob abortedJob = + DispatchJob.builder() + .id(jobId) + .status(JobStatus.ABORTED) + .createdAt(Instant.now().minusSeconds(120)) + .build(); + + when(jobRepository.findById(jobId)).thenReturn(Optional.of(abortedJob)); + + WarehouseSchedule dummySchedule = new WarehouseSchedule(); + + warehouseDispatcherService.saveFinalSolution(dummySchedule, jobId); + + verify(transportOrderService, never()).updateForkliftAssignments(any()); + verify(forkliftService, never()).updateAssignedOrders(any()); + + verify(jobRepository, never()).save(any()); + } + + @Test + void shouldSaveAssignmentsAndTransitionJobToCompleted() { + UUID jobId = UUID.randomUUID(); + DispatchJob activeJob = + DispatchJob.builder() + .id(jobId) + .status(JobStatus.SOLVING) + .createdAt(Instant.now().minusSeconds(30)) + .build(); + + when(jobRepository.findById(jobId)).thenReturn(Optional.of(activeJob)); + when(jobRepository.save(any(DispatchJob.class))).thenAnswer(i -> i.getArgument(0)); + + WarehouseSchedule mockSchedule = mock(WarehouseSchedule.class); + + HardSoftScore realScore = HardSoftScore.of(0, 150); + + when(mockSchedule.getScore()).thenReturn(realScore); + when(mockSchedule.getTransportOrderPool()).thenReturn(Collections.emptyList()); + + warehouseDispatcherService.saveFinalSolution(mockSchedule, jobId); + + verify(transportOrderService, times(1)).updateForkliftAssignments(any()); + + ArgumentCaptor jobCaptor = ArgumentCaptor.forClass(DispatchJob.class); + verify(jobRepository, times(1)).save(jobCaptor.capture()); + + DispatchJob finalizedJob = jobCaptor.getValue(); + assertThat(finalizedJob.getStatus()).isEqualTo(JobStatus.COMPLETED); + assertThat(finalizedJob.getCompletedAt()).isNotNull(); + + assertThat(finalizedJob.getFinalScore()).isEqualTo("0hard/150soft"); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/storagebin/controller/StorageBinControllerTest.java b/src/test/java/com/v1rex/liftnexus/storagebin/controller/StorageBinControllerTest.java new file mode 100644 index 00000000..05bfdfee --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/storagebin/controller/StorageBinControllerTest.java @@ -0,0 +1,160 @@ +package com.v1rex.liftnexus.storagebin.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.v1rex.liftnexus.common.exception.GlobalExceptionHandler; +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.storagebin.dto.CoordinateDto; +import com.v1rex.liftnexus.storagebin.dto.StorageBinRequest; +import com.v1rex.liftnexus.storagebin.dto.StorageBinResponse; +import com.v1rex.liftnexus.storagebin.exception.StorageBinNotFoundException; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(controllers = StorageBinController.class) +@Import({ + GlobalExceptionHandler.class, + StorageBinExceptionHandler.class, + ProblemDetailFactory.class +}) +@DisplayName("StorageBin REST API Gateway Endpoints Tests") +public class StorageBinControllerTest { + + @Autowired private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean private StorageBinService storageBinService; + + @Nested + @DisplayName("Query Endpoints (GET Operations)") + class ReadOperations { + @Test + @DisplayName("GET /api/v1/storage-bins should return nested structural arrays") + void shouldReturnStorageBinsPaginated() throws Exception { + CoordinateDto coordinate = new CoordinateDto(4, 12, 2); + StorageBinResponse response = + new StorageBinResponse(1L, "A-04-B-12-T-02", coordinate, ZoneType.STORAGE, 1000); + + Mockito.when(storageBinService.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(response))); + + mockMvc + .perform(get("/api/v1/storage-bins").accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].binCode").value("A-04-B-12-T-02")) + .andExpect(jsonPath("$.content[0].coordinate.x").value(4)) + .andExpect(jsonPath("$.content[0].coordinate.z").value(2)); + } + + @Test + @DisplayName("GET /api/v1/storage-bins/{id} should return 200 OK and the requested bin") + void shouldReturnStorageBin_WhenIdExists() throws Exception { + // Arrange + CoordinateDto coordinate = new CoordinateDto(2, 5, 1); + StorageBinResponse response = + new StorageBinResponse(99L, "B-02-B-05-T-01", coordinate, ZoneType.STORAGE, 1500); + + Mockito.when(storageBinService.findById(99L)).thenReturn(response); + + // Act & Assert + mockMvc + .perform(get("/api/v1/storage-bins/{id}", 99L).accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(99)) + .andExpect(jsonPath("$.binCode").value("B-02-B-05-T-01")) + .andExpect(jsonPath("$.coordinate.x").value(2)) + .andExpect(jsonPath("$.coordinate.y").value(5)) + .andExpect(jsonPath("$.coordinate.z").value(1)) + .andExpect(jsonPath("$.zoneType").value("STORAGE")); + } + + @Test + @DisplayName("GET /api/v1/storage-bins/{id} should return 404 Not Found if missing") + void shouldReturn404_WhenStorageBinDoesNotExist() throws Exception { + // Arrange + Long missingId = 999L; + Mockito.when(storageBinService.findById(missingId)) + .thenThrow(new StorageBinNotFoundException(missingId)); + + // Act & Assert + mockMvc + .perform(get("/api/v1/storage-bins/{id}", missingId).accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("Query Endpoints (POST Operations)") + class WriteOperations { + + @Test + @DisplayName( + "POST /api/v1/storage-bins should process complex inputs and output 201 HTTP headers") + void shouldCreateStorageBin_WhenPayloadIsValid() throws Exception { + CoordinateDto coordinate = new CoordinateDto(1, 1, 0); + StorageBinRequest request = + new StorageBinRequest("CHARGER-1", coordinate, ZoneType.CHARGING_STATION, 0); + StorageBinResponse response = + new StorageBinResponse(77L, "CHARGER-1", coordinate, ZoneType.CHARGING_STATION, 0); + + Mockito.when(storageBinService.createStorageBin(any(StorageBinRequest.class))) + .thenReturn(response); + + mockMvc + .perform( + post("/api/v1/storage-bins") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect( + header() + .string( + "Location", org.hamcrest.Matchers.containsString("/api/v1/storage-bins/77"))) + .andExpect(jsonPath("$.id").value(77)) + .andExpect(jsonPath("$.maxWeightCapacityKg").value(0)); + } + + @Test + @DisplayName( + "POST /api/v1/storage-bins should return 400 Bad Request if coordinates are malformed") + void shouldRejectCreation_WhenPayloadIsMissingCoordinates() throws Exception { + // Missing the nested CoordinateDto entirely + String invalidJson = + "{\"binCode\":\"ERROR\",\"zoneType\":\"STORAGE\",\"maxWeightCapacityKg\":500}"; + + mockMvc + .perform( + post("/api/v1/storage-bins") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andDo(print()) + .andExpect(status().isBadRequest()); + + Mockito.verifyNoInteractions(storageBinService); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/storagebin/domain/Coordinate3DTest.java b/src/test/java/com/v1rex/liftnexus/storagebin/domain/Coordinate3DTest.java new file mode 100644 index 00000000..d42d6362 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/storagebin/domain/Coordinate3DTest.java @@ -0,0 +1,46 @@ +package com.v1rex.liftnexus.storagebin.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Coordinate3D Domain Unit Tests") +class Coordinate3DTest { + + @Test + @DisplayName("Should correctly calculate Manhattan distance on a flat plane (same tier)") + void shouldCalculateFlatManhattanDistance() { + Coordinate3D binA = new Coordinate3D(2, 5, 1); + Coordinate3D binB = new Coordinate3D(5, 10, 1); + + double distance = binA.calculateDistance(binB); + + assertThat(distance).isEqualTo(8.0); + } + + @Test + @DisplayName("Should apply default vertical penalty factor when transitioning across tiers") + void shouldApplyVerticalPenalty() { + Coordinate3D groundBin = new Coordinate3D(2, 5, 1); + Coordinate3D highBin = new Coordinate3D(2, 5, 4); + + double distance = groundBin.calculateDistance(highBin); + + assertThat(distance).isEqualTo(7.5); + } + + @Test + @DisplayName( + "Should accept custom vertical penalties for specialized material handling equipment") + void shouldAcceptCustomPenalty() { + Coordinate3D groundBin = new Coordinate3D(1, 1, 1); + Coordinate3D highBin = new Coordinate3D(1, 1, 3); + double fastLiftTruckPenalty = 1.2; + + double distance = groundBin.calculateDistance(highBin, fastLiftTruckPenalty); + + assertThat(distance).isEqualTo(2.4, within(0.01)); + } +} diff --git a/src/test/java/com/v1rex/liftnexus/storagebin/mapper/StorageBinMapperTest.java b/src/test/java/com/v1rex/liftnexus/storagebin/mapper/StorageBinMapperTest.java new file mode 100644 index 00000000..6c9e5406 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/storagebin/mapper/StorageBinMapperTest.java @@ -0,0 +1,82 @@ +package com.v1rex.liftnexus.storagebin.mapper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.storagebin.dto.CoordinateDto; +import com.v1rex.liftnexus.storagebin.dto.StorageBinRequest; +import com.v1rex.liftnexus.storagebin.dto.StorageBinResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("StorageBinMapper Unit Tests") +class StorageBinMapperTest { + + private final StorageBinMapper mapper = new StorageBinMapper(); + + @Nested + @DisplayName("Mapping Request to Entity") + class ToEntityTests { + + @Test + @DisplayName("Should map valid StorageBinRequest to a complete Entity") + void shouldMapRequestToEntity() { + CoordinateDto coordDto = new CoordinateDto(4, 12, 3); + StorageBinRequest request = + new StorageBinRequest("A-04-B-12-T-03", coordDto, ZoneType.STORAGE, 1500); + + StorageBin entity = mapper.toEntity(request); + + assertThat(entity).isNotNull(); + assertThat(entity.getId()).isNull(); + assertThat(entity.getBinCode()).isEqualTo("A-04-B-12-T-03"); + assertThat(entity.getCoordinate().getX()).isEqualTo(4); + assertThat(entity.getCoordinate().getY()).isEqualTo(12); + assertThat(entity.getCoordinate().getZ()).isEqualTo(3); + assertThat(entity.getZoneType()).isEqualTo(ZoneType.STORAGE); + assertThat(entity.getMaxWeightCapacityKg()).isEqualTo(1500); + } + + @Test + void shouldReturnNull_WhenRequestIsNull() { + assertThat(mapper.toEntity(null)).isNull(); + } + } + + @Nested + @DisplayName("Mapping Entity to Response") + class ToResponseTests { + + @Test + @DisplayName("Should map complete StorageBin entity to nested Response DTO") + void shouldMapEntityToResponse() { + StorageBin entity = + StorageBin.builder() + .id(42L) + .binCode("C-01-B-02-T-00") + .coordinate(new Coordinate3D(1, 2, 0)) + .zoneType(ZoneType.CHARGING_STATION) + .maxWeightCapacityKg(0) + .build(); + + StorageBinResponse response = mapper.toResponse(entity); + + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(42L); + assertThat(response.binCode()).isEqualTo("C-01-B-02-T-00"); + assertThat(response.coordinate().x()).isEqualTo(1); + assertThat(response.coordinate().y()).isEqualTo(2); + assertThat(response.coordinate().z()).isEqualTo(0); + assertThat(response.zoneType()).isEqualTo(ZoneType.CHARGING_STATION); + assertThat(response.maxWeightCapacityKg()).isZero(); + } + + @Test + void shouldReturnNull_WhenEntityIsNull() { + assertThat(mapper.toResponse(null)).isNull(); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/storagebin/repository/StorageBinRepositoryTest.java b/src/test/java/com/v1rex/liftnexus/storagebin/repository/StorageBinRepositoryTest.java new file mode 100644 index 00000000..a53b01a2 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/storagebin/repository/StorageBinRepositoryTest.java @@ -0,0 +1,108 @@ +package com.v1rex.liftnexus.storagebin.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.v1rex.liftnexus.config.TestContainersConfiguration; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import jakarta.validation.ConstraintViolationException; +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.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("StorageBin Repository Constraints & Integrity Tests") +public class StorageBinRepositoryTest { + @Autowired private StorageBinRepository storageBinRepository; + + @Nested + @DisplayName("Database Level Constraints") + class DatabaseConstraints { + + @Test + @DisplayName("Should enforce data integrity when an explicit unique bin code is duplicated") + void shouldThrowException_WhenBinCodeIsDuplicated() { + StorageBin bin1 = + StorageBin.builder() + .binCode("DUPLICATE") + .coordinate(new Coordinate3D(1, 1, 1)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(1000) + .build(); + + StorageBin bin2 = + StorageBin.builder() + .binCode("DUPLICATE") + .coordinate(new Coordinate3D(2, 2, 2)) + .zoneType(ZoneType.HAZMAT) + .maxWeightCapacityKg(500) + .build(); + + storageBinRepository.save(bin1); + + assertThatThrownBy(() -> storageBinRepository.saveAndFlush(bin2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + } + + @Nested + @DisplayName("JPA Entity Validation Constraints") + class ValidationConstraints { + + @Test + @DisplayName("Should fail validation when embedded coordinate object is missing entirely") + void shouldThrowException_WhenCoordinatesAreNull() { + StorageBin invalidBin = + StorageBin.builder() + .binCode("VALID-CODE-1") + .coordinate(null) // Violates @NotNull + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(1000) + .build(); + + assertThatThrownBy(() -> storageBinRepository.saveAndFlush(invalidBin)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("coordinate"); + } + + @Test + @DisplayName("Should fail validation when the max weight capacity is negative") + void shouldThrowException_WhenWeightCapacityIsNegative() { + StorageBin invalidBin = + StorageBin.builder() + .binCode("VALID-CODE-3") + .coordinate(new Coordinate3D(1, 1, 1)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(-500) // Violates @Min(0) + .build(); + + assertThatThrownBy(() -> storageBinRepository.saveAndFlush(invalidBin)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("maxWeightCapacityKg"); + } + + @Test + @DisplayName("Should fail validation when the bin code is omitted") + void shouldThrowException_WhenBinCodeIsNull() { + StorageBin invalidBin = + StorageBin.builder() + .binCode(null) // Violates @NotNull + .coordinate(new Coordinate3D(1, 1, 1)) + .zoneType(ZoneType.STORAGE) + .maxWeightCapacityKg(1000) + .build(); + + assertThatThrownBy(() -> storageBinRepository.saveAndFlush(invalidBin)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("binCode"); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/storagebin/service/StorageBinServiceTest.java b/src/test/java/com/v1rex/liftnexus/storagebin/service/StorageBinServiceTest.java new file mode 100644 index 00000000..f7a69106 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/storagebin/service/StorageBinServiceTest.java @@ -0,0 +1,185 @@ +package com.v1rex.liftnexus.storagebin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.storagebin.dto.CoordinateDto; +import com.v1rex.liftnexus.storagebin.dto.StorageBinRequest; +import com.v1rex.liftnexus.storagebin.dto.StorageBinResponse; +import com.v1rex.liftnexus.storagebin.exception.StorageBinCodeExistsException; +import com.v1rex.liftnexus.storagebin.exception.StorageBinNotFoundException; +import com.v1rex.liftnexus.storagebin.mapper.StorageBinMapper; +import com.v1rex.liftnexus.storagebin.repository.StorageBinRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +@DisplayName("StorageBin Service Unit Tests") +public class StorageBinServiceTest { + + @Mock private StorageBinRepository storageBinRepository; + @Mock private StorageBinMapper storageBinMapper; + @InjectMocks private StorageBinService storageBinService; + + @Nested + @DisplayName("Create StorageBin Operations") + class CreateStorageBin { + + @Test + @DisplayName("Should save entity and return payload when registration criteria are met") + void shouldCreateBin_WhenRequestIsValid() { + // Arrange + CoordinateDto dtoCoord = new CoordinateDto(1, 2, 3); + StorageBinRequest request = + new StorageBinRequest("B-01-02-03", dtoCoord, ZoneType.STORAGE, 1200); + + StorageBin mockEntity = StorageBin.builder().binCode("B-01-02-03").build(); + + StorageBin savedEntity = StorageBin.builder().id(100L).binCode("B-01-02-03").build(); + + StorageBinResponse expectedResponse = + new StorageBinResponse(100L, "B-01-02-03", dtoCoord, ZoneType.STORAGE, 1200); + + when(storageBinRepository.existsByBinCode("B-01-02-03")).thenReturn(false); + when(storageBinMapper.toEntity(request)).thenReturn(mockEntity); + when(storageBinRepository.save(mockEntity)).thenReturn(savedEntity); + when(storageBinMapper.toResponse(savedEntity)).thenReturn(expectedResponse); + + // Act + StorageBinResponse output = storageBinService.createStorageBin(request); + + // Assert + assertThat(output).isNotNull(); + assertThat(output.id()).isEqualTo(100L); + assertThat(output.binCode()).isEqualTo("B-01-02-03"); + verify(storageBinRepository, times(1)).save(any(StorageBin.class)); + } + + @Test + @DisplayName("Should prevent instantiation if a target code collision occurs") + void shouldThrowException_WhenBinCodeAlreadyExists() { + // Arrange + StorageBinRequest request = + new StorageBinRequest("EXISTS", new CoordinateDto(1, 1, 1), ZoneType.STORAGE, 500); + + // Act + when(storageBinRepository.existsByBinCode("EXISTS")).thenReturn(true); + + // Assert + assertThatThrownBy(() -> storageBinService.createStorageBin(request)) + .isInstanceOf(StorageBinCodeExistsException.class); + + verify(storageBinRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("Query Isolation Verification") + class FindStorageBinById { + + @Test + @DisplayName("Should extract mapped payload cleanly from persistent state records") + void shouldReturnResponse_WhenStorageBinExists() { + // Arrange + Long targetId = 1L; + StorageBin storedBin = StorageBin.builder().id(targetId).binCode("TEST").build(); + + StorageBinResponse responseDto = + new StorageBinResponse( + targetId, "TEST", new CoordinateDto(1, 1, 1), ZoneType.STORAGE, 10); + + when(storageBinRepository.findById(targetId)).thenReturn(Optional.of(storedBin)); + when(storageBinMapper.toResponse(storedBin)).thenReturn(responseDto); + + // Act + StorageBinResponse operationalResult = storageBinService.findById(targetId); + + // Assert + assertThat(operationalResult).isNotNull(); + verify(storageBinRepository).findById(targetId); + } + + @Test + @DisplayName("Should surface structural missing resource exceptions up through operations") + void shouldThrowException_WhenStorageBinMissing() { + // Arrange + Long failingId = 99L; + when(storageBinRepository.findById(failingId)).thenReturn(Optional.empty()); + + // Act and Assert + assertThatThrownBy(() -> storageBinService.findById(failingId)) + .isInstanceOf(StorageBinNotFoundException.class); + } + } + + @Nested + @DisplayName("Find All StorageBins Operations") + class FindAllStorageBins { + + @Test + @DisplayName("Should return a paginated list of StorageBinResponses") + void shouldReturnPageOfStorageBinResponses() { + // Arrange + Pageable pageable = Pageable.unpaged(); + + StorageBin entity = StorageBin.builder().id(1L).binCode("A-01").build(); + + Page entityPage = new PageImpl<>(java.util.List.of(entity)); + + CoordinateDto dtoCoord = new CoordinateDto(1, 1, 1); + StorageBinResponse responseDto = + new StorageBinResponse(1L, "A-01", dtoCoord, ZoneType.STORAGE, 1000); + + when(storageBinRepository.findAll(pageable)).thenReturn(entityPage); + when(storageBinMapper.toResponse(entity)).thenReturn(responseDto); + + // Act + Page result = storageBinService.findAll(pageable); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).binCode()).isEqualTo("A-01"); + + verify(storageBinRepository).findAll(pageable); + verify(storageBinMapper).toResponse(entity); + } + + @Test + @DisplayName("Should return a paginated list of underlying entities directly") + void shouldReturnPageOfStorageBinEntities() { + // Arrange + Pageable pageable = Pageable.unpaged(); + + StorageBin entity = StorageBin.builder().id(2L).binCode("B-02").build(); + + Page entityPage = new PageImpl<>(java.util.List.of(entity)); + + when(storageBinRepository.findAll(pageable)).thenReturn(entityPage); + + // Act + Page result = storageBinService.findAllEntities(pageable); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getBinCode()).isEqualTo("B-02"); + + verify(storageBinRepository).findAll(pageable); + verifyNoMoreInteractions(storageBinMapper); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderControllerTest.java b/src/test/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderControllerTest.java new file mode 100644 index 00000000..a2e68ad2 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/transportorder/controller/TransportOrderControllerTest.java @@ -0,0 +1,222 @@ +package com.v1rex.liftnexus.transportorder.controller; + +import static org.hamcrest.Matchers.containsString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.v1rex.liftnexus.common.exception.GlobalExceptionHandler; +import com.v1rex.liftnexus.common.exception.ProblemDetailFactory; +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderRequest; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderResponse; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderStatusUpdateRequest; +import com.v1rex.liftnexus.transportorder.exception.TransportOrderNotFoundException; +import com.v1rex.liftnexus.transportorder.service.TransportOrderService; +import java.util.List; +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.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(TransportOrderController.class) +@Import({ + GlobalExceptionHandler.class, + TransportOrderExceptionHandler.class, + ProblemDetailFactory.class +}) +@DisplayName("TransportOrderController Gateway Tests") +class TransportOrderControllerTest { + + @Autowired private MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean private TransportOrderService transportOrderService; + + @Nested + @DisplayName("Tests - GET /api/v1/transport-orders/{id}") + class GetOrderById { + + @Test + @DisplayName("Should return 200 OK with details when order exists") + void shouldReturnOrder_WhenIdExists() throws Exception { + TransportOrderResponse response = + new TransportOrderResponse( + 1L, "SKU-999", 10L, 2L, 1L, EquipmentType.STANDARD, TransportOrderStatus.OPEN, null); + + when(transportOrderService.findById(1L)).thenReturn(response); + + mockMvc + .perform(get("/api/v1/transport-orders/{id}", 1L)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.trackingCode").value("SKU-999")) + .andExpect(jsonPath("$.status").value("OPEN")); + } + + @Test + @DisplayName("Should return 404 Not Found when ID does not exist in system") + void shouldReturn404_WhenIdDoesNotExist() throws Exception { + when(transportOrderService.findById(99L)).thenThrow(new TransportOrderNotFoundException(99L)); + + mockMvc + .perform(get("/api/v1/transport-orders/{id}", 99L)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("Tests - GET /api/v1/transport-orders/search") + class SearchOrders { + + @Test + @DisplayName("Should return 200 OK with a page of matching records when parameters are valid") + void shouldReturnPagedOrders_WhenCriteriaAreValid() throws Exception { + TransportOrderResponse response = + new TransportOrderResponse( + 1L, "SKU-999", 10L, 2L, 1L, EquipmentType.STANDARD, TransportOrderStatus.OPEN, null); + + Page page = new PageImpl<>(List.of(response)); + + when(transportOrderService.searchOrders( + eq(TransportOrderStatus.OPEN), eq(500), any(Pageable.class))) + .thenReturn(page); + + mockMvc + .perform( + get("/api/v1/transport-orders/search") + .param("status", "OPEN") + .param("minWeight", "500") + .param("page", "0") + .param("size", "20")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(1)) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + @DisplayName( + "Should return 400 Bad Request when requested minWeight is below zero constraint limit") + void shouldReturn400_WhenMinWeightIsLessThanOne() throws Exception { + mockMvc + .perform(get("/api/v1/transport-orders/search").param("minWeight", "0")) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Tests - POST /api/v1/transport-orders") + class CreateTransportOrder { + + @Test + @DisplayName("Should return 201 Created with Location header when payload is valid") + void shouldCreateAndReturn201() throws Exception { + TransportOrderRequest request = + new TransportOrderRequest(10L, 1L, 2L, EquipmentType.STANDARD); + + TransportOrderResponse response = + new TransportOrderResponse( + 42L, "SKU-999", 10L, 2L, 1L, EquipmentType.STANDARD, TransportOrderStatus.OPEN, null); + + when(transportOrderService.createTransportOrder(any(TransportOrderRequest.class))) + .thenReturn(response); + + mockMvc + .perform( + post("/api/v1/transport-orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", containsString("/api/v1/transport-orders/42"))) + .andExpect(jsonPath("$.id").value(42)) + .andExpect(jsonPath("$.trackingCode").value("SKU-999")) + .andExpect(jsonPath("$.targetLoadUnitId").value(10)) + .andExpect(jsonPath("$.status").value("OPEN")); + } + + @Test + @DisplayName( + "Should return 400 Bad Request when mandatory layout variables are completely null") + void shouldReturn400_WhenPayloadAttributesAreMissing() throws Exception { + TransportOrderRequest invalidRequest = new TransportOrderRequest(null, null, null, null); + + mockMvc + .perform( + post("/api/v1/transport-orders") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Tests - PUT /api/v1/transport-orders/{id}/status") + class UpdateTransportOrder { + + @Test + @DisplayName( + "Should return 200 OK with updated transportorder details when status adjustment is valid") + void shouldReturnUpdatedTask_WhenUpdatePayloadIsSuccessful() throws Exception { + TransportOrderStatusUpdateRequest updateRequest = + new TransportOrderStatusUpdateRequest(TransportOrderStatus.COMPLETED); + + TransportOrderResponse mockResponse = + new TransportOrderResponse( + 10L, + "SKU-999", + 10L, + 2L, + 1L, + EquipmentType.STANDARD, + TransportOrderStatus.COMPLETED, + null); + + when(transportOrderService.updateOrderStatus( + eq(10L), any(TransportOrderStatusUpdateRequest.class))) + .thenReturn(mockResponse); + + mockMvc + .perform( + put("/api/v1/transport-orders/{id}/status", 10L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("COMPLETED")); + } + + @Test + @DisplayName("Should return 400 Bad Request when targeted state updates are null") + void shouldReturn400_WhenTargetStatusIsNull() throws Exception { + TransportOrderStatusUpdateRequest invalidRequest = + new TransportOrderStatusUpdateRequest(null); + + mockMvc + .perform( + put("/api/v1/transport-orders/{id}/status", 10L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest))) + .andDo(print()) + .andExpect(status().isBadRequest()); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/transportorder/mapper/TransportOrderMapperTest.java b/src/test/java/com/v1rex/liftnexus/transportorder/mapper/TransportOrderMapperTest.java new file mode 100644 index 00000000..26e5131a --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/transportorder/mapper/TransportOrderMapperTest.java @@ -0,0 +1,91 @@ +package com.v1rex.liftnexus.transportorder.mapper; + +import static org.junit.jupiter.api.Assertions.*; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.forklift.domain.Forklift; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderRequest; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("TransportOrderMapper Unit Tests") +public class TransportOrderMapperTest { + + private final TransportOrderMapper mapper = new TransportOrderMapper(); + + @Nested + @DisplayName("Tests - toResponse Mapping") + class ToResponseMapping { + + @Test + @DisplayName("Should return null when TransportOrder Entity is null") + void shouldReturnNull_WhenEntityIsNull() { + assertNull(mapper.toResponse(null)); + } + + @Test + @DisplayName("Should map complete Entity to flat ID Response record correctly") + void shouldMapEntityToResponseSuccessfully() { + // Arrange + StorageBin mockSourceBin = StorageBin.builder().id(1L).build(); + StorageBin mockTargetBin = StorageBin.builder().id(2L).build(); + LoadUnit mockLoadUnit = LoadUnit.builder().id(100L).trackingCode("LU-FLAT").build(); + Forklift mockForklift = Forklift.builder().id(5L).build(); + + TransportOrder mockOrder = + TransportOrder.builder() + .id(15L) + .targetLoadUnit(mockLoadUnit) + .sourceBin(mockSourceBin) + .targetBin(mockTargetBin) + .requiredEquipment(EquipmentType.STANDARD) + .status(TransportOrderStatus.OPEN) + .assignedForklift(mockForklift) + .build(); + + // Act + TransportOrderResponse response = mapper.toResponse(mockOrder); + + // Assert + assertNotNull(response); + assertEquals(15L, response.id()); + assertEquals("LU-FLAT", response.trackingCode()); + assertEquals(100L, response.targetLoadUnitId()); + assertEquals(1L, response.sourceBinId()); + assertEquals(2L, response.targetBinId()); + assertEquals(EquipmentType.STANDARD, response.requiredEquipment()); + assertEquals(TransportOrderStatus.OPEN, response.status()); + assertEquals(5L, response.assignedForkliftId()); + } + } + + @Nested + @DisplayName("Tests - toEntity Mapping") + class ToEntityMapping { + + @Test + @DisplayName("Should return null when Request is null") + void shouldReturnNull_WhenRequestIsNull() { + assertNull(mapper.toEntity(null)); + } + + @Test + @DisplayName("Should map Request to fresh Entity with OPEN status") + void shouldMapRequestToEntity() { + TransportOrderRequest request = + new TransportOrderRequest(100L, 1L, 2L, EquipmentType.SIDE_LOADER); + + TransportOrder entity = mapper.toEntity(request); + + assertNotNull(entity); + assertEquals(TransportOrderStatus.OPEN, entity.getStatus()); + assertEquals(EquipmentType.SIDE_LOADER, entity.getRequiredEquipment()); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/transportorder/repository/TransportOrderRepositoryTest.java b/src/test/java/com/v1rex/liftnexus/transportorder/repository/TransportOrderRepositoryTest.java new file mode 100644 index 00000000..1475e824 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/transportorder/repository/TransportOrderRepositoryTest.java @@ -0,0 +1,210 @@ +package com.v1rex.liftnexus.transportorder.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import com.v1rex.liftnexus.config.TestContainersConfiguration; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.domain.LoadUnitStatus; +import com.v1rex.liftnexus.storagebin.domain.Coordinate3D; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.domain.ZoneType; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import jakarta.validation.ConstraintViolationException; +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.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("TransportOrderRepository Integration Tests") +class TransportOrderRepositoryTest { + + @Autowired private TransportOrderRepository transportOrderRepository; + @Autowired private TestEntityManager entityManager; + + private StorageBin defaultSourceBin; + private StorageBin defaultTargetBin; + + @BeforeEach + void setup() { + Coordinate3D sourceCoordinate = new Coordinate3D(1, 1, 1); + Coordinate3D targetCoordinate = new Coordinate3D(10, 10, 1); + + defaultSourceBin = + entityManager.persistAndFlush( + StorageBin.builder() + .binCode("SRC-BIN") + .coordinate(sourceCoordinate) + .maxWeightCapacityKg(5000) + .zoneType(ZoneType.STAGING_IN) + .build()); + + defaultTargetBin = + entityManager.persistAndFlush( + StorageBin.builder() + .binCode("TGT-BIN") + .coordinate(targetCoordinate) + .maxWeightCapacityKg(5000) + .zoneType(ZoneType.STORAGE) + .build()); + } + + @Nested + @DisplayName("Tests - Validation Constraints") + class ValidationConstraints { + + @Test + @DisplayName("Should fail when target LoadUnit is missing") + void shouldFailWhenTargetLoadUnitIsNull() { + TransportOrder invalidOrder = + TransportOrder.builder() + .targetLoadUnit(null) // Defect + .sourceBin(defaultSourceBin) + .targetBin(defaultTargetBin) + .build(); + + assertThatThrownBy(() -> entityManager.persistAndFlush(invalidOrder)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("targetLoadUnit"); + } + + @Test + @DisplayName("Should fail when physical source bin is missing") + void shouldFailWhenSourceBinIsNull() { + LoadUnit validUnit = + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-VAL-1") + .weightKg(500) + .status(LoadUnitStatus.STORED) + .build()); + + TransportOrder invalidOrder = + TransportOrder.builder() + .targetLoadUnit(validUnit) + .sourceBin(null) // Defect + .targetBin(defaultTargetBin) + .build(); + + assertThatThrownBy(() -> entityManager.persistAndFlush(invalidOrder)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("sourceBin"); + } + + @Test + @DisplayName("Should fail when physical destination bin is missing") + void shouldFailWhenTargetBinIsNull() { + LoadUnit validUnit = + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-VAL-2") + .weightKg(500) + .status(LoadUnitStatus.STORED) + .build()); + + TransportOrder invalidOrder = + TransportOrder.builder() + .targetLoadUnit(validUnit) + .sourceBin(defaultSourceBin) + .targetBin(null) // Defect + .build(); + + assertThatThrownBy(() -> entityManager.persistAndFlush(invalidOrder)) + .isInstanceOf(ConstraintViolationException.class) + .hasMessageContaining("targetBin"); + } + } + + @Nested + @DisplayName("Tests - searchOrders Custom Query") + class SearchOrders { + @Test + @DisplayName( + "Should return paginated transportOrders " + + "filtered by minWeight based on linked LoadUnit") + void shouldFilterByJoinedLoadUnitWeight() { + + LoadUnit lightUnit = + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-500") + .weightKg(500) + .status(LoadUnitStatus.STORED) + .build()); + + LoadUnit heavyUnit = + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-1500") + .weightKg(1500) + .status(LoadUnitStatus.STORED) + .build()); + + transportOrderRepository.save( + TransportOrder.builder() + .status(TransportOrderStatus.OPEN) + .targetLoadUnit(lightUnit) + .sourceBin(defaultSourceBin) + .targetBin(defaultTargetBin) + .build()); + + transportOrderRepository.save( + TransportOrder.builder() + .status(TransportOrderStatus.COMPLETED) + .targetLoadUnit(heavyUnit) + .sourceBin(defaultSourceBin) + .targetBin(defaultTargetBin) + .build()); + + transportOrderRepository.flush(); + + Pageable pageable = PageRequest.of(0, 10); + Page result = transportOrderRepository.searchOrders(null, 1000, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getTargetLoadUnit().getTrackingCode()) + .isEqualTo("LU-1500"); + } + + @Test + @DisplayName( + "Should return all transportOrders within " + + "page limits when all query criteria parameters are null") + void shouldReturnAllTasks_WhenAllParametersAreNull() { + LoadUnit unit = + entityManager.persist( + LoadUnit.builder() + .trackingCode("LU-1") + .weightKg(500) + .status(LoadUnitStatus.STORED) + .build()); + + transportOrderRepository.save( + TransportOrder.builder() + .status(TransportOrderStatus.OPEN) + .targetLoadUnit(unit) + .sourceBin(defaultSourceBin) + .targetBin(defaultTargetBin) + .build()); + + transportOrderRepository.flush(); + + Pageable pageable = PageRequest.of(0, 10); + Page result = transportOrderRepository.searchOrders(null, null, pageable); + + assertThat(result.getTotalElements()).isEqualTo(1); + } + } +} diff --git a/src/test/java/com/v1rex/liftnexus/transportorder/service/TransportOrderServiceTest.java b/src/test/java/com/v1rex/liftnexus/transportorder/service/TransportOrderServiceTest.java new file mode 100644 index 00000000..48c14c13 --- /dev/null +++ b/src/test/java/com/v1rex/liftnexus/transportorder/service/TransportOrderServiceTest.java @@ -0,0 +1,290 @@ +package com.v1rex.liftnexus.transportorder.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.v1rex.liftnexus.forklift.domain.EquipmentType; +import com.v1rex.liftnexus.loadunit.domain.LoadUnit; +import com.v1rex.liftnexus.loadunit.service.LoadUnitService; +import com.v1rex.liftnexus.storagebin.domain.StorageBin; +import com.v1rex.liftnexus.storagebin.service.StorageBinService; +import com.v1rex.liftnexus.transportorder.domain.TransportOrder; +import com.v1rex.liftnexus.transportorder.domain.TransportOrderStatus; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderRequest; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderResponse; +import com.v1rex.liftnexus.transportorder.dto.TransportOrderStatusUpdateRequest; +import com.v1rex.liftnexus.transportorder.exception.TransportOrderInvalidStateException; +import com.v1rex.liftnexus.transportorder.exception.TransportOrderNotFoundException; +import com.v1rex.liftnexus.transportorder.mapper.TransportOrderMapper; +import com.v1rex.liftnexus.transportorder.repository.TransportOrderRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TransportOrderService Business Logic Tests") +class TransportOrderServiceTest { + + @Mock private TransportOrderRepository transportOrderRepository; + @Mock private TransportOrderMapper transportOrderMapper; + @Mock private StorageBinService storageBinService; + @Mock private LoadUnitService loadUnitService; + + @InjectMocks private TransportOrderService transportOrderService; + + @Nested + @DisplayName("Tests - createTransportOrder Method") + class CreateOrder { + + @Test + @DisplayName("Should successfully validate physical locations and create order") + void shouldCreateOrderSuccessfully() { + TransportOrderRequest request = + new TransportOrderRequest(10L, 1L, 2L, EquipmentType.STANDARD); + + StorageBin sourceBin = StorageBin.builder().id(1L).build(); + + StorageBin targetBin = StorageBin.builder().id(2L).build(); + + LoadUnit loadUnit = LoadUnit.builder().id(10L).currentBin(sourceBin).build(); + + TransportOrder mappedEntity = new TransportOrder(); + + TransportOrder savedEntity = TransportOrder.builder().id(99L).build(); + + TransportOrderResponse expectedResponse = + new TransportOrderResponse( + 99L, "LU-SKU", 10L, 2L, 1L, EquipmentType.STANDARD, TransportOrderStatus.OPEN, null); + + when(loadUnitService.findEntityById(10L)).thenReturn(loadUnit); + + when(storageBinService.findEntityById(1L)).thenReturn(sourceBin); + + when(storageBinService.findEntityById(2L)).thenReturn(targetBin); + + when(transportOrderMapper.toEntity(request)).thenReturn(mappedEntity); + when(transportOrderRepository.save(any())).thenReturn(savedEntity); + when(transportOrderMapper.toResponse(savedEntity)).thenReturn(expectedResponse); + + TransportOrderResponse response = transportOrderService.createTransportOrder(request); + + assertThat(response.id()).isEqualTo(99L); + verify(transportOrderRepository).save(any(TransportOrder.class)); + } + + @Test + @DisplayName("Should throw exception if LoadUnit currently has no bin assigned (null)") + void shouldThrowException_WhenLoadUnitHasNoBin() { + TransportOrderRequest request = + new TransportOrderRequest(10L, 1L, 2L, EquipmentType.STANDARD); + + StorageBin sourceBin = StorageBin.builder().id(1L).build(); + StorageBin targetBin = StorageBin.builder().id(2L).build(); + LoadUnit loadUnit = + LoadUnit.builder().id(10L).trackingCode("LU-NULL").currentBin(null).build(); + + when(loadUnitService.findEntityById(10L)).thenReturn(loadUnit); + when(storageBinService.findEntityById(1L)).thenReturn(sourceBin); + when(storageBinService.findEntityById(2L)).thenReturn(targetBin); + + assertThatThrownBy(() -> transportOrderService.createTransportOrder(request)) + .isInstanceOf(TransportOrderInvalidStateException.class); + + verify(transportOrderRepository, never()).save(any()); + } + + @Test + @DisplayName("Should throw exception if LoadUnit is in a different physical bin than requested") + void shouldThrowException_WhenLoadUnitInWrongBin() { + TransportOrderRequest request = + new TransportOrderRequest(10L, 1L, 2L, EquipmentType.STANDARD); + + StorageBin sourceBin = StorageBin.builder().id(1L).build(); + + StorageBin targetBin = StorageBin.builder().id(2L).build(); + + StorageBin actualBin = StorageBin.builder().id(99L).build(); + + LoadUnit loadUnit = + LoadUnit.builder().id(10L).trackingCode("LU-WRONG").currentBin(actualBin).build(); + + when(loadUnitService.findEntityById(10L)).thenReturn(loadUnit); + + when(storageBinService.findEntityById(1L)).thenReturn(sourceBin); + + when(storageBinService.findEntityById(2L)).thenReturn(targetBin); + + assertThatThrownBy(() -> transportOrderService.createTransportOrder(request)) + .isInstanceOf(TransportOrderInvalidStateException.class); + + verify(transportOrderRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("Tests - updateOrderStatus Method") + class UpdateOrderStatus { + + @Test + @DisplayName("Should successfully update status when transition is legally valid") + void shouldUpdateStatusSuccessfully() { + TransportOrder order = new TransportOrder(); + order.setId(1L); + order.setStatus(TransportOrderStatus.OPEN); + + TransportOrderStatusUpdateRequest request = + new TransportOrderStatusUpdateRequest(TransportOrderStatus.ASSIGNED); + + TransportOrderResponse expectedResponse = + new TransportOrderResponse( + 1L, + "LU-123", + 10L, + 2L, + 1L, + EquipmentType.STANDARD, + TransportOrderStatus.ASSIGNED, + null); + + when(transportOrderRepository.findById(1L)).thenReturn(Optional.of(order)); + + when(transportOrderMapper.toResponse(order)).thenReturn(expectedResponse); + + TransportOrderResponse response = transportOrderService.updateOrderStatus(1L, request); + + assertThat(response.status()).isEqualTo(TransportOrderStatus.ASSIGNED); + assertThat(order.getStatus()).isEqualTo(TransportOrderStatus.ASSIGNED); + } + + @Test + @DisplayName("Should throw exception if trying to update an already COMPLETED order") + void shouldThrowException_WhenOrderIsAlreadyCompleted() { + TransportOrder order = new TransportOrder(); + order.setId(1L); + order.setStatus(TransportOrderStatus.COMPLETED); + + when(transportOrderRepository.findById(1L)).thenReturn(Optional.of(order)); + + TransportOrderStatusUpdateRequest request = + new TransportOrderStatusUpdateRequest(TransportOrderStatus.OPEN); + + assertThatThrownBy(() -> transportOrderService.updateOrderStatus(1L, request)) + .isInstanceOf(TransportOrderInvalidStateException.class); + } + + @Test + @DisplayName("Should throw exception if trying to roll back from IN_PROGRESS to OPEN") + void shouldThrowException_WhenRollingBackFromInProgressToOpen() { + TransportOrder order = new TransportOrder(); + order.setId(2L); + order.setStatus(TransportOrderStatus.IN_PROGRESS); + + when(transportOrderRepository.findById(2L)).thenReturn(Optional.of(order)); + + TransportOrderStatusUpdateRequest request = + new TransportOrderStatusUpdateRequest(TransportOrderStatus.OPEN); + + assertThatThrownBy(() -> transportOrderService.updateOrderStatus(2L, request)) + .isInstanceOf(TransportOrderInvalidStateException.class); + } + + @Test + @DisplayName("Should throw exception if trying to roll back from ASSIGNED to OPEN") + void shouldThrowException_WhenRollingBackFromAssignedToOpen() { + TransportOrder order = new TransportOrder(); + order.setId(3L); + order.setStatus(TransportOrderStatus.ASSIGNED); + + when(transportOrderRepository.findById(3L)).thenReturn(Optional.of(order)); + + TransportOrderStatusUpdateRequest request = + new TransportOrderStatusUpdateRequest(TransportOrderStatus.OPEN); + + assertThatThrownBy(() -> transportOrderService.updateOrderStatus(3L, request)) + .isInstanceOf(TransportOrderInvalidStateException.class); + } + } + + @Nested + @DisplayName("Tests - findById & findEntityById Methods") + class FindById { + + @Test + @DisplayName("Should map and return Response when order exists") + void shouldReturnMappedResponse_WhenFound() { + TransportOrder order = new TransportOrder(); + order.setId(5L); + TransportOrderResponse expectedResponse = + new TransportOrderResponse( + 5L, "LU-123", 10L, 2L, 1L, EquipmentType.STANDARD, TransportOrderStatus.OPEN, null); + + when(transportOrderRepository.findById(5L)).thenReturn(Optional.of(order)); + + when(transportOrderMapper.toResponse(order)).thenReturn(expectedResponse); + + TransportOrderResponse result = transportOrderService.findById(5L); + + assertThat(result).isEqualTo(expectedResponse); + } + + @Test + @DisplayName("Should throw TransportOrderNotFoundException when ID does not exist in DB") + void shouldThrowResourceNotFound_WhenMissing() { + when(transportOrderRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> transportOrderService.findById(99L)) + .isInstanceOf(TransportOrderNotFoundException.class); + } + } + + @Nested + @DisplayName("Tests - searchOrders Method") + class SearchOrders { + + @Test + @DisplayName("Should return paginated TransportOrderResponses matching criteria") + void searchTasks_ShouldReturnPage_WhenCriteriaIsValid() { + TransportOrderStatus status = TransportOrderStatus.OPEN; + Integer minWeight = 500; + Pageable pageable = Pageable.unpaged(); + + TransportOrder transportOrder = new TransportOrder(); + transportOrder.setId(100L); + + Page mockTaskPage = new PageImpl<>(java.util.List.of(transportOrder)); + + when(transportOrderRepository.searchOrders(status, minWeight, pageable)) + .thenReturn(mockTaskPage); + + TransportOrderResponse mockResponse = + new TransportOrderResponse( + 100L, "LU-123", 10L, 2L, 1L, EquipmentType.STANDARD, TransportOrderStatus.OPEN, null); + + when(transportOrderMapper.toResponse(transportOrder)).thenReturn(mockResponse); + + Page result = + transportOrderService.searchOrders(status, minWeight, pageable); + + assertThat(result).isNotNull(); + assertThat(result.getTotalElements()).isEqualTo(1); + + TransportOrderResponse mappedResponse = result.getContent().get(0); + + assertThat(mappedResponse.id()).isEqualTo(100L); + + assertThat(mappedResponse.status()).isEqualTo(TransportOrderStatus.OPEN); + + verify(transportOrderRepository, times(1)).searchOrders(status, minWeight, pageable); + } + } +} diff --git a/src/test/java/com/v1rex/warehouse_dispatcher/WarehouseDispatcherApplicationTests.java b/src/test/java/com/v1rex/warehouse_dispatcher/WarehouseDispatcherApplicationTests.java deleted file mode 100644 index bbfb6a28..00000000 --- a/src/test/java/com/v1rex/warehouse_dispatcher/WarehouseDispatcherApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.v1rex.warehouse_dispatcher; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class WarehouseDispatcherApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 00000000..24400135 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.flyway.enabled=true +spring.flyway.baseline-on-migrate=true +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.sql.init.mode=never