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 <noreply@anthropic.com>
This commit is contained in:
commit
45b805a81a
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# IntelliJ IDEA
|
||||||
|
.idea/
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/mvnw text eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
[ -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 "$@"
|
||||||
|
|
@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.5.14</version>
|
||||||
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
|
</parent>
|
||||||
|
<groupId>com.teterialosjuanjos.tfg</groupId>
|
||||||
|
<artifactId>sync-service</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>sync-service</name>
|
||||||
|
<description>TFG — Middleware de integración Dolibarr-PrestaShop</description>
|
||||||
|
<url/>
|
||||||
|
<licenses>
|
||||||
|
<license/>
|
||||||
|
</licenses>
|
||||||
|
<developers>
|
||||||
|
<developer/>
|
||||||
|
</developers>
|
||||||
|
<scm>
|
||||||
|
<connection/>
|
||||||
|
<developerConnection/>
|
||||||
|
<tag/>
|
||||||
|
<url/>
|
||||||
|
</scm>
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.8.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Required for MySQL 8 support in Flyway 9+ -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-mysql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>default-compile</id>
|
||||||
|
<phase>compile</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>compile</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>default-testCompile</id>
|
||||||
|
<phase>test-compile</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>testCompile</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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<ProductMappingResponse> getProductMappings(
|
||||||
|
@RequestParam(required = false) SyncStatus status
|
||||||
|
) {
|
||||||
|
List<ProductMapping> mappings = status != null
|
||||||
|
? productMappingRepository.findBySyncStatus(status)
|
||||||
|
: productMappingRepository.findAll();
|
||||||
|
return mappings.stream().map(MappingController::toProductResponse).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "List order mappings")
|
||||||
|
@GetMapping("/orders")
|
||||||
|
public List<OrderMappingResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>Sync runs synchronously — the HTTP call blocks until the sync completes.
|
||||||
|
* Response always returns 200; check {@code hasErrors} and {@code itemsFailed} for partial failures.</p>
|
||||||
|
*/
|
||||||
|
@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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<SyncLogResponse> getLogs(
|
||||||
|
@RequestParam(required = false) SyncType type
|
||||||
|
) {
|
||||||
|
List<SyncLog> 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<SyncLogResponse> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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<String> errors
|
||||||
|
) {}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>CSRF is disabled because this is a stateless REST API authenticated via Basic Auth.
|
||||||
|
* Without session cookies, there is no CSRF surface to protect.</p>
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
@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<DolibarrProductDto> 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<List<DolibarrProductDto>>() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DolibarrProductDto> results = restClient.get()
|
||||||
|
.uri(u -> u.path("/products")
|
||||||
|
.queryParam("ref", ref)
|
||||||
|
.queryParam("limit", 1)
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.body(new ParameterizedTypeReference<List<DolibarrProductDto>>() {});
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p><strong>Note:</strong> verify endpoint and payload against
|
||||||
|
* {@code /dolibarr/api/index.php/explorer} — stock endpoints vary across versions.</p>
|
||||||
|
*/
|
||||||
|
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<DolibarrThirdpartyDto> matches = restClient.get()
|
||||||
|
.uri(u -> u.path("/thirdparties")
|
||||||
|
.queryParam("sqlfilters", "(t.email:=:'%s')".formatted(email))
|
||||||
|
.queryParam("limit", 1)
|
||||||
|
.build())
|
||||||
|
.retrieve()
|
||||||
|
.body(new ParameterizedTypeReference<List<DolibarrThirdpartyDto>>() {});
|
||||||
|
|
||||||
|
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<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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<OrderLine> 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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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}.
|
||||||
|
*
|
||||||
|
* <p><strong>Note:</strong> verify exact endpoint and payload against
|
||||||
|
* {@code /dolibarr/api/index.php/explorer} — stock endpoints vary across Dolibarr versions.</p>
|
||||||
|
*
|
||||||
|
* @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
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>The root {@code /api/} endpoint is known to return 500 in PS 8.2.5;
|
||||||
|
* always target specific resources such as {@code /api/products}.</p>
|
||||||
|
*/
|
||||||
|
@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<PrestashopProductDto> 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<PrestashopProductDto> 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<PrestashopStockAvailableDto> 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.
|
||||||
|
*
|
||||||
|
* <p>PrestaShop date filter syntax: {@code filter[date_add]=[>=yyyy-MM-dd HH:mm:ss]}</p>
|
||||||
|
*
|
||||||
|
* @param since lower bound (inclusive) for {@code date_add}
|
||||||
|
*/
|
||||||
|
public List<PrestashopOrderDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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<OrderRow> 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<PrestashopOrderDto> orders) {}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>Multilingual fields (name, description) are returned as arrays of {@link LangValue}
|
||||||
|
* objects, one per configured shop language.</p>
|
||||||
|
*
|
||||||
|
* <p>Response wrappers ({@link ListResponse}, {@link SingleResponse}) match the JSON envelope
|
||||||
|
* PrestaShop adds around resources: {@code {"products":[...]}} and {@code {"product":{...}}}.</p>
|
||||||
|
*/
|
||||||
|
@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<LangValue> name,
|
||||||
|
List<LangValue> description,
|
||||||
|
@JsonProperty("description_short") List<LangValue> 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<PrestashopProductDto> products) {}
|
||||||
|
|
||||||
|
/** Envelope for {@code GET /api/products/{id}}, {@code POST} and {@code PUT} */
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record SingleResponse(PrestashopProductDto product) {}
|
||||||
|
}
|
||||||
|
|
@ -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<PrestashopStockAvailableDto> stockAvailables
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Envelope for {@code GET /api/stock_availables/{id}} and {@code PUT} */
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record SingleResponse(
|
||||||
|
@JsonProperty("stock_available") PrestashopStockAvailableDto stockAvailable
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<OrderMapping, Long> {
|
||||||
|
|
||||||
|
Optional<OrderMapping> findByPrestashopOrderId(Integer prestashopOrderId);
|
||||||
|
|
||||||
|
/** Used to skip orders that were already imported in a previous sync run. */
|
||||||
|
boolean existsByPrestashopOrderId(Integer prestashopOrderId);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<ProductMapping, Long> {
|
||||||
|
|
||||||
|
Optional<ProductMapping> findBySku(String sku);
|
||||||
|
|
||||||
|
Optional<ProductMapping> findByDolibarrId(Integer dolibarrId);
|
||||||
|
|
||||||
|
Optional<ProductMapping> findByPrestashopId(Integer prestashopId);
|
||||||
|
|
||||||
|
List<ProductMapping> findBySyncStatus(SyncStatus syncStatus);
|
||||||
|
|
||||||
|
boolean existsBySku(String sku);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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<SyncLog, Long> {
|
||||||
|
|
||||||
|
List<SyncLog> 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<SyncLog> findFirstBySyncTypeOrderByStartedAtDesc(SyncType syncType);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>Import logic per order:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>Fetch the PrestaShop customer to get email and name.</li>
|
||||||
|
* <li>Get or create the matching thirdparty in Dolibarr (lookup by email).</li>
|
||||||
|
* <li>Create a sales order ({@code commande}) in Dolibarr with mapped line items.</li>
|
||||||
|
* <li>Create an invoice linked to the order.</li>
|
||||||
|
* <li>Persist an {@link OrderMapping} to prevent re-import on future runs.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Order lines use price excl. tax ({@code subprice}), computed from PrestaShop's
|
||||||
|
* tax-included unit price using the configured {@code defaultTaxRate}.</p>
|
||||||
|
*/
|
||||||
|
@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<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<PrestashopOrderDto> 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<DolibarrOrderDto.OrderLine> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>On each run, only products modified since the previous sync's {@code startedAt}
|
||||||
|
* are fetched from Dolibarr (incremental sync). The first run fetches everything.</p>
|
||||||
|
*
|
||||||
|
* <p>Per-item errors (API failures) are recorded in {@link ProductMapping} and in
|
||||||
|
* {@link SyncLog} but do not abort the remaining items.</p>
|
||||||
|
*/
|
||||||
|
@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<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<DolibarrProductDto> 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<ProductMapping> 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", ""))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>Only products with {@link SyncStatus#SYNCED} (i.e. already existing in both systems)
|
||||||
|
* are processed. Products pending creation or in error state are skipped.</p>
|
||||||
|
*/
|
||||||
|
@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<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<ProductMapping> 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<PrestashopStockAvailableDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> errors) {
|
||||||
|
|
||||||
|
public boolean hasErrors() {
|
||||||
|
return itemsFailed > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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).
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>Intervals use {@code fixedDelay} (time between completions, not between starts),
|
||||||
|
* which prevents queuing runs if a sync takes longer than the configured delay.</p>
|
||||||
|
*/
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# Configuración migrada a 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
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue