From 45b805a81a6c9f9187ed880005ed51cd282871a5 Mon Sep 17 00:00:00 2001 From: luklpz Date: Thu, 14 May 2026 14:09:46 +0200 Subject: [PATCH] feat: add sync-service Spring Boot middleware Dolibarr-PrestaShop integration service (Fases 0-5): - HTTP clients for Dolibarr REST API and PrestaShop Webservice - JPA entities + Flyway migrations (product_mapping, order_mapping, sync_log) - Three sync flows: product push, stock push, order pull - Configurable @Scheduled jobs (disabled in dev, enabled in prod) - REST API with Basic Auth and Swagger UI Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 13 + sync-service/.gitattributes | 2 + sync-service/.gitignore | 37 +++ .../.mvn/wrapper/maven-wrapper.properties | 3 + sync-service/mvnw | 295 ++++++++++++++++++ sync-service/mvnw.cmd | 189 +++++++++++ sync-service/pom.xml | 151 +++++++++ .../sync_service/SyncServiceApplication.java | 15 + .../api/controller/MappingController.java | 59 ++++ .../api/controller/SyncController.java | 59 ++++ .../api/controller/SyncLogController.java | 53 ++++ .../api/dto/OrderMappingResponse.java | 15 + .../api/dto/ProductMappingResponse.java | 19 ++ .../sync_service/api/dto/SyncLogResponse.java | 16 + .../api/dto/SyncTriggerResponse.java | 12 + .../config/DolibarrClientConfig.java | 36 +++ .../config/IntegrationProperties.java | 40 +++ .../config/PrestashopClientConfig.java | 37 +++ .../sync_service/config/SchedulingConfig.java | 32 ++ .../sync_service/config/SecurityConfig.java | 67 ++++ .../integration/dolibarr/DolibarrClient.java | 201 ++++++++++++ .../dolibarr/dto/DolibarrInvoiceDto.java | 20 ++ .../dolibarr/dto/DolibarrOrderDto.java | 49 +++ .../dolibarr/dto/DolibarrProductDto.java | 28 ++ .../dolibarr/dto/DolibarrStockUpdateDto.java | 17 + .../dolibarr/dto/DolibarrThirdpartyDto.java | 20 ++ .../exception/DolibarrApiException.java | 22 ++ .../prestashop/PrestashopClient.java | 197 ++++++++++++ .../prestashop/dto/PrestashopCustomerDto.java | 21 ++ .../prestashop/dto/PrestashopOrderDto.java | 45 +++ .../prestashop/dto/PrestashopProductDto.java | 44 +++ .../dto/PrestashopStockAvailableDto.java | 35 +++ .../exception/PrestashopApiException.java | 22 ++ .../sync_service/mapping/OrderMapping.java | 42 +++ .../mapping/OrderMappingRepository.java | 14 + .../sync_service/mapping/OrderSyncStatus.java | 11 + .../sync_service/mapping/ProductMapping.java | 47 +++ .../mapping/ProductMappingRepository.java | 20 ++ .../tfg/sync_service/mapping/SyncLog.java | 45 +++ .../mapping/SyncLogRepository.java | 18 ++ .../tfg/sync_service/mapping/SyncStatus.java | 11 + .../tfg/sync_service/mapping/SyncType.java | 11 + .../sync_service/sync/OrderSyncService.java | 183 +++++++++++ .../sync_service/sync/ProductSyncService.java | 150 +++++++++ .../sync_service/sync/StockSyncService.java | 117 +++++++ .../tfg/sync_service/sync/SyncResult.java | 17 + .../tfg/sync_service/sync/SyncScheduler.java | 62 ++++ .../resources/application-dev.yml.example | 36 +++ .../resources/application-prod.yml.example | 25 ++ .../src/main/resources/application.properties | 1 + .../src/main/resources/application.yml | 37 +++ .../main/resources/db/migration/V1__init.sql | 31 ++ .../SyncServiceApplicationTests.java | 13 + 53 files changed, 2762 insertions(+) create mode 100644 .gitignore create mode 100644 sync-service/.gitattributes create mode 100644 sync-service/.gitignore create mode 100644 sync-service/.mvn/wrapper/maven-wrapper.properties create mode 100644 sync-service/mvnw create mode 100644 sync-service/mvnw.cmd create mode 100644 sync-service/pom.xml create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplication.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncController.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncLogController.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/OrderMappingResponse.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/ProductMappingResponse.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncLogResponse.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncTriggerResponse.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/DolibarrClientConfig.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/IntegrationProperties.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/PrestashopClientConfig.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SchedulingConfig.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SecurityConfig.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrInvoiceDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrOrderDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrStockUpdateDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrThirdpartyDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/exception/DolibarrApiException.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopCustomerDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopOrderDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopStockAvailableDto.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/exception/PrestashopApiException.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMapping.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMappingRepository.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderSyncStatus.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMapping.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMappingRepository.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLog.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLogRepository.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncStatus.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncType.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/StockSyncService.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncResult.java create mode 100644 sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncScheduler.java create mode 100644 sync-service/src/main/resources/application-dev.yml.example create mode 100644 sync-service/src/main/resources/application-prod.yml.example create mode 100644 sync-service/src/main/resources/application.properties create mode 100644 sync-service/src/main/resources/application.yml create mode 100644 sync-service/src/main/resources/db/migration/V1__init.sql create mode 100644 sync-service/src/test/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplicationTests.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..560a613 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Claude Code +.claude/ +CLAUDE.md + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr + +# OS +.DS_Store +Thumbs.db diff --git a/sync-service/.gitattributes b/sync-service/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/sync-service/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/sync-service/.gitignore b/sync-service/.gitignore new file mode 100644 index 0000000..54621a2 --- /dev/null +++ b/sync-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +target/ + +### Secrets ### +application-prod.yml +application-dev.yml +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/sync-service/.mvn/wrapper/maven-wrapper.properties b/sync-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..5291372 --- /dev/null +++ b/sync-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/sync-service/mvnw b/sync-service/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/sync-service/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/sync-service/mvnw.cmd b/sync-service/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/sync-service/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/sync-service/pom.xml b/sync-service/pom.xml new file mode 100644 index 0000000..cbd27f5 --- /dev/null +++ b/sync-service/pom.xml @@ -0,0 +1,151 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.14 + + + com.teterialosjuanjos.tfg + sync-service + 0.0.1-SNAPSHOT + sync-service + TFG — Middleware de integración Dolibarr-PrestaShop + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + com.h2database + h2 + runtime + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-security + + + org.projectlombok + lombok + true + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.3 + + + + org.flywaydb + flyway-core + + + + org.flywaydb + flyway-mysql + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplication.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplication.java new file mode 100644 index 0000000..25499c6 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplication.java @@ -0,0 +1,15 @@ +package com.teterialosjuanjos.tfg.sync_service; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class SyncServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(SyncServiceApplication.class, args); + } + +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java new file mode 100644 index 0000000..189ff60 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/MappingController.java @@ -0,0 +1,59 @@ +package com.teterialosjuanjos.tfg.sync_service.api.controller; + +import com.teterialosjuanjos.tfg.sync_service.api.dto.OrderMappingResponse; +import com.teterialosjuanjos.tfg.sync_service.api.dto.ProductMappingResponse; +import com.teterialosjuanjos.tfg.sync_service.mapping.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * REST endpoints for inspecting the Dolibarr ↔ PrestaShop ID mappings stored locally. + */ +@Tag(name = "Mappings", description = "Inspect Dolibarr ↔ PrestaShop ID mappings") +@RestController +@RequestMapping("/api/mappings") +@RequiredArgsConstructor +public class MappingController { + + private final ProductMappingRepository productMappingRepository; + private final OrderMappingRepository orderMappingRepository; + + @Operation(summary = "List product mappings, optionally filtered by sync status") + @GetMapping("/products") + public List getProductMappings( + @RequestParam(required = false) SyncStatus status + ) { + List mappings = status != null + ? productMappingRepository.findBySyncStatus(status) + : productMappingRepository.findAll(); + return mappings.stream().map(MappingController::toProductResponse).toList(); + } + + @Operation(summary = "List order mappings") + @GetMapping("/orders") + public List getOrderMappings() { + return orderMappingRepository.findAll() + .stream().map(MappingController::toOrderResponse).toList(); + } + + private static ProductMappingResponse toProductResponse(ProductMapping m) { + return new ProductMappingResponse( + m.getId(), m.getSku(), m.getDolibarrId(), m.getPrestashopId(), + m.getLastSyncedAt(), m.getSyncStatus(), m.getErrorMessage() + ); + } + + private static OrderMappingResponse toOrderResponse(OrderMapping m) { + return new OrderMappingResponse( + m.getId(), m.getPrestashopOrderId(), m.getDolibarrOrderId(), + m.getDolibarrInvoiceId(), m.getImportedAt(), m.getStatus() + ); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncController.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncController.java new file mode 100644 index 0000000..fbc93d4 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncController.java @@ -0,0 +1,59 @@ +package com.teterialosjuanjos.tfg.sync_service.api.controller; + +import com.teterialosjuanjos.tfg.sync_service.api.dto.SyncTriggerResponse; +import com.teterialosjuanjos.tfg.sync_service.sync.OrderSyncService; +import com.teterialosjuanjos.tfg.sync_service.sync.ProductSyncService; +import com.teterialosjuanjos.tfg.sync_service.sync.StockSyncService; +import com.teterialosjuanjos.tfg.sync_service.sync.SyncResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST endpoints for manually triggering synchronization flows. + * Useful in development (where scheduling is disabled) and for on-demand resync in production. + * + *

