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