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:
luklpz 2026-05-14 14:09:46 +02:00
commit 45b805a81a
53 changed files with 2762 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Claude Code
.claude/
CLAUDE.md
# IntelliJ IDEA
.idea/
*.iws
*.iml
*.ipr
# OS
.DS_Store
Thumbs.db

2
sync-service/.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

37
sync-service/.gitignore vendored Normal file
View File

@ -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/

View File

@ -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

295
sync-service/mvnw vendored Normal file
View File

@ -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 "$@"

189
sync-service/mvnw.cmd vendored Normal file
View File

@ -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"

151
sync-service/pom.xml Normal file
View File

@ -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>

View File

@ -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);
}
}

View File

@ -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()
);
}
}

View File

@ -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()
);
}
}

View File

@ -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()
);
}
}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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();
}
}

View File

@ -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
) {}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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
) {}

View File

@ -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
) {}
}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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
) {}

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -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) {}
}

View File

@ -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) {}
}

View File

@ -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) {}
}

View File

@ -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
) {}
}

View File

@ -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; }
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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();
}
}

View File

@ -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", ""))
);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
# Configuración migrada a application.yml

View File

@ -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

View File

@ -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
);

View File

@ -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() {
}
}