Sync runs synchronously — the HTTP call blocks until the sync completes. + * Response always returns 200; check {@code hasErrors} and {@code itemsFailed} for partial failures.

+ */ +@Tag(name = "Sync", description = "Manual synchronization triggers") +@RestController +@RequestMapping("/api/sync") +@RequiredArgsConstructor +public class SyncController { + + private final ProductSyncService productSyncService; + private final StockSyncService stockSyncService; + private final OrderSyncService orderSyncService; + + @Operation(summary = "Push Dolibarr products to PrestaShop (incremental)") + @PostMapping("/products") + public SyncTriggerResponse triggerProductSync() { + return toResponse("PRODUCT_PUSH", productSyncService.synchronize()); + } + + @Operation(summary = "Push Dolibarr stock levels to PrestaShop") + @PostMapping("/stock") + public SyncTriggerResponse triggerStockSync() { + return toResponse("STOCK_PUSH", stockSyncService.synchronize()); + } + + @Operation(summary = "Pull PrestaShop orders into Dolibarr") + @PostMapping("/orders") + public SyncTriggerResponse triggerOrderSync() { + return toResponse("ORDER_PULL", orderSyncService.synchronize()); + } + + private static SyncTriggerResponse toResponse(String syncType, SyncResult result) { + return new SyncTriggerResponse( + syncType, + result.itemsProcessed(), + result.itemsFailed(), + result.hasErrors(), + result.errors() + ); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncLogController.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncLogController.java new file mode 100644 index 0000000..79a9862 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/controller/SyncLogController.java @@ -0,0 +1,53 @@ +package com.teterialosjuanjos.tfg.sync_service.api.controller; + +import com.teterialosjuanjos.tfg.sync_service.api.dto.SyncLogResponse; +import com.teterialosjuanjos.tfg.sync_service.mapping.SyncLog; +import com.teterialosjuanjos.tfg.sync_service.mapping.SyncLogRepository; +import com.teterialosjuanjos.tfg.sync_service.mapping.SyncType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * REST endpoints for querying the synchronization audit log. + */ +@Tag(name = "Logs", description = "Synchronization audit log") +@RestController +@RequestMapping("/api/logs") +@RequiredArgsConstructor +public class SyncLogController { + + private final SyncLogRepository syncLogRepository; + + @Operation(summary = "List sync log entries, optionally filtered by type, newest first") + @GetMapping + public List getLogs( + @RequestParam(required = false) SyncType type + ) { + List logs = type != null + ? syncLogRepository.findBySyncTypeOrderByStartedAtDesc(type) + : syncLogRepository.findAll(Sort.by(Sort.Direction.DESC, "startedAt")); + return logs.stream().map(SyncLogController::toResponse).toList(); + } + + @Operation(summary = "Get a single sync log entry by ID") + @GetMapping("/{id}") + public ResponseEntity getLog(@PathVariable Long id) { + return syncLogRepository.findById(id) + .map(SyncLogController::toResponse) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + private static SyncLogResponse toResponse(SyncLog l) { + return new SyncLogResponse( + l.getId(), l.getSyncType(), l.getStartedAt(), l.getFinishedAt(), + l.getItemsProcessed(), l.getItemsFailed(), l.getErrorDetails() + ); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/OrderMappingResponse.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/OrderMappingResponse.java new file mode 100644 index 0000000..b4a46a7 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/OrderMappingResponse.java @@ -0,0 +1,15 @@ +package com.teterialosjuanjos.tfg.sync_service.api.dto; + +import com.teterialosjuanjos.tfg.sync_service.mapping.OrderSyncStatus; + +import java.time.Instant; + +/** API response for a {@link com.teterialosjuanjos.tfg.sync_service.mapping.OrderMapping}. */ +public record OrderMappingResponse( + Long id, + Integer prestashopOrderId, + Integer dolibarrOrderId, + Integer dolibarrInvoiceId, + Instant importedAt, + OrderSyncStatus status +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/ProductMappingResponse.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/ProductMappingResponse.java new file mode 100644 index 0000000..d920ab5 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/ProductMappingResponse.java @@ -0,0 +1,19 @@ +package com.teterialosjuanjos.tfg.sync_service.api.dto; + +import com.teterialosjuanjos.tfg.sync_service.mapping.SyncStatus; + +import java.time.Instant; + +/** + * API response for a {@link com.teterialosjuanjos.tfg.sync_service.mapping.ProductMapping}. + * Separate from the JPA entity to decouple the API contract from the database schema. + */ +public record ProductMappingResponse( + Long id, + String sku, + Integer dolibarrId, + Integer prestashopId, + Instant lastSyncedAt, + SyncStatus syncStatus, + String errorMessage +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncLogResponse.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncLogResponse.java new file mode 100644 index 0000000..37e0a6c --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncLogResponse.java @@ -0,0 +1,16 @@ +package com.teterialosjuanjos.tfg.sync_service.api.dto; + +import com.teterialosjuanjos.tfg.sync_service.mapping.SyncType; + +import java.time.Instant; + +/** API response for a {@link com.teterialosjuanjos.tfg.sync_service.mapping.SyncLog}. */ +public record SyncLogResponse( + Long id, + SyncType syncType, + Instant startedAt, + Instant finishedAt, + Integer itemsProcessed, + Integer itemsFailed, + String errorDetails +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncTriggerResponse.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncTriggerResponse.java new file mode 100644 index 0000000..d5a4218 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/api/dto/SyncTriggerResponse.java @@ -0,0 +1,12 @@ +package com.teterialosjuanjos.tfg.sync_service.api.dto; + +import java.util.List; + +/** API response returned after a manually triggered synchronization. */ +public record SyncTriggerResponse( + String syncType, + int itemsProcessed, + int itemsFailed, + boolean hasErrors, + List errors +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/DolibarrClientConfig.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/DolibarrClientConfig.java new file mode 100644 index 0000000..a1736e6 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/DolibarrClientConfig.java @@ -0,0 +1,36 @@ +package com.teterialosjuanjos.tfg.sync_service.config; + +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.exception.DolibarrApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StreamUtils; +import org.springframework.web.client.RestClient; + +import java.nio.charset.StandardCharsets; + +/** + * Produces the {@link RestClient} bean for Dolibarr API calls. + * Sets {@code DOLAPIKEY} header on every request and centralizes 4xx/5xx error handling. + */ +@Slf4j +@Configuration +public class DolibarrClientConfig { + + @Bean("dolibarrRestClient") + public RestClient dolibarrRestClient(IntegrationProperties props) { + return RestClient.builder() + .baseUrl(props.dolibarr().baseUrl()) + .defaultHeader("DOLAPIKEY", props.dolibarr().apiKey()) + .defaultStatusHandler( + status -> status.isError(), + (request, response) -> { + String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + log.error("Dolibarr API error {} {} {}: {}", + response.getStatusCode(), request.getMethod(), request.getURI(), body); + throw new DolibarrApiException(response.getStatusCode(), body); + } + ) + .build(); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/IntegrationProperties.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/IntegrationProperties.java new file mode 100644 index 0000000..d911d28 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/IntegrationProperties.java @@ -0,0 +1,40 @@ +package com.teterialosjuanjos.tfg.sync_service.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Externalized configuration for Dolibarr and PrestaShop integration endpoints. + * Bound from {@code integration.*} properties in application.yml; validated at startup. + */ +@ConfigurationProperties(prefix = "integration") +@Validated +public record IntegrationProperties( + @Valid Dolibarr dolibarr, + @Valid Prestashop prestashop +) { + + /** + * @param baseUrl full API base URL, e.g. {@code https://host/dolibarr/api/index.php} + * @param apiKey value sent as {@code DOLAPIKEY} request header + * @param defaultTaxRate VAT rate used when building Dolibarr order lines (e.g. 21.0 for 21%) + */ + public record Dolibarr( + @NotBlank String baseUrl, + @NotBlank String apiKey, + double defaultTaxRate + ) {} + + /** + * @param baseUrl full API base URL, e.g. {@code https://host/tienda/api} + * @param apiKey webservice key sent as {@code ws_key} query param + * @param defaultCategoryId PrestaShop category ID assigned to new products (default: 2 = Home) + */ + public record Prestashop( + @NotBlank String baseUrl, + @NotBlank String apiKey, + int defaultCategoryId + ) {} +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/PrestashopClientConfig.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/PrestashopClientConfig.java new file mode 100644 index 0000000..61ded24 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/PrestashopClientConfig.java @@ -0,0 +1,37 @@ +package com.teterialosjuanjos.tfg.sync_service.config; + +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StreamUtils; +import org.springframework.web.client.RestClient; + +import java.nio.charset.StandardCharsets; + +/** + * Produces the {@link RestClient} bean for PrestaShop Webservice calls. + * + *

Auth uses {@code ws_key} query param (not HTTP Basic Auth header) because + * the nginx reverse proxy in this hosting environment strips the Authorization header.

+ */ +@Slf4j +@Configuration +public class PrestashopClientConfig { + + @Bean("prestashopRestClient") + public RestClient prestashopRestClient(IntegrationProperties props) { + return RestClient.builder() + .baseUrl(props.prestashop().baseUrl()) + .defaultStatusHandler( + status -> status.isError(), + (request, response) -> { + String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + log.error("PrestaShop API error {} {} {}: {}", + response.getStatusCode(), request.getMethod(), request.getURI(), body); + throw new PrestashopApiException(response.getStatusCode(), body); + } + ) + .build(); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SchedulingConfig.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SchedulingConfig.java new file mode 100644 index 0000000..0fa7a5e --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SchedulingConfig.java @@ -0,0 +1,32 @@ +package com.teterialosjuanjos.tfg.sync_service.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * Enables scheduled sync tasks when {@code sync.scheduling.enabled=true}. + * + *

A single-threaded scheduler is used intentionally: all {@code @Scheduled} methods + * execute sequentially, preventing concurrent runs of different sync jobs that share + * the same database and external API connections.

+ * + *

This bean is absent in dev (default) — syncs are triggered manually via the REST API. + * Set {@code sync.scheduling.enabled=true} in production to activate automatic scheduling.

+ */ +@Configuration +@EnableScheduling +@ConditionalOnProperty(name = "sync.scheduling.enabled", havingValue = "true") +public class SchedulingConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("sync-scheduler-"); + return scheduler; + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SecurityConfig.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SecurityConfig.java new file mode 100644 index 0000000..c97e63d --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/config/SecurityConfig.java @@ -0,0 +1,67 @@ +package com.teterialosjuanjos.tfg.sync_service.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +/** + * HTTP Basic Auth security configuration. + * + *

Password encoding uses {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()}, + * which selects the algorithm from the {@code {id}} prefix of the stored password: + * {@code {noop}admin123} for development, {@code {bcrypt}$2a$10$...} for production.

+ * + *

CSRF is disabled because this is a stateless REST API authenticated via Basic Auth. + * Without session cookies, there is no CSRF surface to protect.

+ */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health").permitAll() + // H2 console is only available with H2 datasource (dev profile) + .requestMatchers("/h2-console/**").permitAll() + .anyRequest().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // sameOrigin required for H2 console iframes + .headers(headers -> + headers.frameOptions(fo -> fo.sameOrigin())); + return http.build(); + } + + @Bean + public UserDetailsService userDetailsService( + @Value("${api.security.username}") String username, + @Value("${api.security.password}") String password + ) { + UserDetails admin = User.withUsername(username) + .password(password) + .roles("ADMIN") + .build(); + return new InMemoryUserDetailsManager(admin); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java new file mode 100644 index 0000000..03a3243 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/DolibarrClient.java @@ -0,0 +1,201 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr; + +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * Typed HTTP client for the Dolibarr REST API. + * + *

Authentication ({@code DOLAPIKEY} header) and error handling are configured + * centrally in {@link com.teterialosjuanjos.tfg.sync_service.config.DolibarrClientConfig}. + * This class only contains business-level method signatures.

+ */ +@Slf4j +@Component +public class DolibarrClient { + + private static final DateTimeFormatter DOLIBARR_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC); + + private final RestClient restClient; + + public DolibarrClient(@Qualifier("dolibarrRestClient") RestClient restClient) { + this.restClient = restClient; + } + + // ── Products ────────────────────────────────────────────────────────── + + /** + * Returns all products, optionally filtered by last modification date. + * + * @param modifiedSince if non-null, only products with {@code tms >= modifiedSince} are returned + */ + public List getProducts(Instant modifiedSince) { + return restClient.get() + .uri(u -> { + u.path("/products").queryParam("limit", 500); + if (modifiedSince != null) { + u.queryParam("sqlfilters", + "(t.tms:>=:'%s')".formatted(DOLIBARR_DATE_FORMAT.format(modifiedSince))); + } + return u.build(); + }) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + } + + /** + * Finds a product by its SKU reference. Returns {@code null} if not found. + * + * @param ref SKU value, e.g. "PROD-001" + */ + public DolibarrProductDto getProductByRef(String ref) { + List results = restClient.get() + .uri(u -> u.path("/products") + .queryParam("ref", ref) + .queryParam("limit", 1) + .build()) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + return (results != null && !results.isEmpty()) ? results.get(0) : null; + } + + /** + * Creates a new product in Dolibarr. + * + * @return the created product with its assigned {@code id} + */ + public DolibarrProductDto createProduct(DolibarrProductDto dto) { + log.debug("Creating Dolibarr product ref={}", dto.ref()); + // POST /products returns the new ID as a plain integer body + Integer newId = restClient.post() + .uri("/products") + .body(dto) + .retrieve() + .body(Integer.class); + return getProductById(newId); + } + + /** Updates an existing product. */ + public void updateProduct(Integer id, DolibarrProductDto dto) { + log.debug("Updating Dolibarr product id={}", id); + restClient.put() + .uri("/products/{id}", id) + .body(dto) + .retrieve() + .toBodilessEntity(); + } + + /** + * Updates warehouse stock for a product. + * + *

Note: verify endpoint and payload against + * {@code /dolibarr/api/index.php/explorer} — stock endpoints vary across versions.

+ */ + public void updateStock(Integer productId, DolibarrStockUpdateDto dto) { + log.debug("Updating stock for Dolibarr product id={} → {}", productId, dto.newStock()); + restClient.post() + .uri("/products/{id}/stock", productId) + .body(dto) + .retrieve() + .toBodilessEntity(); + } + + private DolibarrProductDto getProductById(Integer id) { + return restClient.get() + .uri("/products/{id}", id) + .retrieve() + .body(DolibarrProductDto.class); + } + + // ── Thirdparties (customers) ────────────────────────────────────────── + + /** + * Finds a customer by email in Dolibarr, creating one if not found. + * + * @param email customer email from the PrestaShop order + * @param name full name to use if the customer must be created + */ + public DolibarrThirdpartyDto getOrCreateThirdparty(String email, String name) { + log.debug("Looking up Dolibarr thirdparty email={}", email); + List matches = restClient.get() + .uri(u -> u.path("/thirdparties") + .queryParam("sqlfilters", "(t.email:=:'%s')".formatted(email)) + .queryParam("limit", 1) + .build()) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + if (matches != null && !matches.isEmpty()) { + return matches.get(0); + } + + log.debug("Thirdparty not found, creating for email={}", email); + DolibarrThirdpartyDto newThirdparty = new DolibarrThirdpartyDto(null, name, email, 1, null); + Integer newId = restClient.post() + .uri("/thirdparties") + .body(newThirdparty) + .retrieve() + .body(Integer.class); + + return restClient.get() + .uri("/thirdparties/{id}", newId) + .retrieve() + .body(DolibarrThirdpartyDto.class); + } + + // ── Orders ──────────────────────────────────────────────────────────── + + /** + * Creates a sales order in Dolibarr. + * + * @return the created order with its assigned ID + */ + public DolibarrOrderDto createOrder(DolibarrOrderDto dto) { + log.debug("Creating Dolibarr order for socid={}", dto.socid()); + Integer newId = restClient.post() + .uri("/orders") + .body(dto) + .retrieve() + .body(Integer.class); + + return restClient.get() + .uri("/orders/{id}", newId) + .retrieve() + .body(DolibarrOrderDto.class); + } + + // ── Invoices ───────────────────────────────────────────────────────── + + /** + * Creates an invoice linked to an existing order. + * + * @param orderId Dolibarr internal order ID + * @param socid customer (thirdparty) internal ID — required by Dolibarr even with fk_commande + */ + public DolibarrInvoiceDto createInvoiceFromOrder(Integer orderId, Integer socid) { + log.debug("Creating Dolibarr invoice from orderId={}", orderId); + Map body = Map.of("fk_commande", orderId, "socid", socid); + Integer newId = restClient.post() + .uri("/invoices") + .body(body) + .retrieve() + .body(Integer.class); + + return restClient.get() + .uri("/invoices/{id}", newId) + .retrieve() + .body(DolibarrInvoiceDto.class); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrInvoiceDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrInvoiceDto.java new file mode 100644 index 0000000..c37b3b1 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrInvoiceDto.java @@ -0,0 +1,20 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Minimal representation of a Dolibarr invoice. + * Used for {@code GET /invoices/{id}} responses after creation via {@code POST /invoices}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DolibarrInvoiceDto( + Integer id, + String ref, + /** Linked order internal ID */ + @JsonProperty("fk_commande") Integer fkCommande, + /** 0 = draft, 1 = validated, 2 = paid, 3 = abandoned */ + Integer statut +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrOrderDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrOrderDto.java new file mode 100644 index 0000000..858f844 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrOrderDto.java @@ -0,0 +1,49 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; + +import java.util.List; + +/** + * Represents a Dolibarr sales order. + * Used for both {@code GET /orders/{id}} responses and {@code POST /orders} request bodies. + */ +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DolibarrOrderDto( + Integer id, + String ref, + /** Thirdparty (customer) internal ID */ + Integer socid, + /** Order date as UNIX timestamp */ + @JsonProperty("date_commande") Long dateCommande, + /** 0 = draft, 1 = validated, 2 = shipped, 3 = canceled */ + Integer statut, + @JsonProperty("note_public") String notePublic, + List lines +) { + + /** + * One line of a sales order. + * + * @param subprice unit price excl. tax — PrestaShop provides tax-included prices, + * so {@code OrderSyncService} divides by {@code (1 + tvaTx/100)} before setting this + * @param tvaTx VAT rate percentage, e.g. {@code 21.0} + */ + @Builder + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record OrderLine( + Integer id, + @JsonProperty("fk_product") Integer fkProduct, + @JsonProperty("product_ref") String productRef, + String desc, + Double subprice, + Double qty, + @JsonProperty("tva_tx") Double tvaTx + ) {} +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java new file mode 100644 index 0000000..5b64b73 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrProductDto.java @@ -0,0 +1,28 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a Dolibarr product. + * Used for both responses ({@code GET /products}, {@code GET /products/{id}}) + * and request bodies ({@code POST /products}, {@code PUT /products/{id}}). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DolibarrProductDto( + Integer id, + /** SKU — identifier shared with PrestaShop ({@code reference} field there) */ + String ref, + String label, + String description, + String price, + /** Current stock level across all warehouses */ + @JsonProperty("stock_reel") Double stockReel, + /** 1 = on sale, 0 = not for sale */ + @JsonProperty("tosell") Integer toSell, + /** 0 = physical product, 1 = service */ + Integer type, + Double weight +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrStockUpdateDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrStockUpdateDto.java new file mode 100644 index 0000000..e4550d6 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrStockUpdateDto.java @@ -0,0 +1,17 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request body for stock adjustments via {@code POST /products/{id}/stock}. + * + *

Note: verify exact endpoint and payload against + * {@code /dolibarr/api/index.php/explorer} — stock endpoints vary across Dolibarr versions.

+ * + * @param warehouseId Dolibarr warehouse ID; use 0 for default warehouse + * @param newStock absolute target stock quantity (not a delta) + */ +public record DolibarrStockUpdateDto( + @JsonProperty("warehouse_id") Integer warehouseId, + @JsonProperty("new_stock") Double newStock +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrThirdpartyDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrThirdpartyDto.java new file mode 100644 index 0000000..0568d7d --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/dto/DolibarrThirdpartyDto.java @@ -0,0 +1,20 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a Dolibarr thirdparty (customer or supplier). + * Used for {@code GET /thirdparties} responses and {@code POST /thirdparties} requests. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DolibarrThirdpartyDto( + Integer id, + String name, + String email, + /** 1 = customer */ + Integer client, + @JsonProperty("code_client") String codeClient +) {} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/exception/DolibarrApiException.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/exception/DolibarrApiException.java new file mode 100644 index 0000000..fb1774f --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/dolibarr/exception/DolibarrApiException.java @@ -0,0 +1,22 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.exception; + +import org.springframework.http.HttpStatusCode; + +/** + * Thrown when the Dolibarr REST API responds with a 4xx or 5xx status. + * The raw response body is preserved for diagnostics. + */ +public class DolibarrApiException extends RuntimeException { + + private final HttpStatusCode statusCode; + private final String body; + + public DolibarrApiException(HttpStatusCode statusCode, String body) { + super("Dolibarr API error %s: %s".formatted(statusCode, body)); + this.statusCode = statusCode; + this.body = body; + } + + public HttpStatusCode getStatusCode() { return statusCode; } + public String getBody() { return body; } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java new file mode 100644 index 0000000..960acb8 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/PrestashopClient.java @@ -0,0 +1,197 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.prestashop; + +import com.teterialosjuanjos.tfg.sync_service.config.IntegrationProperties; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +/** + * Typed HTTP client for the PrestaShop Webservice. + * + *

Every request appends {@code ws_key} and {@code output_format=JSON} as query params. + * HTTP Basic Auth is not used because the nginx reverse proxy in this hosting environment + * strips the Authorization header.

+ * + *

The root {@code /api/} endpoint is known to return 500 in PS 8.2.5; + * always target specific resources such as {@code /api/products}.

+ */ +@Slf4j +@Component +public class PrestashopClient { + + private static final DateTimeFormatter PS_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final RestClient restClient; + private final String wsKey; + + public PrestashopClient( + @Qualifier("prestashopRestClient") RestClient restClient, + IntegrationProperties props + ) { + this.restClient = restClient; + this.wsKey = props.prestashop().apiKey(); + } + + // ── Products ────────────────────────────────────────────────────────── + + /** + * Returns all active products with full field display. + * Uses {@code display=full} to get complete data in a single request + * (without it, PS returns only IDs). + */ + public List getProducts() { + PrestashopProductDto.ListResponse response = restClient.get() + .uri(u -> u.path("/products") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .queryParam("display", "full") + .build()) + .retrieve() + .body(PrestashopProductDto.ListResponse.class); + + return (response != null && response.products() != null) ? response.products() : List.of(); + } + + /** + * Finds a product by its SKU/reference. Returns empty if not found. + * + * @param reference SKU value, e.g. "PROD-001" + */ + public Optional getProductByReference(String reference) { + PrestashopProductDto.ListResponse response = restClient.get() + .uri(u -> u.path("/products") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .queryParam("display", "full") + .queryParam("filter[reference]", reference) + .build()) + .retrieve() + .body(PrestashopProductDto.ListResponse.class); + + if (response == null || response.products() == null || response.products().isEmpty()) { + return Optional.empty(); + } + return Optional.of(response.products().get(0)); + } + + /** + * Creates a new product in PrestaShop. + * + * @return the created product including its assigned {@code id} + */ + public PrestashopProductDto createProduct(PrestashopProductDto dto) { + log.debug("Creating PrestaShop product reference={}", dto.reference()); + PrestashopProductDto.SingleResponse response = restClient.post() + .uri(u -> u.path("/products") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .build()) + .body(new PrestashopProductDto.SingleResponse(dto)) + .retrieve() + .body(PrestashopProductDto.SingleResponse.class); + + return response != null ? response.product() : null; + } + + /** Updates an existing product by its PrestaShop internal ID. */ + public void updateProduct(Integer id, PrestashopProductDto dto) { + log.debug("Updating PrestaShop product id={}", id); + restClient.put() + .uri(u -> u.path("/products/{id}") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .build(id)) + .body(new PrestashopProductDto.SingleResponse(dto)) + .retrieve() + .toBodilessEntity(); + } + + // ── Stock ───────────────────────────────────────────────────────────── + + /** + * Returns the stock_available record for a simple product (no combination). + * + * @param productId PrestaShop product internal ID + */ + public Optional getStockAvailableForProduct(Integer productId) { + PrestashopStockAvailableDto.ListResponse response = restClient.get() + .uri(u -> u.path("/stock_availables") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .queryParam("display", "full") + .queryParam("filter[id_product]", productId) + .queryParam("filter[id_product_attribute]", "0") + .build()) + .retrieve() + .body(PrestashopStockAvailableDto.ListResponse.class); + + if (response == null || response.stockAvailables() == null || response.stockAvailables().isEmpty()) { + return Optional.empty(); + } + return Optional.of(response.stockAvailables().get(0)); + } + + /** Updates the stock quantity for a stock_available record. */ + public void updateStockAvailable(Integer id, PrestashopStockAvailableDto dto) { + log.debug("Updating PrestaShop stock_available id={} qty={}", id, dto.quantity()); + restClient.put() + .uri(u -> u.path("/stock_availables/{id}") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .build(id)) + .body(new PrestashopStockAvailableDto.SingleResponse(dto)) + .retrieve() + .toBodilessEntity(); + } + + // ── Orders ──────────────────────────────────────────────────────────── + + /** + * Returns orders created on or after {@code since}, with full details and order rows. + * + *

PrestaShop date filter syntax: {@code filter[date_add]=[>=yyyy-MM-dd HH:mm:ss]}

+ * + * @param since lower bound (inclusive) for {@code date_add} + */ + public List getOrdersSince(LocalDateTime since) { + String dateFilter = "[>=%s]".formatted(since.format(PS_DATE_FORMAT)); + PrestashopOrderDto.ListResponse response = restClient.get() + .uri(u -> u.path("/orders") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .queryParam("display", "full") + .queryParam("filter[date_add]", dateFilter) + .build()) + .retrieve() + .body(PrestashopOrderDto.ListResponse.class); + + return (response != null && response.orders() != null) ? response.orders() : List.of(); + } + + // ── Customers ───────────────────────────────────────────────────────── + + /** + * Fetches a PrestaShop customer by internal ID. + * Used during order import to resolve the customer's email and name for Dolibarr. + * + * @return the customer, or {@code null} if not found + */ + public PrestashopCustomerDto getCustomer(Integer id) { + PrestashopCustomerDto.SingleResponse response = restClient.get() + .uri(u -> u.path("/customers/{id}") + .queryParam("ws_key", wsKey) + .queryParam("output_format", "JSON") + .build(id)) + .retrieve() + .body(PrestashopCustomerDto.SingleResponse.class); + return response != null ? response.customer() : null; + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopCustomerDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopCustomerDto.java new file mode 100644 index 0000000..68b05c8 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopCustomerDto.java @@ -0,0 +1,21 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Minimal representation of a PrestaShop customer. + * Fetched via {@code GET /api/customers/{id}} to resolve the email and name + * needed to create or find the matching thirdparty in Dolibarr. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PrestashopCustomerDto( + Integer id, + String firstname, + String lastname, + String email +) { + + /** Envelope for {@code GET /api/customers/{id}} */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record SingleResponse(PrestashopCustomerDto customer) {} +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopOrderDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopOrderDto.java new file mode 100644 index 0000000..85a7e4d --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopOrderDto.java @@ -0,0 +1,45 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Represents a PrestaShop order fetched from {@code GET /api/orders?display=full}. + * Order rows (line items) are nested under {@code associations.order_rows}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PrestashopOrderDto( + Integer id, + /** Human-readable order reference, e.g. "XMTYPSYD" */ + String reference, + @JsonProperty("id_customer") String idCustomer, + /** ISO datetime string: "2024-01-15 10:30:00" */ + @JsonProperty("date_add") String dateAdd, + @JsonProperty("total_paid") String totalPaid, + @JsonProperty("total_shipping") String totalShipping, + /** PrestaShop order state ID */ + @JsonProperty("current_state") String currentState, + Associations associations +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Associations( + @JsonProperty("order_rows") List orderRows + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record OrderRow( + String id, + @JsonProperty("product_id") String productId, + /** SKU — used to find the matching Dolibarr product */ + @JsonProperty("product_reference") String productReference, + @JsonProperty("product_quantity") String productQuantity, + @JsonProperty("unit_price_tax_incl") String unitPriceTaxIncl + ) {} + + /** Envelope for {@code GET /api/orders?display=full} */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListResponse(List orders) {} +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java new file mode 100644 index 0000000..baa5595 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopProductDto.java @@ -0,0 +1,44 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Represents a PrestaShop product. + * + *

Multilingual fields (name, description) are returned as arrays of {@link LangValue} + * objects, one per configured shop language.

+ * + *

Response wrappers ({@link ListResponse}, {@link SingleResponse}) match the JSON envelope + * PrestaShop adds around resources: {@code {"products":[...]}} and {@code {"product":{...}}}.

+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PrestashopProductDto( + Integer id, + /** SKU — identifier shared with Dolibarr ({@code ref} field there) */ + String reference, + String price, + /** "1" = active, "0" = inactive */ + String active, + @JsonProperty("id_category_default") String idCategoryDefault, + List name, + List description, + @JsonProperty("description_short") List descriptionShort +) { + + /** One language entry for a multilingual field, e.g. {@code {"id":"1","value":"Name"}}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record LangValue(String id, String value) {} + + /** Envelope for {@code GET /api/products?display=full} */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListResponse(List products) {} + + /** Envelope for {@code GET /api/products/{id}}, {@code POST} and {@code PUT} */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record SingleResponse(PrestashopProductDto product) {} +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopStockAvailableDto.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopStockAvailableDto.java new file mode 100644 index 0000000..b099833 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/dto/PrestashopStockAvailableDto.java @@ -0,0 +1,35 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Represents a PrestaShop stock_available record. + * Each product/combination pair has one stock_available entry per warehouse. + * For simple products (no combinations), {@code idProductAttribute} is "0". + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record PrestashopStockAvailableDto( + Integer id, + @JsonProperty("id_product") String idProduct, + /** "0" for simple products with no attribute combinations */ + @JsonProperty("id_product_attribute") String idProductAttribute, + String quantity +) { + + /** Envelope for {@code GET /api/stock_availables?display=full} */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ListResponse( + @JsonProperty("stock_availables") List stockAvailables + ) {} + + /** Envelope for {@code GET /api/stock_availables/{id}} and {@code PUT} */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record SingleResponse( + @JsonProperty("stock_available") PrestashopStockAvailableDto stockAvailable + ) {} +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/exception/PrestashopApiException.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/exception/PrestashopApiException.java new file mode 100644 index 0000000..bd62be5 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/integration/prestashop/exception/PrestashopApiException.java @@ -0,0 +1,22 @@ +package com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception; + +import org.springframework.http.HttpStatusCode; + +/** + * Thrown when the PrestaShop Webservice responds with a 4xx or 5xx status. + * The raw response body is preserved for diagnostics. + */ +public class PrestashopApiException extends RuntimeException { + + private final HttpStatusCode statusCode; + private final String body; + + public PrestashopApiException(HttpStatusCode statusCode, String body) { + super("PrestaShop API error %s: %s".formatted(statusCode, body)); + this.statusCode = statusCode; + this.body = body; + } + + public HttpStatusCode getStatusCode() { return statusCode; } + public String getBody() { return body; } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMapping.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMapping.java new file mode 100644 index 0000000..b32af55 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMapping.java @@ -0,0 +1,42 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +/** + * Tracks each PrestaShop order that has been imported into Dolibarr. + * Used to avoid re-importing orders on subsequent sync runs. + */ +@Entity +@Table(name = "order_mapping") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OrderMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** PrestaShop internal order ID. Unique — prevents duplicate imports. */ + @Column(name = "prestashop_order_id", nullable = false, unique = true) + private Integer prestashopOrderId; + + @Column(name = "dolibarr_order_id") + private Integer dolibarrOrderId; + + /** Populated after the invoice is created in Dolibarr; null until then. */ + @Column(name = "dolibarr_invoice_id") + private Integer dolibarrInvoiceId; + + @Column(name = "imported_at", nullable = false) + private Instant importedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private OrderSyncStatus status; +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMappingRepository.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMappingRepository.java new file mode 100644 index 0000000..120e898 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderMappingRepository.java @@ -0,0 +1,14 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** Repository for {@link OrderMapping} records. */ +public interface OrderMappingRepository extends JpaRepository { + + Optional findByPrestashopOrderId(Integer prestashopOrderId); + + /** Used to skip orders that were already imported in a previous sync run. */ + boolean existsByPrestashopOrderId(Integer prestashopOrderId); +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderSyncStatus.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderSyncStatus.java new file mode 100644 index 0000000..d7b12f0 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/OrderSyncStatus.java @@ -0,0 +1,11 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +/** Lifecycle state of a PrestaShop → Dolibarr order import. */ +public enum OrderSyncStatus { + /** Order created in Dolibarr but no invoice generated yet. */ + IMPORTED, + /** Order and invoice both created in Dolibarr. */ + INVOICED, + /** Import failed; manual intervention required. */ + ERROR +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMapping.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMapping.java new file mode 100644 index 0000000..0d63bbf --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMapping.java @@ -0,0 +1,47 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +/** + * Persists the relationship between a Dolibarr product and its PrestaShop counterpart, + * identified by their shared SKU. One row per product. + */ +@Entity +@Table(name = "product_mapping") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProductMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Shared identifier: {@code ref} in Dolibarr, {@code reference} in PrestaShop. */ + @Column(nullable = false, unique = true, length = 100) + private String sku; + + @Column(name = "dolibarr_id") + private Integer dolibarrId; + + @Column(name = "prestashop_id") + private Integer prestashopId; + + /** Timestamp of the last successful synchronization. Used to filter incremental updates. */ + @Column(name = "last_synced_at") + private Instant lastSyncedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private SyncStatus syncStatus = SyncStatus.PENDING; + + /** Last error message from a failed sync attempt; null when {@code syncStatus = SYNCED}. */ + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMappingRepository.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMappingRepository.java new file mode 100644 index 0000000..05b5603 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/ProductMappingRepository.java @@ -0,0 +1,20 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** Repository for {@link ProductMapping} records. */ +public interface ProductMappingRepository extends JpaRepository { + + Optional findBySku(String sku); + + Optional findByDolibarrId(Integer dolibarrId); + + Optional findByPrestashopId(Integer prestashopId); + + List findBySyncStatus(SyncStatus syncStatus); + + boolean existsBySku(String sku); +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLog.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLog.java new file mode 100644 index 0000000..d0c30a4 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLog.java @@ -0,0 +1,45 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +/** + * Audit log for each scheduled synchronization run. + * One row is written per execution of each sync flow, regardless of success or failure. + */ +@Entity +@Table(name = "sync_log") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SyncLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "sync_type", nullable = false, length = 30) + private SyncType syncType; + + @Column(name = "started_at", nullable = false) + private Instant startedAt; + + /** Null while the sync is still running. */ + @Column(name = "finished_at") + private Instant finishedAt; + + @Column(name = "items_processed") + private Integer itemsProcessed; + + @Column(name = "items_failed") + private Integer itemsFailed; + + /** Full error stack trace or message; null on successful runs. */ + @Column(name = "error_details", columnDefinition = "TEXT") + private String errorDetails; +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLogRepository.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLogRepository.java new file mode 100644 index 0000000..12745a1 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncLogRepository.java @@ -0,0 +1,18 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** Repository for {@link SyncLog} records. */ +public interface SyncLogRepository extends JpaRepository { + + List findBySyncTypeOrderByStartedAtDesc(SyncType syncType); + + /** + * Returns the most recent log entry for the given sync type. + * Used by schedulers to determine the {@code lastSyncedAt} cutoff for incremental syncs. + */ + Optional findFirstBySyncTypeOrderByStartedAtDesc(SyncType syncType); +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncStatus.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncStatus.java new file mode 100644 index 0000000..3d2992b --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncStatus.java @@ -0,0 +1,11 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +/** Lifecycle state of a product synchronization record. */ +public enum SyncStatus { + /** Not yet pushed to PrestaShop, or pending retry after an error. */ + PENDING, + /** Successfully synchronized on the last attempt. */ + SYNCED, + /** Last sync attempt failed; see {@code errorMessage} for details. */ + ERROR +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncType.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncType.java new file mode 100644 index 0000000..3d7920e --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/mapping/SyncType.java @@ -0,0 +1,11 @@ +package com.teterialosjuanjos.tfg.sync_service.mapping; + +/** Identifies which synchronization flow a {@link SyncLog} entry belongs to. */ +public enum SyncType { + /** Dolibarr products → PrestaShop products */ + PRODUCT_PUSH, + /** Dolibarr stock levels → PrestaShop stock_availables */ + STOCK_PUSH, + /** PrestaShop orders → Dolibarr orders + invoices */ + ORDER_PULL +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java new file mode 100644 index 0000000..1f7d0ba --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/OrderSyncService.java @@ -0,0 +1,183 @@ +package com.teterialosjuanjos.tfg.sync_service.sync; + +import com.teterialosjuanjos.tfg.sync_service.config.IntegrationProperties; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.DolibarrClient; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrInvoiceDto; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrOrderDto; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrThirdpartyDto; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.exception.DolibarrApiException; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.PrestashopClient; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopCustomerDto; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopOrderDto; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; +import com.teterialosjuanjos.tfg.sync_service.mapping.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +/** + * Pulls new PrestaShop orders and imports them into Dolibarr as sales orders and invoices. + * + *

Import logic per order:

+ *
    + *
  1. Fetch the PrestaShop customer to get email and name.
  2. + *
  3. Get or create the matching thirdparty in Dolibarr (lookup by email).
  4. + *
  5. Create a sales order ({@code commande}) in Dolibarr with mapped line items.
  6. + *
  7. Create an invoice linked to the order.
  8. + *
  9. Persist an {@link OrderMapping} to prevent re-import on future runs.
  10. + *
+ * + *

Order lines use price excl. tax ({@code subprice}), computed from PrestaShop's + * tax-included unit price using the configured {@code defaultTaxRate}.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderSyncService { + + private static final DateTimeFormatter PS_DATE_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final DolibarrClient dolibarrClient; + private final PrestashopClient prestashopClient; + private final OrderMappingRepository orderMappingRepository; + private final SyncLogRepository syncLogRepository; + private final IntegrationProperties integrationProperties; + + /** + * Pulls new orders since the last sync run and imports them into Dolibarr. + * Already-imported orders are skipped via {@link OrderMappingRepository#existsByPrestashopOrderId}. + */ + public SyncResult synchronize() { + // Determine cutoff BEFORE saving current log to avoid self-reference + LocalDateTime lastSyncTime = syncLogRepository + .findFirstBySyncTypeOrderByStartedAtDesc(SyncType.ORDER_PULL) + .map(l -> LocalDateTime.ofInstant(l.getStartedAt(), ZoneOffset.UTC)) + .orElseGet(() -> LocalDateTime.now(ZoneOffset.UTC).minusDays(30)); + + SyncLog syncLog = syncLogRepository.save(SyncLog.builder() + .syncType(SyncType.ORDER_PULL) + .startedAt(Instant.now()) + .build()); + + int processed = 0; + int failed = 0; + List errors = new ArrayList<>(); + + try { + List orders = prestashopClient.getOrdersSince(lastSyncTime); + log.info("OrderSync: {} orders to process since {}", orders.size(), lastSyncTime); + + for (PrestashopOrderDto psOrder : orders) { + if (orderMappingRepository.existsByPrestashopOrderId(psOrder.id())) { + log.debug("OrderSync: PS order {} already imported, skipping", psOrder.id()); + continue; + } + try { + importOrder(psOrder); + processed++; + } catch (DolibarrApiException | PrestashopApiException e) { + failed++; + String msg = "PS order %d: %s".formatted(psOrder.id(), e.getMessage()); + errors.add(msg); + log.warn("OrderSync item failed: {}", msg); + } + } + } finally { + syncLog.setFinishedAt(Instant.now()); + syncLog.setItemsProcessed(processed); + syncLog.setItemsFailed(failed); + if (!errors.isEmpty()) { + syncLog.setErrorDetails(String.join("\n", errors)); + } + syncLogRepository.save(syncLog); + } + + log.info("OrderSync finished: processed={} failed={}", processed, failed); + return new SyncResult(processed, failed, errors); + } + + private void importOrder(PrestashopOrderDto psOrder) { + // 1. Resolve PS customer → Dolibarr thirdparty + DolibarrThirdpartyDto customer = resolveCustomer(psOrder); + + // 2. Build order in Dolibarr + long dateEpoch = LocalDateTime.parse(psOrder.dateAdd(), PS_DATE_FORMAT) + .toEpochSecond(ZoneOffset.UTC); + + DolibarrOrderDto orderDto = DolibarrOrderDto.builder() + .socid(customer.id()) + .dateCommande(dateEpoch) + .notePublic("Pedido PrestaShop ref: " + psOrder.reference()) + .lines(buildOrderLines(psOrder)) + .build(); + + DolibarrOrderDto createdOrder = dolibarrClient.createOrder(orderDto); + + // 3. Create linked invoice + DolibarrInvoiceDto invoice = dolibarrClient.createInvoiceFromOrder( + createdOrder.id(), customer.id()); + + // 4. Persist mapping to prevent re-import + orderMappingRepository.save(OrderMapping.builder() + .prestashopOrderId(psOrder.id()) + .dolibarrOrderId(createdOrder.id()) + .dolibarrInvoiceId(invoice.id()) + .importedAt(Instant.now()) + .status(OrderSyncStatus.INVOICED) + .build()); + + log.info("OrderSync: imported PS order {} → Dolibarr order {} + invoice {}", + psOrder.id(), createdOrder.id(), invoice.id()); + } + + private DolibarrThirdpartyDto resolveCustomer(PrestashopOrderDto psOrder) { + PrestashopCustomerDto psCustomer = prestashopClient.getCustomer( + Integer.parseInt(psOrder.idCustomer())); + + String name = psCustomer != null + ? (psCustomer.firstname() + " " + psCustomer.lastname()).trim() + : "Cliente PS " + psOrder.idCustomer(); + + // Fallback email keeps Dolibarr's email uniqueness constraint satisfied + String email = psCustomer != null + ? psCustomer.email() + : "ps_customer_%s@noemail.local".formatted(psOrder.idCustomer()); + + return dolibarrClient.getOrCreateThirdparty(email, name); + } + + /** + * Maps PrestaShop order rows to Dolibarr order lines. + * Converts tax-included unit price to excl-tax using the configured {@code defaultTaxRate}. + */ + private List buildOrderLines(PrestashopOrderDto psOrder) { + if (psOrder.associations() == null || psOrder.associations().orderRows() == null) { + return List.of(); + } + + double taxRate = integrationProperties.dolibarr().defaultTaxRate(); + + return psOrder.associations().orderRows().stream() + .map(row -> { + double priceInclTax = Double.parseDouble(row.unitPriceTaxIncl()); + double priceExclTax = priceInclTax / (1.0 + taxRate / 100.0); + + return DolibarrOrderDto.OrderLine.builder() + .productRef(row.productReference()) + .desc(row.productReference()) + .subprice(priceExclTax) + .qty(Double.parseDouble(row.productQuantity())) + .tvaTx(taxRate) + .build(); + }) + .toList(); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java new file mode 100644 index 0000000..ec83dd4 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/ProductSyncService.java @@ -0,0 +1,150 @@ +package com.teterialosjuanjos.tfg.sync_service.sync; + +import com.teterialosjuanjos.tfg.sync_service.config.IntegrationProperties; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.DolibarrClient; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrProductDto; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.exception.DolibarrApiException; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.PrestashopClient; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopProductDto; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; +import com.teterialosjuanjos.tfg.sync_service.mapping.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Pushes Dolibarr products to PrestaShop, creating or updating by SKU. + * + *

On each run, only products modified since the previous sync's {@code startedAt} + * are fetched from Dolibarr (incremental sync). The first run fetches everything.

+ * + *

Per-item errors (API failures) are recorded in {@link ProductMapping} and in + * {@link SyncLog} but do not abort the remaining items.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductSyncService { + + private final DolibarrClient dolibarrClient; + private final PrestashopClient prestashopClient; + private final ProductMappingRepository productMappingRepository; + private final SyncLogRepository syncLogRepository; + private final IntegrationProperties integrationProperties; + + /** + * Executes a full incremental product push and returns a summary. + */ + public SyncResult synchronize() { + // Determine cutoff BEFORE saving current log to avoid self-reference + Instant lastSyncStart = syncLogRepository + .findFirstBySyncTypeOrderByStartedAtDesc(SyncType.PRODUCT_PUSH) + .map(SyncLog::getStartedAt) + .orElse(null); + + SyncLog syncLog = syncLogRepository.save(SyncLog.builder() + .syncType(SyncType.PRODUCT_PUSH) + .startedAt(Instant.now()) + .build()); + + int processed = 0; + int failed = 0; + List errors = new ArrayList<>(); + + try { + List products = dolibarrClient.getProducts(lastSyncStart); + log.info("ProductSync: {} products to process (since {})", products.size(), lastSyncStart); + + for (DolibarrProductDto product : products) { + if (product.ref() == null || product.ref().isBlank()) { + log.warn("Skipping Dolibarr product id={} with no ref", product.id()); + continue; + } + try { + pushProduct(product); + processed++; + } catch (DolibarrApiException | PrestashopApiException e) { + failed++; + String msg = "SKU %s: %s".formatted(product.ref(), e.getMessage()); + errors.add(msg); + log.warn("ProductSync item failed: {}", msg); + markError(product.ref(), e.getMessage()); + } + } + } finally { + syncLog.setFinishedAt(Instant.now()); + syncLog.setItemsProcessed(processed); + syncLog.setItemsFailed(failed); + if (!errors.isEmpty()) { + syncLog.setErrorDetails(String.join("\n", errors)); + } + syncLogRepository.save(syncLog); + } + + log.info("ProductSync finished: processed={} failed={}", processed, failed); + return new SyncResult(processed, failed, errors); + } + + private void pushProduct(DolibarrProductDto dolProduct) { + String sku = dolProduct.ref(); + Optional existing = productMappingRepository.findBySku(sku); + + if (existing.isEmpty()) { + PrestashopProductDto created = prestashopClient.createProduct(toPrestashopDto(dolProduct, null)); + productMappingRepository.save(ProductMapping.builder() + .sku(sku) + .dolibarrId(dolProduct.id()) + .prestashopId(created.id()) + .lastSyncedAt(Instant.now()) + .syncStatus(SyncStatus.SYNCED) + .build()); + log.debug("ProductSync: created PS product reference={} id={}", sku, created.id()); + } else { + ProductMapping mapping = existing.get(); + prestashopClient.updateProduct(mapping.getPrestashopId(), + toPrestashopDto(dolProduct, mapping.getPrestashopId())); + mapping.setLastSyncedAt(Instant.now()); + mapping.setSyncStatus(SyncStatus.SYNCED); + mapping.setErrorMessage(null); + productMappingRepository.save(mapping); + log.debug("ProductSync: updated PS product reference={}", sku); + } + } + + private void markError(String sku, String message) { + productMappingRepository.findBySku(sku).ifPresent(m -> { + m.setSyncStatus(SyncStatus.ERROR); + m.setErrorMessage(message); + productMappingRepository.save(m); + }); + } + + /** + * Maps a Dolibarr product to a PrestaShop product DTO. + * Language ID "1" is used for all multilingual fields (default PS language). + * + * @param prestashopId null for create, actual PS ID for update + */ + private PrestashopProductDto toPrestashopDto(DolibarrProductDto src, Integer prestashopId) { + String categoryId = String.valueOf(integrationProperties.prestashop().defaultCategoryId()); + String label = src.label() != null ? src.label() : ""; + String description = src.description() != null ? src.description() : ""; + String active = (src.toSell() != null && src.toSell() == 1) ? "1" : "0"; + + return new PrestashopProductDto( + prestashopId, + src.ref(), + src.price(), + active, + categoryId, + List.of(new PrestashopProductDto.LangValue("1", label)), + List.of(new PrestashopProductDto.LangValue("1", description)), + List.of(new PrestashopProductDto.LangValue("1", "")) + ); + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/StockSyncService.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/StockSyncService.java new file mode 100644 index 0000000..a164835 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/StockSyncService.java @@ -0,0 +1,117 @@ +package com.teterialosjuanjos.tfg.sync_service.sync; + +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.DolibarrClient; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.dto.DolibarrProductDto; +import com.teterialosjuanjos.tfg.sync_service.integration.dolibarr.exception.DolibarrApiException; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.PrestashopClient; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.dto.PrestashopStockAvailableDto; +import com.teterialosjuanjos.tfg.sync_service.integration.prestashop.exception.PrestashopApiException; +import com.teterialosjuanjos.tfg.sync_service.mapping.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Reads current stock levels from Dolibarr for all mapped products and updates + * PrestaShop {@code stock_availables} to match. Dolibarr is the source of truth for stock. + * + *

Only products with {@link SyncStatus#SYNCED} (i.e. already existing in both systems) + * are processed. Products pending creation or in error state are skipped.

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StockSyncService { + + private final DolibarrClient dolibarrClient; + private final PrestashopClient prestashopClient; + private final ProductMappingRepository productMappingRepository; + private final SyncLogRepository syncLogRepository; + + /** + * Executes a full stock push for all synced products and returns a summary. + */ + public SyncResult synchronize() { + SyncLog syncLog = syncLogRepository.save(SyncLog.builder() + .syncType(SyncType.STOCK_PUSH) + .startedAt(Instant.now()) + .build()); + + int processed = 0; + int failed = 0; + List errors = new ArrayList<>(); + + try { + List mappings = productMappingRepository.findBySyncStatus(SyncStatus.SYNCED); + log.info("StockSync: {} mapped products to check", mappings.size()); + + for (ProductMapping mapping : mappings) { + try { + if (pushStock(mapping)) { + processed++; + } + } catch (DolibarrApiException | PrestashopApiException e) { + failed++; + String msg = "SKU %s: %s".formatted(mapping.getSku(), e.getMessage()); + errors.add(msg); + log.warn("StockSync item failed: {}", msg); + } + } + } finally { + syncLog.setFinishedAt(Instant.now()); + syncLog.setItemsProcessed(processed); + syncLog.setItemsFailed(failed); + if (!errors.isEmpty()) { + syncLog.setErrorDetails(String.join("\n", errors)); + } + syncLogRepository.save(syncLog); + } + + log.info("StockSync finished: processed={} failed={}", processed, failed); + return new SyncResult(processed, failed, errors); + } + + /** + * Compares Dolibarr stock with PrestaShop stock and updates PS if they differ. + * + * @return {@code true} if PrestaShop stock was updated, {@code false} if already in sync + */ + private boolean pushStock(ProductMapping mapping) { + DolibarrProductDto dolProduct = dolibarrClient.getProductByRef(mapping.getSku()); + if (dolProduct == null || dolProduct.stockReel() == null) { + log.debug("StockSync: no stock data for SKU={}, skipping", mapping.getSku()); + return false; + } + + Optional stockOpt = + prestashopClient.getStockAvailableForProduct(mapping.getPrestashopId()); + if (stockOpt.isEmpty()) { + log.warn("StockSync: no stock_available for PS product id={} (SKU={})", + mapping.getPrestashopId(), mapping.getSku()); + return false; + } + + PrestashopStockAvailableDto current = stockOpt.get(); + int targetQty = dolProduct.stockReel().intValue(); + int currentQty = Integer.parseInt(current.quantity()); + + if (targetQty == currentQty) { + return false; + } + + PrestashopStockAvailableDto updated = new PrestashopStockAvailableDto( + current.id(), + current.idProduct(), + current.idProductAttribute(), + String.valueOf(targetQty) + ); + prestashopClient.updateStockAvailable(current.id(), updated); + log.debug("StockSync: SKU={} qty {} → {}", mapping.getSku(), currentQty, targetQty); + return true; + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncResult.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncResult.java new file mode 100644 index 0000000..7e58ff5 --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncResult.java @@ -0,0 +1,17 @@ +package com.teterialosjuanjos.tfg.sync_service.sync; + +import java.util.List; + +/** + * Immutable summary returned by each {@code synchronize()} method. + * + * @param itemsProcessed number of items successfully processed + * @param itemsFailed number of items that failed with a recoverable error + * @param errors per-item error messages for failed items + */ +public record SyncResult(int itemsProcessed, int itemsFailed, List errors) { + + public boolean hasErrors() { + return itemsFailed > 0; + } +} diff --git a/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncScheduler.java b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncScheduler.java new file mode 100644 index 0000000..3a8cb8d --- /dev/null +++ b/sync-service/src/main/java/com/teterialosjuanjos/tfg/sync_service/sync/SyncScheduler.java @@ -0,0 +1,62 @@ +package com.teterialosjuanjos.tfg.sync_service.sync; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Triggers the three synchronization flows on a configurable schedule. + * Only active when {@code sync.scheduling.enabled=true} (production profile). + * + *

All methods share a single-threaded scheduler (configured in + * {@link com.teterialosjuanjos.tfg.sync_service.config.SchedulingConfig}), + * so they execute sequentially — no overlap is possible.

+ * + *

Intervals use {@code fixedDelay} (time between completions, not between starts), + * which prevents queuing runs if a sync takes longer than the configured delay.

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "sync.scheduling.enabled", havingValue = "true") +public class SyncScheduler { + + private final ProductSyncService productSyncService; + private final StockSyncService stockSyncService; + private final OrderSyncService orderSyncService; + + @Scheduled( + fixedDelayString = "${sync.scheduling.product-push-delay:PT15M}", + initialDelayString = "${sync.scheduling.initial-delay:PT30S}" + ) + public void scheduleProductPush() { + log.info("=== ProductSync START ==="); + SyncResult result = productSyncService.synchronize(); + log.info("=== ProductSync END processed={} failed={} ===", + result.itemsProcessed(), result.itemsFailed()); + } + + @Scheduled( + fixedDelayString = "${sync.scheduling.stock-push-delay:PT5M}", + initialDelayString = "${sync.scheduling.initial-delay:PT30S}" + ) + public void scheduleStockPush() { + log.info("=== StockSync START ==="); + SyncResult result = stockSyncService.synchronize(); + log.info("=== StockSync END processed={} failed={} ===", + result.itemsProcessed(), result.itemsFailed()); + } + + @Scheduled( + fixedDelayString = "${sync.scheduling.order-pull-delay:PT5M}", + initialDelayString = "${sync.scheduling.initial-delay:PT30S}" + ) + public void scheduleOrderPull() { + log.info("=== OrderSync START ==="); + SyncResult result = orderSyncService.synchronize(); + log.info("=== OrderSync END processed={} failed={} ===", + result.itemsProcessed(), result.itemsFailed()); + } +} diff --git a/sync-service/src/main/resources/application-dev.yml.example b/sync-service/src/main/resources/application-dev.yml.example new file mode 100644 index 0000000..58d8353 --- /dev/null +++ b/sync-service/src/main/resources/application-dev.yml.example @@ -0,0 +1,36 @@ +# Copia este fichero como application-dev.yml y rellena los valores reales. +# application-dev.yml está en .gitignore — nunca se sube al repositorio. + +spring: + datasource: + url: jdbc:h2:mem:syncdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + path: /h2-console + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + format_sql: true + +api: + security: + password: "{noop}admin123" # cambia por tu contraseña de dev + +integration: + dolibarr: + api-key: DOLIBARR_API_KEY_AQUI + prestashop: + api-key: PRESTASHOP_API_KEY_AQUI + +logging: + level: + com.teterialosjuanjos.tfg: DEBUG + org.springframework.web: DEBUG diff --git a/sync-service/src/main/resources/application-prod.yml.example b/sync-service/src/main/resources/application-prod.yml.example new file mode 100644 index 0000000..790ccf7 --- /dev/null +++ b/sync-service/src/main/resources/application-prod.yml.example @@ -0,0 +1,25 @@ +# Copia este fichero como application-prod.yml y rellena los valores reales. +# application-prod.yml está en .gitignore — nunca se sube al repositorio. +# En Railway, usa variables de entorno en lugar de este fichero. + +spring: + datasource: + url: jdbc:mysql://HOST:3306/sync_service?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 + username: USUARIO_MYSQL + password: PASSWORD_MYSQL + hikari: + maximum-pool-size: 5 + minimum-idle: 2 + jpa: + database-platform: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: validate + show-sql: false + +sync: + scheduling: + enabled: true + +logging: + level: + com.teterialosjuanjos.tfg: INFO diff --git a/sync-service/src/main/resources/application.properties b/sync-service/src/main/resources/application.properties new file mode 100644 index 0000000..441ec65 --- /dev/null +++ b/sync-service/src/main/resources/application.properties @@ -0,0 +1 @@ +# Configuración migrada a application.yml diff --git a/sync-service/src/main/resources/application.yml b/sync-service/src/main/resources/application.yml new file mode 100644 index 0000000..68f4702 --- /dev/null +++ b/sync-service/src/main/resources/application.yml @@ -0,0 +1,37 @@ +spring: + application: + name: sync-service + profiles: + active: dev + +integration: + dolibarr: + base-url: ${DOLIBARR_BASE_URL:https://prestashop.loading.net/dolibarr/api/index.php} + api-key: ${DOLIBARR_API_KEY} + default-tax-rate: 21.0 + prestashop: + base-url: ${PRESTASHOP_BASE_URL:https://prestashop.loading.net/tienda/api} + api-key: ${PRESTASHOP_API_KEY} + default-category-id: 2 + +api: + security: + username: ${API_SECURITY_USERNAME:admin} + password: ${API_SECURITY_PASSWORD} # dev: set in application-dev.yml; prod: {bcrypt}HASH via env var + +sync: + scheduling: + enabled: false # set to true in production; use REST API to trigger manually in dev + initial-delay: PT30S + product-push-delay: PT15M + stock-push-delay: PT5M + order-pull-delay: PT5M + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always diff --git a/sync-service/src/main/resources/db/migration/V1__init.sql b/sync-service/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..888ce53 --- /dev/null +++ b/sync-service/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,31 @@ +-- Dolibarr <-> PrestaShop product ID mapping +CREATE TABLE product_mapping ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + sku VARCHAR(100) NOT NULL UNIQUE, + dolibarr_id INT, + prestashop_id INT, + last_synced_at DATETIME(6), + sync_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + error_message TEXT +); + +-- PrestaShop order -> Dolibarr order + invoice mapping +CREATE TABLE order_mapping ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + prestashop_order_id INT NOT NULL UNIQUE, + dolibarr_order_id INT, + dolibarr_invoice_id INT, + imported_at DATETIME(6) NOT NULL, + status VARCHAR(30) NOT NULL +); + +-- Audit log for each scheduled sync run +CREATE TABLE sync_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + sync_type VARCHAR(30) NOT NULL, + started_at DATETIME(6) NOT NULL, + finished_at DATETIME(6), + items_processed INT, + items_failed INT, + error_details TEXT +); diff --git a/sync-service/src/test/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplicationTests.java b/sync-service/src/test/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplicationTests.java new file mode 100644 index 0000000..a9fd820 --- /dev/null +++ b/sync-service/src/test/java/com/teterialosjuanjos/tfg/sync_service/SyncServiceApplicationTests.java @@ -0,0 +1,13 @@ +package com.teterialosjuanjos.tfg.sync_service; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SyncServiceApplicationTests { + + @Test + void contextLoads() { + } + +}