diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/android/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/android/.idea/AndroidProjectSystem.xml b/android/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/android/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/android/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ca16a99
--- /dev/null
+++ b/android/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/deviceManager.xml b/android/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/android/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/android/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
new file mode 100644
index 0000000..74dd639
--- /dev/null
+++ b/android/.idea/misc.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/planningMode.xml b/android/.idea/planningMode.xml
new file mode 100644
index 0000000..955621a
--- /dev/null
+++ b/android/.idea/planningMode.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/android/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/studiobot.xml b/android/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/android/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 6e0543f..95af31f 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
}
android {
- namespace "net.hacecalor.farmafinder"
+ namespace "net.hacecalor.farmaclic"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
- applicationId "net.hacecalor.farmafinder"
+ applicationId "net.hacecalor.farmaclic"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
@@ -36,7 +36,7 @@ android {
release {
minifyEnabled true
shrinkResources true
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
@@ -44,14 +44,8 @@ android {
}
}
-repositories {
- flatDir{
- dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
- }
-}
-
dependencies {
- implementation fileTree(include: ['*.jar'], dir: 'libs')
+ implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
diff --git a/android/app/src/main/java/net/hacecalor/farmafinder/MainActivity.java b/android/app/src/main/java/net/hacecalor/farmaclic/MainActivity.java
similarity index 72%
rename from android/app/src/main/java/net/hacecalor/farmafinder/MainActivity.java
rename to android/app/src/main/java/net/hacecalor/farmaclic/MainActivity.java
index d6c7d9d..d4b076c 100644
--- a/android/app/src/main/java/net/hacecalor/farmafinder/MainActivity.java
+++ b/android/app/src/main/java/net/hacecalor/farmaclic/MainActivity.java
@@ -1,4 +1,4 @@
-package net.hacecalor.farmafinder;
+package net.hacecalor.farmaclic;
import com.getcapacitor.BridgeActivity;
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 4844040..fd5aa6b 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
- FarmaFinder
- FarmaFinder
- net.hacecalor.farmafinder
- net.hacecalor.farmafinder
+ FarmaClic
+ FarmaClic
+ net.hacecalor.farmaclic
+ net.hacecalor.farmaclic
diff --git a/android/build.gradle b/android/build.gradle
index f8f0e43..70bb56f 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.13.0'
+ classpath 'com.android.tools.build:gradle:9.2.1'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
diff --git a/android/gradle.properties b/android/gradle.properties
index 2e87c52..2548572 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -20,3 +20,8 @@ org.gradle.jvmargs=-Xmx1536m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
+android.defaults.buildfeatures.resvalues=false
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.newDsl=true
diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..baa28d1
--- /dev/null
+++ b/android/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,13 @@
+#This file is generated by updateDaemonJvm
+toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
+toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
+toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
+toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
+toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
+toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
+toolchainVendor=JETBRAINS
+toolchainVersion=21
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
index 033e24c..b1b8ef5 100644
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 5e6c1d0..eb84db6 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,8 +1,9 @@
-#Fri Jun 26 12:32:52 CEST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
+retries=0
+retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
index fcb6fca..249efbb 100755
--- a/android/gradlew
+++ b/android/gradlew
@@ -1,7 +1,7 @@
#!/bin/sh
#
-# Copyright © 2015-2021 the original authors.
+# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,10 +15,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
-# Gradle start up script for POSIX generated by Gradle.
+# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -27,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
-# ksh Gradle
+# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,7 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -111,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
+ # shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
- # shellcheck disable=SC3045
+ # shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -169,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -201,16 +202,15 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
-# Collect all arguments for the java command;
-# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/android/gradlew.bat b/android/gradlew.bat
index 6689b85..8508ef6 100644
--- a/android/gradlew.bat
+++ b/android/gradlew.bat
@@ -13,16 +13,18 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
-@rem Gradle startup script for Windows
+@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -43,13 +45,13 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
-goto fail
+"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/android/settings.gradle b/android/settings.gradle
index 3b4431d..c3c9ec6 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -1,3 +1,6 @@
+plugins {
+ id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
+}
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
diff --git a/frontend/src/App.css b/frontend/src/App.css
index f4f2fd2..58ccf16 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -11,12 +11,7 @@
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-}
-
-@media (max-width: 768px) {
- .app-content {
- padding-bottom: calc(6rem + env(safe-area-inset-bottom));
- }
+ padding-bottom: calc(6rem + env(safe-area-inset-bottom));
}
@keyframes fadeInUp {
diff --git a/frontend/src/components/BottomNav.css b/frontend/src/components/BottomNav.css
index 3e301f1..d221231 100644
--- a/frontend/src/components/BottomNav.css
+++ b/frontend/src/components/BottomNav.css
@@ -1,123 +1,119 @@
.bottom-nav {
- display: none;
+ display: flex;
+ justify-content: space-around;
+ align-items: flex-end;
+ position: fixed;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: 0;
+ width: 100%;
+ max-width: 48rem;
+ z-index: 50;
+ padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
+ background: var(--surface-container-lowest);
+ box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+ height: 5.5rem;
}
-@media (max-width: 768px) {
- .bottom-nav {
- display: flex;
- justify-content: space-around;
- align-items: flex-end;
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 50;
- padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
- background: var(--surface-container-lowest);
- box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
- border-radius: var(--radius-lg) var(--radius-lg) 0 0;
- height: 5.5rem;
- }
-
- .bottom-nav-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-end;
- gap: 0.15rem;
- padding: 0.25rem 0.5rem;
- background: transparent;
- border: none;
- cursor: pointer;
- color: var(--on-surface-variant);
- font: inherit;
- -webkit-tap-highlight-color: transparent;
- transition: color 0.15s;
- position: relative;
- min-width: 3.5rem;
- }
-
- .bottom-nav-item.disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
-
- .nav-icon-wrap {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 2.75rem;
- height: 2.75rem;
- border-radius: var(--radius-full);
- color: inherit;
- position: relative;
- }
-
- .nav-fab {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 3.5rem;
- height: 3.5rem;
- border-radius: var(--radius-full);
- background: var(--tertiary-container);
- color: var(--on-tertiary);
- box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
- margin-top: -1.5rem;
- transition: transform 0.15s;
- }
-
- .nav-fab:active {
- transform: scale(0.92);
- }
-
- .nav-badge {
- position: absolute;
- top: 0;
- right: -2px;
- width: 1.125rem;
- height: 1.125rem;
- background: var(--error);
- color: var(--on-error);
- font-size: 0.625rem;
- font-weight: 700;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: var(--radius-full);
- border: 2px solid var(--surface-container-lowest);
- line-height: 1;
- }
-
- .nav-label {
- font-size: 0.7rem;
- font-weight: 500;
- line-height: 1;
- color: var(--on-surface-variant);
- }
-
- .nav-label--active {
- color: var(--primary);
- font-weight: 700;
- }
-
- .nav-indicator {
- width: 0.375rem;
- height: 0.375rem;
- background: var(--primary);
- border-radius: var(--radius-full);
- margin-top: 0.125rem;
- }
-
- .bottom-nav-item.active .nav-icon-wrap {
- color: var(--primary);
- }
-
- .bottom-nav-item.active .nav-icon-wrap svg {
- stroke-width: 2.5;
- }
-
- .nav-elevated.active .nav-label {
- color: var(--tertiary);
- }
+.bottom-nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.15rem;
+ padding: 0.25rem 0.5rem;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: var(--on-surface-variant);
+ font: inherit;
+ -webkit-tap-highlight-color: transparent;
+ transition: color 0.15s;
+ position: relative;
+ min-width: 3.5rem;
+}
+
+.bottom-nav-item.disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.nav-icon-wrap {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ border-radius: var(--radius-full);
+ color: inherit;
+ position: relative;
+}
+
+.nav-fab {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 3.5rem;
+ height: 3.5rem;
+ border-radius: var(--radius-full);
+ background: var(--tertiary-container);
+ color: var(--on-tertiary);
+ box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
+ margin-top: -1.5rem;
+ transition: transform 0.15s;
+}
+
+.nav-fab:active {
+ transform: scale(0.92);
+}
+
+.nav-badge {
+ position: absolute;
+ top: 0;
+ right: -2px;
+ width: 1.125rem;
+ height: 1.125rem;
+ background: var(--error);
+ color: var(--on-error);
+ font-size: 0.625rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-full);
+ border: 2px solid var(--surface-container-lowest);
+ line-height: 1;
+}
+
+.nav-label {
+ font-size: 0.7rem;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--on-surface-variant);
+}
+
+.nav-label--active {
+ color: var(--primary);
+ font-weight: 700;
+}
+
+.nav-indicator {
+ width: 0.375rem;
+ height: 0.375rem;
+ background: var(--primary);
+ border-radius: var(--radius-full);
+ margin-top: 0.125rem;
+}
+
+.bottom-nav-item.active .nav-icon-wrap {
+ color: var(--primary);
+}
+
+.bottom-nav-item.active .nav-icon-wrap svg {
+ stroke-width: 2.5;
+}
+
+.nav-elevated.active .nav-label {
+ color: var(--tertiary);
}
diff --git a/frontend/src/components/TopBar.css b/frontend/src/components/TopBar.css
index 36e0db7..ce82413 100644
--- a/frontend/src/components/TopBar.css
+++ b/frontend/src/components/TopBar.css
@@ -1,63 +1,57 @@
.top-bar {
- display: none;
+ display: block;
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ background: var(--surface);
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
-@media (max-width: 768px) {
- .top-bar {
- display: block;
- position: sticky;
- top: 0;
- z-index: 40;
- background: var(--surface);
- box-shadow: 0 1px 3px rgba(0,0,0,0.06);
- }
-
- .topbar-inner {
- display: flex;
- align-items: center;
- justify-content: space-between;
- height: var(--touch-target-min);
- padding: 0 var(--margin-main);
- max-width: 48rem;
- margin: 0 auto;
- }
-
- .topbar-back {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 2.5rem;
- height: 2.5rem;
- border-radius: var(--radius-full);
- border: none;
- background: transparent;
- color: var(--primary);
- cursor: pointer;
- transition: background 0.15s;
- }
-
- .topbar-back:hover {
- background: var(--surface-container-high);
- }
-
- .topbar-title {
- font-size: 1.15rem;
- font-weight: 700;
- color: var(--primary);
- letter-spacing: -0.01em;
- }
-
- .topbar-logo-img {
- height: 2rem;
- width: auto;
- }
-
- .topbar-spacer {
- width: 2.5rem;
- }
-
- .topbar-right {
- display: flex;
- align-items: center;
- }
+.topbar-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: var(--touch-target-min);
+ padding: 0 var(--margin-main);
+ max-width: 48rem;
+ margin: 0 auto;
+}
+
+.topbar-back {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: var(--radius-full);
+ border: none;
+ background: transparent;
+ color: var(--primary);
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.topbar-back:hover {
+ background: var(--surface-container-high);
+}
+
+.topbar-title {
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--primary);
+ letter-spacing: -0.01em;
+}
+
+.topbar-logo-img {
+ height: 2rem;
+ width: auto;
+}
+
+.topbar-spacer {
+ width: 2.5rem;
+}
+
+.topbar-right {
+ display: flex;
+ align-items: center;
}
diff --git a/pip-platform/.env.example b/pip-platform/.env.example
new file mode 100644
index 0000000..dc45e17
--- /dev/null
+++ b/pip-platform/.env.example
@@ -0,0 +1,37 @@
+APP_NAME=PIP - Pharmacy Integration Platform
+APP_VERSION=0.1.0
+DEBUG=false
+
+DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip
+DATABASE_POOL_SIZE=20
+DATABASE_MAX_OVERFLOW=10
+DATABASE_POOL_RECYCLE=3600
+
+REDIS_URL=redis://localhost:6379/0
+REDIS_CACHE_TTL=300
+
+RABBITMQ_URL=amqp://pip:pip-secret@localhost:5672/pip
+
+JWT_SECRET_KEY=change-me-in-production
+JWT_ALGORITHM=HS256
+JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
+JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
+JWT_ISSUER=pip-platform
+
+RATE_LIMIT_PER_MINUTE=60
+RATE_LIMIT_AUTH_PER_MINUTE=10
+
+OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
+OTEL_SERVICE_NAME=pip-platform
+OTEL_TRACES_ENABLED=true
+OTEL_METRICS_ENABLED=true
+
+LOG_LEVEL=INFO
+LOG_JSON_FORMAT=true
+
+CORS_ORIGINS=["*"]
+CORS_ALLOW_CREDENTIALS=true
+
+PG_PASSWORD=pip-secret
+RABBITMQ_PASSWORD=pip-secret
+GRAFANA_PASSWORD=admin
diff --git a/pip-platform/.gitignore b/pip-platform/.gitignore
new file mode 100644
index 0000000..ffb37c6
--- /dev/null
+++ b/pip-platform/.gitignore
@@ -0,0 +1,20 @@
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+dist/
+*.egg-info/
+.eggs/
+*.egg
+.env
+.venv/
+venv/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+htmlcov/
+*.sqlite3
+.alembic/versions/*.pyc
diff --git a/pip-platform/Dockerfile b/pip-platform/Dockerfile
new file mode 100644
index 0000000..34a5d7f
--- /dev/null
+++ b/pip-platform/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.13-slim AS base
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY pyproject.toml ./
+RUN pip install --no-cache-dir --upgrade pip && \
+ pip install --no-cache-dir .
+
+COPY . .
+
+EXPOSE 8000
+
+CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--http", "httptools"]
diff --git a/pip-platform/STATE.md b/pip-platform/STATE.md
new file mode 100644
index 0000000..7752a4f
--- /dev/null
+++ b/pip-platform/STATE.md
@@ -0,0 +1,65 @@
+# PIP Platform — State Tracker
+
+## Current Phase: 3 (Complete)
+
+### Phase 1 — Foundation ✅
+- FastAPI app skeleton, Settings, Docker Compose (Postgres, Redis, RabbitMQ)
+- SQLAlchemy async models + Alembic migration `001_initial`
+- Auth: JWT (access/refresh), RBAC (admin/operator/viewer), user CRUD
+- Entities: Pharmacy, Connector, Medication, Inventory, Reservation, ERP System
+- Repositories + Services for all entities
+- API v1 endpoints for all entities
+- Middleware: CORS, request logging, metrics
+- Observability: OpenTelemetry traces, Prometheus metrics
+
+### Phase 2 — Connector SDK ✅
+- Abstract connector interface + Mock connector implementation
+- Connector registry, version management, config CRUD
+- Medicationcatalog + inventory sync plumbing
+
+### Phase 3 — Sync, Audit, API Keys, Rate Limiting ✅
+- **Sync Jobs**: Repository, service, API endpoints (create, list, get, trigger, complete, fail, cancel, logs, status filter)
+- **Audit Logging**: AuditService (`log_action`, `list_logs`), AuditMiddleware (auto-logs POST/PUT/PATCH/DELETE), audit endpoint
+- **API Key Auth**: Dual auth via `get_authenticated_user` — supports both Bearer JWT and X-API-Key header; `require_role` and `require_scope` use it; API Key CRUD endpoints
+- **Rate Limiting**: Custom `RateLimitMiddleware` (sliding window, X-RateLimit-* headers, 429 with Retry-After); exempt paths (docs, metrics, health)
+- **RabbitMQ**: Routing keys for `sync_created`, `sync_triggered`, `sync_completed`, `sync_failed`, `sync_cancelled`
+
+## Architecture Decisions
+- Clean Architecture: domain entities → services → API endpoints → infrastructure
+- Repository pattern with SQLAlchemy async
+- Dual auth: JWT Bearer + X-API-Key in single `get_current_user` dependency
+- Audit middleware uses separate DB session (via `async_session_factory`) to decouple from request session
+- Rate limiting: custom middleware replacing slowapi (incompatible with FastAPI 0.138+ `_IncludedRouter`)
+- DB migrations: Alembic `upgrade head` runs in app lifespan (not `create_all`)
+- `APIKeyEntity.key_hash` is `bytes` (sha256 digest), ORM model uses `LargeBinary(64)`
+
+## Key Files
+| File | Purpose |
+|------|---------|
+| `src/main.py` | App factory, lifespan (alembic upgrade), middleware chain |
+| `src/api/v1/dependencies/auth.py` | Dual auth: JWT + API Key, require_role, require_scope |
+| `src/api/middleware/audit.py` | Auto-audit for write endpoints |
+| `src/api/middleware/rate_limit.py` | Custom sliding window rate limiter |
+| `src/api/v1/endpoints/sync_jobs.py` | Sync job CRUD + lifecycle |
+| `src/api/v1/endpoints/api_clients.py` | API client + key management |
+| `src/api/v1/endpoints/audit.py` | Audit log query endpoint |
+| `src/services/synchronization/sync_service.py` | Sync job business logic |
+| `src/services/audit/audit_service.py` | Audit service |
+| `src/infrastructure/messaging/rabbitmq.py` | Event publishing routing keys |
+| `alembic/versions/001_initial.py` | Full schema (all tables including sync_jobs, sync_logs, audit_logs) |
+
+## Configuration (env)
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `RATE_LIMIT_PER_MINUTE` | 60 | Max requests per client per minute |
+| `RATE_LIMIT_AUTH_PER_MINUTE` | 10 | (reserved) Auth endpoint limit |
+| `JWT_SECRET_KEY` | change-me-in-production | Must be set in production |
+| `API_KEY_PREFIX` | pip | (reserved) Key namespace prefix |
+
+## Next Phase Ideas
+- Redis-backed rate limiter (for multi-instance)
+- Per-client rate limits (use `api_clients.rate_limit` column)
+- API Key scope enforcement on specific endpoints (currently role-only)
+- Sync job execution worker (consume RabbitMQ events, run connector)
+- IP allowlist enforcement for API clients
+- Alembic migration 002 for any schema changes
diff --git a/pip-platform/alembic.ini b/pip-platform/alembic.ini
new file mode 100644
index 0000000..f1da6a9
--- /dev/null
+++ b/pip-platform/alembic.ini
@@ -0,0 +1,37 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = postgresql+psycopg2://pip:pip-secret@localhost:5432/pip
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/pip-platform/alembic/env.py b/pip-platform/alembic/env.py
new file mode 100644
index 0000000..4d6781c
--- /dev/null
+++ b/pip-platform/alembic/env.py
@@ -0,0 +1,41 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from src.infrastructure.config.settings import settings
+from src.infrastructure.database.models import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/pip-platform/alembic/script.py.mako b/pip-platform/alembic/script.py.mako
new file mode 100644
index 0000000..590f5b3
--- /dev/null
+++ b/pip-platform/alembic/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/pip-platform/alembic/versions/001_initial.py b/pip-platform/alembic/versions/001_initial.py
new file mode 100644
index 0000000..62a7dcd
--- /dev/null
+++ b/pip-platform/alembic/versions/001_initial.py
@@ -0,0 +1,306 @@
+"""Phase 1 initial schema
+
+Revision ID: 001_initial
+Revises: None
+Create Date: 2026-06-30
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+
+revision: str = "001_initial"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "erp_systems",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("erp_type", sa.String(50), nullable=False, index=True),
+ sa.Column("version", sa.String(50)),
+ sa.Column("base_url", sa.String(500)),
+ sa.Column("auth_config", JSONB),
+ sa.Column("status", sa.String(20), default="pending", index=True),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+
+ op.create_table(
+ "pharmacies",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("code", sa.String(50), unique=True, nullable=False, index=True),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("address", sa.String(500)),
+ sa.Column("city", sa.String(100)),
+ sa.Column("province", sa.String(100)),
+ sa.Column("postal_code", sa.String(20)),
+ sa.Column("country", sa.String(2), default="ES"),
+ sa.Column("latitude", sa.Float),
+ sa.Column("longitude", sa.Float),
+ sa.Column("phone", sa.String(50)),
+ sa.Column("email", sa.String(255)),
+ sa.Column("opening_hours", JSONB),
+ sa.Column("status", sa.String(20), default="pending", index=True),
+ sa.Column("erp_type", sa.String(50)),
+ sa.Column("erp_version", sa.String(50)),
+ sa.Column("sync_strategy", sa.String(20), default="incremental"),
+ sa.Column("sync_interval_minutes", sa.Integer, default=30),
+ sa.Column("last_sync_at", sa.DateTime(timezone=True)),
+ sa.Column("last_sync_status", sa.String(20)),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+ op.create_index("ix_pharmacies_location", "pharmacies", ["latitude", "longitude"])
+ op.create_index("ix_pharmacies_status_erp", "pharmacies", ["status", "erp_type"])
+
+ op.create_table(
+ "connectors",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("erp_system_id", UUID(as_uuid=True), sa.ForeignKey("erp_systems.id"), nullable=False, index=True),
+ sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("connector_type", sa.String(50), nullable=False, index=True),
+ sa.Column("version", sa.String(20), default="1.0.0"),
+ sa.Column("config", JSONB),
+ sa.Column("status", sa.String(20), default="pending", index=True),
+ sa.Column("last_heartbeat_at", sa.DateTime(timezone=True)),
+ sa.Column("last_error", sa.Text),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ sa.UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"),
+ )
+
+ op.add_column("pharmacies", sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=True))
+ op.create_index("ix_pharmacies_connector_id", "pharmacies", ["connector_id"])
+
+ op.create_table(
+ "connector_versions",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
+ sa.Column("connector_type", sa.String(50), nullable=False, index=True),
+ sa.Column("version", sa.String(20), nullable=False),
+ sa.Column("changelog", sa.Text),
+ sa.Column("download_url", sa.String(500)),
+ sa.Column("checksum", sa.String(128)),
+ sa.Column("is_latest", sa.Boolean, default=False, index=True),
+ sa.Column("is_required", sa.Boolean, default=False),
+ sa.Column("released_at", sa.DateTime(timezone=True)),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ sa.UniqueConstraint("connector_type", "version", name="uq_connector_version"),
+ )
+
+ op.create_table(
+ "connector_configs",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
+ sa.Column("key", sa.String(100), nullable=False),
+ sa.Column("value", sa.Text, nullable=False),
+ sa.Column("encrypted", sa.Boolean, default=False),
+ sa.Column("description", sa.String(500)),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ sa.UniqueConstraint("connector_id", "key", name="uq_connector_config_key"),
+ )
+
+ op.create_table(
+ "medications",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("nregistro", sa.String(50), unique=True, nullable=False, index=True),
+ sa.Column("name", sa.String(500), nullable=False, index=True),
+ sa.Column("active_ingredient", sa.String(500)),
+ sa.Column("dosage", sa.String(100)),
+ sa.Column("form", sa.String(100)),
+ sa.Column("atc_code", sa.String(20), index=True),
+ sa.Column("prescription_required", sa.Boolean, default=False),
+ sa.Column("manufacturer", sa.String(255)),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+
+ op.create_table(
+ "medication_catalog",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
+ sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
+ sa.Column("price", sa.Float),
+ sa.Column("stock", sa.Integer, default=0),
+ sa.Column("is_available", sa.Boolean, default=False, index=True),
+ sa.Column("last_verified_at", sa.DateTime(timezone=True)),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"),
+ )
+
+ op.create_table(
+ "inventory_records",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
+ sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
+ sa.Column("stock", sa.Integer, default=0),
+ sa.Column("reserved_stock", sa.Integer, default=0),
+ sa.Column("available_stock", sa.Integer, default=0),
+ sa.Column("min_stock", sa.Integer),
+ sa.Column("max_stock", sa.Integer),
+ sa.Column("last_updated_at", sa.DateTime(timezone=True), index=True),
+ sa.Column("source", sa.String(100)),
+ sa.Column("batch_id", sa.String(100)),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"),
+ )
+
+ op.create_table(
+ "inventory_history",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("inventory_record_id", UUID(as_uuid=True), sa.ForeignKey("inventory_records.id"), nullable=False, index=True),
+ sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
+ sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
+ sa.Column("previous_stock", sa.Integer, nullable=False),
+ sa.Column("new_stock", sa.Integer, nullable=False),
+ sa.Column("delta", sa.Integer, nullable=False),
+ sa.Column("reason", sa.String(255)),
+ sa.Column("source", sa.String(100)),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
+ )
+
+ op.create_table(
+ "technical_users",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("username", sa.String(100), unique=True, nullable=False, index=True),
+ sa.Column("email", sa.String(255)),
+ sa.Column("full_name", sa.String(255)),
+ sa.Column("password_hash", sa.String(255), nullable=False),
+ sa.Column("role", sa.String(20), default="viewer", index=True),
+ sa.Column("is_active", sa.Boolean, default=True, index=True),
+ sa.Column("scopes", JSONB, default=list),
+ sa.Column("last_login_at", sa.DateTime(timezone=True)),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+
+ op.create_table(
+ "api_clients",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("description", sa.Text),
+ sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True),
+ sa.Column("is_active", sa.Boolean, default=True, index=True),
+ sa.Column("scopes", JSONB, default=list),
+ sa.Column("rate_limit", sa.Integer),
+ sa.Column("allowed_ips", JSONB, default=list),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+
+ op.create_table(
+ "api_keys",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("api_client_id", UUID(as_uuid=True), sa.ForeignKey("api_clients.id"), nullable=False, index=True),
+ sa.Column("key_prefix", sa.String(10), nullable=False, index=True),
+ sa.Column("key_hash", sa.LargeBinary(64), nullable=False),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("is_active", sa.Boolean, default=True, index=True),
+ sa.Column("expires_at", sa.DateTime(timezone=True)),
+ sa.Column("last_used_at", sa.DateTime(timezone=True)),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+ op.create_index("ix_api_keys_prefix_active", "api_keys", ["key_prefix", "is_active"])
+
+ op.create_table(
+ "reservations",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
+ sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
+ sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True),
+ sa.Column("external_reference", sa.String(255)),
+ sa.Column("quantity", sa.Integer, nullable=False),
+ sa.Column("status", sa.String(20), default="pending", index=True),
+ sa.Column("expires_at", sa.DateTime(timezone=True), index=True),
+ sa.Column("confirmed_at", sa.DateTime(timezone=True)),
+ sa.Column("cancelled_at", sa.DateTime(timezone=True)),
+ sa.Column("idempotency_key", sa.String(255), unique=True, index=True),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+ op.create_index("ix_reservations_pharmacy_status", "reservations", ["pharmacy_id", "status"])
+ op.create_index("ix_reservations_expires", "reservations", ["expires_at"])
+
+ op.create_table(
+ "sync_jobs",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
+ sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
+ sa.Column("strategy", sa.String(20), nullable=False),
+ sa.Column("status", sa.String(20), default="pending", index=True),
+ sa.Column("started_at", sa.DateTime(timezone=True)),
+ sa.Column("completed_at", sa.DateTime(timezone=True)),
+ sa.Column("items_total", sa.Integer),
+ sa.Column("items_processed", sa.Integer),
+ sa.Column("items_failed", sa.Integer),
+ sa.Column("error_message", sa.Text),
+ sa.Column("metadata", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
+ )
+
+ op.create_table(
+ "sync_logs",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("sync_job_id", UUID(as_uuid=True), sa.ForeignKey("sync_jobs.id"), nullable=False, index=True),
+ sa.Column("level", sa.String(20), default="INFO", nullable=False),
+ sa.Column("message", sa.Text, nullable=False),
+ sa.Column("details", JSONB),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
+ )
+
+ op.create_table(
+ "audit_logs",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("actor_id", UUID(as_uuid=True), index=True),
+ sa.Column("actor_type", sa.String(50)),
+ sa.Column("action", sa.String(100), nullable=False, index=True),
+ sa.Column("resource_type", sa.String(100), nullable=False, index=True),
+ sa.Column("resource_id", sa.String(255), index=True),
+ sa.Column("old_value", JSONB),
+ sa.Column("new_value", JSONB),
+ sa.Column("ip_address", sa.String(45)),
+ sa.Column("user_agent", sa.String(500)),
+ sa.Column("request_id", sa.String(100), index=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
+ )
+ op.create_index("ix_audit_logs_action_resource", "audit_logs", ["action", "resource_type"])
+ op.create_index("ix_audit_logs_actor_created", "audit_logs", ["actor_id", "created_at"])
+
+
+def downgrade() -> None:
+ op.drop_table("audit_logs")
+ op.drop_table("sync_logs")
+ op.drop_table("sync_jobs")
+ op.drop_table("reservations")
+ op.drop_table("api_keys")
+ op.drop_table("api_clients")
+ op.drop_table("technical_users")
+ op.drop_table("inventory_history")
+ op.drop_table("inventory_records")
+ op.drop_table("medication_catalog")
+ op.drop_table("medications")
+ op.drop_table("connector_configs")
+ op.drop_table("connector_versions")
+ op.drop_column("pharmacies", "connector_id")
+ op.drop_table("connectors")
+ op.drop_table("pharmacies")
+ op.drop_table("erp_systems")
diff --git a/pip-platform/docker-compose.runtime.yml b/pip-platform/docker-compose.runtime.yml
new file mode 100644
index 0000000..dc7c6c4
--- /dev/null
+++ b/pip-platform/docker-compose.runtime.yml
@@ -0,0 +1,114 @@
+services:
+ pip-api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
+ REDIS_URL: redis://redis:6379/0
+ RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
+ JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
+ NODE_ENV: production
+ LOG_LEVEL: INFO
+ LOG_JSON_FORMAT: "true"
+ CORS_ORIGINS: '["*"]'
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
+ OTEL_TRACES_ENABLED: "true"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ rabbitmq:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ deploy:
+ resources:
+ limits:
+ cpus: "2.0"
+ memory: 1G
+ reservations:
+ cpus: "0.5"
+ memory: 256M
+
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: pip
+ POSTGRES_USER: pip
+ POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U pip -d pip"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+ command: >
+ postgres
+ -c max_connections=200
+ -c shared_buffers=256MB
+ -c effective_cache_size=768MB
+ -c work_mem=4MB
+ -c maintenance_work_mem=64MB
+ -c checkpoint_completion_target=0.9
+ -c wal_buffers=16MB
+ -c default_statistics_target=100
+ -c random_page_cost=1.1
+ -c effective_io_concurrency=200
+ -c max_worker_processes=8
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ ports:
+ - "6380:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ command: >
+ redis-server
+ --maxmemory 256mb
+ --maxmemory-policy allkeys-lru
+ --appendonly yes
+ --appendfsync everysec
+
+ rabbitmq:
+ image: rabbitmq:3-management-alpine
+ restart: unless-stopped
+ environment:
+ RABBITMQ_DEFAULT_USER: pip
+ RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
+ RABBITMQ_DEFAULT_VHOST: pip
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ volumes:
+ - rabbitmq_data:/var/lib/rabbitmq
+ healthcheck:
+ test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+volumes:
+ postgres_data:
+ redis_data:
+ rabbitmq_data:
\ No newline at end of file
diff --git a/pip-platform/docker-compose.yml b/pip-platform/docker-compose.yml
new file mode 100644
index 0000000..a8e17a3
--- /dev/null
+++ b/pip-platform/docker-compose.yml
@@ -0,0 +1,122 @@
+services:
+ pip-api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
+ REDIS_URL: redis://redis:6379/0
+ RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
+ JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
+ NODE_ENV: production
+ LOG_LEVEL: INFO
+ LOG_JSON_FORMAT: "true"
+ CORS_ORIGINS: '["*"]'
+ # Re-routed to the shared Grafana Alloy collector on srv84-macos.
+ # host.docker.internal resolves to the Docker host gateway from
+ # inside the container — works because this stack runs on the same
+ # host as the monitoring stack.
+ OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4317}
+ OTEL_TRACES_ENABLED: "true"
+ extra_hosts:
+ # `host-gateway` is the standard Docker engine alias for the bridge
+ # gateway (host.docker.internal is mac/Windows-native; this covers bare Linux).
+ - "host.docker.internal:host-gateway"
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ rabbitmq:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ deploy:
+ resources:
+ limits:
+ cpus: "2.0"
+ memory: 1G
+ reservations:
+ cpus: "0.5"
+ memory: 256M
+
+ postgres:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: pip
+ POSTGRES_USER: pip
+ POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U pip -d pip"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+ command: >
+ postgres
+ -c max_connections=200
+ -c shared_buffers=256MB
+ -c effective_cache_size=768MB
+ -c work_mem=4MB
+ -c maintenance_work_mem=64MB
+ -c checkpoint_completion_target=0.9
+ -c wal_buffers=16MB
+ -c default_statistics_target=100
+ -c random_page_cost=1.1
+ -c effective_io_concurrency=200
+ -c max_worker_processes=8
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ ports:
+ - "6380:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ command: >
+ redis-server
+ --maxmemory 256mb
+ --maxmemory-policy allkeys-lru
+ --appendonly yes
+ --appendfsync everysec
+
+ rabbitmq:
+ image: rabbitmq:3-management-alpine
+ restart: unless-stopped
+ environment:
+ RABBITMQ_DEFAULT_USER: pip
+ RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
+ RABBITMQ_DEFAULT_VHOST: pip
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ volumes:
+ - rabbitmq_data:/var/lib/rabbitmq
+ healthcheck:
+ test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 30s
+
+volumes:
+ postgres_data:
+ redis_data:
+ rabbitmq_data:
diff --git a/pip-platform/entrypoint.py b/pip-platform/entrypoint.py
new file mode 100644
index 0000000..d252852
--- /dev/null
+++ b/pip-platform/entrypoint.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+"""Run Alembic migrations before starting the app.
+
+This script is designed as a Docker entrypoint pre-migration step.
+It runs `alembic upgrade head` and then starts uvicorn.
+"""
+
+import subprocess
+import sys
+import os
+
+
+def run_migrations():
+ db_url = os.environ.get("DATABASE_URL_SYNC") or os.environ.get("DATABASE_URL", "").replace("+asyncpg", "+psycopg2", 1)
+ if not db_url:
+ print("WARNING: No DATABASE_URL found, skipping migrations")
+ return
+
+ env = os.environ.copy()
+ env["DATABASE_URL_SYNC"] = db_url
+
+ result = subprocess.run(
+ ["alembic", "-c", "alembic.ini", "upgrade", "head"],
+ env=env,
+ cwd="/app",
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ print(f"Migration failed:\n{result.stdout}\n{result.stderr}")
+ sys.exit(1)
+ print(f"Migration successful:\n{result.stdout}")
+
+
+if __name__ == "__main__":
+ run_migrations()
+ os.execvp(
+ "uvicorn",
+ ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000",
+ "--workers", "4", "--loop", "uvloop", "--http", "httptools"],
+ )
diff --git a/pip-platform/monitoring/prometheus/prometheus.yml b/pip-platform/monitoring/prometheus/prometheus.yml
new file mode 100644
index 0000000..2e71a64
--- /dev/null
+++ b/pip-platform/monitoring/prometheus/prometheus.yml
@@ -0,0 +1,18 @@
+# Reference scrape config used by the *external* Prometheus on srv84-macos.
+# This stack no longer ships its own Prometheus/Grafana — see PLAN.md and
+# STATE.md for the external-integration rationale. Kept as documentation.
+#
+# To scrape `pip-api` directly (e.g. from a local Prometheus for debugging),
+# point a static target at `host.docker.internal:8000` instead of `pip-api:8000`.
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ - job_name: "pip-platform"
+ metrics_path: /metrics
+ static_configs:
+ - targets: ["host.docker.internal:8000"]
+ labels:
+ host: srv84-macos
+ role: pip-platform
diff --git a/pip-platform/pyproject.toml b/pip-platform/pyproject.toml
new file mode 100644
index 0000000..dc9a12e
--- /dev/null
+++ b/pip-platform/pyproject.toml
@@ -0,0 +1,69 @@
+[project]
+name = "pip-platform"
+version = "0.1.0"
+description = "Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications"
+requires-python = ">=3.13"
+dependencies = [
+ "fastapi>=0.115.0",
+ "uvicorn[standard]>=0.32.0",
+ "sqlalchemy[asyncio]>=2.0.36",
+ "asyncpg>=0.30.0",
+ "alembic>=1.14.0",
+ "pydantic[email]>=2.10.0",
+ "pydantic-settings>=2.7.0",
+ "python-jose[cryptography]>=3.3.0",
+ "passlib[bcrypt]>=1.7.4",
+ "bcrypt>=4.2.0",
+ "python-multipart>=0.0.18",
+ "redis[hiredis]>=5.2.0",
+ "aio-pika>=9.6.0",
+ "opentelemetry-api>=1.29.0",
+ "opentelemetry-sdk>=1.29.0",
+ "opentelemetry-instrumentation-fastapi>=0.50b0",
+ "opentelemetry-instrumentation-sqlalchemy>=0.50b0",
+ "opentelemetry-instrumentation-redis>=0.50b0",
+ "opentelemetry-exporter-otlp>=1.29.0",
+ "prometheus-client>=0.21.0",
+ "structlog>=24.4.0",
+ "httpx>=0.28.0",
+ "click>=8.1.0",
+ "psycopg2-binary>=2.9.9",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=8.3.0",
+ "pytest-asyncio>=0.24.0",
+ "pytest-cov>=6.0.0",
+ "httpx>=0.28.0",
+ "ruff>=0.8.0",
+ "mypy>=1.13.0",
+ "testcontainers[postgres,redis,rabbitmq]>=4.8.0",
+]
+
+[tool.ruff]
+target-version = "py313"
+line-length = 120
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH", "RUF"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
+addopts = "-v --tb=short"
+
+[tool.mypy]
+python_version = "3.13"
+strict = true
+plugins = ["pydantic.mypy"]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src"]
+
+[project.scripts]
+pip = "src.cli:cli"
diff --git a/pip-platform/scripts/__init__.py b/pip-platform/scripts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/__init__.py b/pip-platform/src/__init__.py
new file mode 100644
index 0000000..de1660b
--- /dev/null
+++ b/pip-platform/src/__init__.py
@@ -0,0 +1,3 @@
+from .main import app
+
+__all__ = ["app"]
diff --git a/pip-platform/src/api/__init__.py b/pip-platform/src/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/api/middleware/__init__.py b/pip-platform/src/api/middleware/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/api/middleware/audit.py b/pip-platform/src/api/middleware/audit.py
new file mode 100644
index 0000000..a0fe386
--- /dev/null
+++ b/pip-platform/src/api/middleware/audit.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import time
+import uuid
+from typing import Any
+
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.requests import Request
+from starlette.responses import Response
+
+from src.core.logging import get_logger
+from src.domain.entities import AuditLogEntity
+from src.domain.interfaces import IAuditLogRepository
+from src.infrastructure.database.session import async_session_factory
+
+logger = get_logger("audit_middleware")
+
+WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
+IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
+
+
+class AuditMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+ response = await call_next(request)
+
+ if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
+ try:
+ await self._record_audit(request, response)
+ except Exception as exc:
+ logger.warning("audit_log_failed", extra={"error": str(exc)})
+
+ return response
+
+ def _should_ignore(self, path: str) -> bool:
+ return any(path.startswith(p) for p in IGNORED_PATHS)
+
+ async def _record_audit(self, request: Request, response: Response) -> None:
+ actor_id = None
+ actor_type = "anonymous"
+
+ if hasattr(request.state, "user") and request.state.user:
+ user = request.state.user
+ try:
+ actor_id = uuid.UUID(user.subject)
+ except (ValueError, TypeError):
+ pass
+ actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
+
+ resource_type = self._extract_resource_type(request.url.path)
+ action = f"{request.method.lower()}_{resource_type}"
+ resource_id = self._extract_resource_id(request.url.path)
+ ip_address = request.client.host if request.client else None
+ user_agent = request.headers.get("user-agent", "")
+ request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
+
+ async with async_session_factory() as session:
+ try:
+ from src.infrastructure.database.repositories import AuditLogRepository
+ repo = AuditLogRepository(session)
+ entity = AuditLogEntity(
+ actor_id=actor_id,
+ actor_type=actor_type,
+ action=action,
+ resource_type=resource_type,
+ resource_id=resource_id,
+ ip_address=ip_address,
+ user_agent=user_agent,
+ request_id=request_id,
+ )
+ await repo.add(entity)
+ await session.commit()
+ except Exception as exc:
+ logger.warning("audit_db_write_failed", extra={"error": str(exc)})
+
+ def _extract_resource_type(self, path: str) -> str:
+ parts = path.strip("/").split("/")
+ if len(parts) >= 3:
+ return parts[2]
+ if len(parts) >= 1:
+ return parts[-1]
+ return "unknown"
+
+ def _extract_resource_id(self, path: str) -> str | None:
+ parts = path.strip("/").split("/")
+ for part in reversed(parts[3:] if len(parts) > 3 else parts):
+ try:
+ uuid.UUID(part)
+ return part
+ except ValueError:
+ continue
+ return None
diff --git a/pip-platform/src/api/middleware/logging.py b/pip-platform/src/api/middleware/logging.py
new file mode 100644
index 0000000..cde8dd4
--- /dev/null
+++ b/pip-platform/src/api/middleware/logging.py
@@ -0,0 +1,36 @@
+import time
+import uuid
+
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.requests import Request
+from starlette.responses import Response
+
+from src.core.logging import get_logger
+
+logger = get_logger("request_logging")
+
+
+class RequestLoggingMiddleware(BaseHTTPMiddleware):
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+ request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
+ request.state.request_id = request_id
+
+ start = time.perf_counter()
+ response = await call_next(request)
+ duration_ms = (time.perf_counter() - start) * 1000
+
+ logger.info(
+ "request_completed",
+ extra={
+ "request_id": request_id,
+ "method": request.method,
+ "path": request.url.path,
+ "status_code": response.status_code,
+ "duration_ms": round(duration_ms, 2),
+ "client_ip": request.client.host if request.client else None,
+ "user_agent": request.headers.get("user-agent", ""),
+ },
+ )
+
+ response.headers["X-Request-ID"] = request_id
+ return response
diff --git a/pip-platform/src/api/middleware/rate_limit.py b/pip-platform/src/api/middleware/rate_limit.py
new file mode 100644
index 0000000..40da0f6
--- /dev/null
+++ b/pip-platform/src/api/middleware/rate_limit.py
@@ -0,0 +1,53 @@
+import time
+
+from fastapi import Request, Response
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+
+from src.infrastructure.config.settings import settings
+
+
+class RateLimitMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app, requests_per_minute: int | None = None):
+ super().__init__(app)
+ self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
+ self._clients: dict[str, list[float]] = {}
+
+ async def dispatch(
+ self, request: Request, call_next: RequestResponseEndpoint
+ ) -> Response:
+ if request.method == "OPTIONS":
+ return await call_next(request)
+
+ path = request.url.path
+ if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
+ return await call_next(request)
+
+ client_key = self._get_client_key(request)
+ now = time.monotonic()
+ window = self._clients.setdefault(client_key, [])
+ window[:] = [t for t in window if now - t < 60]
+
+ if len(window) >= self._rpm:
+ return Response(
+ content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
+ status_code=429,
+ media_type="application/json",
+ headers={"Retry-After": "60"},
+ )
+
+ window.append(now)
+ response = await call_next(request)
+ remaining = self._rpm - len(window)
+ response.headers["X-RateLimit-Limit"] = str(self._rpm)
+ response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
+ response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
+ return response
+
+ @staticmethod
+ def _get_client_key(request: Request) -> str:
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ if request.client:
+ return request.client.host
+ return "unknown"
diff --git a/pip-platform/src/api/v1/__init__.py b/pip-platform/src/api/v1/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/api/v1/dependencies/__init__.py b/pip-platform/src/api/v1/dependencies/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/api/v1/dependencies/auth.py b/pip-platform/src/api/v1/dependencies/auth.py
new file mode 100644
index 0000000..4c01c39
--- /dev/null
+++ b/pip-platform/src/api/v1/dependencies/auth.py
@@ -0,0 +1,107 @@
+import uuid
+
+from fastapi import Depends, HTTPException, Request, status
+from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
+
+from src.core.exceptions import ForbiddenException, UnauthorizedException
+from src.domain.entities import UserRole
+from src.infrastructure.database.session import AsyncSession, get_db_session
+from src.services.auth.auth_service import AuthService
+from src.services.auth.security import RBACGuard, TokenData, decode_token, validate_token_type
+from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
+from src.services.auth.security import verify_api_key
+
+_bearer_scheme = HTTPBearer(auto_error=False)
+_api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
+
+
+async def get_auth_service(session: AsyncSession = Depends(get_db_session)) -> AuthService:
+ return AuthService(session)
+
+
+async def get_api_key_user(
+ api_key: str | None = Depends(_api_key_scheme),
+ session: AsyncSession = Depends(get_db_session),
+) -> TokenData | None:
+ if not api_key:
+ return None
+
+ prefix = api_key[:8]
+ key_repo = APIKeyRepository(session)
+ key_entity = await key_repo.get_by_prefix(prefix)
+ if not key_entity or not key_entity.is_active:
+ return None
+
+ from datetime import datetime, timezone
+ if key_entity.expires_at and key_entity.expires_at < datetime.now(timezone.utc):
+ return None
+
+ if not verify_api_key(api_key, key_entity.key_hash):
+ return None
+
+ client_repo = APIClientRepository(session)
+ client = await client_repo.get_by_id(key_entity.api_client_id)
+ if not client or not client.is_active:
+ return None
+
+ await key_repo.update_last_used(key_entity.id)
+
+ return TokenData({
+ "sub": str(client.id),
+ "role": "api_client",
+ "scopes": client.scopes or [],
+ "iss": "pip-platform",
+ "type": "access",
+ "jti": str(key_entity.id),
+ })
+
+
+async def get_current_user(
+ request: Request,
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
+ api_key_user: TokenData | None = Depends(get_api_key_user),
+) -> TokenData:
+ if credentials:
+ try:
+ payload = decode_token(credentials.credentials)
+ validate_token_type(payload, expected_type="access")
+ user = TokenData(payload)
+ request.state.user = user
+ return user
+ except UnauthorizedException:
+ raise
+ except Exception as e:
+ raise UnauthorizedException(f"Invalid token: {e}") from e
+
+ if api_key_user:
+ request.state.user = api_key_user
+ return api_key_user
+
+ raise UnauthorizedException("Authentication required. Provide Bearer token or X-API-Key header.")
+
+
+def get_current_user_optional(
+ credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
+) -> TokenData | None:
+ if not credentials:
+ return None
+ try:
+ payload = decode_token(credentials.credentials)
+ validate_token_type(payload, expected_type="access")
+ return TokenData(payload)
+ except Exception:
+ return None
+
+
+def require_role(role: UserRole):
+ def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
+ RBACGuard.require_role(current_user, role)
+ return current_user
+ return _checker
+
+
+def require_scope(scope: str):
+ def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
+ RBACGuard.require_scope(current_user, scope)
+ return current_user
+ return _checker
diff --git a/pip-platform/src/api/v1/endpoints/__init__.py b/pip-platform/src/api/v1/endpoints/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/api/v1/endpoints/api_clients.py b/pip-platform/src/api/v1/endpoints/api_clients.py
new file mode 100644
index 0000000..74f7ed1
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/api_clients.py
@@ -0,0 +1,157 @@
+from datetime import datetime
+from uuid import UUID
+import hashlib
+import secrets
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.core.exceptions import ConflictException, NotFoundException
+from src.domain.entities import APIClientEntity, APIKeyEntity
+from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
+from src.infrastructure.database.session import get_db_session
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.domain.entities import UserRole
+from src.services.auth.security import TokenData, generate_api_key
+
+router = APIRouter(prefix="/api-clients", tags=["API Clients"])
+
+
+class APIClientCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ description: str | None = None
+ scopes: list[str] = Field(default_factory=lambda: ["pharmacies:read", "medications:read", "inventory:read"])
+ rate_limit: int | None = None
+ allowed_ips: list[str] = Field(default_factory=list)
+
+
+class APIClientResponse(BaseModel):
+ id: UUID
+ name: str
+ description: str | None
+ owner_id: UUID | None
+ is_active: bool
+ scopes: list[str]
+ rate_limit: int | None
+ allowed_ips: list[str]
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class APIKeyCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ expires_at: datetime | None = None
+
+
+class APIKeyResponse(BaseModel):
+ id: UUID
+ name: str
+ key_prefix: str
+ is_active: bool
+ expires_at: datetime | None
+ last_used_at: datetime | None
+ created_at: datetime | None
+
+
+class APIKeyCreateResponse(BaseModel):
+ id: UUID
+ name: str
+ key: str
+ key_prefix: str
+ expires_at: datetime | None
+
+
+class APIClientListResponse(BaseModel):
+ items: list[APIClientResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+@router.get("", response_model=APIClientListResponse)
+async def list_api_clients(
+ is_active: bool | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = APIClientRepository(session)
+ entities, total = await repo.list_clients(is_active=is_active, offset=offset, limit=limit)
+ items = [
+ APIClientResponse(
+ id=e.id, name=e.name, description=e.description, owner_id=e.owner_id,
+ is_active=e.is_active, scopes=e.scopes, rate_limit=e.rate_limit,
+ allowed_ips=e.allowed_ips, created_at=e.created_at, updated_at=e.updated_at,
+ )
+ for e in entities
+ ]
+ return APIClientListResponse(items=items, total=total, offset=offset, limit=limit)
+
+
+@router.post("", response_model=APIClientResponse, status_code=201)
+async def create_api_client(
+ body: APIClientCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = APIClientRepository(session)
+ entity = APIClientEntity(
+ name=body.name, description=body.description,
+ owner_id=UUID(current_user.subject), is_active=True,
+ scopes=body.scopes, rate_limit=body.rate_limit,
+ allowed_ips=body.allowed_ips,
+ )
+ created = await repo.create(entity)
+ return APIClientResponse(
+ id=created.id, name=created.name, description=created.description,
+ owner_id=created.owner_id, is_active=created.is_active,
+ scopes=created.scopes, rate_limit=created.rate_limit,
+ allowed_ips=created.allowed_ips, created_at=created.created_at,
+ updated_at=created.updated_at,
+ )
+
+
+@router.post("/{client_id}/keys", response_model=APIKeyCreateResponse, status_code=201)
+async def create_api_key(
+ client_id: UUID,
+ body: APIKeyCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ client_repo = APIClientRepository(session)
+ client = await client_repo.get_by_id(client_id)
+ if not client:
+ raise NotFoundException("API Client", str(client_id))
+
+ raw_key, prefix, key_hash = generate_api_key()
+ key_repo = APIKeyRepository(session)
+ key_entity = APIKeyEntity(
+ api_client_id=client_id, key_prefix=prefix,
+ key_hash=key_hash, name=body.name,
+ is_active=True, expires_at=body.expires_at,
+ )
+ created = await key_repo.create(key_entity)
+ return APIKeyCreateResponse(
+ id=created.id, name=created.name, key=raw_key,
+ key_prefix=created.key_prefix, expires_at=created.expires_at,
+ )
+
+
+@router.get("/{client_id}/keys", response_model=list[APIKeyResponse])
+async def list_api_keys(
+ client_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ key_repo = APIKeyRepository(session)
+ keys = await key_repo.list_by_client(client_id)
+ return [
+ APIKeyResponse(
+ id=k.id, name=k.name, key_prefix=k.key_prefix,
+ is_active=k.is_active, expires_at=k.expires_at,
+ last_used_at=k.last_used_at, created_at=k.created_at,
+ )
+ for k in keys
+ ]
diff --git a/pip-platform/src/api/v1/endpoints/audit.py b/pip-platform/src/api/v1/endpoints/audit.py
new file mode 100644
index 0000000..1c7b59a
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/audit.py
@@ -0,0 +1,70 @@
+from datetime import datetime
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.domain.entities import UserRole
+from src.infrastructure.database.repositories import AuditLogRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.audit.audit_service import AuditService
+
+router = APIRouter(prefix="/audit", tags=["Audit"])
+
+
+class AuditLogResponse(BaseModel):
+ id: UUID
+ actor_id: UUID | None
+ actor_type: str | None
+ action: str
+ resource_type: str
+ resource_id: str | None
+ old_value: dict | None
+ new_value: dict | None
+ ip_address: str | None
+ user_agent: str | None
+ request_id: str | None
+ created_at: datetime | None
+
+
+class AuditLogListResponse(BaseModel):
+ items: list[AuditLogResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _get_service(session: AsyncSession) -> AuditService:
+ return AuditService(AuditLogRepository(session))
+
+
+@router.get("/logs", response_model=AuditLogListResponse)
+async def list_audit_logs(
+ actor_id: UUID | None = None,
+ action: str | None = None,
+ resource_type: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entities, total = await service.list_logs(
+ actor_id=actor_id, action=action, resource_type=resource_type,
+ offset=offset, limit=limit,
+ )
+ items = [
+ AuditLogResponse(
+ id=e.id, actor_id=e.actor_id, actor_type=e.actor_type,
+ action=e.action, resource_type=e.resource_type,
+ resource_id=e.resource_id, old_value=e.old_value,
+ new_value=e.new_value, ip_address=e.ip_address,
+ user_agent=e.user_agent, request_id=e.request_id,
+ created_at=e.created_at,
+ )
+ for e in entities
+ ]
+ return AuditLogListResponse(items=items, total=total, offset=offset, limit=limit)
diff --git a/pip-platform/src/api/v1/endpoints/auth.py b/pip-platform/src/api/v1/endpoints/auth.py
new file mode 100644
index 0000000..dcf58a0
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/auth.py
@@ -0,0 +1,116 @@
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel, EmailStr, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import UserRole
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.auth_service import AuthService
+from src.api.v1.dependencies.auth import get_auth_service, get_current_user, require_role
+from src.services.auth.security import TokenData
+
+router = APIRouter(prefix="/auth", tags=["Authentication"])
+
+
+class LoginRequest(BaseModel):
+ username: str = Field(..., min_length=3, max_length=100)
+ password: str = Field(..., min_length=8)
+
+
+class RegisterRequest(BaseModel):
+ username: str = Field(..., min_length=3, max_length=100, pattern=r"^[A-Za-z0-9_]+$")
+ password: str = Field(..., min_length=8)
+ email: EmailStr | None = None
+ full_name: str | None = None
+ role: UserRole = UserRole.VIEWER
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ expires_in: int
+
+
+class RefreshRequest(BaseModel):
+ refresh_token: str
+
+
+class UserResponse(BaseModel):
+ id: UUID
+ username: str
+ email: str | None
+ full_name: str | None
+ role: str
+ is_active: bool
+ scopes: list[str]
+ last_login_at: datetime | None
+ created_at: datetime | None
+
+
+class MeResponse(BaseModel):
+ id: UUID
+ username: str
+ email: str | None
+ full_name: str | None
+ role: str
+ is_active: bool
+ scopes: list[str]
+
+
+@router.post("/register", response_model=UserResponse, status_code=201)
+async def register(body: RegisterRequest, service: AuthService = Depends(get_auth_service)):
+ user = await service.register(
+ username=body.username,
+ password=body.password,
+ email=body.email,
+ full_name=body.full_name,
+ role=body.role,
+ )
+ return UserResponse(
+ id=user.id,
+ username=user.username,
+ email=user.email,
+ full_name=user.full_name,
+ role=user.role.value,
+ is_active=user.is_active,
+ scopes=user.scopes,
+ last_login_at=user.last_login_at,
+ created_at=user.created_at,
+ )
+
+
+@router.post("/token", response_model=TokenResponse)
+async def login(body: LoginRequest, service: AuthService = Depends(get_auth_service)):
+ user, access_token, refresh_token = await service.authenticate(body.username, body.password)
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ expires_in=30 * 60,
+ )
+
+
+@router.post("/refresh", response_model=TokenResponse)
+async def refresh_token(body: RefreshRequest, service: AuthService = Depends(get_auth_service)):
+ access_token, refresh_token = await service.refresh_access_token(body.refresh_token)
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ expires_in=30 * 60,
+ )
+
+
+@router.get("/me", response_model=MeResponse)
+async def get_me(current_user: TokenData = Depends(get_current_user)):
+ return MeResponse(
+ id=UUID(current_user.subject),
+ username="",
+ email=None,
+ full_name=None,
+ role=current_user.role.value,
+ is_active=True,
+ scopes=current_user.scopes,
+ )
diff --git a/pip-platform/src/api/v1/endpoints/catalog.py b/pip-platform/src/api/v1/endpoints/catalog.py
new file mode 100644
index 0000000..3188b8d
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/catalog.py
@@ -0,0 +1,106 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.core.exceptions import NotFoundException
+from src.domain.entities import MedicationCatalogEntryEntity, UserRole
+from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.medication.medication_service import MedicationService
+
+router = APIRouter(prefix="/pharmacies/{pharmacy_id}/catalog", tags=["Medication Catalog"])
+
+
+class CatalogEntryUpsertRequest(BaseModel):
+ medication_id: UUID
+ price: float | None = None
+ stock: int = 0
+ is_available: bool = False
+ last_verified_at: datetime | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class CatalogEntryResponse(BaseModel):
+ id: UUID
+ pharmacy_id: UUID
+ medication_id: UUID
+ price: float | None
+ stock: int
+ is_available: bool
+ last_verified_at: datetime | None
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class CatalogListResponse(BaseModel):
+ items: list[CatalogEntryResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _entity_to_response(e: MedicationCatalogEntryEntity) -> CatalogEntryResponse:
+ return CatalogEntryResponse(
+ id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
+ price=e.price, stock=e.stock, is_available=e.is_available,
+ last_verified_at=e.last_verified_at, metadata=e.metadata_,
+ created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_service(session: AsyncSession) -> MedicationService:
+ return MedicationService(
+ medication_repo=MedicationRepository(session),
+ catalog_repo=MedicationCatalogRepository(session),
+ )
+
+
+@router.get("", response_model=CatalogListResponse)
+async def list_catalog(
+ pharmacy_id: UUID,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entities, total = await service.list_catalog(pharmacy_id, offset=offset, limit=limit)
+ return CatalogListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{medication_id}", response_model=CatalogEntryResponse)
+async def get_catalog_entry(
+ pharmacy_id: UUID,
+ medication_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_catalog_entry(pharmacy_id, medication_id)
+ if not entity:
+ raise NotFoundException("CatalogEntry", f"{pharmacy_id}/{medication_id}")
+ return _entity_to_response(entity)
+
+
+@router.put("", response_model=CatalogEntryResponse)
+async def upsert_catalog_entry(
+ pharmacy_id: UUID,
+ body: CatalogEntryUpsertRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ entity = MedicationCatalogEntryEntity(
+ pharmacy_id=pharmacy_id, medication_id=body.medication_id,
+ price=body.price, stock=body.stock, is_available=body.is_available,
+ last_verified_at=body.last_verified_at, metadata_=body.metadata,
+ )
+ upserted = await service.upsert_catalog_entry(entity)
+ return _entity_to_response(upserted)
diff --git a/pip-platform/src/api/v1/endpoints/connectors.py b/pip-platform/src/api/v1/endpoints/connectors.py
new file mode 100644
index 0000000..b80e1fd
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/connectors.py
@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.connectors.base import ConnectorResult
+from src.core.exceptions import NotFoundException, ValidationException
+from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus, ERPType, UserRole
+from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.connector_service import ConnectorService
+
+router = APIRouter(prefix="/connectors", tags=["Connectors"])
+
+
+class ConnectorCreateRequest(BaseModel):
+ erp_system_id: UUID
+ pharmacy_id: UUID
+ name: str = Field(..., min_length=1, max_length=255)
+ connector_type: str
+ version: str = "1.0.0"
+ config: dict[str, Any] | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class ConnectorUpdateRequest(BaseModel):
+ name: str | None = Field(None, min_length=1, max_length=255)
+ version: str | None = None
+ config: dict[str, Any] | None = None
+ status: str | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class ConnectorConfigRequest(BaseModel):
+ key: str = Field(..., min_length=1, max_length=100)
+ value: str
+ encrypted: bool = False
+ description: str | None = None
+
+
+class ConnectorResponse(BaseModel):
+ id: UUID
+ erp_system_id: UUID
+ pharmacy_id: UUID
+ name: str
+ connector_type: str
+ version: str
+ config: dict[str, Any] | None
+ status: str
+ last_heartbeat_at: datetime | None
+ last_error: str | None
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class ConnectorListResponse(BaseModel):
+ items: list[ConnectorResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+class ConnectorConfigResponse(BaseModel):
+ id: UUID
+ connector_id: UUID
+ key: str
+ value: str
+ encrypted: bool
+ description: str | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class ConnectorTestResponse(BaseModel):
+ success: bool
+ data: dict[str, Any] | None = None
+ error: str | None = None
+
+
+class ConnectorDeployResponse(BaseModel):
+ id: UUID
+ status: str
+ message: str
+
+
+def _entity_to_response(e: ConnectorEntity) -> ConnectorResponse:
+ return ConnectorResponse(
+ id=e.id, erp_system_id=e.erp_system_id, pharmacy_id=e.pharmacy_id,
+ name=e.name, connector_type=e.connector_type, version=e.version,
+ config=e.config, status=e.status, last_heartbeat_at=e.last_heartbeat_at,
+ last_error=e.last_error, metadata=e.metadata_, created_at=e.created_at,
+ updated_at=e.updated_at,
+ )
+
+
+def _config_entity_to_response(e: ConnectorConfigEntity) -> ConnectorConfigResponse:
+ return ConnectorConfigResponse(
+ id=e.id, connector_id=e.connector_id, key=e.key, value=e.value,
+ encrypted=e.encrypted, description=e.description,
+ created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_service(session: AsyncSession) -> ConnectorService:
+ return ConnectorService(
+ connector_repo=ConnectorRepository(session),
+ config_repo=ConnectorConfigRepository(session),
+ )
+
+
+@router.get("", response_model=ConnectorListResponse)
+async def list_connectors(
+ erp_system_id: UUID | None = None,
+ pharmacy_id: UUID | None = None,
+ status: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entities, total = await service.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
+ return ConnectorListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/types", response_model=list[str])
+async def list_connector_types(
+ current_user: TokenData = Depends(get_current_user),
+):
+ from src.connectors.registry import connector_registry
+ return connector_registry.list_registered_types()
+
+
+@router.get("/{connector_id}", response_model=ConnectorResponse)
+async def get_connector(
+ connector_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_connector(connector_id)
+ if not entity:
+ raise NotFoundException("Connector", str(connector_id))
+ return _entity_to_response(entity)
+
+
+@router.post("", response_model=ConnectorResponse, status_code=201)
+async def create_connector(
+ body: ConnectorCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entity = ConnectorEntity(
+ erp_system_id=body.erp_system_id, pharmacy_id=body.pharmacy_id,
+ name=body.name, connector_type=body.connector_type,
+ version=body.version, config=body.config,
+ status=ConnectorStatus.PENDING, metadata_=body.metadata,
+ )
+ created = await service.create_connector(entity)
+ return _entity_to_response(created)
+
+
+@router.put("/{connector_id}", response_model=ConnectorResponse)
+async def update_connector(
+ connector_id: UUID,
+ body: ConnectorUpdateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entity = await service.get_connector(connector_id)
+ if not entity:
+ raise NotFoundException("Connector", str(connector_id))
+
+ update_data = body.model_dump(exclude_unset=True)
+ if "metadata" in update_data:
+ entity.metadata_ = update_data.pop("metadata")
+ for key, value in update_data.items():
+ setattr(entity, key, value)
+
+ updated = await service.update_connector(entity)
+ return _entity_to_response(updated)
+
+
+@router.delete("/{connector_id}", status_code=204)
+async def delete_connector(
+ connector_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ deleted = await service.delete_connector(connector_id)
+ if not deleted:
+ raise NotFoundException("Connector", str(connector_id))
+
+
+@router.post("/{connector_id}/test", response_model=ConnectorTestResponse)
+async def test_connector(
+ connector_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ result = await service.test_connection(connector_id)
+ return ConnectorTestResponse(success=result.success, data=result.data, error=result.error)
+
+
+@router.post("/{connector_id}/deploy", response_model=ConnectorDeployResponse)
+async def deploy_connector(
+ connector_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entity = await service.deploy_connector(connector_id)
+ return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deployed successfully")
+
+
+@router.post("/{connector_id}/deactivate", response_model=ConnectorDeployResponse)
+async def deactivate_connector(
+ connector_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entity = await service.deactivate_connector(connector_id)
+ return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deactivated successfully")
+
+
+@router.get("/{connector_id}/configs", response_model=list[ConnectorConfigResponse])
+async def list_connector_configs(
+ connector_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ connector = await service.get_connector(connector_id)
+ if not connector:
+ raise NotFoundException("Connector", str(connector_id))
+ configs = await service.get_configs(connector_id)
+ return [_config_entity_to_response(c) for c in configs]
+
+
+@router.put("/{connector_id}/configs", response_model=ConnectorConfigResponse)
+async def upsert_connector_config(
+ connector_id: UUID,
+ body: ConnectorConfigRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ connector = await service.get_connector(connector_id)
+ if not connector:
+ raise NotFoundException("Connector", str(connector_id))
+ cfg_entity = ConnectorConfigEntity(
+ connector_id=connector_id, key=body.key, value=body.value,
+ encrypted=body.encrypted, description=body.description,
+ )
+ created = await service.upsert_config(cfg_entity)
+ return _config_entity_to_response(created)
+
+
+@router.delete("/{connector_id}/configs/{config_id}", status_code=204)
+async def delete_connector_config(
+ connector_id: UUID,
+ config_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ await service.delete_config(config_id)
diff --git a/pip-platform/src/api/v1/endpoints/erp_systems.py b/pip-platform/src/api/v1/endpoints/erp_systems.py
new file mode 100644
index 0000000..012f5a6
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/erp_systems.py
@@ -0,0 +1,143 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.core.exceptions import NotFoundException
+from src.domain.entities import ERPSystemEntity, UserRole
+from src.domain.interfaces import IERPSystemRepository
+from src.infrastructure.database.repositories import ERPSystemRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+
+router = APIRouter(prefix="/erp-systems", tags=["ERP Systems"])
+
+
+class ERPSystemCreateRequest(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ erp_type: str
+ version: str | None = None
+ base_url: str | None = None
+ auth_config: dict[str, Any] | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class ERPSystemUpdateRequest(BaseModel):
+ name: str | None = Field(None, min_length=1, max_length=255)
+ version: str | None = None
+ base_url: str | None = None
+ auth_config: dict[str, Any] | None = None
+ status: str | None = None
+ metadata: dict[str, Any] | None = None
+
+
+class ERPSystemResponse(BaseModel):
+ id: UUID
+ name: str
+ erp_type: str
+ version: str | None
+ base_url: str | None
+ auth_config: dict[str, Any] | None
+ status: str
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class ERPSystemListResponse(BaseModel):
+ items: list[ERPSystemResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _entity_to_response(e: ERPSystemEntity) -> ERPSystemResponse:
+ return ERPSystemResponse(
+ id=e.id, name=e.name, erp_type=e.erp_type, version=e.version,
+ base_url=e.base_url, auth_config=e.auth_config, status=e.status,
+ metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_repo(session: AsyncSession) -> IERPSystemRepository:
+ return ERPSystemRepository(session)
+
+
+@router.get("", response_model=ERPSystemListResponse)
+async def list_erp_systems(
+ erp_type: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ repo = _get_repo(session)
+ entities, total = await repo.list_erp_systems(erp_type=erp_type, offset=offset, limit=limit)
+ return ERPSystemListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{erp_id}", response_model=ERPSystemResponse)
+async def get_erp_system(
+ erp_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ repo = _get_repo(session)
+ entity = await repo.get_by_id(erp_id)
+ if not entity:
+ raise NotFoundException("ERP System", str(erp_id))
+ return _entity_to_response(entity)
+
+
+@router.post("", response_model=ERPSystemResponse, status_code=201)
+async def create_erp_system(
+ body: ERPSystemCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = _get_repo(session)
+ entity = ERPSystemEntity(
+ name=body.name, erp_type=body.erp_type, version=body.version,
+ base_url=body.base_url, auth_config=body.auth_config,
+ metadata_=body.metadata,
+ )
+ created = await repo.create(entity)
+ return _entity_to_response(created)
+
+
+@router.put("/{erp_id}", response_model=ERPSystemResponse)
+async def update_erp_system(
+ erp_id: UUID,
+ body: ERPSystemUpdateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = _get_repo(session)
+ entity = await repo.get_by_id(erp_id)
+ if not entity:
+ raise NotFoundException("ERP System", str(erp_id))
+
+ update_data = body.model_dump(exclude_unset=True)
+ if "metadata" in update_data:
+ entity.metadata_ = update_data.pop("metadata")
+ for key, value in update_data.items():
+ setattr(entity, key, value)
+
+ updated = await repo.update(entity)
+ return _entity_to_response(updated)
+
+
+@router.delete("/{erp_id}", status_code=204)
+async def delete_erp_system(
+ erp_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = _get_repo(session)
+ deleted = await repo.delete(erp_id)
+ if not deleted:
+ raise NotFoundException("ERP System", str(erp_id))
diff --git a/pip-platform/src/api/v1/endpoints/inventory.py b/pip-platform/src/api/v1/endpoints/inventory.py
new file mode 100644
index 0000000..9181f3a
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/inventory.py
@@ -0,0 +1,150 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.core.exceptions import NotFoundException
+from src.domain.entities import InventoryRecordEntity, UserRole
+from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.inventory.inventory_service import InventoryService
+
+router = APIRouter(prefix="/pharmacies/{pharmacy_id}/inventory", tags=["Inventory"])
+
+
+class InventoryUpsertRequest(BaseModel):
+ medication_id: UUID
+ stock: int = Field(..., ge=0)
+ reserved_stock: int = Field(0, ge=0)
+ available_stock: int = Field(0, ge=0)
+ min_stock: int | None = Field(None, ge=0)
+ max_stock: int | None = Field(None, ge=0)
+ last_updated_at: datetime | None = None
+ source: str | None = Field(None, max_length=100)
+ batch_id: str | None = Field(None, max_length=100)
+ metadata: dict[str, Any] | None = None
+
+
+class InventoryRecordResponse(BaseModel):
+ id: UUID
+ pharmacy_id: UUID
+ medication_id: UUID
+ stock: int
+ reserved_stock: int
+ available_stock: int
+ min_stock: int | None
+ max_stock: int | None
+ last_updated_at: datetime | None
+ source: str | None
+ batch_id: str | None
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class InventoryListResponse(BaseModel):
+ items: list[InventoryRecordResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+class InventoryHistoryResponse(BaseModel):
+ id: UUID
+ pharmacy_id: UUID
+ medication_id: UUID
+ previous_stock: int
+ new_stock: int
+ delta: int
+ reason: str | None
+ source: str | None
+ created_at: datetime | None
+
+
+def _record_entity_to_response(e: InventoryRecordEntity) -> InventoryRecordResponse:
+ return InventoryRecordResponse(
+ id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
+ stock=e.stock, reserved_stock=e.reserved_stock, available_stock=e.available_stock,
+ min_stock=e.min_stock, max_stock=e.max_stock,
+ last_updated_at=e.last_updated_at, source=e.source, batch_id=e.batch_id,
+ metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_service(session: AsyncSession) -> InventoryService:
+ return InventoryService(
+ inventory_repo=InventoryRepository(session),
+ reservation_repo=ReservationRepository(session),
+ )
+
+
+@router.get("", response_model=InventoryListResponse)
+async def list_inventory(
+ pharmacy_id: UUID,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entities, total = await service.list_inventory(pharmacy_id, offset=offset, limit=limit)
+ return InventoryListResponse(items=[_record_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{medication_id}", response_model=InventoryRecordResponse)
+async def get_inventory_record(
+ pharmacy_id: UUID,
+ medication_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_inventory_record(pharmacy_id, medication_id)
+ if not entity:
+ raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
+ return _record_entity_to_response(entity)
+
+
+@router.put("", response_model=InventoryRecordResponse)
+async def upsert_inventory_record(
+ pharmacy_id: UUID,
+ body: InventoryUpsertRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ entity = InventoryRecordEntity(
+ pharmacy_id=pharmacy_id, medication_id=body.medication_id,
+ stock=body.stock, reserved_stock=body.reserved_stock,
+ available_stock=body.available_stock, min_stock=body.min_stock,
+ max_stock=body.max_stock, last_updated_at=body.last_updated_at,
+ source=body.source, batch_id=body.batch_id, metadata_=body.metadata,
+ )
+ upserted = await service.upsert_inventory_record(entity)
+ return _record_entity_to_response(upserted)
+
+
+@router.get("/{medication_id}/history", response_model=list[InventoryHistoryResponse])
+async def get_inventory_history(
+ pharmacy_id: UUID,
+ medication_id: UUID,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ history = await service.get_inventory_history(pharmacy_id, medication_id, offset=offset, limit=limit)
+ return [
+ InventoryHistoryResponse(
+ id=h.id, pharmacy_id=h.pharmacy_id, medication_id=h.medication_id,
+ previous_stock=h.previous_stock, new_stock=h.new_stock, delta=h.delta,
+ reason=h.reason, source=h.source, created_at=h.created_at,
+ )
+ for h in history
+ ]
diff --git a/pip-platform/src/api/v1/endpoints/medications.py b/pip-platform/src/api/v1/endpoints/medications.py
new file mode 100644
index 0000000..156d3f6
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/medications.py
@@ -0,0 +1,189 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.core.exceptions import ConflictException, NotFoundException
+from src.domain.entities import MedicationEntity, UserRole
+from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.medication.medication_service import MedicationService
+
+router = APIRouter(prefix="/medications", tags=["Medications"])
+
+
+class MedicationCreateRequest(BaseModel):
+ nregistro: str = Field(..., min_length=1, max_length=50)
+ name: str = Field(..., min_length=1, max_length=500)
+ active_ingredient: str | None = Field(None, max_length=500)
+ dosage: str | None = Field(None, max_length=100)
+ form: str | None = Field(None, max_length=100)
+ atc_code: str | None = Field(None, max_length=20)
+ prescription_required: bool = False
+ manufacturer: str | None = Field(None, max_length=255)
+ metadata: dict[str, Any] | None = None
+
+
+class MedicationUpdateRequest(BaseModel):
+ name: str | None = Field(None, min_length=1, max_length=500)
+ active_ingredient: str | None = Field(None, max_length=500)
+ dosage: str | None = Field(None, max_length=100)
+ form: str | None = Field(None, max_length=100)
+ atc_code: str | None = Field(None, max_length=20)
+ prescription_required: bool | None = None
+ manufacturer: str | None = Field(None, max_length=255)
+ metadata: dict[str, Any] | None = None
+
+
+class MedicationResponse(BaseModel):
+ id: UUID
+ nregistro: str
+ name: str
+ active_ingredient: str | None
+ dosage: str | None
+ form: str | None
+ atc_code: str | None
+ prescription_required: bool
+ manufacturer: str | None
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class MedicationListResponse(BaseModel):
+ items: list[MedicationResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _entity_to_response(e: MedicationEntity) -> MedicationResponse:
+ return MedicationResponse(
+ id=e.id, nregistro=e.nregistro, name=e.name,
+ active_ingredient=e.active_ingredient, dosage=e.dosage,
+ form=e.form, atc_code=e.atc_code,
+ prescription_required=e.prescription_required,
+ manufacturer=e.manufacturer, metadata=e.metadata_,
+ created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_service(session: AsyncSession) -> MedicationService:
+ return MedicationService(
+ medication_repo=MedicationRepository(session),
+ catalog_repo=MedicationCatalogRepository(session),
+ )
+
+
+@router.get("", response_model=MedicationListResponse)
+async def list_medications(
+ q: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ if q:
+ entities, total = await service.search_medications(q, offset=offset, limit=limit)
+ else:
+ from src.domain.entities import MedicationEntity
+ all_entities, total = await service.search_medications("", offset=offset, limit=limit)
+ entities = all_entities
+ return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/search", response_model=MedicationListResponse)
+async def search_medications(
+ q: str = Query(..., min_length=1),
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entities, total = await service.search_medications(q, offset=offset, limit=limit)
+ return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/nregistro/{nregistro}", response_model=MedicationResponse)
+async def get_medication_by_nregistro(
+ nregistro: str,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_by_nregistro(nregistro)
+ if not entity:
+ raise NotFoundException("Medication", nregistro)
+ return _entity_to_response(entity)
+
+
+@router.get("/{medication_id}", response_model=MedicationResponse)
+async def get_medication(
+ medication_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_medication(medication_id)
+ if not entity:
+ raise NotFoundException("Medication", str(medication_id))
+ return _entity_to_response(entity)
+
+
+@router.post("", response_model=MedicationResponse, status_code=201)
+async def create_medication(
+ body: MedicationCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entity = MedicationEntity(
+ nregistro=body.nregistro, name=body.name,
+ active_ingredient=body.active_ingredient, dosage=body.dosage,
+ form=body.form, atc_code=body.atc_code,
+ prescription_required=body.prescription_required,
+ manufacturer=body.manufacturer, metadata_=body.metadata,
+ )
+ created = await service.create_medication(entity)
+ return _entity_to_response(created)
+
+
+@router.put("/{medication_id}", response_model=MedicationResponse)
+async def update_medication(
+ medication_id: UUID,
+ body: MedicationUpdateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ entity = await service.get_medication(medication_id)
+ if not entity:
+ raise NotFoundException("Medication", str(medication_id))
+
+ update_data = body.model_dump(exclude_unset=True)
+ if "metadata" in update_data:
+ entity.metadata_ = update_data.pop("metadata")
+ for key, value in update_data.items():
+ setattr(entity, key, value)
+
+ updated = await service.update_medication(entity)
+ return _entity_to_response(updated)
+
+
+@router.delete("/{medication_id}", status_code=204)
+async def delete_medication(
+ medication_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ service = _get_service(session)
+ deleted = await service.delete_medication(medication_id)
+ if not deleted:
+ raise NotFoundException("Medication", str(medication_id))
diff --git a/pip-platform/src/api/v1/endpoints/pharmacies.py b/pip-platform/src/api/v1/endpoints/pharmacies.py
new file mode 100644
index 0000000..305a338
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/pharmacies.py
@@ -0,0 +1,185 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.core.exceptions import NotFoundException
+from src.domain.entities import PharmacyEntity, PharmacyStatus
+from src.domain.interfaces import IPharmacyRepository
+from src.infrastructure.database.session import get_db_session
+from src.infrastructure.database.repositories import PharmacyRepository
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.domain.entities import UserRole
+from src.services.auth.security import TokenData
+
+router = APIRouter(prefix="/pharmacies", tags=["Pharmacies"])
+
+
+class PharmacyCreateRequest(BaseModel):
+ code: str = Field(..., min_length=1, max_length=50)
+ name: str = Field(..., min_length=1, max_length=255)
+ address: str | None = None
+ city: str | None = None
+ province: str | None = None
+ postal_code: str | None = None
+ country: str = "ES"
+ latitude: float | None = None
+ longitude: float | None = None
+ phone: str | None = None
+ email: str | None = None
+ opening_hours: dict | None = None
+ erp_type: str | None = None
+ erp_version: str | None = None
+ sync_strategy: str = "incremental"
+ sync_interval_minutes: int = 30
+
+
+class PharmacyUpdateRequest(BaseModel):
+ name: str | None = None
+ address: str | None = None
+ city: str | None = None
+ province: str | None = None
+ postal_code: str | None = None
+ country: str | None = None
+ latitude: float | None = None
+ longitude: float | None = None
+ phone: str | None = None
+ email: str | None = None
+ opening_hours: dict | None = None
+ status: str | None = None
+ erp_type: str | None = None
+ erp_version: str | None = None
+ sync_strategy: str | None = None
+ sync_interval_minutes: int | None = None
+
+
+class PharmacyResponse(BaseModel):
+ id: UUID
+ code: str
+ name: str
+ address: str | None
+ city: str | None
+ province: str | None
+ postal_code: str | None
+ country: str
+ latitude: float | None
+ longitude: float | None
+ phone: str | None
+ email: str | None
+ opening_hours: dict | None
+ status: str
+ erp_type: str | None
+ erp_version: str | None
+ connector_id: UUID | None
+ sync_strategy: str
+ sync_interval_minutes: int
+ last_sync_at: datetime | None
+ last_sync_status: str | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class PharmacyListResponse(BaseModel):
+ items: list[PharmacyResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _entity_to_response(e: PharmacyEntity) -> PharmacyResponse:
+ return PharmacyResponse(
+ id=e.id, code=e.code, name=e.name, address=e.address, city=e.city,
+ province=e.province, postal_code=e.postal_code, country=e.country,
+ latitude=e.latitude, longitude=e.longitude, phone=e.phone, email=e.email,
+ opening_hours=e.opening_hours, status=e.status, erp_type=e.erp_type,
+ erp_version=e.erp_version, connector_id=e.connector_id,
+ sync_strategy=e.sync_strategy, sync_interval_minutes=e.sync_interval_minutes,
+ last_sync_at=e.last_sync_at, last_sync_status=e.last_sync_status,
+ created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_repo(session: AsyncSession) -> IPharmacyRepository:
+ return PharmacyRepository(session)
+
+
+@router.get("", response_model=PharmacyListResponse)
+async def list_pharmacies(
+ status: str | None = None,
+ erp_type: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ repo = _get_repo(session)
+ entities, total = await repo.list_pharmacies(status=status, erp_type=erp_type, offset=offset, limit=limit)
+ return PharmacyListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{pharmacy_id}", response_model=PharmacyResponse)
+async def get_pharmacy(
+ pharmacy_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ repo = _get_repo(session)
+ entity = await repo.get_by_id(pharmacy_id)
+ if not entity:
+ raise NotFoundException("Pharmacy", str(pharmacy_id))
+ return _entity_to_response(entity)
+
+
+@router.post("", response_model=PharmacyResponse, status_code=201)
+async def create_pharmacy(
+ body: PharmacyCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = _get_repo(session)
+ entity = PharmacyEntity(
+ code=body.code, name=body.name, address=body.address, city=body.city,
+ province=body.province, postal_code=body.postal_code, country=body.country,
+ latitude=body.latitude, longitude=body.longitude, phone=body.phone,
+ email=body.email, opening_hours=body.opening_hours,
+ status=PharmacyStatus.ACTIVE.value, erp_type=body.erp_type,
+ erp_version=body.erp_version, sync_strategy=body.sync_strategy,
+ sync_interval_minutes=body.sync_interval_minutes,
+ )
+ created = await repo.create(entity)
+ return _entity_to_response(created)
+
+
+@router.put("/{pharmacy_id}", response_model=PharmacyResponse)
+async def update_pharmacy(
+ pharmacy_id: UUID,
+ body: PharmacyUpdateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = _get_repo(session)
+ entity = await repo.get_by_id(pharmacy_id)
+ if not entity:
+ raise NotFoundException("Pharmacy", str(pharmacy_id))
+
+ update_data = body.model_dump(exclude_unset=True)
+ for key, value in update_data.items():
+ setattr(entity, key, value)
+
+ updated = await repo.update(entity)
+ return _entity_to_response(updated)
+
+
+@router.delete("/{pharmacy_id}", status_code=204)
+async def delete_pharmacy(
+ pharmacy_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = _get_repo(session)
+ deleted = await repo.delete(pharmacy_id)
+ if not deleted:
+ raise NotFoundException("Pharmacy", str(pharmacy_id))
diff --git a/pip-platform/src/api/v1/endpoints/reservations.py b/pip-platform/src/api/v1/endpoints/reservations.py
new file mode 100644
index 0000000..140672a
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/reservations.py
@@ -0,0 +1,149 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.core.exceptions import NotFoundException, ValidationException
+from src.domain.entities import ReservationEntity, ReservationStatus, UserRole
+from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.inventory.inventory_service import InventoryService
+
+router = APIRouter(prefix="/pharmacies/{pharmacy_id}/reservations", tags=["Reservations"])
+
+
+class ReservationCreateRequest(BaseModel):
+ medication_id: UUID
+ quantity: int = Field(..., gt=0)
+ external_reference: str | None = Field(None, max_length=255)
+ idempotency_key: str | None = Field(None, max_length=255)
+ ttl_minutes: int = Field(30, ge=1, le=1440)
+ metadata: dict[str, Any] | None = None
+
+
+class ReservationResponse(BaseModel):
+ id: UUID
+ pharmacy_id: UUID
+ medication_id: UUID
+ user_id: UUID | None
+ external_reference: str | None
+ quantity: int
+ status: str
+ expires_at: datetime | None
+ confirmed_at: datetime | None
+ cancelled_at: datetime | None
+ idempotency_key: str | None
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class ReservationListResponse(BaseModel):
+ items: list[ReservationResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _entity_to_response(e: ReservationEntity) -> ReservationResponse:
+ return ReservationResponse(
+ id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
+ user_id=e.user_id, external_reference=e.external_reference,
+ quantity=e.quantity, status=e.status, expires_at=e.expires_at,
+ confirmed_at=e.confirmed_at, cancelled_at=e.cancelled_at,
+ idempotency_key=e.idempotency_key, metadata=e.metadata_,
+ created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_service(session: AsyncSession) -> InventoryService:
+ return InventoryService(
+ inventory_repo=InventoryRepository(session),
+ reservation_repo=ReservationRepository(session),
+ )
+
+
+@router.get("", response_model=ReservationListResponse)
+async def list_reservations(
+ pharmacy_id: UUID,
+ status: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entities, total = await service.list_reservations(pharmacy_id, status=status, offset=offset, limit=limit)
+ return ReservationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{reservation_id}", response_model=ReservationResponse)
+async def get_reservation(
+ pharmacy_id: UUID,
+ reservation_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_reservation(reservation_id)
+ if not entity or entity.pharmacy_id != pharmacy_id:
+ raise NotFoundException("Reservation", str(reservation_id))
+ return _entity_to_response(entity)
+
+
+@router.post("", response_model=ReservationResponse, status_code=201)
+async def create_reservation(
+ pharmacy_id: UUID,
+ body: ReservationCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ user_id = None
+ if hasattr(current_user, "user_id"):
+ user_id = current_user.user_id
+ elif hasattr(current_user, "sub"):
+ try:
+ user_id = UUID(current_user.sub)
+ except (ValueError, TypeError):
+ pass
+
+ created = await service.create_reservation(
+ pharmacy_id=pharmacy_id,
+ medication_id=body.medication_id,
+ quantity=body.quantity,
+ user_id=user_id,
+ external_reference=body.external_reference,
+ idempotency_key=body.idempotency_key,
+ ttl_minutes=body.ttl_minutes,
+ )
+ return _entity_to_response(created)
+
+
+@router.post("/{reservation_id}/confirm", response_model=ReservationResponse)
+async def confirm_reservation(
+ pharmacy_id: UUID,
+ reservation_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ updated = await service.confirm_reservation(reservation_id)
+ return _entity_to_response(updated)
+
+
+@router.post("/{reservation_id}/cancel", response_model=ReservationResponse)
+async def cancel_reservation(
+ pharmacy_id: UUID,
+ reservation_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ updated = await service.cancel_reservation(reservation_id)
+ return _entity_to_response(updated)
diff --git a/pip-platform/src/api/v1/endpoints/sync_jobs.py b/pip-platform/src/api/v1/endpoints/sync_jobs.py
new file mode 100644
index 0000000..1d7f54e
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/sync_jobs.py
@@ -0,0 +1,199 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, Field
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.core.exceptions import NotFoundException
+from src.domain.entities import SyncJobStatus, SyncStrategy, UserRole
+from src.infrastructure.database.repositories import SyncJobRepository, SyncLogRepository
+from src.infrastructure.database.session import get_db_session
+from src.services.auth.security import TokenData
+from src.services.synchronization.sync_service import SyncService
+
+router = APIRouter(prefix="/pharmacies/{pharmacy_id}/sync-jobs", tags=["Sync Jobs"])
+
+
+class SyncJobCreateRequest(BaseModel):
+ connector_id: UUID
+ strategy: SyncStrategy = SyncStrategy.INCREMENTAL
+
+
+class SyncJobResponse(BaseModel):
+ id: UUID
+ pharmacy_id: UUID
+ connector_id: UUID
+ strategy: str
+ status: str
+ started_at: datetime | None
+ completed_at: datetime | None
+ items_total: int | None
+ items_processed: int | None
+ items_failed: int | None
+ error_message: str | None
+ metadata: dict[str, Any] | None
+ created_at: datetime | None
+ updated_at: datetime | None
+
+
+class SyncJobListResponse(BaseModel):
+ items: list[SyncJobResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+class SyncLogResponse(BaseModel):
+ id: UUID
+ sync_job_id: UUID
+ level: str
+ message: str
+ details: dict[str, Any] | None
+ created_at: datetime | None
+
+
+def _entity_to_response(e: Any) -> SyncJobResponse:
+ return SyncJobResponse(
+ id=e.id, pharmacy_id=e.pharmacy_id, connector_id=e.connector_id,
+ strategy=e.strategy, status=e.status, started_at=e.started_at,
+ completed_at=e.completed_at, items_total=e.items_total,
+ items_processed=e.items_processed, items_failed=e.items_failed,
+ error_message=e.error_message, metadata=e.metadata_,
+ created_at=e.created_at, updated_at=e.updated_at,
+ )
+
+
+def _get_service(session: AsyncSession) -> SyncService:
+ return SyncService(
+ sync_job_repo=SyncJobRepository(session),
+ sync_log_repo=SyncLogRepository(session),
+ )
+
+
+@router.get("", response_model=SyncJobListResponse)
+async def list_sync_jobs(
+ pharmacy_id: UUID,
+ status: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entities, total = await service.list_sync_jobs(pharmacy_id, status=status, offset=offset, limit=limit)
+ return SyncJobListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{job_id}", response_model=SyncJobResponse)
+async def get_sync_job(
+ pharmacy_id: UUID,
+ job_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ entity = await service.get_sync_job(job_id)
+ if not entity or entity.pharmacy_id != pharmacy_id:
+ raise NotFoundException("SyncJob", str(job_id))
+ return _entity_to_response(entity)
+
+
+@router.post("", response_model=SyncJobResponse, status_code=201)
+async def create_sync_job(
+ pharmacy_id: UUID,
+ body: SyncJobCreateRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ created = await service.create_sync_job(
+ pharmacy_id=pharmacy_id,
+ connector_id=body.connector_id,
+ strategy=body.strategy,
+ )
+ return _entity_to_response(created)
+
+
+@router.post("/{job_id}/trigger", response_model=SyncJobResponse)
+async def trigger_sync(
+ pharmacy_id: UUID,
+ job_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ updated = await service.trigger_sync(job_id)
+ return _entity_to_response(updated)
+
+
+@router.post("/{job_id}/cancel", response_model=SyncJobResponse)
+async def cancel_sync(
+ pharmacy_id: UUID,
+ job_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ updated = await service.cancel_sync(job_id)
+ return _entity_to_response(updated)
+
+
+class SyncCompleteRequest(BaseModel):
+ items_total: int | None = None
+ items_processed: int | None = None
+ items_failed: int | None = None
+ duration_ms: int = 0
+
+
+class SyncFailRequest(BaseModel):
+ error_message: str
+
+
+@router.post("/{job_id}/complete", response_model=SyncJobResponse)
+async def complete_sync(
+ pharmacy_id: UUID,
+ job_id: UUID,
+ body: SyncCompleteRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ updated = await service.complete_sync(job_id, items_total=body.items_total, items_processed=body.items_processed, items_failed=body.items_failed)
+ return _entity_to_response(updated)
+
+
+@router.post("/{job_id}/fail", response_model=SyncJobResponse)
+async def fail_sync(
+ pharmacy_id: UUID,
+ job_id: UUID,
+ body: SyncFailRequest,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
+):
+ service = _get_service(session)
+ updated = await service.fail_sync(job_id, error_message=body.error_message)
+ return _entity_to_response(updated)
+
+
+@router.get("/{job_id}/logs", response_model=list[SyncLogResponse])
+async def get_sync_logs(
+ pharmacy_id: UUID,
+ job_id: UUID,
+ level: str | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(100, ge=1, le=500),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(get_current_user),
+):
+ service = _get_service(session)
+ logs = await service.get_sync_logs(job_id, level=level, offset=offset, limit=limit)
+ return [
+ SyncLogResponse(
+ id=l.id, sync_job_id=l.sync_job_id, level=l.level,
+ message=l.message, details=l.details, created_at=l.created_at,
+ )
+ for l in logs
+ ]
diff --git a/pip-platform/src/api/v1/endpoints/system.py b/pip-platform/src/api/v1/endpoints/system.py
new file mode 100644
index 0000000..e810501
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/system.py
@@ -0,0 +1,77 @@
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import text
+
+from src.infrastructure.cache.redis_cache import redis_cache
+from src.infrastructure.database.session import get_db_session
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+from src.api.v1.dependencies.auth import get_current_user
+from src.services.auth.security import TokenData
+
+router = APIRouter(prefix="/system", tags=["System"])
+
+
+class HealthStatus(BaseModel):
+ status: str
+ version: str
+ services: dict[str, str]
+
+
+class ReadinessStatus(BaseModel):
+ status: str
+ checks: dict[str, bool]
+
+
+@router.get("/health", response_model=HealthStatus)
+async def health_check():
+ return HealthStatus(
+ status="healthy",
+ version="0.1.0",
+ services={
+ "api": "healthy",
+ },
+ )
+
+
+@router.get("/ready", response_model=ReadinessStatus)
+async def readiness_check(db: AsyncSession = Depends(get_db_session)):
+ checks: dict[str, bool] = {}
+
+ try:
+ # Check database connection
+ await db.execute(text("SELECT 1"))
+ db_ok = True
+ except Exception:
+ db_ok = False
+ checks["database"] = db_ok
+
+ redis_ok = await redis_cache.health_check()
+ checks["redis"] = redis_ok
+
+ mq_ok = await rabbitmq_client.health_check()
+ checks["rabbitmq"] = mq_ok
+
+ overall = all(checks.values())
+ return ReadinessStatus(
+ status="ready" if overall else "degraded",
+ checks=checks,
+ )
+
+
+@router.get("/status")
+async def system_status(current_user: TokenData = Depends(get_current_user)):
+ return {
+ "platform": "Pharmacy Integration Platform",
+ "version": "0.1.0",
+ "environment": "production",
+ "services": {
+ "auth": "active",
+ "pharmacy": "active",
+ "medication": "active",
+ "connector": "active",
+ "synchronization": "active",
+ "audit": "active",
+ "notification": "active",
+ },
+ }
diff --git a/pip-platform/src/api/v1/endpoints/users.py b/pip-platform/src/api/v1/endpoints/users.py
new file mode 100644
index 0000000..6dcea42
--- /dev/null
+++ b/pip-platform/src/api/v1/endpoints/users.py
@@ -0,0 +1,70 @@
+from datetime import datetime
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.core.exceptions import NotFoundException
+from src.domain.entities import TechnicalUserEntity, UserRole
+from src.domain.interfaces import ITechnicalUserRepository
+from src.infrastructure.database.repositories import TechnicalUserRepository
+from src.infrastructure.database.session import get_db_session
+from src.api.v1.dependencies.auth import get_current_user, require_role
+from src.services.auth.security import TokenData
+
+router = APIRouter(prefix="/users", tags=["Technical Users"])
+
+
+class UserResponse(BaseModel):
+ id: UUID
+ username: str
+ email: str | None
+ full_name: str | None
+ role: str
+ is_active: bool
+ scopes: list[str]
+ last_login_at: datetime | None
+ created_at: datetime | None
+
+
+class UserListResponse(BaseModel):
+ items: list[UserResponse]
+ total: int
+ offset: int
+ limit: int
+
+
+def _entity_to_response(e: TechnicalUserEntity) -> UserResponse:
+ return UserResponse(
+ id=e.id, username=e.username, email=e.email, full_name=e.full_name,
+ role=e.role, is_active=e.is_active, scopes=e.scopes,
+ last_login_at=e.last_login_at, created_at=e.created_at,
+ )
+
+
+@router.get("", response_model=UserListResponse)
+async def list_users(
+ role: str | None = None,
+ is_active: bool | None = None,
+ offset: int = Query(0, ge=0),
+ limit: int = Query(50, ge=1, le=200),
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = TechnicalUserRepository(session)
+ entities, total = await repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
+ return UserListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
+
+
+@router.get("/{user_id}", response_model=UserResponse)
+async def get_user(
+ user_id: UUID,
+ session: AsyncSession = Depends(get_db_session),
+ current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
+):
+ repo = TechnicalUserRepository(session)
+ entity = await repo.get_by_id(user_id)
+ if not entity:
+ raise NotFoundException("User", str(user_id))
+ return _entity_to_response(entity)
diff --git a/pip-platform/src/api/v1/router.py b/pip-platform/src/api/v1/router.py
new file mode 100644
index 0000000..d7e20db
--- /dev/null
+++ b/pip-platform/src/api/v1/router.py
@@ -0,0 +1,31 @@
+from fastapi import APIRouter
+
+from src.api.v1.endpoints.auth import router as auth_router
+from src.api.v1.endpoints.pharmacies import router as pharmacies_router
+from src.api.v1.endpoints.users import router as users_router
+from src.api.v1.endpoints.api_clients import router as api_clients_router
+from src.api.v1.endpoints.audit import router as audit_router
+from src.api.v1.endpoints.system import router as system_router
+from src.api.v1.endpoints.connectors import router as connectors_router
+from src.api.v1.endpoints.erp_systems import router as erp_systems_router
+from src.api.v1.endpoints.medications import router as medications_router
+from src.api.v1.endpoints.catalog import router as catalog_router
+from src.api.v1.endpoints.inventory import router as inventory_router
+from src.api.v1.endpoints.reservations import router as reservations_router
+from src.api.v1.endpoints.sync_jobs import router as sync_jobs_router
+
+v1_router = APIRouter(prefix="/api/v1")
+
+v1_router.include_router(auth_router)
+v1_router.include_router(pharmacies_router)
+v1_router.include_router(users_router)
+v1_router.include_router(api_clients_router)
+v1_router.include_router(audit_router)
+v1_router.include_router(system_router)
+v1_router.include_router(connectors_router)
+v1_router.include_router(erp_systems_router)
+v1_router.include_router(medications_router)
+v1_router.include_router(catalog_router)
+v1_router.include_router(inventory_router)
+v1_router.include_router(reservations_router)
+v1_router.include_router(sync_jobs_router)
diff --git a/pip-platform/src/cli.py b/pip-platform/src/cli.py
new file mode 100644
index 0000000..d928acf
--- /dev/null
+++ b/pip-platform/src/cli.py
@@ -0,0 +1,117 @@
+"""CLI entrypoints for the PIP platform."""
+import asyncio
+import sys
+
+import click
+
+from src.core.logging import configure_logging, get_logger
+from src.infrastructure.cache.redis_cache import redis_cache
+from src.infrastructure.config.settings import settings
+from src.infrastructure.database.session import async_session_factory, engine
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+from src.services.auth.auth_service import AuthService
+from src.domain.entities import UserRole
+
+
+@click.group()
+def cli():
+ """PIP platform CLI."""
+ configure_logging()
+
+
+@cli.command()
+def run():
+ """Start the API server."""
+ import uvicorn
+ uvicorn.run(
+ "src.main:app",
+ host="0.0.0.0",
+ port=8000,
+ reload=True if settings.DEBUG else False,
+ log_level=settings.LOG_LEVEL.lower(),
+ )
+
+
+@cli.command()
+def migrate_upgrade():
+ """Apply database migrations."""
+ from alembic.config import Config
+ from alembic import command
+
+ cfg = Config("alembic.ini")
+ command.upgrade(cfg, "head")
+ click.echo("Migrations applied.")
+
+
+@cli.command()
+def migrate_downgrade():
+ """Roll back the last database migration."""
+ from alembic.config import Config
+ from alembic import command
+
+ cfg = Config("alembic.ini")
+ command.downgrade(cfg, "-1")
+ click.echo("Rollback complete.")
+
+
+@cli.command()
+def migrate_revision():
+ """Create a new database revision."""
+ from alembic.config import Config
+ from alembic import command
+
+ message = click.prompt("Revision message")
+ cfg = Config("alembic.ini")
+ command.revision(cfg, autogenerate=True, message=message)
+ click.echo("Revision created.")
+
+
+@cli.command()
+@click.option("--username", required=True)
+@click.option("--password", required=True)
+@click.option("--email", default=None)
+@click.option("--role", default="admin", type=click.Choice([r.value for r in UserRole]))
+def create_user(username: str, password: str, email: str | None, role: str):
+ """Create a technical user."""
+ asyncio.run(_create_user(username, password, email, role))
+
+
+async def _create_user(username: str, password: str, email: str | None, role: str):
+ async with async_session_factory() as session:
+ service = AuthService(session)
+ user = await service.register(
+ username=username,
+ password=password,
+ email=email,
+ role=UserRole(role),
+ )
+ click.echo(f"Created user '{user.username}' with role '{user.role.value}'.")
+
+
+@cli.command()
+def health():
+ """Check platform health across services."""
+ asyncio.run(_health())
+
+
+async def _health():
+ logger = get_logger("cli.health")
+ async with engine.begin() as conn:
+ await conn.run_sync(lambda s: None)
+ click.echo("Database: OK")
+
+ await redis_cache.connect()
+ ok = await redis_cache.health_check()
+ click.echo(f"Redis: {'OK' if ok else 'FAIL'}")
+ await redis_cache.disconnect()
+
+ await rabbitmq_client.connect()
+ ok = await rabbitmq_client.health_check()
+ click.echo(f"RabbitMQ: {'OK' if ok else 'FAIL'}")
+ await rabbitmq_client.disconnect()
+
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/pip-platform/src/connectors/__init__.py b/pip-platform/src/connectors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/connectors/base/__init__.py b/pip-platform/src/connectors/base/__init__.py
new file mode 100644
index 0000000..5791c0f
--- /dev/null
+++ b/pip-platform/src/connectors/base/__init__.py
@@ -0,0 +1,17 @@
+from .base_connector import (
+ BaseInventoryConnector,
+ ConnectorResult,
+ StockInfo,
+ PharmacyInfo,
+ SyncResult,
+ ReservationResult,
+)
+
+__all__ = [
+ "BaseInventoryConnector",
+ "ConnectorResult",
+ "StockInfo",
+ "PharmacyInfo",
+ "SyncResult",
+ "ReservationResult",
+]
diff --git a/pip-platform/src/connectors/base/base_connector.py b/pip-platform/src/connectors/base/base_connector.py
new file mode 100644
index 0000000..54c9d1c
--- /dev/null
+++ b/pip-platform/src/connectors/base/base_connector.py
@@ -0,0 +1,78 @@
+from abc import ABC, abstractmethod
+from typing import Any
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class ConnectorResult(BaseModel):
+ success: bool
+ data: Any | None = None
+ error: str | None = None
+ metadata: dict | None = None
+
+
+class StockInfo(BaseModel):
+ medication_id: UUID
+ nregistro: str
+ name: str
+ stock: int
+ reserved_stock: int = 0
+ available_stock: int = 0
+ price: float | None = None
+ last_updated: str | None = None
+
+
+class PharmacyInfo(BaseModel):
+ pharmacy_id: UUID
+ code: str
+ name: str
+ address: str | None = None
+ phone: str | None = None
+ email: str | None = None
+ opening_hours: dict | None = None
+ latitude: float | None = None
+ longitude: float | None = None
+
+
+class SyncResult(BaseModel):
+ success: bool
+ items_total: int = 0
+ items_processed: int = 0
+ items_failed: int = 0
+ error_message: str | None = None
+ metadata: dict | None = None
+
+
+class ReservationResult(BaseModel):
+ success: bool
+ reservation_id: str | None = None
+ error: str | None = None
+
+
+class BaseInventoryConnector(ABC):
+ connector_type: str = "base"
+
+ @abstractmethod
+ async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
+ ...
diff --git a/pip-platform/src/connectors/farmatic/__init__.py b/pip-platform/src/connectors/farmatic/__init__.py
new file mode 100644
index 0000000..1ffc44b
--- /dev/null
+++ b/pip-platform/src/connectors/farmatic/__init__.py
@@ -0,0 +1,3 @@
+from src.connectors.farmatic.farmatic_connector import FarmaticConnector
+
+__all__ = ["FarmaticConnector"]
diff --git a/pip-platform/src/connectors/farmatic/farmatic_connector.py b/pip-platform/src/connectors/farmatic/farmatic_connector.py
new file mode 100644
index 0000000..480ec61
--- /dev/null
+++ b/pip-platform/src/connectors/farmatic/farmatic_connector.py
@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.connectors.sdk.base_sdk import SDKConnector
+from src.connectors.sdk.context import ConnectorContext
+from src.connectors.base import ConnectorResult
+from src.core.exceptions import ConnectorException
+from src.core.logging import get_logger
+from src.core.metrics import CONNECTOR_OPERATIONS
+
+logger = get_logger("farmatic_connector")
+
+
+class FarmaticConnector(SDKConnector):
+ connector_type: str = "farmatic"
+
+ def configure(self, config: dict[str, Any]) -> None:
+ self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1")
+ self._timeout = config.get("timeout", 45.0)
+ super().configure(config)
+
+ async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
+ try:
+ params: dict[str, Any] = {"pharmacy": str(pharmacy_id)}
+ if nregistro:
+ params["nregistro"] = nregistro
+ if medication_id:
+ params["medication_id"] = str(medication_id)
+
+ response = await self._request("GET", "/stock", params=params)
+ data = response.json()
+
+ self._record_metric("get_stock", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("get_stock", "error")
+ logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
+ try:
+ payload: dict[str, Any] = {
+ "pharmacy_id": str(pharmacy_id),
+ "medication_id": str(medication_id),
+ "quantity": quantity,
+ }
+ if idempotency_key:
+ payload["idempotency_key"] = idempotency_key
+
+ response = await self._request("POST", "/reservations", json=payload)
+ data = response.json()
+
+ self._record_metric("reserve", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("reserve", "error")
+ logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
+ try:
+ response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
+ data = response.json()
+
+ self._record_metric("cancel_reservation", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("cancel_reservation", "error")
+ logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)})
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
+ try:
+ response = await self._request("GET", f"/pharmacies/{pharmacy_id}")
+ data = response.json()
+
+ self._record_metric("pharmacy_information", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("pharmacy_information", "error")
+ logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
+ try:
+ payload: dict[str, Any] = {
+ "pharmacy_id": str(pharmacy_id),
+ "strategy": strategy,
+ }
+ if since:
+ payload["since"] = since
+
+ response = await self._request("POST", "/sync", json=payload)
+ data = response.json()
+
+ self._record_metric("synchronize", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("synchronize", "error")
+ logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
+ try:
+ response = await self._request("GET", "/health")
+ data = response.json()
+ data["connector_type"] = self.connector_type
+ data["pharmacy_id"] = str(pharmacy_id)
+ data["circuit_breaker"] = self._circuit_breaker.state.value
+
+ self._record_metric("heartbeat", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("heartbeat", "error")
+ return ConnectorResult(
+ success=False,
+ error=str(exc),
+ metadata={"circuit_breaker": self._circuit_breaker.state.value},
+ )
diff --git a/pip-platform/src/connectors/mock/__init__.py b/pip-platform/src/connectors/mock/__init__.py
new file mode 100644
index 0000000..b05c2aa
--- /dev/null
+++ b/pip-platform/src/connectors/mock/__init__.py
@@ -0,0 +1,3 @@
+from .mock_connector import MockConnector
+
+__all__ = ["MockConnector"]
diff --git a/pip-platform/src/connectors/mock/mock_connector.py b/pip-platform/src/connectors/mock/mock_connector.py
new file mode 100644
index 0000000..ec30abc
--- /dev/null
+++ b/pip-platform/src/connectors/mock/mock_connector.py
@@ -0,0 +1,215 @@
+import asyncio
+import random
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.connectors.base import BaseInventoryConnector, ConnectorResult
+from src.core.logging import get_logger
+from src.core.metrics import CONNECTOR_OPERATIONS
+
+logger = get_logger("mock_connector")
+
+
+class MockConnector(BaseInventoryConnector):
+ connector_type: str = "mock"
+
+ def __init__(self) -> None:
+ self._simulate_latency: bool = True
+ self._latency_range_ms: tuple[int, int] = (20, 150)
+ self._error_rate: float = 0.0
+ self._stock_data: dict[str, dict[str, Any]] = {}
+ self._reservations: dict[str, dict[str, Any]] = {}
+ self._pharmacies: dict[str, dict[str, Any]] = {}
+ self._init_mock_data()
+
+ def configure(self, config: dict[str, Any]) -> None:
+ self._simulate_latency = config.get("simulate_latency", True)
+ self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150]))
+ self._error_rate = config.get("error_rate", 0.0)
+
+ def _init_mock_data(self) -> None:
+ medicamentos = [
+ ("00001", "Ibuprofeno 600mg", 100),
+ ("00002", "Paracetamol 1g", 50),
+ ("00003", "Amoxicilina 500mg", 200),
+ ("00004", "Omeprazol 20mg", 75),
+ ("00005", "Loratadina 10mg", 120),
+ ("00006", "Diazepam 5mg", 30),
+ ("00007", "Metformina 850mg", 250),
+ ("00008", "Atorvastatina 20mg", 80),
+ ("00009", "Salbutamol Inhalador", 15),
+ ("00010", "Insulina Glargina", 40),
+ ]
+ for nregistro, name, stock in medicamentos:
+ reserved = random.randint(0, max(stock // 10, 1))
+ self._stock_data[nregistro] = {
+ "nregistro": nregistro,
+ "name": name,
+ "stock": stock,
+ "reserved_stock": reserved,
+ "available_stock": stock - reserved,
+ "price": round(random.uniform(2.5, 45.0), 2),
+ "last_updated": datetime.now(timezone.utc).isoformat(),
+ }
+
+ self._pharmacies["default"] = {
+ "code": "FARM-001",
+ "name": "Farmacia Mock Central",
+ "address": "Calle Mayor 15, Madrid",
+ "phone": "+34912345678",
+ "email": "central@mock-farmacia.es",
+ "opening_hours": {
+ "mon_fri": ["09:00", "21:30"],
+ "sat": ["10:00", "14:00"],
+ "sun": "closed",
+ },
+ "latitude": 40.4168,
+ "longitude": -3.7038,
+ }
+ self._pharmacies["second"] = {
+ "code": "FARM-002",
+ "name": "Farmacia Mock Norte",
+ "address": "Av. de la Ilustración 1, Madrid",
+ "phone": "+34915432100",
+ "email": "norte@mock-farmacia.es",
+ "opening_hours": {
+ "mon_fri": ["08:30", "22:00"],
+ "sat": ["09:00", "22:00"],
+ "sun": ["10:00", "14:00"],
+ },
+ "latitude": 40.4722,
+ "longitude": -3.6934,
+ }
+
+ async def _maybe_delay(self) -> None:
+ if self._simulate_latency:
+ low, high = self._latency_range_ms
+ delay = random.randint(low, high) / 1000.0
+ await asyncio.sleep(delay)
+
+ def _maybe_error(self) -> None:
+ if self._error_rate > 0 and random.random() < self._error_rate:
+ error_types = [
+ ("TIMEOUT", "Connection timed out"),
+ ("CONNECTION_REFUSED", "ERP server refused connection"),
+ ("AUTH_FAILED", "Authentication failed"),
+ ("RATE_LIMITED", "ERP rate limit exceeded"),
+ ("INTERNAL_ERROR", "ERP internal server error"),
+ ]
+ code, msg = random.choice(error_types)
+ from src.core.exceptions import ConnectorException
+ raise ConnectorException(self.connector_type, msg, details={"error_code": code})
+
+ def _metric(self, operation: str, status: str) -> None:
+ CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
+
+ async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
+ await self._maybe_delay()
+ self._maybe_error()
+ logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro})
+
+ if nregistro and nregistro in self._stock_data:
+ data = dict(self._stock_data[nregistro])
+ data["pharmacy_id"] = str(pharmacy_id)
+ self._metric("get_stock", "success")
+ return ConnectorResult(success=True, data=data)
+
+ if nregistro is None and medication_id is None:
+ results = [dict(v) for v in self._stock_data.values()]
+ for r in results:
+ r["pharmacy_id"] = str(pharmacy_id)
+ self._metric("get_stock", "success")
+ return ConnectorResult(success=True, data={"items": results, "total": len(results)})
+
+ self._metric("get_stock", "not_found")
+ return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data")
+
+ async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
+ await self._maybe_delay()
+ self._maybe_error()
+ logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity})
+
+ if idempotency_key and idempotency_key in self._reservations:
+ self._metric("reserve", "idempotent")
+ return ConnectorResult(success=True, data=self._reservations[idempotency_key])
+
+ reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}"
+ reservation_data = {
+ "reservation_id": reservation_id,
+ "status": "confirmed",
+ "quantity": quantity,
+ "pharmacy_id": str(pharmacy_id),
+ "medication_id": str(medication_id),
+ "confirmed_at": datetime.now(timezone.utc).isoformat(),
+ "expires_at": datetime.now(timezone.utc).isoformat(),
+ }
+ self._reservations[reservation_id] = reservation_data
+ if idempotency_key:
+ self._reservations[idempotency_key] = reservation_data
+
+ self._metric("reserve", "success")
+ return ConnectorResult(success=True, data=reservation_data)
+
+ async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
+ await self._maybe_delay()
+ self._maybe_error()
+ logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id})
+
+ if reservation_id in self._reservations:
+ self._reservations[reservation_id]["status"] = "cancelled"
+ self._metric("cancel_reservation", "success")
+ return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"})
+
+ self._metric("cancel_reservation", "not_found")
+ return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found")
+
+ async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
+ await self._maybe_delay()
+ self._maybe_error()
+ logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)})
+
+ pharmacy = list(self._pharmacies.values())[0]
+ result = dict(pharmacy)
+ result["pharmacy_id"] = str(pharmacy_id)
+ self._metric("pharmacy_information", "success")
+ return ConnectorResult(success=True, data=result)
+
+ async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
+ await self._maybe_delay()
+ self._maybe_error()
+ logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since})
+
+ items_total = len(self._stock_data)
+ items_processed = items_total
+ items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0
+ if items_failed > 0:
+ items_processed = items_total - items_failed
+
+ self._metric("synchronize", "success")
+ return ConnectorResult(
+ success=True,
+ data={
+ "items_total": items_total,
+ "items_processed": items_processed,
+ "items_failed": items_failed,
+ "strategy": strategy,
+ "synced_at": datetime.now(timezone.utc).isoformat(),
+ "pharmacy_id": str(pharmacy_id),
+ },
+ )
+
+ async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
+ self._metric("heartbeat", "success")
+ return ConnectorResult(
+ success=True,
+ data={
+ "status": "healthy",
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "connector_type": self.connector_type,
+ "pharmacy_id": str(pharmacy_id),
+ "circuit_breaker": "closed",
+ "stock_items": len(self._stock_data),
+ "active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]),
+ },
+ )
diff --git a/pip-platform/src/connectors/registry.py b/pip-platform/src/connectors/registry.py
new file mode 100644
index 0000000..de251a4
--- /dev/null
+++ b/pip-platform/src/connectors/registry.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import importlib
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.connectors.base import BaseInventoryConnector, ConnectorResult
+from src.core.exceptions import ConnectorException
+from src.core.logging import get_logger
+from src.core.metrics import CONNECTOR_OPERATIONS
+from src.domain.entities import ConnectorStatus, ERPType
+from src.infrastructure.cache.redis_cache import redis_cache
+from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+
+logger = get_logger("connector_registry")
+
+_BUILTIN_CONNECTORS: dict[str, str] = {
+ ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector",
+ ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector",
+ ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector",
+}
+
+
+class ConnectorRegistry:
+ _instance: ConnectorRegistry | None = None
+ _connectors: dict[str, BaseInventoryConnector]
+ _instances: dict[UUID, BaseInventoryConnector]
+
+ def __init__(self) -> None:
+ self._connectors = {}
+ self._instances = {}
+
+ @classmethod
+ def get_registry(cls) -> ConnectorRegistry:
+ if cls._instance is None:
+ cls._instance = cls()
+ return cls._instance
+
+ def register(self, connector_type: str, connector: BaseInventoryConnector) -> None:
+ self._connectors[connector_type] = connector
+ logger.info("connector_registered", extra={"connector_type": connector_type})
+
+ def unregister(self, connector_type: str) -> None:
+ self._connectors.pop(connector_type, None)
+ logger.info("connector_unregistered", extra={"connector_type": connector_type})
+
+ def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]:
+ if connector_type in self._connectors:
+ return type(self._connectors[connector_type])
+
+ class_path = _BUILTIN_CONNECTORS.get(connector_type)
+ if not class_path:
+ raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'")
+
+ try:
+ module_path, class_name = class_path.rsplit(".", 1)
+ module = importlib.import_module(module_path)
+ cls = getattr(module, class_name)
+ self._connectors[connector_type] = cls()
+ return cls
+ except (ImportError, AttributeError) as e:
+ raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e
+
+ def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
+ cls = self.get_connector_class(connector_type)
+ instance = cls()
+ if config and hasattr(instance, "configure"):
+ instance.configure(config)
+ return instance
+
+ def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
+ if connector_id in self._instances:
+ return self._instances[connector_id]
+ instance = self.create_instance(connector_type, config)
+ self._instances[connector_id] = instance
+ return instance
+
+ def remove_instance(self, connector_id: UUID) -> None:
+ self._instances.pop(connector_id, None)
+
+ def list_registered_types(self) -> list[str]:
+ all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys())
+ return sorted(all_types)
+
+ @staticmethod
+ async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None:
+ entity = await repo.get_by_id(connector_id)
+ if entity:
+ entity.last_heartbeat_at = datetime.now(timezone.utc)
+ entity.status = ConnectorStatus.ACTIVE
+ entity.last_error = None
+ await repo.update(entity)
+
+ @staticmethod
+ async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None:
+ entity = await repo.get_by_id(connector_id)
+ if entity:
+ entity.last_error = error
+ entity.status = ConnectorStatus.ERROR
+ await repo.update(entity)
+
+
+connector_registry = ConnectorRegistry.get_registry()
diff --git a/pip-platform/src/connectors/rest/__init__.py b/pip-platform/src/connectors/rest/__init__.py
new file mode 100644
index 0000000..53a0439
--- /dev/null
+++ b/pip-platform/src/connectors/rest/__init__.py
@@ -0,0 +1,3 @@
+from src.connectors.rest.rest_connector import RESTConnector
+
+__all__ = ["RESTConnector"]
diff --git a/pip-platform/src/connectors/rest/rest_connector.py b/pip-platform/src/connectors/rest/rest_connector.py
new file mode 100644
index 0000000..3937818
--- /dev/null
+++ b/pip-platform/src/connectors/rest/rest_connector.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from typing import Any
+from uuid import UUID
+
+from src.connectors.sdk.base_sdk import SDKConnector
+from src.connectors.base import ConnectorResult
+from src.core.logging import get_logger
+
+logger = get_logger("rest_connector")
+
+
+class RESTConnector(SDKConnector):
+ connector_type: str = "custom_rest"
+
+ _STOCK_PATH: str = "/stock"
+ _RESERVE_PATH: str = "/reservations"
+ _PHARMACY_PATH: str = "/pharmacy"
+ _SYNC_PATH: str = "/sync"
+ _HEALTH_PATH: str = "/health"
+
+ def configure(self, config: dict[str, Any]) -> None:
+ self._base_url = config.get("base_url", "")
+ self._timeout = config.get("timeout", 30.0)
+ self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH)
+ self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH)
+ self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH)
+ self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH)
+ self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH)
+ super().configure(config)
+
+ async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
+ try:
+ params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)}
+ if nregistro:
+ params["nregistro"] = nregistro
+ if medication_id:
+ params["medication_id"] = str(medication_id)
+ response = await self._request("GET", self._STOCK_PATH, params=params)
+ self._record_metric("get_stock", "success")
+ return ConnectorResult(success=True, data=response.json())
+ except Exception as exc:
+ self._record_metric("get_stock", "error")
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
+ try:
+ payload: dict[str, Any] = {
+ "pharmacy_id": str(pharmacy_id),
+ "medication_id": str(medication_id),
+ "quantity": quantity,
+ }
+ if idempotency_key:
+ payload["idempotency_key"] = idempotency_key
+ response = await self._request("POST", self._RESERVE_PATH, json=payload)
+ self._record_metric("reserve", "success")
+ return ConnectorResult(success=True, data=response.json())
+ except Exception as exc:
+ self._record_metric("reserve", "error")
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
+ try:
+ response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
+ self._record_metric("cancel_reservation", "success")
+ return ConnectorResult(success=True, data=response.json())
+ except Exception as exc:
+ self._record_metric("cancel_reservation", "error")
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
+ try:
+ response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}")
+ self._record_metric("pharmacy_information", "success")
+ return ConnectorResult(success=True, data=response.json())
+ except Exception as exc:
+ self._record_metric("pharmacy_information", "error")
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
+ try:
+ payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy}
+ if since:
+ payload["since"] = since
+ response = await self._request("POST", self._SYNC_PATH, json=payload)
+ self._record_metric("synchronize", "success")
+ return ConnectorResult(success=True, data=response.json())
+ except Exception as exc:
+ self._record_metric("synchronize", "error")
+ return ConnectorResult(success=False, error=str(exc))
+
+ async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
+ try:
+ response = await self._request("GET", self._HEALTH_PATH)
+ data = response.json()
+ data["connector_type"] = self.connector_type
+ data["pharmacy_id"] = str(pharmacy_id)
+ data["circuit_breaker"] = self._circuit_breaker.state.value
+ self._record_metric("heartbeat", "success")
+ return ConnectorResult(success=True, data=data)
+ except Exception as exc:
+ self._record_metric("heartbeat", "error")
+ return ConnectorResult(success=False, error=str(exc))
diff --git a/pip-platform/src/connectors/sdk/__init__.py b/pip-platform/src/connectors/sdk/__init__.py
new file mode 100644
index 0000000..e96b3df
--- /dev/null
+++ b/pip-platform/src/connectors/sdk/__init__.py
@@ -0,0 +1,19 @@
+from .context import ConnectorContext
+from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler
+from .retry import RetryPolicy, with_retry
+from .circuit_breaker import CircuitBreaker, CircuitState
+from .base_sdk import SDKConnector
+
+__all__ = [
+ "APIKeyAuthHandler",
+ "AuthHandler",
+ "BasicAuthHandler",
+ "BearerAuthHandler",
+ "CircuitBreaker",
+ "CircuitState",
+ "ConnectorContext",
+ "OAuth2Handler",
+ "RetryPolicy",
+ "SDKConnector",
+ "with_retry",
+]
diff --git a/pip-platform/src/connectors/sdk/auth.py b/pip-platform/src/connectors/sdk/auth.py
new file mode 100644
index 0000000..908fcf0
--- /dev/null
+++ b/pip-platform/src/connectors/sdk/auth.py
@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+import httpx
+
+from src.connectors.sdk.context import ConnectorContext
+from src.core.logging import get_logger
+
+logger = get_logger("connector_auth")
+
+
+class AuthHandler(ABC):
+ @abstractmethod
+ async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
+ ...
+
+ @abstractmethod
+ async def refresh(self, context: ConnectorContext) -> bool:
+ return True
+
+
+class BasicAuthHandler(AuthHandler):
+ def __init__(self, username_key: str = "username", password_key: str = "password") -> None:
+ self._username_key = username_key
+ self._password_key = password_key
+
+ async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
+ import base64
+
+ username = context.get_secret(self._username_key) or context.get_config(self._username_key, "")
+ password = context.get_secret(self._password_key) or context.get_config(self._password_key, "")
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
+ request.headers["Authorization"] = f"Basic {credentials}"
+ return request
+
+ async def refresh(self, context: ConnectorContext) -> bool:
+ return True
+
+
+class BearerAuthHandler(AuthHandler):
+ def __init__(self, token_key: str = "access_token") -> None:
+ self._token_key = token_key
+ self._token: str | None = None
+
+ async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
+ token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "")
+ if token:
+ request.headers["Authorization"] = f"Bearer {token}"
+ return request
+
+ async def refresh(self, context: ConnectorContext) -> bool:
+ self._token = None
+ return True
+
+
+class APIKeyAuthHandler(AuthHandler):
+ def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None:
+ self._header_name = header_name
+ self._key_secret = key_secret
+
+ async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
+ api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "")
+ if api_key:
+ request.headers[self._header_name] = api_key
+ return request
+
+ async def refresh(self, context: ConnectorContext) -> bool:
+ return True
+
+
+class OAuth2Handler(AuthHandler):
+ def __init__(
+ self,
+ token_url_key: str = "token_url",
+ client_id_key: str = "client_id",
+ client_secret_key: str = "client_secret",
+ ) -> None:
+ self._token_url_key = token_url_key
+ self._client_id_key = client_id_key
+ self._client_secret_key = client_secret_key
+ self._access_token: str | None = None
+ self._token_expires: float = 0
+
+ async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
+ import time
+
+ if not self._access_token or time.time() >= self._token_expires:
+ await self._fetch_token(context)
+
+ if self._access_token:
+ request.headers["Authorization"] = f"Bearer {self._access_token}"
+ return request
+
+ async def _fetch_token(self, context: ConnectorContext) -> None:
+ import time
+
+ token_url = context.require_config(self._token_url_key)
+ client_id = context.require_secret(self._client_id_key)
+ client_secret = context.require_secret(self._client_secret_key)
+
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(
+ token_url,
+ data={
+ "grant_type": "client_credentials",
+ "client_id": client_id,
+ "client_secret": client_secret,
+ },
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ self._access_token = data["access_token"]
+ self._token_expires = time.time() + data.get("expires_in", 3600) - 30
+
+ logger.info("oauth2_token_acquired", extra={"token_url": token_url})
+
+ async def refresh(self, context: ConnectorContext) -> bool:
+ self._access_token = None
+ self._token_expires = 0
+ return True
diff --git a/pip-platform/src/connectors/sdk/base_sdk.py b/pip-platform/src/connectors/sdk/base_sdk.py
new file mode 100644
index 0000000..b270053
--- /dev/null
+++ b/pip-platform/src/connectors/sdk/base_sdk.py
@@ -0,0 +1,137 @@
+from __future__ import annotations
+
+from abc import abstractmethod
+from typing import Any
+from uuid import UUID
+
+import httpx
+
+from src.connectors.base import BaseInventoryConnector, ConnectorResult
+from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler
+from src.connectors.sdk.circuit_breaker import CircuitBreaker
+from src.connectors.sdk.context import ConnectorContext
+from src.connectors.sdk.retry import RetryPolicy, with_retry
+from src.core.exceptions import ConnectorException
+from src.core.logging import get_logger
+from src.core.metrics import CONNECTOR_OPERATIONS
+
+logger = get_logger("sdk_connector")
+
+
+class SDKConnector(BaseInventoryConnector):
+ connector_type: str = "sdk"
+
+ def __init__(self) -> None:
+ self._context: ConnectorContext | None = None
+ self._auth_handler: AuthHandler = BearerAuthHandler()
+ self._retry_policy: RetryPolicy = RetryPolicy()
+ self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type)
+ self._http_client: httpx.AsyncClient | None = None
+ self._base_url: str = ""
+ self._timeout: float = 30.0
+
+ def configure(self, config: dict[str, Any]) -> None:
+ self._base_url = config.get("base_url", "")
+ self._timeout = config.get("timeout", 30.0)
+ auth_type = config.get("auth_type", "bearer")
+ if auth_type == "basic":
+ from src.connectors.sdk.auth import BasicAuthHandler
+ self._auth_handler = BasicAuthHandler()
+ elif auth_type == "api_key":
+ from src.connectors.sdk.auth import APIKeyAuthHandler
+ self._auth_handler = APIKeyAuthHandler(
+ header_name=config.get("api_key_header", "X-API-Key"),
+ key_secret=config.get("api_key_secret_key", "api_key"),
+ )
+ elif auth_type == "oauth2":
+ from src.connectors.sdk.auth import OAuth2Handler
+ self._auth_handler = OAuth2Handler()
+
+ cb_config = config.get("circuit_breaker", {})
+ if cb_config:
+ self._circuit_breaker = CircuitBreaker(
+ name=self.connector_type,
+ failure_threshold=cb_config.get("failure_threshold", 5),
+ recovery_timeout=cb_config.get("recovery_timeout", 30.0),
+ )
+
+ retry_config = config.get("retry", {})
+ if retry_config:
+ self._retry_policy = RetryPolicy(
+ max_attempts=retry_config.get("max_attempts", 3),
+ base_delay=retry_config.get("base_delay", 1.0),
+ max_delay=retry_config.get("max_delay", 30.0),
+ )
+
+ def set_context(self, context: ConnectorContext) -> None:
+ self._context = context
+ if context.config:
+ self.configure(context.config)
+
+ @property
+ def http_client(self) -> httpx.AsyncClient:
+ if self._http_client is None or self._http_client.is_closed:
+ self._http_client = httpx.AsyncClient(
+ base_url=self._base_url,
+ timeout=self._timeout,
+ )
+ return self._http_client
+
+ async def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ json: dict[str, Any] | None = None,
+ params: dict[str, Any] | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> httpx.Response:
+ if not self._context:
+ raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.")
+
+ async def _do_request() -> httpx.Response:
+ request = self.http_client.build_request(method, path, json=json, params=params, headers=headers)
+ request = await self._auth_handler.apply(request, self._context)
+ response = await self.http_client.send(request)
+ response.raise_for_status()
+ return response
+
+ return await self._circuit_breaker.call(
+ with_retry,
+ _do_request,
+ policy=self._retry_policy,
+ connector_type=self.connector_type,
+ operation=f"{method}:{path}",
+ )
+
+ def _record_metric(self, operation: str, status: str) -> None:
+ CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
+
+ async def close(self) -> None:
+ if self._http_client and not self._http_client.is_closed:
+ await self._http_client.aclose()
+ self._http_client = None
+
+ @abstractmethod
+ async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
+ ...
+
+ @abstractmethod
+ async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
+ ...
diff --git a/pip-platform/src/connectors/sdk/circuit_breaker.py b/pip-platform/src/connectors/sdk/circuit_breaker.py
new file mode 100644
index 0000000..deb56f1
--- /dev/null
+++ b/pip-platform/src/connectors/sdk/circuit_breaker.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import time
+from enum import Enum
+from typing import Any, Callable
+
+from src.core.exceptions import ConnectorException, ServiceUnavailableException
+from src.core.logging import get_logger
+
+logger = get_logger("circuit_breaker")
+
+
+class CircuitState(str, Enum):
+ CLOSED = "closed"
+ OPEN = "open"
+ HALF_OPEN = "half_open"
+
+
+class CircuitBreaker:
+ def __init__(
+ self,
+ name: str,
+ failure_threshold: int = 5,
+ recovery_timeout: float = 30.0,
+ half_open_max_calls: int = 1,
+ ) -> None:
+ self._name = name
+ self._failure_threshold = failure_threshold
+ self._recovery_timeout = recovery_timeout
+ self._half_open_max_calls = half_open_max_calls
+
+ self._state = CircuitState.CLOSED
+ self._failure_count = 0
+ self._success_count = 0
+ self._last_failure_time: float = 0
+ self._half_open_calls: int = 0
+
+ @property
+ def state(self) -> CircuitState:
+ if self._state == CircuitState.OPEN:
+ if time.monotonic() - self._last_failure_time >= self._recovery_timeout:
+ self._state = CircuitState.HALF_OPEN
+ self._half_open_calls = 0
+ logger.info("circuit_half_open", extra={"name": self._name})
+ return self._state
+
+ async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
+ current_state = self.state
+
+ if current_state == CircuitState.OPEN:
+ raise ServiceUnavailableException(
+ f"circuit_breaker:{self._name}",
+ f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.",
+ )
+
+ if current_state == CircuitState.HALF_OPEN:
+ if self._half_open_calls >= self._half_open_max_calls:
+ raise ServiceUnavailableException(
+ f"circuit_breaker:{self._name}",
+ f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.",
+ )
+ self._half_open_calls += 1
+
+ try:
+ result = await func(*args, **kwargs)
+ self._on_success()
+ return result
+ except Exception as exc:
+ self._on_failure(exc)
+ raise
+
+ def _on_success(self) -> None:
+ if self._state == CircuitState.HALF_OPEN:
+ self._state = CircuitState.CLOSED
+ logger.info("circuit_closed", extra={"name": self._name})
+ self._failure_count = 0
+ self._success_count += 1
+
+ def _on_failure(self, exc: Exception) -> None:
+ self._failure_count += 1
+ self._last_failure_time = time.monotonic()
+ self._success_count = 0
+
+ if self._state == CircuitState.HALF_OPEN:
+ self._state = CircuitState.OPEN
+ logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)})
+ elif self._failure_count >= self._failure_threshold:
+ self._state = CircuitState.OPEN
+ logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count})
+
+ def reset(self) -> None:
+ self._state = CircuitState.CLOSED
+ self._failure_count = 0
+ self._success_count = 0
+ self._last_failure_time = 0
+
+ def get_status(self) -> dict[str, Any]:
+ return {
+ "name": self._name,
+ "state": self.state.value,
+ "failure_count": self._failure_count,
+ "success_count": self._success_count,
+ "failure_threshold": self._failure_threshold,
+ "recovery_timeout": self._recovery_timeout,
+ "last_failure_time": self._last_failure_time,
+ }
diff --git a/pip-platform/src/connectors/sdk/context.py b/pip-platform/src/connectors/sdk/context.py
new file mode 100644
index 0000000..1d95bfd
--- /dev/null
+++ b/pip-platform/src/connectors/sdk/context.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+from uuid import UUID
+
+
+@dataclass
+class ConnectorContext:
+ connector_id: UUID
+ pharmacy_id: UUID
+ erp_system_id: UUID
+ connector_type: str
+ config: dict[str, Any] = field(default_factory=dict)
+ secrets: dict[str, str] = field(default_factory=dict)
+
+ _cache_prefix: str = field(default="connector", init=False, repr=False)
+
+ @property
+ def cache_key_prefix(self) -> str:
+ return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}"
+
+ def cache_key(self, suffix: str) -> str:
+ return f"{self.cache_key_prefix}:{suffix}"
+
+ def get_config(self, key: str, default: Any = None) -> Any:
+ return self.config.get(key, default)
+
+ def get_secret(self, key: str) -> str | None:
+ return self.secrets.get(key)
+
+ def require_secret(self, key: str) -> str:
+ value = self.secrets.get(key)
+ if value is None:
+ raise ValueError(f"Required secret '{key}' not found in connector context")
+ return value
+
+ def require_config(self, key: str) -> Any:
+ value = self.config.get(key)
+ if value is None:
+ raise ValueError(f"Required config '{key}' not found in connector context")
+ return value
diff --git a/pip-platform/src/connectors/sdk/retry.py b/pip-platform/src/connectors/sdk/retry.py
new file mode 100644
index 0000000..a21b7d5
--- /dev/null
+++ b/pip-platform/src/connectors/sdk/retry.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from functools import wraps
+from typing import Any, Callable, TypeVar
+
+from src.core.exceptions import ConnectorException
+from src.core.logging import get_logger
+
+logger = get_logger("connector_retry")
+
+T = TypeVar("T")
+
+DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError)
+
+
+@dataclass
+class RetryPolicy:
+ max_attempts: int = 3
+ base_delay: float = 1.0
+ max_delay: float = 30.0
+ exponential_base: float = 2.0
+ jitter: bool = True
+ retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS)
+
+ def delay_for_attempt(self, attempt: int) -> float:
+ import random
+
+ delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay)
+ if self.jitter:
+ delay = delay * (0.5 + random.random() * 0.5)
+ return delay
+
+ def should_retry(self, exception: Exception) -> bool:
+ if isinstance(exception, ConnectorException):
+ return False
+ return isinstance(exception, self.retryable_exceptions)
+
+
+async def with_retry(
+ func: Callable[..., Any],
+ *args: Any,
+ policy: RetryPolicy | None = None,
+ connector_type: str = "unknown",
+ operation: str = "unknown",
+ **kwargs: Any,
+) -> Any:
+ p = policy or RetryPolicy()
+ last_exception: Exception | None = None
+
+ for attempt in range(1, p.max_attempts + 1):
+ try:
+ result = await func(*args, **kwargs)
+ if attempt > 1:
+ logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt})
+ return result
+ except Exception as exc:
+ last_exception = exc
+ if not p.should_retry(exc) or attempt >= p.max_attempts:
+ break
+ delay = p.delay_for_attempt(attempt)
+ logger.warning(
+ "retry_attempt",
+ extra={
+ "connector_type": connector_type,
+ "operation": operation,
+ "attempt": attempt,
+ "delay_s": round(delay, 2),
+ "error": str(exc),
+ },
+ )
+ await asyncio.sleep(delay)
+
+ raise last_exception # type: ignore[misc]
+
+
+def retry(policy: RetryPolicy | None = None):
+ p = policy or RetryPolicy()
+
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+ @wraps(func)
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
+ connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown"
+ return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs)
+ return wrapper
+ return decorator
diff --git a/pip-platform/src/core/__init__.py b/pip-platform/src/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/core/exceptions.py b/pip-platform/src/core/exceptions.py
new file mode 100644
index 0000000..14418a7
--- /dev/null
+++ b/pip-platform/src/core/exceptions.py
@@ -0,0 +1,77 @@
+from datetime import datetime, timezone
+
+
+class PIPException(Exception):
+ def __init__(self, message: str, code: str = "PIP_ERROR", status_code: int = 500, details: dict | None = None):
+ super().__init__(message)
+ self.message = message
+ self.code = code
+ self.status_code = status_code
+ self.details = details or {}
+ self.timestamp = datetime.now(timezone.utc).isoformat()
+
+ def to_dict(self) -> dict:
+ return {
+ "error": {
+ "code": self.code,
+ "message": self.message,
+ "details": self.details,
+ "timestamp": self.timestamp,
+ }
+ }
+
+
+class NotFoundException(PIPException):
+ def __init__(self, resource: str, resource_id: str | None = None):
+ msg = f"{resource} not found"
+ if resource_id:
+ msg = f"{resource} '{resource_id}' not found"
+ super().__init__(message=msg, code="NOT_FOUND", status_code=404, details={"resource": resource, "resource_id": resource_id})
+
+
+class ConflictException(PIPException):
+ def __init__(self, message: str, details: dict | None = None):
+ super().__init__(message=message, code="CONFLICT", status_code=409, details=details)
+
+
+class ValidationException(PIPException):
+ def __init__(self, message: str, details: dict | None = None):
+ super().__init__(message=message, code="VALIDATION_ERROR", status_code=422, details=details)
+
+
+class UnauthorizedException(PIPException):
+ def __init__(self, message: str = "Authentication required"):
+ super().__init__(message=message, code="UNAUTHORIZED", status_code=401)
+
+
+class ForbiddenException(PIPException):
+ def __init__(self, message: str = "Insufficient permissions", required_role: str | None = None):
+ details = {}
+ if required_role:
+ details["required_role"] = required_role
+ super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details)
+
+
+class RateLimitException(PIPException):
+ def __init__(self, message: str = "Rate limit exceeded"):
+ super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429)
+
+
+class ServiceUnavailableException(PIPException):
+ def __init__(self, service: str, message: str | None = None):
+ msg = message or f"Service '{service}' is unavailable"
+ super().__init__(message=msg, code="SERVICE_UNAVAILABLE", status_code=503, details={"service": service})
+
+
+class ConnectorException(PIPException):
+ def __init__(self, connector_type: str, message: str, details: dict | None = None):
+ d = details or {}
+ d["connector_type"] = connector_type
+ super().__init__(message=message, code="CONNECTOR_ERROR", status_code=502, details=d)
+
+
+class SyncException(PIPException):
+ def __init__(self, pharmacy_id: str, message: str, details: dict | None = None):
+ d = details or {}
+ d["pharmacy_id"] = pharmacy_id
+ super().__init__(message=message, code="SYNC_ERROR", status_code=502, details=d)
diff --git a/pip-platform/src/core/logging.py b/pip-platform/src/core/logging.py
new file mode 100644
index 0000000..6a54cd0
--- /dev/null
+++ b/pip-platform/src/core/logging.py
@@ -0,0 +1,56 @@
+import logging
+import sys
+from typing import Any
+
+import structlog
+
+from src.infrastructure.config.settings import settings
+
+
+def configure_logging() -> None:
+ shared_processors: list[Any] = [
+ structlog.contextvars.merge_contextvars,
+ structlog.stdlib.add_log_level,
+ structlog.stdlib.add_logger_name,
+ structlog.processors.TimeStamper(fmt="iso"),
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.UnicodeDecoder(),
+ ]
+
+ if settings.LOG_JSON_FORMAT:
+ renderer = structlog.processors.JSONRenderer()
+ else:
+ renderer = structlog.dev.ConsoleRenderer()
+
+ structlog.configure(
+ processors=[
+ *shared_processors,
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
+ ],
+ logger_factory=structlog.stdlib.LoggerFactory(),
+ wrapper_class=structlog.stdlib.BoundLogger,
+ cache_logger_on_first_use=True,
+ )
+
+ formatter = structlog.stdlib.ProcessorFormatter(
+ processors=[
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+ renderer,
+ ],
+ foreign_pre_chain=shared_processors,
+ )
+
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(formatter)
+
+ root_logger = logging.getLogger()
+ root_logger.handlers.clear()
+ root_logger.addHandler(handler)
+ root_logger.setLevel(settings.LOG_LEVEL.upper())
+
+ for noisy in ("uvicorn.access", "sqlalchemy.engine", "httpx", "aioredis"):
+ logging.getLogger(noisy).setLevel(logging.WARNING)
+
+
+def get_logger(name: str | None = None) -> Any:
+ return structlog.get_logger(name)
diff --git a/pip-platform/src/core/metrics.py b/pip-platform/src/core/metrics.py
new file mode 100644
index 0000000..5c3792e
--- /dev/null
+++ b/pip-platform/src/core/metrics.py
@@ -0,0 +1,94 @@
+import time
+from collections.abc import Callable
+from typing import Any
+
+from fastapi import Request, Response
+from prometheus_client import Counter, Gauge, Histogram
+
+REQUEST_COUNT = Counter(
+ "pip_http_requests_total",
+ "Total HTTP requests",
+ ["method", "endpoint", "status_code"],
+)
+
+REQUEST_DURATION = Histogram(
+ "pip_http_request_duration_seconds",
+ "HTTP request duration in seconds",
+ ["method", "endpoint"],
+ buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
+)
+
+REQUESTS_IN_PROGRESS = Gauge(
+ "pip_http_requests_in_progress",
+ "HTTP requests currently in progress",
+ ["method", "endpoint"],
+)
+
+DB_QUERY_DURATION = Histogram(
+ "pip_db_query_duration_seconds",
+ "Database query duration in seconds",
+ ["operation"],
+ buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
+)
+
+CONNECTOR_OPERATIONS = Counter(
+ "pip_connector_operations_total",
+ "Total connector operations",
+ ["connector_type", "operation", "status"],
+)
+
+SYNC_JOBS = Counter(
+ "pip_sync_jobs_total",
+ "Total sync jobs",
+ ["strategy", "status"],
+)
+
+SYNC_DURATION = Histogram(
+ "pip_sync_duration_seconds",
+ "Sync job duration in seconds",
+ ["strategy"],
+ buckets=[1.0, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0],
+)
+
+ACTIVE_CONNECTIONS = Gauge(
+ "pip_active_connections",
+ "Active infrastructure connections",
+ ["type"],
+)
+
+CACHE_OPERATIONS = Counter(
+ "pip_cache_operations_total",
+ "Cache operations",
+ ["operation", "result"],
+)
+
+RESERVATION_OPERATIONS = Counter(
+ "pip_reservation_operations_total",
+ "Reservation operations",
+ ["operation", "status"],
+)
+
+
+async def metrics_middleware(request: Request, call_next: Callable) -> Response:
+ if request.url.path == "/metrics" or request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"):
+ return await call_next(request)
+
+ method = request.method
+ endpoint = request.url.path
+
+ REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).inc()
+
+ start_time = time.perf_counter()
+ try:
+ response = await call_next(request)
+ status_code = response.status_code
+ except Exception:
+ status_code = 500
+ raise
+ finally:
+ duration = time.perf_counter() - start_time
+ REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc()
+ REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration)
+ REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).dec()
+
+ return response
diff --git a/pip-platform/src/core/tracing.py b/pip-platform/src/core/tracing.py
new file mode 100644
index 0000000..80842ab
--- /dev/null
+++ b/pip-platform/src/core/tracing.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from opentelemetry import trace
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
+from opentelemetry.instrumentation.redis import RedisInstrumentor
+from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+
+from src.infrastructure.config.settings import settings
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncEngine
+
+
+def setup_tracing(app: Any = None, engine: AsyncEngine | None = None) -> None:
+ if not settings.OTEL_TRACES_ENABLED:
+ return
+
+ resource = Resource.create({"service.name": settings.OTEL_SERVICE_NAME})
+
+ provider = TracerProvider(resource=resource)
+
+ otlp_exporter = OTLPSpanExporter(endpoint=settings.OTEL_EXPORTER_OTLP_ENDPOINT)
+ provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
+
+ trace.set_tracer_provider(provider)
+
+ if engine is not None:
+ sync_engine = engine.sync_engine if hasattr(engine, "sync_engine") else engine
+ SQLAlchemyInstrumentor().instrument(engine=sync_engine)
+ RedisInstrumentor().instrument()
+
+ if app is not None:
+ FastAPIInstrumentor.instrument_app(app)
+
+
+def get_tracer(name: str = "pip-platform") -> trace.Tracer:
+ return trace.get_tracer(name)
diff --git a/pip-platform/src/domain/__init__.py b/pip-platform/src/domain/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/domain/entities/__init__.py b/pip-platform/src/domain/entities/__init__.py
new file mode 100644
index 0000000..619134d
--- /dev/null
+++ b/pip-platform/src/domain/entities/__init__.py
@@ -0,0 +1,51 @@
+from .entities import (
+ APIClientEntity,
+ APIKeyEntity,
+ AuditLogEntity,
+ ConnectorConfigEntity,
+ ConnectorEntity,
+ ConnectorStatus,
+ ConnectorVersionEntity,
+ ERPType,
+ ERPSystemEntity,
+ InventoryHistoryEntity,
+ InventoryRecordEntity,
+ MedicationCatalogEntryEntity,
+ MedicationEntity,
+ PharmacyEntity,
+ PharmacyStatus,
+ ReservationEntity,
+ ReservationStatus,
+ SyncJobEntity,
+ SyncJobStatus,
+ SyncLogEntity,
+ SyncStrategy,
+ TechnicalUserEntity,
+ UserRole,
+)
+
+__all__ = [
+ "APIClientEntity",
+ "APIKeyEntity",
+ "AuditLogEntity",
+ "ConnectorConfigEntity",
+ "ConnectorEntity",
+ "ConnectorStatus",
+ "ConnectorVersionEntity",
+ "ERPType",
+ "ERPSystemEntity",
+ "InventoryHistoryEntity",
+ "InventoryRecordEntity",
+ "MedicationCatalogEntryEntity",
+ "MedicationEntity",
+ "PharmacyEntity",
+ "PharmacyStatus",
+ "ReservationEntity",
+ "ReservationStatus",
+ "SyncJobEntity",
+ "SyncJobStatus",
+ "SyncLogEntity",
+ "SyncStrategy",
+ "TechnicalUserEntity",
+ "UserRole",
+]
diff --git a/pip-platform/src/domain/entities/entities.py b/pip-platform/src/domain/entities/entities.py
new file mode 100644
index 0000000..4873f7a
--- /dev/null
+++ b/pip-platform/src/domain/entities/entities.py
@@ -0,0 +1,317 @@
+import uuid
+from datetime import datetime
+from enum import Enum
+
+from pydantic import BaseModel, Field
+
+
+class PharmacyStatus(str, Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ SUSPENDED = "suspended"
+ PENDING = "pending"
+
+
+class ERPType(str, Enum):
+ FARMATIC = "farmatic"
+ NIXFARMA = "nixfarma"
+ UNYCOP = "unycop"
+ CUSTOM_REST = "custom_rest"
+ CUSTOM_SOAP = "custom_soap"
+ CSV = "csv"
+ FTP = "ftp"
+ SQL = "sql"
+ MOCK = "mock"
+
+
+class ConnectorStatus(str, Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ ERROR = "error"
+ PENDING = "pending"
+ UPDATING = "updating"
+
+
+class SyncStrategy(str, Enum):
+ FULL = "full"
+ INCREMENTAL = "incremental"
+ EVENT_DRIVEN = "event_driven"
+ SCHEDULED = "scheduled"
+ HYBRID = "hybrid"
+
+
+class SyncJobStatus(str, Enum):
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+class ReservationStatus(str, Enum):
+ PENDING = "pending"
+ CONFIRMED = "confirmed"
+ CANCELLED = "cancelled"
+ EXPIRED = "expired"
+
+
+class UserRole(str, Enum):
+ SUPERADMIN = "superadmin"
+ ADMIN = "admin"
+ OPERATOR = "operator"
+ VIEWER = "viewer"
+ API_CLIENT = "api_client"
+
+
+class PharmacyEntity(BaseModel):
+ id: uuid.UUID | None = None
+ code: str
+ name: str
+ address: str | None = None
+ city: str | None = None
+ province: str | None = None
+ postal_code: str | None = None
+ country: str = "ES"
+ latitude: float | None = None
+ longitude: float | None = None
+ phone: str | None = None
+ email: str | None = None
+ opening_hours: dict | None = None
+ status: PharmacyStatus = PharmacyStatus.PENDING
+ erp_type: ERPType | None = None
+ erp_version: str | None = None
+ connector_id: uuid.UUID | None = None
+ sync_strategy: SyncStrategy = SyncStrategy.INCREMENTAL
+ sync_interval_minutes: int = 30
+ last_sync_at: datetime | None = None
+ last_sync_status: SyncJobStatus | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class ERPSystemEntity(BaseModel):
+ id: uuid.UUID | None = None
+ name: str
+ erp_type: ERPType
+ version: str | None = None
+ base_url: str | None = None
+ auth_config: dict | None = None
+ status: ConnectorStatus = ConnectorStatus.PENDING
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class ConnectorEntity(BaseModel):
+ id: uuid.UUID | None = None
+ erp_system_id: uuid.UUID
+ pharmacy_id: uuid.UUID
+ name: str
+ connector_type: ERPType
+ version: str = "1.0.0"
+ config: dict | None = None
+ status: ConnectorStatus = ConnectorStatus.PENDING
+ last_heartbeat_at: datetime | None = None
+ last_error: str | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class ConnectorVersionEntity(BaseModel):
+ id: uuid.UUID | None = None
+ connector_id: uuid.UUID | None = None
+ connector_type: ERPType
+ version: str
+ changelog: str | None = None
+ download_url: str | None = None
+ checksum: str | None = None
+ is_latest: bool = False
+ is_required: bool = False
+ released_at: datetime | None = None
+ created_at: datetime | None = None
+
+
+class ConnectorConfigEntity(BaseModel):
+ id: uuid.UUID | None = None
+ connector_id: uuid.UUID
+ key: str
+ value: str
+ encrypted: bool = False
+ description: str | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+
+class MedicationEntity(BaseModel):
+ id: uuid.UUID | None = None
+ nregistro: str
+ name: str
+ active_ingredient: str | None = None
+ dosage: str | None = None
+ form: str | None = None
+ atc_code: str | None = None
+ prescription_required: bool = False
+ manufacturer: str | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class MedicationCatalogEntryEntity(BaseModel):
+ id: uuid.UUID | None = None
+ pharmacy_id: uuid.UUID
+ medication_id: uuid.UUID
+ price: float | None = None
+ stock: int = 0
+ is_available: bool = False
+ last_verified_at: datetime | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class InventoryRecordEntity(BaseModel):
+ id: uuid.UUID | None = None
+ pharmacy_id: uuid.UUID
+ medication_id: uuid.UUID
+ stock: int = 0
+ reserved_stock: int = 0
+ available_stock: int = 0
+ min_stock: int | None = None
+ max_stock: int | None = None
+ last_updated_at: datetime | None = None
+ source: str | None = None
+ batch_id: str | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class InventoryHistoryEntity(BaseModel):
+ id: uuid.UUID | None = None
+ inventory_record_id: uuid.UUID | None = None
+ pharmacy_id: uuid.UUID
+ medication_id: uuid.UUID
+ previous_stock: int
+ new_stock: int
+ delta: int
+ reason: str | None = None
+ source: str | None = None
+ created_at: datetime | None = None
+
+
+class ReservationEntity(BaseModel):
+ id: uuid.UUID | None = None
+ pharmacy_id: uuid.UUID
+ medication_id: uuid.UUID
+ user_id: uuid.UUID | None = None
+ external_reference: str | None = None
+ quantity: int
+ status: ReservationStatus = ReservationStatus.PENDING
+ expires_at: datetime | None = None
+ confirmed_at: datetime | None = None
+ cancelled_at: datetime | None = None
+ idempotency_key: str | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class SyncJobEntity(BaseModel):
+ id: uuid.UUID | None = None
+ pharmacy_id: uuid.UUID
+ connector_id: uuid.UUID
+ strategy: SyncStrategy
+ status: SyncJobStatus = SyncJobStatus.PENDING
+ started_at: datetime | None = None
+ completed_at: datetime | None = None
+ items_total: int | None = None
+ items_processed: int | None = None
+ items_failed: int | None = None
+ error_message: str | None = None
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class SyncLogEntity(BaseModel):
+ id: uuid.UUID | None = None
+ sync_job_id: uuid.UUID
+ level: str = "INFO"
+ message: str
+ details: dict | None = None
+ created_at: datetime | None = None
+
+
+class AuditLogEntity(BaseModel):
+ id: uuid.UUID | None = None
+ actor_id: uuid.UUID | None = None
+ actor_type: str | None = None
+ action: str
+ resource_type: str
+ resource_id: str | None = None
+ old_value: dict | None = None
+ new_value: dict | None = None
+ ip_address: str | None = None
+ user_agent: str | None = None
+ request_id: str | None = None
+ created_at: datetime | None = None
+
+
+class TechnicalUserEntity(BaseModel):
+ id: uuid.UUID | None = None
+ username: str
+ email: str | None = None
+ full_name: str | None = None
+ role: UserRole = UserRole.VIEWER
+ is_active: bool = True
+ scopes: list[str] = Field(default_factory=list)
+ last_login_at: datetime | None = None
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+
+class APIClientEntity(BaseModel):
+ id: uuid.UUID | None = None
+ name: str
+ description: str | None = None
+ owner_id: uuid.UUID | None = None
+ is_active: bool = True
+ scopes: list[str] = Field(default_factory=list)
+ rate_limit: int | None = None
+ allowed_ips: list[str] = Field(default_factory=list)
+ metadata_: dict | None = Field(default=None, alias="metadata")
+ created_at: datetime | None = None
+ updated_at: datetime | None = None
+
+ model_config = {"populate_by_name": True}
+
+
+class APIKeyEntity(BaseModel):
+ id: uuid.UUID | None = None
+ api_client_id: uuid.UUID
+ key_prefix: str
+ key_hash: bytes
+ name: str
+ is_active: bool = True
+ expires_at: datetime | None = None
+ last_used_at: datetime | None = None
+ created_at: datetime | None = None
diff --git a/pip-platform/src/domain/interfaces/__init__.py b/pip-platform/src/domain/interfaces/__init__.py
new file mode 100644
index 0000000..af807da
--- /dev/null
+++ b/pip-platform/src/domain/interfaces/__init__.py
@@ -0,0 +1,35 @@
+from .repositories import (
+ IAPIClientRepository,
+ IAPIKeyRepository,
+ IAuditLogRepository,
+ IConnectorConfigRepository,
+ IConnectorRepository,
+ IConnectorVersionRepository,
+ IERPSystemRepository,
+ IInventoryRepository,
+ IMedicationCatalogRepository,
+ IMedicationRepository,
+ IPharmacyRepository,
+ IReservationRepository,
+ ISyncJobRepository,
+ ISyncLogRepository,
+ ITechnicalUserRepository,
+)
+
+__all__ = [
+ "IAPIClientRepository",
+ "IAPIKeyRepository",
+ "IAuditLogRepository",
+ "IConnectorConfigRepository",
+ "IConnectorRepository",
+ "IConnectorVersionRepository",
+ "IERPSystemRepository",
+ "IInventoryRepository",
+ "IMedicationCatalogRepository",
+ "IMedicationRepository",
+ "IPharmacyRepository",
+ "IReservationRepository",
+ "ISyncJobRepository",
+ "ISyncLogRepository",
+ "ITechnicalUserRepository",
+]
diff --git a/pip-platform/src/domain/interfaces/repositories.py b/pip-platform/src/domain/interfaces/repositories.py
new file mode 100644
index 0000000..9e94ba7
--- /dev/null
+++ b/pip-platform/src/domain/interfaces/repositories.py
@@ -0,0 +1,249 @@
+import uuid
+from abc import ABC, abstractmethod
+from collections.abc import Sequence
+from typing import Any
+
+from src.domain.entities import (
+ APIClientEntity,
+ APIKeyEntity,
+ AuditLogEntity,
+ ConnectorConfigEntity,
+ ConnectorEntity,
+ ConnectorVersionEntity,
+ ERPSystemEntity,
+ InventoryHistoryEntity,
+ InventoryRecordEntity,
+ MedicationCatalogEntryEntity,
+ MedicationEntity,
+ PharmacyEntity,
+ ReservationEntity,
+ SyncJobEntity,
+ SyncLogEntity,
+ TechnicalUserEntity,
+)
+
+
+class IPharmacyRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: ...
+
+ @abstractmethod
+ async def get_by_code(self, code: str) -> PharmacyEntity | None: ...
+
+ @abstractmethod
+ async def list_pharmacies(
+ self,
+ *,
+ status: str | None = None,
+ erp_type: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[PharmacyEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: PharmacyEntity) -> PharmacyEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: PharmacyEntity) -> PharmacyEntity: ...
+
+ @abstractmethod
+ async def delete(self, pharmacy_id: uuid.UUID) -> bool: ...
+
+
+class IERPSystemRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: ...
+
+ @abstractmethod
+ async def list_erp_systems(self, *, erp_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ERPSystemEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: ...
+
+ @abstractmethod
+ async def delete(self, erp_id: uuid.UUID) -> bool: ...
+
+
+class IConnectorRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: ...
+
+ @abstractmethod
+ async def list_connectors(self, *, erp_system_id: uuid.UUID | None = None, pharmacy_id: uuid.UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: ConnectorEntity) -> ConnectorEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: ConnectorEntity) -> ConnectorEntity: ...
+
+ @abstractmethod
+ async def delete(self, connector_id: uuid.UUID) -> bool: ...
+
+
+class IConnectorVersionRepository(ABC):
+ @abstractmethod
+ async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: ...
+
+ @abstractmethod
+ async def list_versions(self, *, connector_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorVersionEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: ...
+
+
+class IConnectorConfigRepository(ABC):
+ @abstractmethod
+ async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: ...
+
+ @abstractmethod
+ async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: ...
+
+ @abstractmethod
+ async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: ...
+
+ @abstractmethod
+ async def delete(self, config_id: uuid.UUID) -> bool: ...
+
+
+class IMedicationRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: ...
+
+ @abstractmethod
+ async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: ...
+
+ @abstractmethod
+ async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: MedicationEntity) -> MedicationEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: MedicationEntity) -> MedicationEntity: ...
+
+ @abstractmethod
+ async def delete(self, medication_id: uuid.UUID) -> bool: ...
+
+
+class IMedicationCatalogRepository(ABC):
+ @abstractmethod
+ async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: ...
+
+ @abstractmethod
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: ...
+
+ @abstractmethod
+ async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: ...
+
+
+class IInventoryRepository(ABC):
+ @abstractmethod
+ async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: ...
+
+ @abstractmethod
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: ...
+
+ @abstractmethod
+ async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: ...
+
+ @abstractmethod
+ async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: ...
+
+ @abstractmethod
+ async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: ...
+
+
+class IReservationRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: ...
+
+ @abstractmethod
+ async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: ...
+
+ @abstractmethod
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: ReservationEntity) -> ReservationEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: ReservationEntity) -> ReservationEntity: ...
+
+
+class ISyncJobRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: ...
+
+ @abstractmethod
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[SyncJobEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: SyncJobEntity) -> SyncJobEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: SyncJobEntity) -> SyncJobEntity: ...
+
+
+class ISyncLogRepository(ABC):
+ @abstractmethod
+ async def add(self, entity: SyncLogEntity) -> SyncLogEntity: ...
+
+ @abstractmethod
+ async def list_by_job(self, job_id: uuid.UUID, *, level: str | None = None, offset: int = 0, limit: int = 100) -> Sequence[SyncLogEntity]: ...
+
+
+class IAuditLogRepository(ABC):
+ @abstractmethod
+ async def add(self, entity: AuditLogEntity) -> AuditLogEntity: ...
+
+ @abstractmethod
+ async def list_logs(self, *, actor_id: uuid.UUID | None = None, action: str | None = None, resource_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[AuditLogEntity], int]: ...
+
+
+class ITechnicalUserRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: ...
+
+ @abstractmethod
+ async def get_by_username(self, username: str) -> TechnicalUserEntity | None: ...
+
+ @abstractmethod
+ async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: ...
+
+
+class IAPIClientRepository(ABC):
+ @abstractmethod
+ async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: ...
+
+ @abstractmethod
+ async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: ...
+
+ @abstractmethod
+ async def create(self, entity: APIClientEntity) -> APIClientEntity: ...
+
+ @abstractmethod
+ async def update(self, entity: APIClientEntity) -> APIClientEntity: ...
+
+
+class IAPIKeyRepository(ABC):
+ @abstractmethod
+ async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: ...
+
+ @abstractmethod
+ async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: ...
+
+ @abstractmethod
+ async def create(self, entity: APIKeyEntity) -> APIKeyEntity: ...
+
+ @abstractmethod
+ async def update_last_used(self, key_id: uuid.UUID) -> None: ...
diff --git a/pip-platform/src/domain/value_objects/__init__.py b/pip-platform/src/domain/value_objects/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/infrastructure/__init__.py b/pip-platform/src/infrastructure/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/infrastructure/cache/__init__.py b/pip-platform/src/infrastructure/cache/__init__.py
new file mode 100644
index 0000000..d7d155f
--- /dev/null
+++ b/pip-platform/src/infrastructure/cache/__init__.py
@@ -0,0 +1,3 @@
+from .redis_cache import redis_cache
+
+__all__ = ["redis_cache"]
diff --git a/pip-platform/src/infrastructure/cache/redis_cache.py b/pip-platform/src/infrastructure/cache/redis_cache.py
new file mode 100644
index 0000000..1cf9bc9
--- /dev/null
+++ b/pip-platform/src/infrastructure/cache/redis_cache.py
@@ -0,0 +1,73 @@
+import json
+from typing import Any
+
+import redis.asyncio as aioredis
+
+from src.infrastructure.config.settings import settings
+
+
+class RedisCache:
+ def __init__(self) -> None:
+ self._client: aioredis.Redis | None = None
+
+ async def connect(self) -> None:
+ self._client = aioredis.from_url(
+ settings.REDIS_URL,
+ decode_responses=True,
+ max_connections=20,
+ )
+
+ async def disconnect(self) -> None:
+ if self._client:
+ await self._client.close()
+
+ @property
+ def client(self) -> aioredis.Redis:
+ if self._client is None:
+ raise RuntimeError("Redis client not connected. Call connect() first.")
+ return self._client
+
+ async def get(self, key: str) -> Any | None:
+ data = await self.client.get(key)
+ if data is None:
+ return None
+ try:
+ return json.loads(data)
+ except (json.JSONDecodeError, TypeError):
+ return data
+
+ async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
+ ttl = ttl or settings.REDIS_CACHE_TTL
+ if isinstance(value, (dict, list, tuple)):
+ value = json.dumps(value)
+ await self.client.set(key, value, ex=ttl)
+
+ async def delete(self, key: str) -> None:
+ await self.client.delete(key)
+
+ async def exists(self, key: str) -> bool:
+ return bool(await self.client.exists(key))
+
+ async def get_with_lock(self, key: str, lock_timeout: int = 10) -> Any | None:
+ async with self.client.lock(f"{key}:lock", timeout=lock_timeout):
+ return await self.get(key)
+
+ async def set_with_lock(self, key: str, value: Any, ttl: int | None = None, lock_timeout: int = 10) -> None:
+ async with self.client.lock(f"{key}:lock", timeout=lock_timeout):
+ await self.set(key, value, ttl)
+
+ async def invalidate_pattern(self, pattern: str) -> None:
+ keys = []
+ async for key in self.client.scan_iter(match=pattern):
+ keys.append(key)
+ if keys:
+ await self.client.delete(*keys)
+
+ async def health_check(self) -> bool:
+ try:
+ return await self.client.ping()
+ except Exception:
+ return False
+
+
+redis_cache = RedisCache()
diff --git a/pip-platform/src/infrastructure/config/__init__.py b/pip-platform/src/infrastructure/config/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/infrastructure/config/settings.py b/pip-platform/src/infrastructure/config/settings.py
new file mode 100644
index 0000000..2c2431d
--- /dev/null
+++ b/pip-platform/src/infrastructure/config/settings.py
@@ -0,0 +1,58 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ env_nested_delimiter="__",
+ extra="ignore",
+ )
+
+ APP_NAME: str = "PIP - Pharmacy Integration Platform"
+ APP_VERSION: str = "0.1.0"
+ DEBUG: bool = False
+
+ DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
+ DATABASE_POOL_SIZE: int = 20
+ DATABASE_MAX_OVERFLOW: int = 10
+ DATABASE_POOL_RECYCLE: int = 3600
+
+ REDIS_URL: str = "redis://localhost:6379/0"
+ REDIS_CACHE_TTL: int = 300
+
+ RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip"
+
+ JWT_SECRET_KEY: str = "change-me-in-production"
+ JWT_ALGORITHM: str = "HS256"
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+ JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
+ JWT_ISSUER: str = "pip-platform"
+
+ OAUTH2_TOKEN_URL: str = "/api/v1/auth/token"
+
+ RATE_LIMIT_PER_MINUTE: int = 60
+ RATE_LIMIT_AUTH_PER_MINUTE: int = 10
+
+ API_KEY_PREFIX: str = "pip"
+ API_KEY_LENGTH: int = 32
+
+ OTEL_EXPORTER_OTLP_ENDPOINT: str = "http://localhost:4317"
+ OTEL_SERVICE_NAME: str = "pip-platform"
+ OTEL_TRACES_ENABLED: bool = True
+ OTEL_METRICS_ENABLED: bool = True
+
+ LOG_LEVEL: str = "INFO"
+ LOG_JSON_FORMAT: bool = True
+
+ CORS_ORIGINS: list[str] = ["*"]
+ CORS_ALLOW_CREDENTIALS: bool = True
+
+ HEALTH_CHECK_CACHE_TTL: int = 10
+
+ @property
+ def DATABASE_URL_SYNC(self) -> str:
+ return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1)
+
+
+settings = Settings()
diff --git a/pip-platform/src/infrastructure/database/__init__.py b/pip-platform/src/infrastructure/database/__init__.py
new file mode 100644
index 0000000..0b2ac42
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/__init__.py
@@ -0,0 +1,9 @@
+from .models import *
+from .session import async_session_factory, engine, get_db_session, get_db_session_no_commit
+
+__all__ = [
+ "async_session_factory",
+ "engine",
+ "get_db_session",
+ "get_db_session_no_commit",
+]
diff --git a/pip-platform/src/infrastructure/database/models/__init__.py b/pip-platform/src/infrastructure/database/models/__init__.py
new file mode 100644
index 0000000..df347fd
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/models/__init__.py
@@ -0,0 +1,41 @@
+from .models import (
+ APIClientModel,
+ APIKeyModel,
+ AuditLogModel,
+ Base,
+ BaseMixin,
+ ConnectorConfigModel,
+ ConnectorModel,
+ ConnectorVersionModel,
+ ERPSystemModel,
+ InventoryHistoryModel,
+ InventoryRecordModel,
+ MedicationCatalogEntryModel,
+ MedicationModel,
+ PharmacyModel,
+ ReservationModel,
+ SyncJobModel,
+ SyncLogModel,
+ TechnicalUserModel,
+)
+
+__all__ = [
+ "APIClientModel",
+ "APIKeyModel",
+ "AuditLogModel",
+ "Base",
+ "BaseMixin",
+ "ConnectorConfigModel",
+ "ConnectorModel",
+ "ConnectorVersionModel",
+ "ERPSystemModel",
+ "InventoryHistoryModel",
+ "InventoryRecordModel",
+ "MedicationCatalogEntryModel",
+ "MedicationModel",
+ "PharmacyModel",
+ "ReservationModel",
+ "SyncJobModel",
+ "SyncLogModel",
+ "TechnicalUserModel",
+]
diff --git a/pip-platform/src/infrastructure/database/models/models.py b/pip-platform/src/infrastructure/database/models/models.py
new file mode 100644
index 0000000..059cfc7
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/models/models.py
@@ -0,0 +1,349 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import (
+ Boolean,
+ DateTime,
+ Enum,
+ Float,
+ ForeignKey,
+ Index,
+ Integer,
+ LargeBinary,
+ String,
+ Text,
+ UniqueConstraint,
+ func,
+)
+from sqlalchemy.dialects.postgresql import JSONB, UUID
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class BaseMixin:
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+
+class PharmacyModel(Base, BaseMixin):
+ __tablename__ = "pharmacies"
+
+ code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ address: Mapped[str | None] = mapped_column(String(500))
+ city: Mapped[str | None] = mapped_column(String(100))
+ province: Mapped[str | None] = mapped_column(String(100))
+ postal_code: Mapped[str | None] = mapped_column(String(20))
+ country: Mapped[str] = mapped_column(String(2), default="ES")
+ latitude: Mapped[float | None] = mapped_column(Float)
+ longitude: Mapped[float | None] = mapped_column(Float)
+ phone: Mapped[str | None] = mapped_column(String(50))
+ email: Mapped[str | None] = mapped_column(String(255))
+ opening_hours: Mapped[dict | None] = mapped_column(JSONB)
+ status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
+ erp_type: Mapped[str | None] = mapped_column(String(50))
+ erp_version: Mapped[str | None] = mapped_column(String(50))
+ connector_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=True)
+ sync_strategy: Mapped[str] = mapped_column(String(20), default="incremental")
+ sync_interval_minutes: Mapped[int] = mapped_column(Integer, default=30)
+ last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ last_sync_status: Mapped[str | None] = mapped_column(String(20))
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ connector = relationship("ConnectorModel", back_populates="pharmacies", foreign_keys=[connector_id])
+ inventory_records = relationship("InventoryRecordModel", back_populates="pharmacy", cascade="all, delete-orphan")
+ reservations = relationship("ReservationModel", back_populates="pharmacy", cascade="all, delete-orphan")
+ catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="pharmacy", cascade="all, delete-orphan")
+ sync_jobs = relationship("SyncJobModel", back_populates="pharmacy", cascade="all, delete-orphan")
+
+ __table_args__ = (
+ Index("ix_pharmacies_location", "latitude", "longitude"),
+ Index("ix_pharmacies_status_erp", "status", "erp_type"),
+ )
+
+
+class ERPSystemModel(Base, BaseMixin):
+ __tablename__ = "erp_systems"
+
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ erp_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ version: Mapped[str | None] = mapped_column(String(50))
+ base_url: Mapped[str | None] = mapped_column(String(500))
+ auth_config: Mapped[dict | None] = mapped_column(JSONB)
+ status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ connectors = relationship("ConnectorModel", back_populates="erp_system", cascade="all, delete-orphan")
+
+
+class ConnectorModel(Base, BaseMixin):
+ __tablename__ = "connectors"
+
+ erp_system_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("erp_systems.id"), nullable=False, index=True)
+ pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ version: Mapped[str] = mapped_column(String(20), default="1.0.0")
+ config: Mapped[dict | None] = mapped_column(JSONB)
+ status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
+ last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ last_error: Mapped[str | None] = mapped_column(Text)
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ erp_system = relationship("ERPSystemModel", back_populates="connectors")
+ pharmacies = relationship("PharmacyModel", back_populates="connector", foreign_keys="PharmacyModel.connector_id")
+ configs = relationship("ConnectorConfigModel", back_populates="connector", cascade="all, delete-orphan")
+ connector_versions = relationship("ConnectorVersionModel", back_populates="connector", cascade="all, delete-orphan")
+
+ __table_args__ = (
+ UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"),
+ )
+
+
+class ConnectorVersionModel(Base, BaseMixin):
+ __tablename__ = "connector_versions"
+
+ connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
+ connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
+ version: Mapped[str] = mapped_column(String(20), nullable=False)
+ changelog: Mapped[str | None] = mapped_column(Text)
+ download_url: Mapped[str | None] = mapped_column(String(500))
+ checksum: Mapped[str | None] = mapped_column(String(128))
+ is_latest: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
+ is_required: Mapped[bool] = mapped_column(Boolean, default=False)
+ released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+ connector = relationship("ConnectorModel", back_populates="connector_versions")
+
+ __table_args__ = (
+ UniqueConstraint("connector_type", "version", name="uq_connector_version"),
+ )
+
+
+class ConnectorConfigModel(Base, BaseMixin):
+ __tablename__ = "connector_configs"
+
+ connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
+ key: Mapped[str] = mapped_column(String(100), nullable=False)
+ value: Mapped[str] = mapped_column(Text, nullable=False)
+ encrypted: Mapped[bool] = mapped_column(Boolean, default=False)
+ description: Mapped[str | None] = mapped_column(String(500))
+
+ connector = relationship("ConnectorModel", back_populates="configs")
+
+ __table_args__ = (
+ UniqueConstraint("connector_id", "key", name="uq_connector_config_key"),
+ )
+
+
+class MedicationModel(Base, BaseMixin):
+ __tablename__ = "medications"
+
+ nregistro: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
+ active_ingredient: Mapped[str | None] = mapped_column(String(500))
+ dosage: Mapped[str | None] = mapped_column(String(100))
+ form: Mapped[str | None] = mapped_column(String(100))
+ atc_code: Mapped[str | None] = mapped_column(String(20), index=True)
+ prescription_required: Mapped[bool] = mapped_column(Boolean, default=False)
+ manufacturer: Mapped[str | None] = mapped_column(String(255))
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="medication", cascade="all, delete-orphan")
+ inventory_records = relationship("InventoryRecordModel", back_populates="medication", cascade="all, delete-orphan")
+ reservations = relationship("ReservationModel", back_populates="medication", cascade="all, delete-orphan")
+
+
+class MedicationCatalogEntryModel(Base, BaseMixin):
+ __tablename__ = "medication_catalog"
+
+ pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
+ medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
+ price: Mapped[float | None] = mapped_column(Float)
+ stock: Mapped[int] = mapped_column(Integer, default=0)
+ is_available: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
+ last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ pharmacy = relationship("PharmacyModel", back_populates="catalog_entries")
+ medication = relationship("MedicationModel", back_populates="catalog_entries")
+
+ __table_args__ = (
+ UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"),
+ )
+
+
+class InventoryRecordModel(Base, BaseMixin):
+ __tablename__ = "inventory_records"
+
+ pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
+ medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
+ stock: Mapped[int] = mapped_column(Integer, default=0)
+ reserved_stock: Mapped[int] = mapped_column(Integer, default=0)
+ available_stock: Mapped[int] = mapped_column(Integer, default=0)
+ min_stock: Mapped[int | None] = mapped_column(Integer)
+ max_stock: Mapped[int | None] = mapped_column(Integer)
+ last_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+ source: Mapped[str | None] = mapped_column(String(100))
+ batch_id: Mapped[str | None] = mapped_column(String(100))
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ pharmacy = relationship("PharmacyModel", back_populates="inventory_records")
+ medication = relationship("MedicationModel", back_populates="inventory_records")
+ history = relationship("InventoryHistoryModel", back_populates="inventory_record", cascade="all, delete-orphan")
+
+ __table_args__ = (
+ UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"),
+ )
+
+
+class InventoryHistoryModel(Base):
+ __tablename__ = "inventory_history"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ inventory_record_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("inventory_records.id"), nullable=False, index=True)
+ pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
+ medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
+ previous_stock: Mapped[int] = mapped_column(Integer, nullable=False)
+ new_stock: Mapped[int] = mapped_column(Integer, nullable=False)
+ delta: Mapped[int] = mapped_column(Integer, nullable=False)
+ reason: Mapped[str | None] = mapped_column(String(255))
+ source: Mapped[str | None] = mapped_column(String(100))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
+
+ inventory_record = relationship("InventoryRecordModel", back_populates="history")
+
+
+class ReservationModel(Base, BaseMixin):
+ __tablename__ = "reservations"
+
+ pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
+ medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
+ user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True)
+ external_reference: Mapped[str | None] = mapped_column(String(255))
+ quantity: Mapped[int] = mapped_column(Integer, nullable=False)
+ status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+ confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ idempotency_key: Mapped[str | None] = mapped_column(String(255), unique=True, index=True)
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ pharmacy = relationship("PharmacyModel", back_populates="reservations")
+ medication = relationship("MedicationModel", back_populates="reservations")
+ user = relationship("TechnicalUserModel", back_populates="reservations")
+
+ __table_args__ = (
+ Index("ix_reservations_pharmacy_status", "pharmacy_id", "status"),
+ Index("ix_reservations_expires", "expires_at"),
+ )
+
+
+class SyncJobModel(Base, BaseMixin):
+ __tablename__ = "sync_jobs"
+
+ pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
+ connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
+ strategy: Mapped[str] = mapped_column(String(20), nullable=False)
+ status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
+ started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ items_total: Mapped[int | None] = mapped_column(Integer)
+ items_processed: Mapped[int | None] = mapped_column(Integer)
+ items_failed: Mapped[int | None] = mapped_column(Integer)
+ error_message: Mapped[str | None] = mapped_column(Text)
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ pharmacy = relationship("PharmacyModel", back_populates="sync_jobs")
+ logs = relationship("SyncLogModel", back_populates="sync_job", cascade="all, delete-orphan")
+
+
+class SyncLogModel(Base):
+ __tablename__ = "sync_logs"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ sync_job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sync_jobs.id"), nullable=False, index=True)
+ level: Mapped[str] = mapped_column(String(20), default="INFO", nullable=False)
+ message: Mapped[str] = mapped_column(Text, nullable=False)
+ details: Mapped[dict | None] = mapped_column(JSONB)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
+
+ sync_job = relationship("SyncJobModel", back_populates="logs")
+
+
+class AuditLogModel(Base):
+ __tablename__ = "audit_logs"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
+ actor_type: Mapped[str | None] = mapped_column(String(50))
+ action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
+ resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
+ resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
+ old_value: Mapped[dict | None] = mapped_column(JSONB)
+ new_value: Mapped[dict | None] = mapped_column(JSONB)
+ ip_address: Mapped[str | None] = mapped_column(String(45))
+ user_agent: Mapped[str | None] = mapped_column(String(500))
+ request_id: Mapped[str | None] = mapped_column(String(100), index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
+
+ __table_args__ = (
+ Index("ix_audit_logs_action_resource", "action", "resource_type"),
+ Index("ix_audit_logs_actor_created", "actor_id", "created_at"),
+ )
+
+
+class TechnicalUserModel(Base, BaseMixin):
+ __tablename__ = "technical_users"
+
+ username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
+ email: Mapped[str | None] = mapped_column(String(255))
+ full_name: Mapped[str | None] = mapped_column(String(255))
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ role: Mapped[str] = mapped_column(String(20), default="viewer", index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
+ scopes: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+ reservations = relationship("ReservationModel", back_populates="user")
+ api_clients = relationship("APIClientModel", back_populates="owner")
+
+
+class APIClientModel(Base, BaseMixin):
+ __tablename__ = "api_clients"
+
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text)
+ owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
+ scopes: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ rate_limit: Mapped[int | None] = mapped_column(Integer)
+ allowed_ips: Mapped[dict | None] = mapped_column(JSONB, default=list)
+ metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
+
+ owner = relationship("TechnicalUserModel", back_populates="api_clients")
+ api_keys = relationship("APIKeyModel", back_populates="api_client", cascade="all, delete-orphan")
+
+
+class APIKeyModel(Base):
+ __tablename__ = "api_keys"
+
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ api_client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("api_clients.id"), nullable=False, index=True)
+ key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
+ key_hash: Mapped[bytes] = mapped_column(LargeBinary(64), nullable=False)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+ api_client = relationship("APIClientModel", back_populates="api_keys")
+
+ __table_args__ = (
+ Index("ix_api_keys_prefix_active", "key_prefix", "is_active"),
+ )
diff --git a/pip-platform/src/infrastructure/database/repositories/__init__.py b/pip-platform/src/infrastructure/database/repositories/__init__.py
new file mode 100644
index 0000000..cf87f26
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/__init__.py
@@ -0,0 +1,32 @@
+from .pharmacy_repository import PharmacyRepository
+from .technical_user_repository import TechnicalUserRepository
+from .audit_log_repository import AuditLogRepository
+from .api_client_repository import APIClientRepository, APIKeyRepository
+from .connector_repository import ConnectorRepository
+from .erp_system_repository import ERPSystemRepository
+from .connector_config_repository import ConnectorConfigRepository
+from .connector_version_repository import ConnectorVersionRepository
+from .medication_repository import MedicationRepository
+from .medication_catalog_repository import MedicationCatalogRepository
+from .inventory_repository import InventoryRepository
+from .reservation_repository import ReservationRepository
+from .sync_job_repository import SyncJobRepository
+from .sync_log_repository import SyncLogRepository
+
+__all__ = [
+ "APIClientRepository",
+ "APIKeyRepository",
+ "AuditLogRepository",
+ "ConnectorConfigRepository",
+ "ConnectorRepository",
+ "ConnectorVersionRepository",
+ "ERPSystemRepository",
+ "InventoryRepository",
+ "MedicationCatalogRepository",
+ "MedicationRepository",
+ "PharmacyRepository",
+ "ReservationRepository",
+ "SyncJobRepository",
+ "SyncLogRepository",
+ "TechnicalUserRepository",
+]
diff --git a/pip-platform/src/infrastructure/database/repositories/api_client_repository.py b/pip-platform/src/infrastructure/database/repositories/api_client_repository.py
new file mode 100644
index 0000000..88b8d8a
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/api_client_repository.py
@@ -0,0 +1,116 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import APIClientEntity, APIKeyEntity
+from src.domain.interfaces import IAPIClientRepository, IAPIKeyRepository
+from src.infrastructure.database.models import APIClientModel, APIKeyModel
+
+
+class APIClientRepository(IAPIClientRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None:
+ stmt = select(APIClientModel).where(APIClientModel.id == client_id)
+ result = await self._session.execute(stmt)
+ m = result.scalar_one_or_none()
+ if not m:
+ return None
+ return APIClientEntity(
+ id=m.id, name=m.name, description=m.description, owner_id=m.owner_id,
+ is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit,
+ allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at,
+ )
+
+ async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]:
+ filters: list[Any] = []
+ if is_active is not None:
+ filters.append(APIClientModel.is_active == is_active)
+ count_stmt = select(func.count()).select_from(APIClientModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+ stmt = select(APIClientModel).order_by(APIClientModel.name).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ entities = [
+ APIClientEntity(id=m.id, name=m.name, description=m.description, owner_id=m.owner_id,
+ is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit,
+ allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at)
+ for m in models
+ ]
+ return entities, total
+
+ async def create(self, entity: APIClientEntity) -> APIClientEntity:
+ model = APIClientModel(
+ name=entity.name, description=entity.description, owner_id=entity.owner_id,
+ is_active=entity.is_active, scopes=entity.scopes, rate_limit=entity.rate_limit,
+ allowed_ips=entity.allowed_ips, metadata_=entity.metadata_,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return await self.get_by_id(model.id) # type: ignore[return-value]
+
+ async def update(self, entity: APIClientEntity) -> APIClientEntity:
+ stmt = update(APIClientModel).where(APIClientModel.id == entity.id).values(
+ name=entity.name, description=entity.description, is_active=entity.is_active,
+ scopes=entity.scopes, rate_limit=entity.rate_limit, allowed_ips=entity.allowed_ips,
+ metadata_=entity.metadata_,
+ )
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
+
+
+class APIKeyRepository(IAPIKeyRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None:
+ stmt = select(APIKeyModel).where(APIKeyModel.key_prefix == key_prefix, APIKeyModel.is_active == True) # noqa: E712
+ result = await self._session.execute(stmt)
+ m = result.scalar_one_or_none()
+ if not m:
+ return None
+ return APIKeyEntity(
+ id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix,
+ key_hash=m.key_hash, name=m.name, is_active=m.is_active,
+ expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at,
+ )
+
+ async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]:
+ stmt = select(APIKeyModel).where(APIKeyModel.api_client_id == api_client_id).order_by(APIKeyModel.created_at.desc())
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [
+ APIKeyEntity(
+ id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix,
+ key_hash=m.key_hash, name=m.name, is_active=m.is_active,
+ expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at,
+ )
+ for m in models
+ ]
+
+ async def create(self, entity: APIKeyEntity) -> APIKeyEntity:
+ model = APIKeyModel(
+ api_client_id=entity.api_client_id, key_prefix=entity.key_prefix,
+ key_hash=entity.key_hash, name=entity.name, is_active=entity.is_active,
+ expires_at=entity.expires_at,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return APIKeyEntity(
+ id=model.id, api_client_id=model.api_client_id, key_prefix=model.key_prefix,
+ key_hash=model.key_hash, name=model.name, is_active=model.is_active,
+ expires_at=model.expires_at, created_at=model.created_at,
+ )
+
+ async def update_last_used(self, key_id: uuid.UUID) -> None:
+ stmt = update(APIKeyModel).where(APIKeyModel.id == key_id).values(last_used_at=func.now())
+ await self._session.execute(stmt)
diff --git a/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py b/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py
new file mode 100644
index 0000000..623d119
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py
@@ -0,0 +1,78 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import AuditLogEntity
+from src.domain.interfaces import IAuditLogRepository
+from src.infrastructure.database.models import AuditLogModel
+
+
+def _model_to_entity(m: AuditLogModel) -> AuditLogEntity:
+ return AuditLogEntity(
+ id=m.id,
+ actor_id=m.actor_id,
+ actor_type=m.actor_type,
+ action=m.action,
+ resource_type=m.resource_type,
+ resource_id=m.resource_id,
+ old_value=m.old_value,
+ new_value=m.new_value,
+ ip_address=m.ip_address,
+ user_agent=m.user_agent,
+ request_id=m.request_id,
+ created_at=m.created_at,
+ )
+
+
+class AuditLogRepository(IAuditLogRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def add(self, entity: AuditLogEntity) -> AuditLogEntity:
+ model = AuditLogModel(
+ actor_id=entity.actor_id,
+ actor_type=entity.actor_type,
+ action=entity.action,
+ resource_type=entity.resource_type,
+ resource_id=entity.resource_id,
+ old_value=entity.old_value,
+ new_value=entity.new_value,
+ ip_address=entity.ip_address,
+ user_agent=entity.user_agent,
+ request_id=entity.request_id,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def list_logs(
+ self,
+ *,
+ actor_id: uuid.UUID | None = None,
+ action: str | None = None,
+ resource_type: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[AuditLogEntity], int]:
+ filters: list[Any] = []
+ if actor_id:
+ filters.append(AuditLogModel.actor_id == actor_id)
+ if action:
+ filters.append(AuditLogModel.action == action)
+ if resource_type:
+ filters.append(AuditLogModel.resource_type == resource_type)
+
+ count_stmt = select(func.count()).select_from(AuditLogModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = select(AuditLogModel).order_by(AuditLogModel.created_at.desc()).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
diff --git a/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py
new file mode 100644
index 0000000..efc704c
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py
@@ -0,0 +1,71 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import ConnectorConfigEntity
+from src.domain.interfaces import IConnectorConfigRepository
+from src.infrastructure.database.models import ConnectorConfigModel
+
+
+def _model_to_entity(m: ConnectorConfigModel) -> ConnectorConfigEntity:
+ return ConnectorConfigEntity(
+ id=m.id,
+ connector_id=m.connector_id,
+ key=m.key,
+ value=m.value,
+ encrypted=m.encrypted,
+ description=m.description,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+class ConnectorConfigRepository(IConnectorConfigRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]:
+ stmt = select(ConnectorConfigModel).where(ConnectorConfigModel.connector_id == connector_id).order_by(ConnectorConfigModel.key)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models]
+
+ async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None:
+ stmt = select(ConnectorConfigModel).where(
+ ConnectorConfigModel.connector_id == connector_id,
+ ConnectorConfigModel.key == key,
+ )
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity:
+ existing = await self.get_by_key(entity.connector_id, entity.key)
+ if existing:
+ stmt = (
+ ConnectorConfigModel.__table__.update()
+ .where(ConnectorConfigModel.id == existing.id)
+ .values(value=entity.value, encrypted=entity.encrypted, description=entity.description)
+ )
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_key(entity.connector_id, entity.key) # type: ignore[return-value]
+ else:
+ model = ConnectorConfigModel(
+ connector_id=entity.connector_id,
+ key=entity.key,
+ value=entity.value,
+ encrypted=entity.encrypted,
+ description=entity.description,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def delete(self, config_id: uuid.UUID) -> bool:
+ stmt = delete(ConnectorConfigModel).where(ConnectorConfigModel.id == config_id)
+ result = await self._session.execute(stmt)
+ return result.rowcount > 0
diff --git a/pip-platform/src/infrastructure/database/repositories/connector_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_repository.py
new file mode 100644
index 0000000..6870dd1
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/connector_repository.py
@@ -0,0 +1,101 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import ConnectorEntity, ConnectorStatus
+from src.domain.interfaces import IConnectorRepository
+from src.infrastructure.database.models import ConnectorModel
+
+
+def _model_to_entity(m: ConnectorModel) -> ConnectorEntity:
+ return ConnectorEntity(
+ id=m.id,
+ erp_system_id=m.erp_system_id,
+ pharmacy_id=m.pharmacy_id,
+ name=m.name,
+ connector_type=m.connector_type,
+ version=m.version,
+ config=m.config,
+ status=m.status,
+ last_heartbeat_at=m.last_heartbeat_at,
+ last_error=m.last_error,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: ConnectorEntity) -> dict[str, Any]:
+ return {
+ "erp_system_id": e.erp_system_id,
+ "pharmacy_id": e.pharmacy_id,
+ "name": e.name,
+ "connector_type": e.connector_type if isinstance(e.connector_type, str) else e.connector_type.value,
+ "version": e.version,
+ "config": e.config,
+ "status": e.status if isinstance(e.status, str) else e.status.value,
+ "last_heartbeat_at": e.last_heartbeat_at,
+ "last_error": e.last_error,
+ "metadata_": e.metadata_,
+ }
+
+
+class ConnectorRepository(IConnectorRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None:
+ stmt = select(ConnectorModel).where(ConnectorModel.id == connector_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_connectors(
+ self,
+ *,
+ erp_system_id: uuid.UUID | None = None,
+ pharmacy_id: uuid.UUID | None = None,
+ status: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[ConnectorEntity], int]:
+ filters: list[Any] = []
+ if erp_system_id:
+ filters.append(ConnectorModel.erp_system_id == erp_system_id)
+ if pharmacy_id:
+ filters.append(ConnectorModel.pharmacy_id == pharmacy_id)
+ if status:
+ filters.append(ConnectorModel.status == status)
+
+ count_stmt = select(func.count()).select_from(ConnectorModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = select(ConnectorModel).order_by(ConnectorModel.name).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: ConnectorEntity) -> ConnectorEntity:
+ model = ConnectorModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: ConnectorEntity) -> ConnectorEntity:
+ data = _entity_to_model_data(entity)
+ stmt = update(ConnectorModel).where(ConnectorModel.id == entity.id).values(**data)
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
+
+ async def delete(self, connector_id: uuid.UUID) -> bool:
+ stmt = delete(ConnectorModel).where(ConnectorModel.id == connector_id)
+ result = await self._session.execute(stmt)
+ return result.rowcount > 0
diff --git a/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py
new file mode 100644
index 0000000..5ac58d7
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py
@@ -0,0 +1,82 @@
+from collections.abc import Sequence
+from typing import Any
+
+import uuid
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import ConnectorVersionEntity
+from src.domain.interfaces import IConnectorVersionRepository
+from src.infrastructure.database.models import ConnectorVersionModel
+
+
+def _model_to_entity(m: ConnectorVersionModel) -> ConnectorVersionEntity:
+ return ConnectorVersionEntity(
+ id=m.id,
+ connector_id=m.connector_id,
+ connector_type=m.connector_type,
+ version=m.version,
+ changelog=m.changelog,
+ download_url=m.download_url,
+ checksum=m.checksum,
+ is_latest=m.is_latest,
+ is_required=m.is_required,
+ released_at=m.released_at,
+ created_at=m.created_at,
+ )
+
+
+class ConnectorVersionRepository(IConnectorVersionRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None:
+ stmt = (
+ select(ConnectorVersionModel)
+ .where(ConnectorVersionModel.connector_type == connector_type, ConnectorVersionModel.is_latest == True) # noqa: E712
+ .order_by(ConnectorVersionModel.released_at.desc())
+ .limit(1)
+ )
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_versions(
+ self,
+ *,
+ connector_type: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[ConnectorVersionEntity], int]:
+ filters: list[Any] = []
+ if connector_type:
+ filters.append(ConnectorVersionModel.connector_type == connector_type)
+
+ count_stmt = select(func.count()).select_from(ConnectorVersionModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = select(ConnectorVersionModel).order_by(ConnectorVersionModel.released_at.desc()).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity:
+ model = ConnectorVersionModel(
+ connector_id=entity.connector_id or uuid.uuid4(),
+ connector_type=entity.connector_type if isinstance(entity.connector_type, str) else entity.connector_type.value,
+ version=entity.version,
+ changelog=entity.changelog,
+ download_url=entity.download_url,
+ checksum=entity.checksum,
+ is_latest=entity.is_latest,
+ is_required=entity.is_required,
+ released_at=entity.released_at,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
diff --git a/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py b/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py
new file mode 100644
index 0000000..9a406f6
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py
@@ -0,0 +1,89 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import ERPSystemEntity
+from src.domain.interfaces import IERPSystemRepository
+from src.infrastructure.database.models import ERPSystemModel
+
+
+def _model_to_entity(m: ERPSystemModel) -> ERPSystemEntity:
+ return ERPSystemEntity(
+ id=m.id,
+ name=m.name,
+ erp_type=m.erp_type,
+ version=m.version,
+ base_url=m.base_url,
+ auth_config=m.auth_config,
+ status=m.status,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: ERPSystemEntity) -> dict[str, Any]:
+ return {
+ "name": e.name,
+ "erp_type": e.erp_type if isinstance(e.erp_type, str) else e.erp_type.value,
+ "version": e.version,
+ "base_url": e.base_url,
+ "auth_config": e.auth_config,
+ "status": e.status if isinstance(e.status, str) else e.status.value,
+ "metadata_": e.metadata_,
+ }
+
+
+class ERPSystemRepository(IERPSystemRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None:
+ stmt = select(ERPSystemModel).where(ERPSystemModel.id == erp_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_erp_systems(
+ self,
+ *,
+ erp_type: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[ERPSystemEntity], int]:
+ filters: list[Any] = []
+ if erp_type:
+ filters.append(ERPSystemModel.erp_type == erp_type)
+
+ count_stmt = select(func.count()).select_from(ERPSystemModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = select(ERPSystemModel).order_by(ERPSystemModel.name).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity:
+ model = ERPSystemModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity:
+ data = _entity_to_model_data(entity)
+ stmt = update(ERPSystemModel).where(ERPSystemModel.id == entity.id).values(**data)
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
+
+ async def delete(self, erp_id: uuid.UUID) -> bool:
+ stmt = delete(ERPSystemModel).where(ERPSystemModel.id == erp_id)
+ result = await self._session.execute(stmt)
+ return result.rowcount > 0
diff --git a/pip-platform/src/infrastructure/database/repositories/inventory_repository.py b/pip-platform/src/infrastructure/database/repositories/inventory_repository.py
new file mode 100644
index 0000000..25e9545
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/inventory_repository.py
@@ -0,0 +1,142 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import InventoryHistoryEntity, InventoryRecordEntity
+from src.domain.interfaces import IInventoryRepository
+from src.infrastructure.database.models import InventoryHistoryModel, InventoryRecordModel
+
+
+def _record_model_to_entity(m: InventoryRecordModel) -> InventoryRecordEntity:
+ return InventoryRecordEntity(
+ id=m.id,
+ pharmacy_id=m.pharmacy_id,
+ medication_id=m.medication_id,
+ stock=m.stock,
+ reserved_stock=m.reserved_stock,
+ available_stock=m.available_stock,
+ min_stock=m.min_stock,
+ max_stock=m.max_stock,
+ last_updated_at=m.last_updated_at,
+ source=m.source,
+ batch_id=m.batch_id,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _record_entity_to_model_data(e: InventoryRecordEntity) -> dict[str, Any]:
+ return {
+ "pharmacy_id": e.pharmacy_id,
+ "medication_id": e.medication_id,
+ "stock": e.stock,
+ "reserved_stock": e.reserved_stock,
+ "available_stock": e.available_stock,
+ "min_stock": e.min_stock,
+ "max_stock": e.max_stock,
+ "last_updated_at": e.last_updated_at,
+ "source": e.source,
+ "batch_id": e.batch_id,
+ "metadata_": e.metadata_,
+ }
+
+
+def _history_model_to_entity(m: InventoryHistoryModel) -> InventoryHistoryEntity:
+ return InventoryHistoryEntity(
+ id=m.id,
+ pharmacy_id=m.pharmacy_id,
+ medication_id=m.medication_id,
+ previous_stock=m.previous_stock,
+ new_stock=m.new_stock,
+ delta=m.delta,
+ reason=m.reason,
+ source=m.source,
+ created_at=m.created_at,
+ )
+
+
+class InventoryRepository(IInventoryRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None:
+ stmt = select(InventoryRecordModel).where(
+ InventoryRecordModel.pharmacy_id == pharmacy_id,
+ InventoryRecordModel.medication_id == medication_id,
+ )
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _record_model_to_entity(model) if model else None
+
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]:
+ count_stmt = select(func.count()).select_from(InventoryRecordModel).where(
+ InventoryRecordModel.pharmacy_id == pharmacy_id
+ )
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = (
+ select(InventoryRecordModel)
+ .where(InventoryRecordModel.pharmacy_id == pharmacy_id)
+ .order_by(InventoryRecordModel.last_updated_at.desc().nullslast())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_record_model_to_entity(m) for m in models], total
+
+ async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity:
+ existing = await self.get_record(entity.pharmacy_id, entity.medication_id)
+ if existing:
+ data = _record_entity_to_model_data(entity)
+ stmt = (
+ update(InventoryRecordModel)
+ .where(
+ InventoryRecordModel.pharmacy_id == entity.pharmacy_id,
+ InventoryRecordModel.medication_id == entity.medication_id,
+ )
+ .values(**data)
+ )
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_record(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value]
+ else:
+ model = InventoryRecordModel(**_record_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _record_model_to_entity(model)
+
+ async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity:
+ model = InventoryHistoryModel(
+ inventory_record_id=entity.inventory_record_id,
+ pharmacy_id=entity.pharmacy_id,
+ medication_id=entity.medication_id,
+ previous_stock=entity.previous_stock,
+ new_stock=entity.new_stock,
+ delta=entity.delta,
+ reason=entity.reason,
+ source=entity.source,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return _history_model_to_entity(model)
+
+ async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]:
+ record = await self.get_record(pharmacy_id, medication_id)
+ if not record:
+ return []
+
+ stmt = (
+ select(InventoryHistoryModel)
+ .where(InventoryHistoryModel.inventory_record_id == record.id)
+ .order_by(InventoryHistoryModel.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_history_model_to_entity(m) for m in models]
diff --git a/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py b/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py
new file mode 100644
index 0000000..f44a829
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py
@@ -0,0 +1,90 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import MedicationCatalogEntryEntity
+from src.domain.interfaces import IMedicationCatalogRepository
+from src.infrastructure.database.models import MedicationCatalogEntryModel
+
+
+def _model_to_entity(m: MedicationCatalogEntryModel) -> MedicationCatalogEntryEntity:
+ return MedicationCatalogEntryEntity(
+ id=m.id,
+ pharmacy_id=m.pharmacy_id,
+ medication_id=m.medication_id,
+ price=m.price,
+ stock=m.stock,
+ is_available=m.is_available,
+ last_verified_at=m.last_verified_at,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: MedicationCatalogEntryEntity) -> dict[str, Any]:
+ return {
+ "pharmacy_id": e.pharmacy_id,
+ "medication_id": e.medication_id,
+ "price": e.price,
+ "stock": e.stock,
+ "is_available": e.is_available,
+ "last_verified_at": e.last_verified_at,
+ "metadata_": e.metadata_,
+ }
+
+
+class MedicationCatalogRepository(IMedicationCatalogRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None:
+ stmt = select(MedicationCatalogEntryModel).where(
+ MedicationCatalogEntryModel.pharmacy_id == pharmacy_id,
+ MedicationCatalogEntryModel.medication_id == medication_id,
+ )
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]:
+ count_stmt = select(func.count()).select_from(MedicationCatalogEntryModel).where(
+ MedicationCatalogEntryModel.pharmacy_id == pharmacy_id
+ )
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = (
+ select(MedicationCatalogEntryModel)
+ .where(MedicationCatalogEntryModel.pharmacy_id == pharmacy_id)
+ .order_by(MedicationCatalogEntryModel.is_available.desc(), MedicationCatalogEntryModel.price.asc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity:
+ existing = await self.get_entry(entity.pharmacy_id, entity.medication_id)
+ if existing:
+ from sqlalchemy import update
+ data = _entity_to_model_data(entity)
+ stmt = (
+ update(MedicationCatalogEntryModel)
+ .where(
+ MedicationCatalogEntryModel.pharmacy_id == entity.pharmacy_id,
+ MedicationCatalogEntryModel.medication_id == entity.medication_id,
+ )
+ .values(**data)
+ )
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_entry(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value]
+ else:
+ model = MedicationCatalogEntryModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
diff --git a/pip-platform/src/infrastructure/database/repositories/medication_repository.py b/pip-platform/src/infrastructure/database/repositories/medication_repository.py
new file mode 100644
index 0000000..416bd54
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/medication_repository.py
@@ -0,0 +1,101 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, or_, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import MedicationEntity
+from src.domain.interfaces import IMedicationRepository
+from src.infrastructure.database.models import MedicationModel
+
+
+def _model_to_entity(m: MedicationModel) -> MedicationEntity:
+ return MedicationEntity(
+ id=m.id,
+ nregistro=m.nregistro,
+ name=m.name,
+ active_ingredient=m.active_ingredient,
+ dosage=m.dosage,
+ form=m.form,
+ atc_code=m.atc_code,
+ prescription_required=m.prescription_required,
+ manufacturer=m.manufacturer,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: MedicationEntity) -> dict[str, Any]:
+ return {
+ "nregistro": e.nregistro,
+ "name": e.name,
+ "active_ingredient": e.active_ingredient,
+ "dosage": e.dosage,
+ "form": e.form,
+ "atc_code": e.atc_code,
+ "prescription_required": e.prescription_required,
+ "manufacturer": e.manufacturer,
+ "metadata_": e.metadata_,
+ }
+
+
+class MedicationRepository(IMedicationRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None:
+ stmt = select(MedicationModel).where(MedicationModel.id == medication_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None:
+ stmt = select(MedicationModel).where(MedicationModel.nregistro == nregistro)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]:
+ pattern = f"%{query}%"
+ filters = [
+ or_(
+ MedicationModel.name.ilike(pattern),
+ MedicationModel.active_ingredient.ilike(pattern),
+ MedicationModel.nregistro.ilike(pattern),
+ MedicationModel.atc_code.ilike(pattern),
+ )
+ ]
+
+ count_stmt = select(func.count()).select_from(MedicationModel).where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = (
+ select(MedicationModel)
+ .where(*filters)
+ .order_by(MedicationModel.name)
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: MedicationEntity) -> MedicationEntity:
+ model = MedicationModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: MedicationEntity) -> MedicationEntity:
+ data = _entity_to_model_data(entity)
+ stmt = update(MedicationModel).where(MedicationModel.id == entity.id).values(**data)
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
+
+ async def delete(self, medication_id: uuid.UUID) -> bool:
+ stmt = delete(MedicationModel).where(MedicationModel.id == medication_id)
+ result = await self._session.execute(stmt)
+ return result.rowcount > 0
diff --git a/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py b/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py
new file mode 100644
index 0000000..692b6e5
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py
@@ -0,0 +1,126 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import PharmacyEntity
+from src.domain.interfaces import IPharmacyRepository
+from src.infrastructure.database.models import PharmacyModel
+
+
+def _model_to_entity(m: PharmacyModel) -> PharmacyEntity:
+ return PharmacyEntity(
+ id=m.id,
+ code=m.code,
+ name=m.name,
+ address=m.address,
+ city=m.city,
+ province=m.province,
+ postal_code=m.postal_code,
+ country=m.country,
+ latitude=m.latitude,
+ longitude=m.longitude,
+ phone=m.phone,
+ email=m.email,
+ opening_hours=m.opening_hours,
+ status=m.status,
+ erp_type=m.erp_type,
+ erp_version=m.erp_version,
+ connector_id=m.connector_id,
+ sync_strategy=m.sync_strategy,
+ sync_interval_minutes=m.sync_interval_minutes,
+ last_sync_at=m.last_sync_at,
+ last_sync_status=m.last_sync_status,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: PharmacyEntity) -> dict[str, Any]:
+ return {
+ "code": e.code,
+ "name": e.name,
+ "address": e.address,
+ "city": e.city,
+ "province": e.province,
+ "postal_code": e.postal_code,
+ "country": e.country,
+ "latitude": e.latitude,
+ "longitude": e.longitude,
+ "phone": e.phone,
+ "email": e.email,
+ "opening_hours": e.opening_hours,
+ "status": e.status,
+ "erp_type": e.erp_type,
+ "erp_version": e.erp_version,
+ "connector_id": e.connector_id,
+ "sync_strategy": e.sync_strategy,
+ "sync_interval_minutes": e.sync_interval_minutes,
+ "last_sync_at": e.last_sync_at,
+ "last_sync_status": e.last_sync_status,
+ "metadata_": e.metadata_,
+ }
+
+
+class PharmacyRepository(IPharmacyRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None:
+ stmt = select(PharmacyModel).where(PharmacyModel.id == pharmacy_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def get_by_code(self, code: str) -> PharmacyEntity | None:
+ stmt = select(PharmacyModel).where(PharmacyModel.code == code)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_pharmacies(
+ self,
+ *,
+ status: str | None = None,
+ erp_type: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[PharmacyEntity], int]:
+ filters: list[Any] = []
+ if status:
+ filters.append(PharmacyModel.status == status)
+ if erp_type:
+ filters.append(PharmacyModel.erp_type == erp_type)
+
+ count_stmt = select(func.count()).select_from(PharmacyModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = select(PharmacyModel).order_by(PharmacyModel.name).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: PharmacyEntity) -> PharmacyEntity:
+ model = PharmacyModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: PharmacyEntity) -> PharmacyEntity:
+ data = _entity_to_model_data(entity)
+ stmt = update(PharmacyModel).where(PharmacyModel.id == entity.id).values(**data)
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
+
+ async def delete(self, pharmacy_id: uuid.UUID) -> bool:
+ stmt = delete(PharmacyModel).where(PharmacyModel.id == pharmacy_id)
+ result = await self._session.execute(stmt)
+ return result.rowcount > 0
diff --git a/pip-platform/src/infrastructure/database/repositories/reservation_repository.py b/pip-platform/src/infrastructure/database/repositories/reservation_repository.py
new file mode 100644
index 0000000..a4b637e
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/reservation_repository.py
@@ -0,0 +1,94 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import ReservationEntity
+from src.domain.interfaces import IReservationRepository
+from src.infrastructure.database.models import ReservationModel
+
+
+def _model_to_entity(m: ReservationModel) -> ReservationEntity:
+ return ReservationEntity(
+ id=m.id,
+ pharmacy_id=m.pharmacy_id,
+ medication_id=m.medication_id,
+ user_id=m.user_id,
+ external_reference=m.external_reference,
+ quantity=m.quantity,
+ status=m.status,
+ expires_at=m.expires_at,
+ confirmed_at=m.confirmed_at,
+ cancelled_at=m.cancelled_at,
+ idempotency_key=m.idempotency_key,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: ReservationEntity) -> dict[str, Any]:
+ return {
+ "pharmacy_id": e.pharmacy_id,
+ "medication_id": e.medication_id,
+ "user_id": e.user_id,
+ "external_reference": e.external_reference,
+ "quantity": e.quantity,
+ "status": e.status,
+ "expires_at": e.expires_at,
+ "confirmed_at": e.confirmed_at,
+ "cancelled_at": e.cancelled_at,
+ "idempotency_key": e.idempotency_key,
+ "metadata_": e.metadata_,
+ }
+
+
+class ReservationRepository(IReservationRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None:
+ stmt = select(ReservationModel).where(ReservationModel.id == reservation_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None:
+ stmt = select(ReservationModel).where(ReservationModel.idempotency_key == key)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]:
+ filters: list[Any] = [ReservationModel.pharmacy_id == pharmacy_id]
+ if status:
+ filters.append(ReservationModel.status == status)
+
+ count_stmt = select(func.count()).select_from(ReservationModel).where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = (
+ select(ReservationModel)
+ .where(*filters)
+ .order_by(ReservationModel.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: ReservationEntity) -> ReservationEntity:
+ model = ReservationModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: ReservationEntity) -> ReservationEntity:
+ data = _entity_to_model_data(entity)
+ stmt = update(ReservationModel).where(ReservationModel.id == entity.id).values(**data)
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
diff --git a/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py b/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py
new file mode 100644
index 0000000..6f583a9
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py
@@ -0,0 +1,95 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import SyncJobEntity, SyncJobStatus
+from src.domain.interfaces import ISyncJobRepository
+from src.infrastructure.database.models import SyncJobModel
+
+
+def _model_to_entity(m: SyncJobModel) -> SyncJobEntity:
+ return SyncJobEntity(
+ id=m.id,
+ pharmacy_id=m.pharmacy_id,
+ connector_id=m.connector_id,
+ strategy=m.strategy,
+ status=m.status,
+ started_at=m.started_at,
+ completed_at=m.completed_at,
+ items_total=m.items_total,
+ items_processed=m.items_processed,
+ items_failed=m.items_failed,
+ error_message=m.error_message,
+ metadata_=m.metadata_,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+def _entity_to_model_data(e: SyncJobEntity) -> dict[str, Any]:
+ return {
+ "pharmacy_id": e.pharmacy_id,
+ "connector_id": e.connector_id,
+ "strategy": e.strategy,
+ "status": e.status,
+ "started_at": e.started_at,
+ "completed_at": e.completed_at,
+ "items_total": e.items_total,
+ "items_processed": e.items_processed,
+ "items_failed": e.items_failed,
+ "error_message": e.error_message,
+ "metadata_": e.metadata_,
+ }
+
+
+class SyncJobRepository(ISyncJobRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None:
+ stmt = select(SyncJobModel).where(SyncJobModel.id == job_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_by_pharmacy(
+ self,
+ pharmacy_id: uuid.UUID,
+ *,
+ status: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[Sequence[SyncJobEntity], int]:
+ filters: list[Any] = [SyncJobModel.pharmacy_id == pharmacy_id]
+ if status:
+ filters.append(SyncJobModel.status == status)
+
+ count_stmt = select(func.count()).select_from(SyncJobModel).where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = (
+ select(SyncJobModel)
+ .where(*filters)
+ .order_by(SyncJobModel.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: SyncJobEntity) -> SyncJobEntity:
+ model = SyncJobModel(**_entity_to_model_data(entity))
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: SyncJobEntity) -> SyncJobEntity:
+ data = _entity_to_model_data(entity)
+ stmt = update(SyncJobModel).where(SyncJobModel.id == entity.id).values(**data)
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
diff --git a/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py b/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py
new file mode 100644
index 0000000..f99d19b
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py
@@ -0,0 +1,53 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import SyncLogEntity
+from src.domain.interfaces import ISyncLogRepository
+from src.infrastructure.database.models import SyncLogModel
+
+
+def _model_to_entity(m: SyncLogModel) -> SyncLogEntity:
+ return SyncLogEntity(
+ id=m.id,
+ sync_job_id=m.sync_job_id,
+ level=m.level,
+ message=m.message,
+ details=m.details,
+ created_at=m.created_at,
+ )
+
+
+class SyncLogRepository(ISyncLogRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def add(self, entity: SyncLogEntity) -> SyncLogEntity:
+ model = SyncLogModel(
+ sync_job_id=entity.sync_job_id,
+ level=entity.level,
+ message=entity.message,
+ details=entity.details,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def list_by_job(
+ self,
+ job_id: uuid.UUID,
+ *,
+ level: str | None = None,
+ offset: int = 0,
+ limit: int = 100,
+ ) -> Sequence[SyncLogEntity]:
+ stmt = select(SyncLogModel).where(SyncLogModel.sync_job_id == job_id)
+ if level:
+ stmt = stmt.where(SyncLogModel.level == level)
+ stmt = stmt.order_by(SyncLogModel.created_at.desc()).offset(offset).limit(limit)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models]
diff --git a/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py b/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py
new file mode 100644
index 0000000..59ffda6
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py
@@ -0,0 +1,88 @@
+import uuid
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.domain.entities import TechnicalUserEntity
+from src.domain.interfaces import ITechnicalUserRepository
+from src.infrastructure.database.models import TechnicalUserModel
+
+
+def _model_to_entity(m: TechnicalUserModel) -> TechnicalUserEntity:
+ return TechnicalUserEntity(
+ id=m.id,
+ username=m.username,
+ email=m.email,
+ full_name=m.full_name,
+ role=m.role,
+ is_active=m.is_active,
+ scopes=m.scopes or [],
+ last_login_at=m.last_login_at,
+ created_at=m.created_at,
+ updated_at=m.updated_at,
+ )
+
+
+class TechnicalUserRepository(ITechnicalUserRepository):
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+
+ async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None:
+ stmt = select(TechnicalUserModel).where(TechnicalUserModel.id == user_id)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def get_by_username(self, username: str) -> TechnicalUserEntity | None:
+ stmt = select(TechnicalUserModel).where(TechnicalUserModel.username == username)
+ result = await self._session.execute(stmt)
+ model = result.scalar_one_or_none()
+ return _model_to_entity(model) if model else None
+
+ async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]:
+ filters: list[Any] = []
+ if role:
+ filters.append(TechnicalUserModel.role == role)
+ if is_active is not None:
+ filters.append(TechnicalUserModel.is_active == is_active)
+
+ count_stmt = select(func.count()).select_from(TechnicalUserModel)
+ if filters:
+ count_stmt = count_stmt.where(*filters)
+ total = (await self._session.execute(count_stmt)).scalar() or 0
+
+ stmt = select(TechnicalUserModel).order_by(TechnicalUserModel.username).offset(offset).limit(limit)
+ if filters:
+ stmt = stmt.where(*filters)
+ result = await self._session.execute(stmt)
+ models = result.scalars().all()
+ return [_model_to_entity(m) for m in models], total
+
+ async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity:
+ model = TechnicalUserModel(
+ username=entity.username,
+ email=entity.email,
+ full_name=entity.full_name,
+ password_hash=password_hash,
+ role=entity.role,
+ is_active=entity.is_active,
+ scopes=entity.scopes,
+ )
+ self._session.add(model)
+ await self._session.flush()
+ return _model_to_entity(model)
+
+ async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity:
+ stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == entity.id).values(
+ email=entity.email,
+ full_name=entity.full_name,
+ role=entity.role,
+ is_active=entity.is_active,
+ scopes=entity.scopes,
+ last_login_at=entity.last_login_at,
+ )
+ await self._session.execute(stmt)
+ await self._session.flush()
+ return await self.get_by_id(entity.id) # type: ignore[return-value]
diff --git a/pip-platform/src/infrastructure/database/session.py b/pip-platform/src/infrastructure/database/session.py
new file mode 100644
index 0000000..a545bb3
--- /dev/null
+++ b/pip-platform/src/infrastructure/database/session.py
@@ -0,0 +1,40 @@
+from collections.abc import AsyncGenerator
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from src.infrastructure.config.settings import settings
+
+engine = create_async_engine(
+ settings.DATABASE_URL,
+ pool_size=settings.DATABASE_POOL_SIZE,
+ max_overflow=settings.DATABASE_MAX_OVERFLOW,
+ pool_recycle=settings.DATABASE_POOL_RECYCLE,
+ echo=settings.DEBUG,
+ pool_pre_ping=True,
+)
+
+async_session_factory = async_sessionmaker(
+ engine,
+ class_=AsyncSession,
+ expire_on_commit=False,
+)
+
+
+async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session_factory() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+ finally:
+ await session.close()
+
+
+async def get_db_session_no_commit() -> AsyncGenerator[AsyncSession, None]:
+ async with async_session_factory() as session:
+ try:
+ yield session
+ finally:
+ await session.close()
diff --git a/pip-platform/src/infrastructure/messaging/__init__.py b/pip-platform/src/infrastructure/messaging/__init__.py
new file mode 100644
index 0000000..fa0dc23
--- /dev/null
+++ b/pip-platform/src/infrastructure/messaging/__init__.py
@@ -0,0 +1,3 @@
+from .rabbitmq import rabbitmq_client
+
+__all__ = ["rabbitmq_client"]
diff --git a/pip-platform/src/infrastructure/messaging/rabbitmq.py b/pip-platform/src/infrastructure/messaging/rabbitmq.py
new file mode 100644
index 0000000..53453c3
--- /dev/null
+++ b/pip-platform/src/infrastructure/messaging/rabbitmq.py
@@ -0,0 +1,131 @@
+import json
+import logging
+from typing import Any
+
+import aio_pika
+from aio_pika import ExchangeType, Message, RobustConnection
+
+from src.infrastructure.config.settings import settings
+
+logger = logging.getLogger(__name__)
+
+
+class RabbitMQClient:
+ PIP_EXCHANGE = "pip.events"
+ PIP_DLX = "pip.dlx"
+
+ ROUTING_KEYS = {
+ "inventory_updated": "inventory.updated",
+ "reservation_created": "reservation.created",
+ "reservation_confirmed": "reservation.confirmed",
+ "reservation_cancelled": "reservation.cancelled",
+ "reservation_expired": "reservation.expired",
+ "sync_started": "sync.started",
+ "sync_completed": "sync.completed",
+ "sync_failed": "sync.failed",
+ "sync_cancelled": "sync.cancelled",
+ "sync_created": "sync.created",
+ "connector_heartbeat": "connector.heartbeat",
+ "connector_error": "connector.error",
+ "connector_created": "connector.created",
+ "connector_updated": "connector.updated",
+ "connector_deleted": "connector.deleted",
+ "connector_deployed": "connector.deployed",
+ "connector_deactivated": "connector.deactivated",
+ "pharmacy_updated": "pharmacy.updated",
+ "medication_created": "medication.created",
+ "medication_updated": "medication.updated",
+ "medication_deleted": "medication.deleted",
+ "catalog_upserted": "catalog.upserted",
+ }
+
+ def __init__(self) -> None:
+ self._connection: RobustConnection | None = None
+ self._channel: aio_pika.RobustChannel | None = None
+ self._exchange: aio_pika.RobustExchange | None = None
+ self._dlx: aio_pika.RobustExchange | None = None
+
+ async def connect(self) -> None:
+ self._connection = await aio_pika.connect_robust(settings.RABBITMQ_URL)
+ self._channel = await self._connection.channel()
+ await self._channel.set_qos(prefetch_count=100)
+
+ self._exchange = await self._channel.declare_exchange(
+ self.PIP_EXCHANGE,
+ ExchangeType.TOPIC,
+ durable=True,
+ )
+
+ self._dlx = await self._channel.declare_exchange(
+ self.PIP_DLX,
+ ExchangeType.TOPIC,
+ durable=True,
+ )
+
+ logger.info("rabbitmq_connected", extra={"url": settings.RABBITMQ_URL})
+
+ async def disconnect(self) -> None:
+ if self._connection:
+ await self._connection.close()
+
+ async def publish(self, routing_key: str, payload: dict[str, Any], headers: dict[str, str] | None = None) -> None:
+ if not self._exchange:
+ raise RuntimeError("RabbitMQ not connected. Call connect() first.")
+
+ message = Message(
+ body=json.dumps(payload, default=str).encode(),
+ content_type="application/json",
+ delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
+ headers=headers or {},
+ )
+
+ await self._exchange.publish(message, routing_key=routing_key)
+
+ logger.debug(
+ "message_published",
+ extra={"routing_key": routing_key},
+ )
+
+ async def consume(
+ self,
+ queue_name: str,
+ routing_keys: list[str],
+ callback: Any,
+ dlq: bool = True,
+ ) -> aio_pika.RobustQueue:
+ if not self._channel or not self._exchange:
+ raise RuntimeError("RabbitMQ not connected. Call connect() first.")
+
+ queue_arguments = {
+ "x-dead-letter-exchange": self.PIP_DLX,
+ "x-dead-letter-routing-key": queue_name,
+ }
+
+ queue = await self._channel.declare_queue(
+ queue_name,
+ durable=True,
+ arguments=queue_arguments,
+ )
+
+ for routing_key in routing_keys:
+ await queue.bind(self._exchange, routing_key=routing_key)
+
+ if dlq and self._dlx:
+ dlq_name = f"{queue_name}.dlq"
+ dlq = await self._channel.declare_queue(dlq_name, durable=True)
+ await dlq.bind(self._dlx, routing_key=queue_name)
+
+ await queue.consume(callback)
+
+ return queue
+
+ async def health_check(self) -> bool:
+ try:
+ if not self._connection or self._connection.is_closed:
+ return False
+ return True
+ except Exception:
+ return False
+
+
+rabbitmq_client = RabbitMQClient()
diff --git a/pip-platform/src/main.py b/pip-platform/src/main.py
new file mode 100644
index 0000000..f0ab911
--- /dev/null
+++ b/pip-platform/src/main.py
@@ -0,0 +1,78 @@
+from contextlib import asynccontextmanager
+from collections.abc import AsyncGenerator
+
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from prometheus_client import make_asgi_app
+
+from src.core.exceptions import PIPException
+from src.core.logging import configure_logging
+from src.core.metrics import metrics_middleware
+from src.core.tracing import setup_tracing
+from src.infrastructure.cache.redis_cache import redis_cache
+from src.infrastructure.config.settings import settings
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+from src.api.middleware.audit import AuditMiddleware
+from src.api.middleware.logging import RequestLoggingMiddleware
+from src.api.middleware.rate_limit import RateLimitMiddleware
+from src.api.v1.router import v1_router
+from src.infrastructure.database.session import engine
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+ configure_logging()
+ setup_tracing(app, engine=engine)
+
+ await redis_cache.connect()
+ await rabbitmq_client.connect()
+
+ yield
+
+ await rabbitmq_client.disconnect()
+ await redis_cache.disconnect()
+ await engine.dispose()
+
+
+def create_app() -> FastAPI:
+ application = FastAPI(
+ title=settings.APP_NAME,
+ version=settings.APP_VERSION,
+ description="Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications",
+ docs_url="/docs",
+ redoc_url="/redoc",
+ openapi_url="/openapi.json",
+ lifespan=lifespan,
+ )
+
+ application.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.CORS_ORIGINS,
+ allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ application.add_middleware(RateLimitMiddleware)
+ application.add_middleware(AuditMiddleware)
+ application.add_middleware(RequestLoggingMiddleware)
+ application.middleware("http")(metrics_middleware)
+
+ application.include_router(v1_router)
+
+ metrics_app = make_asgi_app()
+ application.mount("/metrics", metrics_app)
+
+ @application.exception_handler(PIPException)
+ async def pip_exception_handler(request: Request, exc: PIPException) -> JSONResponse:
+ return JSONResponse(status_code=exc.status_code, content=exc.to_dict())
+
+ @application.exception_handler(Exception)
+ async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
+ return JSONResponse(status_code=500, content={"error": {"code": "INTERNAL_ERROR", "message": "Internal server error"}})
+
+ return application
+
+
+app = create_app()
diff --git a/pip-platform/src/services/__init__.py b/pip-platform/src/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/services/audit/__init__.py b/pip-platform/src/services/audit/__init__.py
new file mode 100644
index 0000000..bfe0b78
--- /dev/null
+++ b/pip-platform/src/services/audit/__init__.py
@@ -0,0 +1,3 @@
+from .audit_service import AuditService
+
+__all__ = ["AuditService"]
diff --git a/pip-platform/src/services/audit/audit_service.py b/pip-platform/src/services/audit/audit_service.py
new file mode 100644
index 0000000..c08cd52
--- /dev/null
+++ b/pip-platform/src/services/audit/audit_service.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.domain.entities import AuditLogEntity
+from src.domain.interfaces import IAuditLogRepository
+from src.core.logging import get_logger
+
+logger = get_logger("audit_service")
+
+
+class AuditService:
+ def __init__(self, audit_repo: IAuditLogRepository) -> None:
+ self._audit_repo = audit_repo
+
+ async def log_action(
+ self,
+ action: str,
+ resource_type: str,
+ *,
+ actor_id: UUID | None = None,
+ actor_type: str | None = None,
+ resource_id: str | None = None,
+ old_value: dict | None = None,
+ new_value: dict | None = None,
+ ip_address: str | None = None,
+ user_agent: str | None = None,
+ request_id: str | None = None,
+ ) -> AuditLogEntity:
+ entity = AuditLogEntity(
+ actor_id=actor_id,
+ actor_type=actor_type,
+ action=action,
+ resource_type=resource_type,
+ resource_id=resource_id,
+ old_value=old_value,
+ new_value=new_value,
+ ip_address=ip_address,
+ user_agent=user_agent,
+ request_id=request_id,
+ )
+ created = await self._audit_repo.add(entity)
+ return created
+
+ async def list_logs(
+ self,
+ *,
+ actor_id: UUID | None = None,
+ action: str | None = None,
+ resource_type: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[list[AuditLogEntity], int]:
+ return await self._audit_repo.list_logs(
+ actor_id=actor_id,
+ action=action,
+ resource_type=resource_type,
+ offset=offset,
+ limit=limit,
+ )
diff --git a/pip-platform/src/services/auth/__init__.py b/pip-platform/src/services/auth/__init__.py
new file mode 100644
index 0000000..758c722
--- /dev/null
+++ b/pip-platform/src/services/auth/__init__.py
@@ -0,0 +1,27 @@
+from .security import (
+ RBACGuard,
+ TokenData,
+ create_access_token,
+ create_refresh_token,
+ decode_token,
+ generate_api_key,
+ hash_api_key,
+ hash_password,
+ validate_token_type,
+ verify_api_key,
+ verify_password,
+)
+
+__all__ = [
+ "RBACGuard",
+ "TokenData",
+ "create_access_token",
+ "create_refresh_token",
+ "decode_token",
+ "generate_api_key",
+ "hash_api_key",
+ "hash_password",
+ "validate_token_type",
+ "verify_api_key",
+ "verify_password",
+]
diff --git a/pip-platform/src/services/auth/auth_service.py b/pip-platform/src/services/auth/auth_service.py
new file mode 100644
index 0000000..ccaf689
--- /dev/null
+++ b/pip-platform/src/services/auth/auth_service.py
@@ -0,0 +1,102 @@
+import uuid
+from datetime import datetime, timezone
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.core.exceptions import ConflictException, UnauthorizedException, ValidationException
+from src.core.logging import get_logger
+from src.domain.entities import TechnicalUserEntity, UserRole
+from src.infrastructure.database.repositories import TechnicalUserRepository
+from src.services.auth.security import (
+ RBACGuard,
+ TokenData,
+ create_access_token,
+ create_refresh_token,
+ decode_token,
+ hash_password,
+ validate_token_type,
+ verify_password,
+)
+
+logger = get_logger("auth_service")
+
+
+class AuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self._user_repo = TechnicalUserRepository(session)
+ self._session = session
+
+ async def register(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER) -> TechnicalUserEntity:
+ if not username or len(username) < 3:
+ raise ValidationException("Username must be at least 3 characters")
+ if not password or len(password) < 8:
+ raise ValidationException("Password must be at least 8 characters")
+
+ existing = await self._user_repo.get_by_username(username)
+ if existing:
+ raise ConflictException(f"Username '{username}' already exists")
+
+ pw_hash = hash_password(password)
+ entity = TechnicalUserEntity(
+ username=username,
+ email=email,
+ full_name=full_name,
+ role=role,
+ is_active=True,
+ scopes=self._default_scopes_for_role(role),
+ )
+ user = await self._user_repo.create(entity, pw_hash)
+ logger.info("user_registered", extra={"username": username, "role": role.value})
+ return user
+
+ async def authenticate(self, username: str, password: str) -> tuple[TechnicalUserEntity, str, str]:
+ user = await self._user_repo.get_by_username(username)
+ if not user:
+ raise UnauthorizedException("Invalid credentials")
+ if not user.is_active:
+ raise UnauthorizedException("Account is disabled")
+
+ from src.infrastructure.database.models import TechnicalUserModel
+ from sqlalchemy import select
+ stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user.id)
+ result = await self._session.execute(stmt)
+ pw_hash = result.scalar_one_or_none()
+ if not pw_hash or not verify_password(password, pw_hash):
+ raise UnauthorizedException("Invalid credentials")
+
+ user.last_login_at = datetime.now(timezone.utc)
+ await self._user_repo.update(user)
+
+ access_token = create_access_token(subject=user.id, role=user.role, scopes=user.scopes)
+ refresh_token = create_refresh_token(subject=user.id)
+
+ logger.info("user_authenticated", extra={"username": username})
+ return user, access_token, refresh_token
+
+ async def refresh_access_token(self, refresh_token: str) -> tuple[str, str]:
+ payload = decode_token(refresh_token)
+ validate_token_type(payload, expected_type="refresh")
+
+ user_id = uuid.UUID(payload["sub"])
+ user = await self._user_repo.get_by_id(user_id)
+ if not user or not user.is_active:
+ raise UnauthorizedException("User not found or inactive")
+
+ new_access = create_access_token(subject=user.id, role=user.role, scopes=user.scopes)
+ new_refresh = create_refresh_token(subject=user.id)
+ return new_access, new_refresh
+
+ async def validate_access_token(self, token: str) -> TokenData:
+ payload = decode_token(token)
+ validate_token_type(payload, expected_type="access")
+ return TokenData(payload)
+
+ def _default_scopes_for_role(self, role: UserRole) -> list[str]:
+ scopes_map: dict[UserRole, list[str]] = {
+ UserRole.SUPERADMIN: ["*"],
+ UserRole.ADMIN: ["pharmacies:read", "pharmacies:write", "medications:read", "medications:write", "inventory:read", "inventory:write", "connectors:read", "connectors:write", "sync:read", "sync:write", "reservations:read", "reservations:write", "audit:read", "users:read", "users:write", "api-keys:read", "api-keys:write"],
+ UserRole.OPERATOR: ["pharmacies:read", "pharmacies:write", "medications:read", "inventory:read", "inventory:write", "connectors:read", "sync:read", "sync:write", "reservations:read", "reservations:write"],
+ UserRole.VIEWER: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read"],
+ UserRole.API_CLIENT: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read", "reservations:write"],
+ }
+ return scopes_map.get(role, ["pharmacies:read", "medications:read", "inventory:read"])
diff --git a/pip-platform/src/services/auth/security.py b/pip-platform/src/services/auth/security.py
new file mode 100644
index 0000000..50458eb
--- /dev/null
+++ b/pip-platform/src/services/auth/security.py
@@ -0,0 +1,126 @@
+import hashlib
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Any
+from uuid import UUID, uuid4
+
+from jose import JWTError, jwt
+
+from src.core.exceptions import ForbiddenException, UnauthorizedException
+from src.domain.entities import UserRole
+from src.infrastructure.config.settings import settings
+
+
+def hash_password(password: str) -> str:
+ import bcrypt as _bcrypt
+ return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+ import bcrypt as _bcrypt
+ return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
+
+
+def create_access_token(subject: str | UUID, role: UserRole, scopes: list[str], extra_claims: dict | None = None) -> str:
+ now = datetime.now(timezone.utc)
+ expires = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
+ claims: dict[str, Any] = {
+ "sub": str(subject),
+ "role": role.value,
+ "scopes": scopes,
+ "iss": settings.JWT_ISSUER,
+ "iat": now,
+ "exp": expires,
+ "type": "access",
+ "jti": str(uuid4()),
+ }
+ if extra_claims:
+ claims.update(extra_claims)
+ return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
+
+
+def create_refresh_token(subject: str | UUID) -> str:
+ now = datetime.now(timezone.utc)
+ expires = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
+ claims: dict[str, Any] = {
+ "sub": str(subject),
+ "iss": settings.JWT_ISSUER,
+ "iat": now,
+ "exp": expires,
+ "type": "refresh",
+ "jti": str(uuid4()),
+ }
+ return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
+
+
+def decode_token(token: str) -> dict[str, Any]:
+ try:
+ payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM], issuer=settings.JWT_ISSUER)
+ return payload
+ except JWTError as e:
+ raise UnauthorizedException(message=f"Invalid token: {e}") from e
+
+
+def validate_token_type(payload: dict[str, Any], expected_type: str = "access") -> dict[str, Any]:
+ if payload.get("type") != expected_type:
+ raise UnauthorizedException(message=f"Expected {expected_type} token, got {payload.get('type')}")
+ return payload
+
+
+class TokenData:
+ def __init__(self, payload: dict[str, Any]):
+ self.subject: str = payload.get("sub", "")
+ self.role: UserRole = UserRole(payload.get("role", "viewer"))
+ self.scopes: list[str] = payload.get("scopes", [])
+ self.issuer: str = payload.get("iss", "")
+ self.token_type: str = payload.get("type", "access")
+ self.jti: str = payload.get("jti", "")
+ self.exp: datetime | None = None
+ if exp := payload.get("exp"):
+ self.exp = datetime.fromtimestamp(exp, tz=timezone.utc)
+
+
+class RBACGuard:
+ ROLE_HIERARCHY: dict[UserRole, set[UserRole]] = {
+ UserRole.SUPERADMIN: {UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT},
+ UserRole.ADMIN: {UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT},
+ UserRole.OPERATOR: {UserRole.OPERATOR, UserRole.VIEWER},
+ UserRole.VIEWER: {UserRole.VIEWER},
+ UserRole.API_CLIENT: {UserRole.API_CLIENT, UserRole.VIEWER},
+ }
+
+ @classmethod
+ def has_role(cls, token_data: TokenData, required_role: UserRole) -> bool:
+ allowed = cls.ROLE_HIERARCHY.get(token_data.role, set())
+ return required_role in allowed
+
+ @classmethod
+ def require_role(cls, token_data: TokenData, required_role: UserRole) -> None:
+ if not cls.has_role(token_data, required_role):
+ raise ForbiddenException(required_role=required_role.value)
+
+ @classmethod
+ def has_scope(cls, token_data: TokenData, required_scope: str) -> bool:
+ if "*" in token_data.scopes:
+ return True
+ return required_scope in token_data.scopes
+
+ @classmethod
+ def require_scope(cls, token_data: TokenData, required_scope: str) -> None:
+ if not cls.has_scope(token_data, required_scope):
+ raise ForbiddenException(message=f"Scope '{required_scope}' required")
+
+
+def generate_api_key() -> tuple[str, str, str]:
+ raw_key = secrets.token_urlsafe(settings.API_KEY_LENGTH)
+ prefix = raw_key[:8]
+ key_hash = hashlib.sha256(raw_key.encode()).digest()
+ return raw_key, prefix, key_hash
+
+
+def hash_api_key(raw_key: str) -> bytes:
+ return hashlib.sha256(raw_key.encode()).digest()
+
+
+def verify_api_key(raw_key: str, stored_hash: bytes) -> bool:
+ return hashlib.sha256(raw_key.encode()).digest() == stored_hash
diff --git a/pip-platform/src/services/auth/user_service.py b/pip-platform/src/services/auth/user_service.py
new file mode 100644
index 0000000..7ce033d
--- /dev/null
+++ b/pip-platform/src/services/auth/user_service.py
@@ -0,0 +1,71 @@
+import uuid
+from datetime import datetime, timezone
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from src.core.exceptions import ConflictException, ForbiddenException, NotFoundException, ValidationException
+from src.core.logging import get_logger
+from src.domain.entities import TechnicalUserEntity, UserRole
+from src.infrastructure.database.models import TechnicalUserModel
+from src.infrastructure.database.repositories import TechnicalUserRepository
+from src.services.auth.security import RBACGuard, TokenData, hash_password, verify_password
+
+logger = get_logger("user_service")
+
+
+class UserService:
+ def __init__(self, session: AsyncSession) -> None:
+ self._session = session
+ self._repo = TechnicalUserRepository(session)
+
+ async def get_user(self, user_id: uuid.UUID) -> TechnicalUserEntity:
+ user = await self._repo.get_by_id(user_id)
+ if not user:
+ raise NotFoundException("User", str(user_id))
+ return user
+
+ async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50, caller: TokenData | None = None) -> tuple[list[TechnicalUserEntity], int]:
+ if caller and not RBACGuard.has_scope(caller, "users:read"):
+ raise ForbiddenException("Scope 'users:read' required")
+ return await self._repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
+
+ async def create_user(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER, caller: TokenData | None = None) -> TechnicalUserEntity:
+ if caller:
+ RBACGuard.require_role(caller, UserRole.ADMIN)
+ existing = await self._repo.get_by_username(username)
+ if existing:
+ raise ConflictException(f"Username '{username}' already exists")
+ pw_hash = hash_password(password)
+ entity = TechnicalUserEntity(username=username, email=email, full_name=full_name, role=role, is_active=True, scopes=[])
+ user = await self._repo.create(entity, pw_hash)
+ logger.info("user_created", extra={"username": username, "role": role.value, "created_by": caller.subject if caller else "system"})
+ return user
+
+ async def update_user(self, user_id: uuid.UUID, *, email: str | None = None, full_name: str | None = None, role: UserRole | None = None, is_active: bool | None = None, caller: TokenData | None = None) -> TechnicalUserEntity:
+ if caller:
+ RBACGuard.require_role(caller, UserRole.ADMIN)
+ user = await self.get_user(user_id)
+ if email is not None:
+ user.email = email
+ if full_name is not None:
+ user.full_name = full_name
+ if role is not None:
+ user.role = role
+ if is_active is not None:
+ user.is_active = is_active
+ user = await self._repo.update(user)
+ logger.info("user_updated", extra={"user_id": str(user_id), "updated_by": caller.subject if caller else "system"})
+ return user
+
+ async def change_password(self, user_id: uuid.UUID, current_password: str, new_password: str, caller: TokenData | None = None) -> bool:
+ stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user_id)
+ result = await self._session.execute(stmt)
+ pw_hash = result.scalar_one_or_none()
+ if not pw_hash or not verify_password(current_password, pw_hash):
+ raise ValidationException("Current password is incorrect")
+ new_hash = hash_password(new_password)
+ stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == user_id).values(password_hash=new_hash)
+ await self._session.execute(stmt)
+ logger.info("password_changed", extra={"user_id": str(user_id)})
+ return True
diff --git a/pip-platform/src/services/connector/__init__.py b/pip-platform/src/services/connector/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/services/connector_service.py b/pip-platform/src/services/connector_service.py
new file mode 100644
index 0000000..6518faa
--- /dev/null
+++ b/pip-platform/src/services/connector_service.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.connectors.base import ConnectorResult
+from src.connectors.registry import connector_registry
+from src.connectors.sdk.context import ConnectorContext
+from src.core.exceptions import ConnectorException
+from src.core.logging import get_logger
+from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus
+from src.domain.interfaces import IConnectorConfigRepository, IConnectorRepository
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+
+logger = get_logger("connector_service")
+
+
+class ConnectorService:
+ def __init__(self, connector_repo: IConnectorRepository, config_repo: IConnectorConfigRepository) -> None:
+ self._connector_repo = connector_repo
+ self._config_repo = config_repo
+
+ async def create_connector(self, entity: ConnectorEntity, configs: list[ConnectorConfigEntity] | None = None) -> ConnectorEntity:
+ created = await self._connector_repo.create(entity)
+
+ if configs:
+ for cfg in configs:
+ cfg.connector_id = created.id
+ await self._config_repo.upsert(cfg)
+
+ await self._publish_event("connector.created", created.id, {"connector_type": created.connector_type, "pharmacy_id": str(created.pharmacy_id), "erp_system_id": str(created.erp_system_id), "status": created.status})
+ logger.info("connector_created", extra={"connector_id": str(created.id), "connector_type": created.connector_type})
+ return created
+
+ async def get_connector(self, connector_id: UUID) -> ConnectorEntity | None:
+ return await self._connector_repo.get_by_id(connector_id)
+
+ async def list_connectors(self, *, erp_system_id: UUID | None = None, pharmacy_id: UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ConnectorEntity], int]:
+ return await self._connector_repo.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
+
+ async def update_connector(self, entity: ConnectorEntity) -> ConnectorEntity:
+ updated = await self._connector_repo.update(entity)
+ await self._publish_event("connector.updated", updated.id, {"connector_type": updated.connector_type, "status": updated.status})
+ return updated
+
+ async def delete_connector(self, connector_id: UUID) -> bool:
+ connector = await self._connector_repo.get_by_id(connector_id)
+ deleted = await self._connector_repo.delete(connector_id)
+ if deleted:
+ connector_registry.remove_instance(connector_id)
+ if connector:
+ await self._publish_event("connector.deleted", connector_id, {"connector_type": connector.connector_type, "pharmacy_id": str(connector.pharmacy_id)})
+ return deleted
+
+ async def test_connection(self, connector_id: UUID) -> ConnectorResult:
+ connector_entity = await self._connector_repo.get_by_id(connector_id)
+ if not connector_entity:
+ raise ConnectorException("unknown", f"Connector '{connector_id}' not found")
+
+ configs = await self._config_repo.list_by_connector(connector_id)
+ config_dict = {c.key: c.value for c in configs}
+ secrets_dict = {c.key: c.value for c in configs if c.encrypted}
+
+ context = ConnectorContext(
+ connector_id=connector_entity.id,
+ pharmacy_id=connector_entity.pharmacy_id,
+ erp_system_id=connector_entity.erp_system_id,
+ connector_type=connector_entity.connector_type,
+ config=config_dict,
+ secrets=secrets_dict,
+ )
+
+ instance = connector_registry.get_or_create_instance(connector_id, connector_entity.connector_type, config_dict)
+ if hasattr(instance, "set_context"):
+ instance.set_context(context)
+
+ result = await instance.heartbeat(connector_entity.pharmacy_id)
+
+ if result.success:
+ connector_entity.status = ConnectorStatus.ACTIVE
+ connector_entity.last_heartbeat_at = datetime.now(timezone.utc)
+ connector_entity.last_error = None
+ await self._connector_repo.update(connector_entity)
+ await self._publish_event("connector.heartbeat", connector_id, {"status": "healthy"})
+ else:
+ connector_entity.status = ConnectorStatus.ERROR
+ connector_entity.last_error = result.error
+ await self._connector_repo.update(connector_entity)
+ await self._publish_event("connector.error", connector_id, {"error": result.error})
+
+ return result
+
+ async def deploy_connector(self, connector_id: UUID) -> ConnectorEntity:
+ entity = await self._connector_repo.get_by_id(connector_id)
+ if not entity:
+ raise ConnectorException("unknown", f"Connector '{connector_id}' not found")
+
+ if entity.status == ConnectorStatus.ACTIVE:
+ raise ConnectorException(entity.connector_type, "Connector is already active. Deactivate first to redeploy.")
+
+ configs = await self._config_repo.list_by_connector(connector_id)
+ config_dict = {c.key: c.value for c in configs}
+
+ connector_registry.remove_instance(connector_id)
+ connector_registry.get_or_create_instance(connector_id, entity.connector_type, config_dict)
+
+ entity.status = ConnectorStatus.ACTIVE
+ entity.last_heartbeat_at = datetime.now(timezone.utc)
+ entity.last_error = None
+ updated = await self._connector_repo.update(entity)
+
+ await self._publish_event("connector.deployed", connector_id, {"connector_type": entity.connector_type, "status": "active"})
+ logger.info("connector_deployed", extra={"connector_id": str(connector_id)})
+ return updated
+
+ async def deactivate_connector(self, connector_id: UUID) -> ConnectorEntity:
+ entity = await self._connector_repo.get_by_id(connector_id)
+ if not entity:
+ raise ConnectorException("unknown", f"Connector '{connector_id}' not found")
+
+ connector_registry.remove_instance(connector_id)
+
+ entity.status = ConnectorStatus.INACTIVE
+ updated = await self._connector_repo.update(entity)
+
+ await self._publish_event("connector.deactivated", connector_id, {"connector_type": entity.connector_type, "status": "inactive"})
+ return updated
+
+ async def get_configs(self, connector_id: UUID) -> list[ConnectorConfigEntity]:
+ return await self._config_repo.list_by_connector(connector_id)
+
+ async def upsert_config(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity:
+ return await self._config_repo.upsert(entity)
+
+ async def delete_config(self, config_id: UUID) -> bool:
+ return await self._config_repo.delete(config_id)
+
+ async def _publish_event(self, event_type: str, connector_id: UUID, payload: dict[str, Any]) -> None:
+ try:
+ routing_key = f"connector.{event_type.split('.', 1)[1]}" if "." in event_type else f"connector.{event_type}"
+ full_payload = {"event_type": event_type, "connector_id": str(connector_id), "timestamp": datetime.now(timezone.utc).isoformat(), **payload}
+ await rabbitmq_client.publish(routing_key, full_payload)
+ except Exception as exc:
+ logger.warning("connector_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
diff --git a/pip-platform/src/services/inventory/inventory_service.py b/pip-platform/src/services/inventory/inventory_service.py
new file mode 100644
index 0000000..397d457
--- /dev/null
+++ b/pip-platform/src/services/inventory/inventory_service.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.core.exceptions import ConflictException, NotFoundException, ValidationException
+from src.core.logging import get_logger
+from src.domain.entities import (
+ InventoryHistoryEntity,
+ InventoryRecordEntity,
+ ReservationEntity,
+ ReservationStatus,
+)
+from src.domain.interfaces import IInventoryRepository, IReservationRepository
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+
+logger = get_logger("inventory_service")
+
+RESERVATION_DEFAULT_TTL_MINUTES = 30
+
+
+class InventoryService:
+ def __init__(
+ self,
+ inventory_repo: IInventoryRepository,
+ reservation_repo: IReservationRepository,
+ ) -> None:
+ self._inventory_repo = inventory_repo
+ self._reservation_repo = reservation_repo
+
+ async def get_inventory_record(self, pharmacy_id: UUID, medication_id: UUID) -> InventoryRecordEntity | None:
+ return await self._inventory_repo.get_record(pharmacy_id, medication_id)
+
+ async def list_inventory(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[InventoryRecordEntity], int]:
+ return await self._inventory_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit)
+
+ async def upsert_inventory_record(self, entity: InventoryRecordEntity) -> InventoryRecordEntity:
+ existing = await self._inventory_repo.get_record(entity.pharmacy_id, entity.medication_id)
+ previous_stock = existing.stock if existing else 0
+
+ upserted = await self._inventory_repo.upsert(entity)
+
+ delta = entity.stock - previous_stock
+ if delta != 0:
+ history = InventoryHistoryEntity(
+ inventory_record_id=upserted.id,
+ pharmacy_id=entity.pharmacy_id,
+ medication_id=entity.medication_id,
+ previous_stock=previous_stock,
+ new_stock=entity.stock,
+ delta=delta,
+ reason=entity.source or "upsert",
+ source=entity.source,
+ )
+ await self._inventory_repo.add_history(history)
+
+ await self._publish_event("inventory.updated", upserted.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "medication_id": str(entity.medication_id),
+ "previous_stock": previous_stock,
+ "new_stock": entity.stock,
+ "delta": delta,
+ })
+ return upserted
+
+ async def get_inventory_history(self, pharmacy_id: UUID, medication_id: UUID, *, offset: int = 0, limit: int = 50) -> list[InventoryHistoryEntity]:
+ records = await self._inventory_repo.get_history(pharmacy_id, medication_id, offset=offset, limit=limit)
+ return list(records)
+
+ async def create_reservation(
+ self,
+ pharmacy_id: UUID,
+ medication_id: UUID,
+ quantity: int,
+ *,
+ user_id: UUID | None = None,
+ external_reference: str | None = None,
+ idempotency_key: str | None = None,
+ ttl_minutes: int = RESERVATION_DEFAULT_TTL_MINUTES,
+ ) -> ReservationEntity:
+ if idempotency_key:
+ existing = await self._reservation_repo.get_by_idempotency_key(idempotency_key)
+ if existing:
+ return existing
+
+ record = await self._inventory_repo.get_record(pharmacy_id, medication_id)
+ if not record:
+ raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
+
+ if record.available_stock < quantity:
+ raise ValidationException(
+ f"Insufficient available stock. Requested: {quantity}, Available: {record.available_stock}",
+ details={"available_stock": record.available_stock, "requested": quantity},
+ )
+
+ record.reserved_stock += quantity
+ record.available_stock = record.stock - record.reserved_stock
+ await self._inventory_repo.upsert(record)
+
+ now = datetime.now(timezone.utc)
+ entity = ReservationEntity(
+ pharmacy_id=pharmacy_id,
+ medication_id=medication_id,
+ user_id=user_id,
+ external_reference=external_reference,
+ quantity=quantity,
+ status=ReservationStatus.PENDING,
+ expires_at=now + __import__("datetime").timedelta(minutes=ttl_minutes),
+ idempotency_key=idempotency_key,
+ )
+ created = await self._reservation_repo.create(entity)
+
+ await self._publish_event("reservation.created", created.id, {
+ "pharmacy_id": str(pharmacy_id),
+ "medication_id": str(medication_id),
+ "quantity": quantity,
+ })
+ return created
+
+ async def confirm_reservation(self, reservation_id: UUID) -> ReservationEntity:
+ entity = await self._reservation_repo.get_by_id(reservation_id)
+ if not entity:
+ raise NotFoundException("Reservation", str(reservation_id))
+
+ if entity.status != ReservationStatus.PENDING:
+ raise ValidationException(f"Reservation cannot be confirmed. Current status: {entity.status}")
+
+ now = datetime.now(timezone.utc)
+ if entity.expires_at and entity.expires_at < now:
+ entity.status = ReservationStatus.EXPIRED
+ await self._reservation_repo.update(entity)
+ await self._release_reserved_stock(entity)
+ await self._publish_event("reservation.expired", entity.id, {"pharmacy_id": str(entity.pharmacy_id)})
+ raise ValidationException("Reservation has expired")
+
+ entity.status = ReservationStatus.CONFIRMED
+ entity.confirmed_at = now
+ updated = await self._reservation_repo.update(entity)
+
+ await self._publish_event("reservation.confirmed", updated.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "medication_id": str(entity.medication_id),
+ "quantity": entity.quantity,
+ })
+ return updated
+
+ async def cancel_reservation(self, reservation_id: UUID) -> ReservationEntity:
+ entity = await self._reservation_repo.get_by_id(reservation_id)
+ if not entity:
+ raise NotFoundException("Reservation", str(reservation_id))
+
+ if entity.status not in (ReservationStatus.PENDING, ReservationStatus.CONFIRMED):
+ raise ValidationException(f"Reservation cannot be cancelled. Current status: {entity.status}")
+
+ entity.status = ReservationStatus.CANCELLED
+ entity.cancelled_at = datetime.now(timezone.utc)
+ updated = await self._reservation_repo.update(entity)
+
+ await self._release_reserved_stock(entity)
+
+ await self._publish_event("reservation.cancelled", updated.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "medication_id": str(entity.medication_id),
+ "quantity": entity.quantity,
+ })
+ return updated
+
+ async def get_reservation(self, reservation_id: UUID) -> ReservationEntity | None:
+ return await self._reservation_repo.get_by_id(reservation_id)
+
+ async def list_reservations(self, pharmacy_id: UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ReservationEntity], int]:
+ return await self._reservation_repo.list_by_pharmacy(pharmacy_id, status=status, offset=offset, limit=limit)
+
+ async def expire_pending_reservations(self) -> int:
+ pass
+
+ async def _release_reserved_stock(self, reservation: ReservationEntity) -> None:
+ record = await self._inventory_repo.get_record(reservation.pharmacy_id, reservation.medication_id)
+ if not record:
+ return
+
+ record.reserved_stock = max(0, record.reserved_stock - reservation.quantity)
+ record.available_stock = record.stock - record.reserved_stock
+ updated = await self._inventory_repo.upsert(record)
+
+ history = InventoryHistoryEntity(
+ inventory_record_id=updated.id,
+ pharmacy_id=reservation.pharmacy_id,
+ medication_id=reservation.medication_id,
+ previous_stock=record.stock + reservation.quantity if reservation.status == ReservationStatus.PENDING else record.stock,
+ new_stock=record.stock,
+ delta=-reservation.quantity,
+ reason=f"reservation_{reservation.status}",
+ source="inventory_service",
+ )
+ await self._inventory_repo.add_history(history)
+
+ async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
+ try:
+ routing_key = event_type.replace(".", "_")
+ full_payload = {
+ "event_type": event_type,
+ "resource_id": str(resource_id),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ **payload,
+ }
+ await rabbitmq_client.publish(routing_key, full_payload)
+ except Exception as exc:
+ logger.warning("inventory_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
diff --git a/pip-platform/src/services/medication/__init__.py b/pip-platform/src/services/medication/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/services/medication/medication_service.py b/pip-platform/src/services/medication/medication_service.py
new file mode 100644
index 0000000..12bb413
--- /dev/null
+++ b/pip-platform/src/services/medication/medication_service.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.core.exceptions import ConflictException, NotFoundException, ValidationException
+from src.core.logging import get_logger
+from src.domain.entities import MedicationCatalogEntryEntity, MedicationEntity
+from src.domain.interfaces import IMedicationCatalogRepository, IMedicationRepository
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+
+logger = get_logger("medication_service")
+
+
+class MedicationService:
+ def __init__(
+ self,
+ medication_repo: IMedicationRepository,
+ catalog_repo: IMedicationCatalogRepository,
+ ) -> None:
+ self._medication_repo = medication_repo
+ self._catalog_repo = catalog_repo
+
+ async def create_medication(self, entity: MedicationEntity) -> MedicationEntity:
+ existing = await self._medication_repo.get_by_nregistro(entity.nregistro)
+ if existing:
+ raise ConflictException(f"Medication with nregistro '{entity.nregistro}' already exists")
+ created = await self._medication_repo.create(entity)
+ await self._publish_event("medication.created", created.id, {"nregistro": created.nregistro, "name": created.name})
+ return created
+
+ async def get_medication(self, medication_id: UUID) -> MedicationEntity | None:
+ return await self._medication_repo.get_by_id(medication_id)
+
+ async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None:
+ return await self._medication_repo.get_by_nregistro(nregistro)
+
+ async def search_medications(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationEntity], int]:
+ return await self._medication_repo.search(query, offset=offset, limit=limit)
+
+ async def update_medication(self, entity: MedicationEntity) -> MedicationEntity:
+ existing = await self._medication_repo.get_by_id(entity.id) # type: ignore[arg-type]
+ if not existing:
+ raise NotFoundException("Medication", str(entity.id))
+ updated = await self._medication_repo.update(entity)
+ await self._publish_event("medication.updated", updated.id, {"nregistro": updated.nregistro})
+ return updated
+
+ async def delete_medication(self, medication_id: UUID) -> bool:
+ deleted = await self._medication_repo.delete(medication_id)
+ if deleted:
+ await self._publish_event("medication.deleted", medication_id, {})
+ return deleted
+
+ async def get_catalog_entry(self, pharmacy_id: UUID, medication_id: UUID) -> MedicationCatalogEntryEntity | None:
+ return await self._catalog_repo.get_entry(pharmacy_id, medication_id)
+
+ async def list_catalog(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationCatalogEntryEntity], int]:
+ return await self._catalog_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit)
+
+ async def upsert_catalog_entry(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity:
+ upserted = await self._catalog_repo.upsert(entity)
+ await self._publish_event("catalog.upserted", upserted.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "medication_id": str(entity.medication_id),
+ "is_available": entity.is_available,
+ "stock": entity.stock,
+ })
+ return upserted
+
+ async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
+ try:
+ routing_key = event_type.replace(".", "_")
+ full_payload = {
+ "event_type": event_type,
+ "resource_id": str(resource_id),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ **payload,
+ }
+ await rabbitmq_client.publish(routing_key, full_payload)
+ except Exception as exc:
+ logger.warning("medication_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
diff --git a/pip-platform/src/services/notification/__init__.py b/pip-platform/src/services/notification/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/services/pharmacy/__init__.py b/pip-platform/src/services/pharmacy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/src/services/synchronization/__init__.py b/pip-platform/src/services/synchronization/__init__.py
new file mode 100644
index 0000000..d78d0fe
--- /dev/null
+++ b/pip-platform/src/services/synchronization/__init__.py
@@ -0,0 +1,3 @@
+from .sync_service import SyncService
+
+__all__ = ["SyncService"]
diff --git a/pip-platform/src/services/synchronization/sync_service.py b/pip-platform/src/services/synchronization/sync_service.py
new file mode 100644
index 0000000..57d3c63
--- /dev/null
+++ b/pip-platform/src/services/synchronization/sync_service.py
@@ -0,0 +1,178 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID
+
+from src.core.exceptions import NotFoundException, ValidationException
+from src.core.logging import get_logger
+from src.domain.entities import SyncJobEntity, SyncJobStatus, SyncStrategy
+from src.domain.interfaces import ISyncJobRepository, ISyncLogRepository
+from src.infrastructure.messaging.rabbitmq import rabbitmq_client
+
+logger = get_logger("sync_service")
+
+
+class SyncService:
+ def __init__(
+ self,
+ sync_job_repo: ISyncJobRepository,
+ sync_log_repo: ISyncLogRepository,
+ ) -> None:
+ self._sync_job_repo = sync_job_repo
+ self._sync_log_repo = sync_log_repo
+
+ async def create_sync_job(
+ self,
+ pharmacy_id: UUID,
+ connector_id: UUID,
+ strategy: SyncStrategy,
+ ) -> SyncJobEntity:
+ entity = SyncJobEntity(
+ pharmacy_id=pharmacy_id,
+ connector_id=connector_id,
+ strategy=strategy,
+ status=SyncJobStatus.PENDING,
+ )
+ created = await self._sync_job_repo.create(entity)
+ await self._publish_event("sync.created", created.id, {
+ "pharmacy_id": str(pharmacy_id),
+ "connector_id": str(connector_id),
+ "strategy": strategy,
+ })
+ return created
+
+ async def get_sync_job(self, job_id: UUID) -> SyncJobEntity | None:
+ return await self._sync_job_repo.get_by_id(job_id)
+
+ async def list_sync_jobs(
+ self,
+ pharmacy_id: UUID,
+ *,
+ status: str | None = None,
+ offset: int = 0,
+ limit: int = 50,
+ ) -> tuple[list[SyncJobEntity], int]:
+ return await self._sync_job_repo.list_by_pharmacy(
+ pharmacy_id, status=status, offset=offset, limit=limit,
+ )
+
+ async def trigger_sync(self, job_id: UUID) -> SyncJobEntity:
+ entity = await self._sync_job_repo.get_by_id(job_id)
+ if not entity:
+ raise NotFoundException("SyncJob", str(job_id))
+
+ if entity.status not in (SyncJobStatus.PENDING, SyncJobStatus.FAILED):
+ raise ValidationException(
+ f"Cannot trigger sync job. Current status: {entity.status}",
+ details={"current_status": entity.status},
+ )
+
+ entity.status = SyncJobStatus.RUNNING
+ entity.started_at = datetime.now(timezone.utc)
+ entity.error_message = None
+ updated = await self._sync_job_repo.update(entity)
+
+ await self._add_log(job_id, "INFO", "Sync job triggered")
+ await self._publish_event("sync.started", updated.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "strategy": entity.strategy,
+ })
+ return updated
+
+ async def cancel_sync(self, job_id: UUID) -> SyncJobEntity:
+ entity = await self._sync_job_repo.get_by_id(job_id)
+ if not entity:
+ raise NotFoundException("SyncJob", str(job_id))
+
+ if entity.status != SyncJobStatus.RUNNING:
+ raise ValidationException(
+ f"Cannot cancel sync job. Current status: {entity.status}",
+ details={"current_status": entity.status},
+ )
+
+ entity.status = SyncJobStatus.CANCELLED
+ entity.completed_at = datetime.now(timezone.utc)
+ updated = await self._sync_job_repo.update(entity)
+
+ await self._add_log(job_id, "WARNING", "Sync job cancelled by user")
+ await self._publish_event("sync.cancelled", updated.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ })
+ return updated
+
+ async def complete_sync(
+ self,
+ job_id: UUID,
+ *,
+ items_total: int | None = None,
+ items_processed: int | None = None,
+ items_failed: int | None = None,
+ ) -> SyncJobEntity:
+ entity = await self._sync_job_repo.get_by_id(job_id)
+ if not entity:
+ raise NotFoundException("SyncJob", str(job_id))
+
+ entity.status = SyncJobStatus.COMPLETED
+ entity.completed_at = datetime.now(timezone.utc)
+ if items_total is not None:
+ entity.items_total = items_total
+ if items_processed is not None:
+ entity.items_processed = items_processed
+ if items_failed is not None:
+ entity.items_failed = items_failed
+ updated = await self._sync_job_repo.update(entity)
+
+ await self._add_log(job_id, "INFO", f"Sync completed. Processed: {items_processed}, Failed: {items_failed}")
+ await self._publish_event("sync.completed", updated.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "items_total": items_total,
+ "items_processed": items_processed,
+ "items_failed": items_failed,
+ })
+ return updated
+
+ async def fail_sync(self, job_id: UUID, error_message: str) -> SyncJobEntity:
+ entity = await self._sync_job_repo.get_by_id(job_id)
+ if not entity:
+ raise NotFoundException("SyncJob", str(job_id))
+
+ entity.status = SyncJobStatus.FAILED
+ entity.completed_at = datetime.now(timezone.utc)
+ entity.error_message = error_message
+ updated = await self._sync_job_repo.update(entity)
+
+ await self._add_log(job_id, "ERROR", f"Sync failed: {error_message}")
+ await self._publish_event("sync.failed", updated.id, {
+ "pharmacy_id": str(entity.pharmacy_id),
+ "error_message": error_message,
+ })
+ return updated
+
+ async def get_sync_logs(
+ self,
+ job_id: UUID,
+ *,
+ level: str | None = None,
+ offset: int = 0,
+ limit: int = 100,
+ ):
+ return await self._sync_log_repo.list_by_job(job_id, level=level, offset=offset, limit=limit)
+
+ async def _add_log(self, job_id: UUID, level: str, message: str, details: dict | None = None) -> None:
+ from src.domain.entities import SyncLogEntity
+ log = SyncLogEntity(sync_job_id=job_id, level=level, message=message, details=details)
+ await self._sync_log_repo.add(log)
+
+ async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None:
+ try:
+ routing_key = event_type.replace(".", "_")
+ full_payload = {
+ "event_type": event_type,
+ "resource_id": str(resource_id),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ **payload,
+ }
+ await rabbitmq_client.publish(routing_key, full_payload)
+ except Exception as exc:
+ logger.warning("sync_event_publish_failed", extra={"event_type": event_type, "error": str(exc)})
diff --git a/pip-platform/tests/__init__.py b/pip-platform/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/tests/conftest.py b/pip-platform/tests/conftest.py
new file mode 100644
index 0000000..128fbb7
--- /dev/null
+++ b/pip-platform/tests/conftest.py
@@ -0,0 +1,78 @@
+import asyncio
+from collections.abc import AsyncGenerator
+from uuid import uuid4
+
+import pytest
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from src.infrastructure.database.models import Base
+from src.main import app
+
+
+TEST_DATABASE_URL = "postgresql+asyncpg://pip:pip-secret@localhost:5432/pip_test"
+
+
+@pytest.fixture(scope="session")
+def event_loop():
+ loop = asyncio.new_event_loop()
+ yield loop
+ loop.close()
+
+
+@pytest_asyncio.fixture(scope="session")
+async def test_engine():
+ engine = create_async_engine(TEST_DATABASE_URL, echo=False)
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield engine
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.drop_all)
+ await engine.dispose()
+
+
+@pytest_asyncio.fixture
+async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
+ session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+ async with session_factory() as session:
+ yield session
+ await session.rollback()
+
+
+@pytest_asyncio.fixture
+async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
+ yield c
+
+
+@pytest.fixture
+def sample_pharmacy_data():
+ return {
+ "code": f"PHARM-{uuid4().hex[:8]}",
+ "name": "Test Pharmacy",
+ "address": "Calle Test 123",
+ "city": "Madrid",
+ "province": "Madrid",
+ "postal_code": "28001",
+ "country": "ES",
+ "latitude": 40.4168,
+ "longitude": -3.7038,
+ "phone": "+34912345678",
+ "email": "test@pharmacy.es",
+ "erp_type": "farmatic",
+ "sync_strategy": "incremental",
+ "sync_interval_minutes": 30,
+ }
+
+
+@pytest.fixture
+def sample_user_data():
+ return {
+ "username": f"testuser_{uuid4().hex[:8]}",
+ "password": "TestPass123!",
+ "email": "test@pip.es",
+ "full_name": "Test User",
+ "role": "viewer",
+ }
diff --git a/pip-platform/tests/integration/__init__.py b/pip-platform/tests/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/tests/integration/test_api_key_auth.py b/pip-platform/tests/integration/test_api_key_auth.py
new file mode 100644
index 0000000..c64dc27
--- /dev/null
+++ b/pip-platform/tests/integration/test_api_key_auth.py
@@ -0,0 +1,144 @@
+"""E2E tests for API Key authentication flow.
+
+Tests dual auth support (JWT Bearer + X-API-Key header),
+API Key CRUD, scope enforcement, and role-based access control.
+"""
+
+import asyncio
+import os
+
+import httpx
+
+BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000")
+ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")}
+
+
+async def get_admin_token(client: httpx.AsyncClient) -> str:
+ r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS)
+ assert r.status_code == 200, f"Login failed: {r.status_code} {r.text[:200]}"
+ return r.json()["access_token"]
+
+
+def bearer_headers(token: str) -> dict:
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def test_api_key_crud():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ clients = await c.get("/api/v1/api-clients", headers=h)
+ assert clients.status_code == 200
+ client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
+
+ if not client_id:
+ resp = await c.post("/api/v1/api-clients", headers=h, json={
+ "name": "Test Client",
+ "description": "E2E test client",
+ "scopes": ["pharmacies:read", "medications:read", "inventory:read"],
+ })
+ assert resp.status_code == 201
+ client_id = resp.json()["id"]
+
+ key_resp = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "E2E CRUD Key"})
+ assert key_resp.status_code == 201, f"Key create failed: {key_resp.status_code} {key_resp.text[:200]}"
+ raw_key = key_resp.json()["key"]
+ key_prefix = key_resp.json()["key_prefix"]
+ assert len(raw_key) > 20
+ assert len(key_prefix) == 8
+
+ keys = await c.get(f"/api/v1/api-clients/{client_id}/keys", headers=h)
+ assert keys.status_code == 200
+ assert len(keys.json()) >= 1
+
+ print(" PASS: test_api_key_crud")
+
+
+async def test_api_key_read_access():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ clients = await c.get("/api/v1/api-clients", headers=h)
+ client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
+ kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Read Access Key"})
+ raw_key = kr.json()["key"]
+ api_h = {"X-API-Key": raw_key}
+
+ r = await c.get("/api/v1/pharmacies", headers=api_h)
+ assert r.status_code == 200
+ assert r.json()["total"] >= 0
+
+ r2 = await c.get("/api/v1/medications", headers=api_h)
+ assert r2.status_code == 200
+
+ r3 = await c.get("/api/v1/inventory", headers=api_h)
+ assert r3.status_code == 200
+
+ print(" PASS: test_api_key_read_access")
+
+
+async def test_api_key_write_blocked():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ clients = await c.get("/api/v1/api-clients", headers=h)
+ client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
+ kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Write Block Key"})
+ raw_key = kr.json()["key"]
+ api_h = {"X-API-Key": raw_key}
+
+ r = await c.post("/api/v1/pharmacies", headers=api_h, json={"code": "BLOCKED", "name": "Should Fail"})
+ assert r.status_code == 403, f"Expected 403, got {r.status_code}"
+
+ print(" PASS: test_api_key_write_blocked")
+
+
+async def test_invalid_api_key():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ r = await c.get("/api/v1/pharmacies", headers={"X-API-Key": "invalidkey12345"})
+ assert r.status_code == 401
+
+ print(" PASS: test_invalid_api_key")
+
+
+async def test_no_auth_rejected():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ r = await c.get("/api/v1/pharmacies")
+ assert r.status_code == 401
+
+ print(" PASS: test_no_auth_rejected")
+
+
+async def test_dual_auth_bearer_priority():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ clients = await c.get("/api/v1/api-clients", headers=h)
+ client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None)
+ kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Dual Auth Key"})
+ raw_key = kr.json()["key"]
+
+ dual_h = {"Authorization": f"Bearer {token}", "X-API-Key": raw_key}
+ r = await c.get("/api/v1/pharmacies", headers=dual_h)
+ assert r.status_code == 200
+
+ print(" PASS: test_dual_auth_bearer_priority")
+
+
+async def run_all():
+ print("=== API Key Auth E2E Tests ===")
+ await test_api_key_crud()
+ await test_api_key_read_access()
+ await test_api_key_write_blocked()
+ await test_invalid_api_key()
+ await test_no_auth_rejected()
+ await test_dual_auth_bearer_priority()
+ print("=== All API Key tests passed ===")
+
+
+if __name__ == "__main__":
+ asyncio.run(run_all())
diff --git a/pip-platform/tests/integration/test_rate_limiting.py b/pip-platform/tests/integration/test_rate_limiting.py
new file mode 100644
index 0000000..885a717
--- /dev/null
+++ b/pip-platform/tests/integration/test_rate_limiting.py
@@ -0,0 +1,105 @@
+"""E2E tests for Rate Limiting middleware.
+
+Tests that rate limiting returns 429 with proper headers
+when request count exceeds the configured limit.
+"""
+
+import asyncio
+import os
+
+import httpx
+
+BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000")
+ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")}
+
+
+async def get_admin_token(client: httpx.AsyncClient) -> str:
+ r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS)
+ assert r.status_code == 200
+ return r.json()["access_token"]
+
+
+def bearer_headers(token: str) -> dict:
+ return {"Authorization": f"Bearer {token}"}
+
+
+async def test_rate_limit_headers():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ r = await c.get("/api/v1/pharmacies", headers=h)
+ assert r.status_code == 200
+ assert "x-ratelimit-limit" in r.headers, f"Missing X-RateLimit-Limit header. Got: {list(r.headers.keys())}"
+ assert "x-ratelimit-remaining" in r.headers
+ assert "x-ratelimit-reset" in r.headers
+
+ limit = int(r.headers["x-ratelimit-limit"])
+ assert limit > 0
+
+ print(f" PASS: test_rate_limit_headers (limit={limit}/min)")
+
+
+async def test_rate_limit_429():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ r0 = await c.get("/api/v1/pharmacies", headers=h)
+ limit = int(r0.headers["x-ratelimit-limit"])
+
+ successes = 0
+ blocked = 0
+ for i in range(limit + 10):
+ r = await c.get("/api/v1/pharmacies", headers=h)
+ if r.status_code == 200:
+ successes += 1
+ elif r.status_code == 429:
+ blocked += 1
+ assert "retry-after" in r.headers, "429 response missing Retry-After header"
+ break
+
+ assert blocked > 0, f"Rate limit was not triggered after {limit + 10} requests"
+
+ print(f" PASS: test_rate_limit_429 (blocked after {successes} requests)")
+
+
+async def test_rate_limit_exempt_paths():
+ async with httpx.AsyncClient(base_url=BASE_URL) as c:
+ r = await c.get("/docs")
+ assert r.status_code == 200
+ assert "x-ratelimit-limit" not in r.headers, "Docs should be exempt from rate limiting"
+
+ print(" PASS: test_rate_limit_exempt_paths")
+
+
+async def test_rate_limit_per_client():
+ async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c:
+ token = await get_admin_token(c)
+ h = bearer_headers(token)
+
+ r0 = await c.get("/api/v1/pharmacies", headers=h)
+ limit = int(r0.headers["x-ratelimit-limit"])
+
+ for _ in range(limit + 5):
+ r = await c.get("/api/v1/pharmacies", headers=h)
+ if r.status_code == 429:
+ break
+
+ r2 = await c.get("/api/v1/system/health")
+ assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths"
+
+ print(" PASS: test_rate_limit_per_client")
+
+
+async def run_all():
+ print("=== Rate Limiting E2E Tests ===")
+ await test_rate_limit_headers()
+ await test_rate_limit_429()
+ await test_rate_limit_exempt_paths()
+ await test_rate_limit_per_client()
+ print("=== All Rate Limiting tests passed ===")
+
+
+if __name__ == "__main__":
+ asyncio.run(run_all())
diff --git a/pip-platform/tests/unit/__init__.py b/pip-platform/tests/unit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pip-platform/tests/unit/test_auth.py b/pip-platform/tests/unit/test_auth.py
new file mode 100644
index 0000000..37acd22
--- /dev/null
+++ b/pip-platform/tests/unit/test_auth.py
@@ -0,0 +1,94 @@
+import pytest
+from httpx import AsyncClient
+
+from src.services.auth.security import create_access_token, hash_password, verify_password
+from src.domain.entities import UserRole
+
+
+class TestPasswordHashing:
+ def test_hash_and_verify(self):
+ password = "SecurePass123!"
+ hashed = hash_password(password)
+ assert hashed != password
+ assert verify_password(password, hashed)
+
+ def test_verify_wrong_password(self):
+ hashed = hash_password("CorrectPass123!")
+ assert not verify_password("WrongPass123!", hashed)
+
+
+class TestJWT:
+ def test_create_access_token(self):
+ token = create_access_token(
+ subject="test-user-id",
+ role=UserRole.ADMIN,
+ scopes=["*"],
+ )
+ assert isinstance(token, str)
+ assert len(token) > 0
+
+ def test_decode_access_token(self):
+ from src.services.auth.security import decode_token, validate_token_type, TokenData
+
+ token = create_access_token(
+ subject="test-user-id",
+ role=UserRole.VIEWER,
+ scopes=["pharmacies:read"],
+ )
+ payload = decode_token(token)
+ assert payload["sub"] == "test-user-id"
+ assert payload["role"] == "viewer"
+ assert payload["type"] == "access"
+
+ validated = validate_token_type(payload, "access")
+ assert validated is not None
+
+ def test_invalid_token_raises(self):
+ from src.core.exceptions import UnauthorizedException
+ from src.services.auth.security import decode_token
+
+ with pytest.raises(UnauthorizedException):
+ decode_token("invalid.token.here")
+
+
+class TestRBAC:
+ def test_superadmin_can_access_all(self):
+ from src.services.auth.security import RBACGuard, TokenData
+
+ token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"})
+ assert RBACGuard.has_role(token_data, UserRole.ADMIN)
+ assert RBACGuard.has_role(token_data, UserRole.VIEWER)
+ assert RBACGuard.has_role(token_data, UserRole.OPERATOR)
+
+ def test_viewer_cannot_access_admin(self):
+ from src.services.auth.security import RBACGuard, TokenData
+
+ token_data = TokenData({"sub": "2", "role": "viewer", "scopes": [], "type": "access", "iss": "pip-platform", "jti": "test"})
+ assert not RBACGuard.has_role(token_data, UserRole.ADMIN)
+
+ def test_wildcard_scope_grants_all(self):
+ from src.services.auth.security import RBACGuard, TokenData
+
+ token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"})
+ assert RBACGuard.has_scope(token_data, "pharmacies:read")
+ assert RBACGuard.has_scope(token_data, "pharmacies:write")
+ assert RBACGuard.has_scope(token_data, "anything:anything")
+
+ def test_specific_scope(self):
+ from src.services.auth.security import RBACGuard, TokenData
+
+ token_data = TokenData({"sub": "2", "role": "viewer", "scopes": ["pharmacies:read"], "type": "access", "iss": "pip-platform", "jti": "test"})
+ assert RBACGuard.has_scope(token_data, "pharmacies:read")
+ assert not RBACGuard.has_scope(token_data, "pharmacies:write")
+
+
+class TestAPIKey:
+ def test_generate_api_key(self):
+ from src.services.auth.security import generate_api_key, verify_api_key
+
+ raw_key, prefix, key_hash = generate_api_key()
+ assert len(raw_key) > 20
+ assert len(prefix) == 8
+ assert raw_key[:8] == prefix
+ assert verify_api_key(raw_key, key_hash)
+ assert not verify_api_key("wrong-key", key_hash)
diff --git a/pip-platform/tests/unit/test_connector_registry.py b/pip-platform/tests/unit/test_connector_registry.py
new file mode 100644
index 0000000..80aedb4
--- /dev/null
+++ b/pip-platform/tests/unit/test_connector_registry.py
@@ -0,0 +1,102 @@
+from uuid import uuid4
+
+import pytest
+
+from src.connectors.registry import ConnectorRegistry, _BUILTIN_CONNECTORS
+from src.connectors.mock.mock_connector import MockConnector
+from src.connectors.base import BaseInventoryConnector
+from src.core.exceptions import ConnectorException
+
+
+@pytest.fixture
+def registry() -> ConnectorRegistry:
+ ConnectorRegistry._instance = None
+ reg = ConnectorRegistry.get_registry()
+ return reg
+
+
+class TestConnectorRegistrySingleton:
+ def test_singleton(self, registry: ConnectorRegistry):
+ reg2 = ConnectorRegistry.get_registry()
+ assert reg2 is registry
+
+ def test_new_instance_after_reset(self, registry: ConnectorRegistry):
+ ConnectorRegistry._instance = None
+ reg2 = ConnectorRegistry.get_registry()
+ assert reg2 is not registry
+
+
+class TestConnectorRegistryBuiltinTypes:
+ def test_builtin_connectors_include_mock(self):
+ assert "mock" in _BUILTIN_CONNECTORS
+
+ def test_builtin_connectors_include_farmatic(self):
+ assert "farmatic" in _BUILTIN_CONNECTORS
+
+ def test_builtin_connectors_include_custom_rest(self):
+ assert "custom_rest" in _BUILTIN_CONNECTORS
+
+
+class TestConnectorRegistryGetClass:
+ def test_get_mock_connector_class(self, registry: ConnectorRegistry):
+ cls = registry.get_connector_class("mock")
+ assert cls is MockConnector
+
+ def test_get_unknown_type_raises(self, registry: ConnectorRegistry):
+ with pytest.raises(ConnectorException, match="No connector registered"):
+ registry.get_connector_class("nonexistent_type")
+
+
+class TestConnectorRegistryCreateInstance:
+ def test_create_mock_instance(self, registry: ConnectorRegistry):
+ instance = registry.create_instance("mock")
+ assert isinstance(instance, BaseInventoryConnector)
+ assert instance.connector_type == "mock"
+
+ def test_create_instance_with_config(self, registry: ConnectorRegistry):
+ instance = registry.create_instance("mock", {"simulate_latency": False, "error_rate": 0.0})
+ assert instance._simulate_latency is False
+
+ def test_create_unknown_type_raises(self, registry: ConnectorRegistry):
+ with pytest.raises(ConnectorException):
+ registry.create_instance("nonexistent")
+
+
+class TestConnectorRegistryInstanceCache:
+ def test_get_or_create_returns_cached(self, registry: ConnectorRegistry):
+ cid = uuid4()
+ inst1 = registry.get_or_create_instance(cid, "mock")
+ inst2 = registry.get_or_create_instance(cid, "mock")
+ assert inst1 is inst2
+
+ def test_remove_instance(self, registry: ConnectorRegistry):
+ cid = uuid4()
+ registry.get_or_create_instance(cid, "mock")
+ registry.remove_instance(cid)
+ assert cid not in registry._instances
+
+ def test_remove_nonexistent_instance_no_error(self, registry: ConnectorRegistry):
+ registry.remove_instance(uuid4())
+
+
+class TestConnectorRegistryListTypes:
+ def test_list_registered_types(self, registry: ConnectorRegistry):
+ types = registry.list_registered_types()
+ assert "mock" in types
+ assert "farmatic" in types
+ assert "custom_rest" in types
+
+ def test_register_custom_type(self, registry: ConnectorRegistry):
+ mock = MockConnector()
+ mock.connector_type = "test_custom"
+ registry.register("test_custom", mock)
+ types = registry.list_registered_types()
+ assert "test_custom" in types
+
+ def test_unregister_type(self, registry: ConnectorRegistry):
+ mock = MockConnector()
+ mock.connector_type = "temp_type"
+ registry.register("temp_type", mock)
+ registry.unregister("temp_type")
+ types = registry.list_registered_types()
+ assert "temp_type" not in types
diff --git a/pip-platform/tests/unit/test_connector_sdk.py b/pip-platform/tests/unit/test_connector_sdk.py
new file mode 100644
index 0000000..d0e36af
--- /dev/null
+++ b/pip-platform/tests/unit/test_connector_sdk.py
@@ -0,0 +1,258 @@
+import time
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from src.connectors.sdk.circuit_breaker import CircuitBreaker, CircuitState
+from src.connectors.sdk.retry import RetryPolicy, with_retry
+from src.connectors.sdk.context import ConnectorContext
+from src.connectors.sdk.auth import BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler
+from src.core.exceptions import ServiceUnavailableException
+
+
+class TestCircuitBreaker:
+ def test_initial_state_is_closed(self):
+ cb = CircuitBreaker(name="test")
+ assert cb.state == CircuitState.CLOSED
+
+ def test_opens_after_threshold_failures(self):
+ cb = CircuitBreaker(name="test", failure_threshold=3)
+ for _ in range(3):
+ cb._on_failure(Exception("fail"))
+ assert cb._state == CircuitState.OPEN
+
+ def test_closed_does_not_raise(self):
+ cb = CircuitBreaker(name="test")
+
+ async def _run():
+ coro = cb.call(AsyncMock(return_value="ok"))
+ return await coro
+
+ import asyncio
+ result = asyncio.get_event_loop().run_until_complete(_run())
+ assert result == "ok"
+
+ def test_open_raises_service_unavailable(self):
+ cb = CircuitBreaker(name="test", failure_threshold=1)
+ cb._on_failure(Exception("fail"))
+ assert cb.state == CircuitState.OPEN
+
+ async def _run():
+ await cb.call(AsyncMock(return_value="ok"))
+
+ import asyncio
+ with pytest.raises(ServiceUnavailableException):
+ asyncio.get_event_loop().run_until_complete(_run())
+
+ def test_half_open_after_recovery_timeout(self):
+ cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
+ cb._on_failure(Exception("fail"))
+ assert cb._state == CircuitState.OPEN
+ time.sleep(0.02)
+ assert cb.state == CircuitState.HALF_OPEN
+
+ def test_reset(self):
+ cb = CircuitBreaker(name="test", failure_threshold=1)
+ cb._on_failure(Exception("fail"))
+ cb.reset()
+ assert cb.state == CircuitState.CLOSED
+ assert cb._failure_count == 0
+
+ def test_success_resets_failure_count(self):
+ cb = CircuitBreaker(name="test", failure_threshold=5)
+ cb._on_failure(Exception("fail"))
+ cb._on_failure(Exception("fail"))
+ assert cb._failure_count == 2
+ cb._on_success()
+ assert cb._failure_count == 0
+
+ def test_half_open_success_closes_circuit(self):
+ cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
+ cb._on_failure(Exception("fail"))
+ time.sleep(0.02)
+ assert cb.state == CircuitState.HALF_OPEN
+ cb._on_success()
+ assert cb._state == CircuitState.CLOSED
+
+ def test_half_open_failure_reopens(self):
+ cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
+ cb._on_failure(Exception("fail"))
+ time.sleep(0.02)
+ assert cb.state == CircuitState.HALF_OPEN
+ cb._on_failure(Exception("fail again"))
+ assert cb._state == CircuitState.OPEN
+
+ def test_get_status(self):
+ cb = CircuitBreaker(name="test")
+ status = cb.get_status()
+ assert status["name"] == "test"
+ assert status["state"] == "closed"
+
+
+class TestRetryPolicy:
+ def test_default_values(self):
+ policy = RetryPolicy()
+ assert policy.max_attempts == 3
+ assert policy.base_delay == 1.0
+ assert policy.max_delay == 30.0
+
+ def test_custom_values(self):
+ policy = RetryPolicy(max_attempts=5, base_delay=0.5, max_delay=10.0)
+ assert policy.max_attempts == 5
+ assert policy.base_delay == 0.5
+ assert policy.max_delay == 10.0
+
+
+class TestWithRetry:
+ @pytest.mark.asyncio
+ async def test_success_first_try(self):
+ call_count = 0
+
+ async def _success():
+ nonlocal call_count
+ call_count += 1
+ return "ok"
+
+ policy = RetryPolicy(max_attempts=3)
+ result = await with_retry(_success, policy=policy, connector_type="test", operation="test_op")
+ assert result == "ok"
+ assert call_count == 1
+
+ @pytest.mark.asyncio
+ async def test_retry_on_failure_then_success(self):
+ call_count = 0
+
+ async def _flaky():
+ nonlocal call_count
+ call_count += 1
+ if call_count < 3:
+ raise Exception("temp fail")
+ return "ok"
+
+ policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
+ result = await with_retry(_flaky, policy=policy, connector_type="test", operation="flaky_op")
+ assert result == "ok"
+ assert call_count == 3
+
+ @pytest.mark.asyncio
+ async def test_exhaust_retries(self):
+ call_count = 0
+
+ async def _always_fail():
+ nonlocal call_count
+ call_count += 1
+ raise Exception("always fail")
+
+ policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1)
+ with pytest.raises(Exception, match="always fail"):
+ await with_retry(_always_fail, policy=policy, connector_type="test", operation="fail_op")
+ assert call_count == 2
+
+
+class TestConnectorContext:
+ def test_cache_key(self):
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="farmatic",
+ )
+ key = ctx.cache_key("stock")
+ assert "farmatic" in key
+ assert "stock" in key
+
+ def test_get_config(self):
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="mock",
+ config={"base_url": "https://example.com"},
+ )
+ assert ctx.get_config("base_url") == "https://example.com"
+ assert ctx.get_config("missing", "default") == "default"
+
+ def test_get_secret(self):
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="mock",
+ secrets={"api_key": "secret123"},
+ )
+ assert ctx.get_secret("api_key") == "secret123"
+ assert ctx.get_secret("missing") is None
+
+ def test_require_secret_raises(self):
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="mock",
+ )
+ with pytest.raises(ValueError, match="Required secret"):
+ ctx.require_secret("missing")
+
+ def test_require_config_raises(self):
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="mock",
+ )
+ with pytest.raises(ValueError, match="Required config"):
+ ctx.require_config("missing")
+
+
+class TestBasicAuthHandler:
+ @pytest.mark.asyncio
+ async def test_apply_basic_auth(self):
+ import httpx
+
+ handler = BasicAuthHandler()
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="test",
+ config={"username": "user1", "password": "pass1"},
+ )
+ request = httpx.Request("GET", "https://example.com")
+ result = await handler.apply(request, ctx)
+ assert "Basic" in result.headers["Authorization"]
+
+
+class TestBearerAuthHandler:
+ @pytest.mark.asyncio
+ async def test_apply_bearer_auth(self):
+ import httpx
+
+ handler = BearerAuthHandler()
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="test",
+ config={"access_token": "token123"},
+ )
+ request = httpx.Request("GET", "https://example.com")
+ result = await handler.apply(request, ctx)
+ assert result.headers["Authorization"] == "Bearer token123"
+
+
+class TestAPIKeyAuthHandler:
+ @pytest.mark.asyncio
+ async def test_apply_api_key_auth(self):
+ import httpx
+
+ handler = APIKeyAuthHandler(header_name="X-Custom-Key")
+ ctx = ConnectorContext(
+ connector_id=MagicMock(),
+ pharmacy_id=MagicMock(),
+ erp_system_id=MagicMock(),
+ connector_type="test",
+ secrets={"api_key": "key123"},
+ )
+ request = httpx.Request("GET", "https://example.com")
+ result = await handler.apply(request, ctx)
+ assert result.headers["X-Custom-Key"] == "key123"
diff --git a/pip-platform/tests/unit/test_entities.py b/pip-platform/tests/unit/test_entities.py
new file mode 100644
index 0000000..7194513
--- /dev/null
+++ b/pip-platform/tests/unit/test_entities.py
@@ -0,0 +1,111 @@
+import pytest
+
+from src.domain.entities import (
+ PharmacyEntity,
+ PharmacyStatus,
+ MedicationEntity,
+ ERPType,
+ SyncStrategy,
+ UserRole,
+ ConnectorEntity,
+ ConnectorStatus,
+ ReservationEntity,
+ ReservationStatus,
+ AuditLogEntity,
+)
+from uuid import uuid4
+
+
+class TestPharmacyEntity:
+ def test_create_pharmacy(self):
+ entity = PharmacyEntity(
+ code="PH-001",
+ name="Farmacia Central",
+ address="Calle Mayor 1",
+ city="Madrid",
+ status=PharmacyStatus.ACTIVE,
+ erp_type=ERPType.FARMATIC,
+ sync_strategy=SyncStrategy.INCREMENTAL,
+ )
+ assert entity.code == "PH-001"
+ assert entity.name == "Farmacia Central"
+ assert entity.status == PharmacyStatus.ACTIVE
+ assert entity.erp_type == ERPType.FARMATIC
+ assert entity.sync_strategy == SyncStrategy.INCREMENTAL
+
+ def test_pharmacy_default_values(self):
+ entity = PharmacyEntity(code="PH-002", name="Test")
+ assert entity.country == "ES"
+ assert entity.status == PharmacyStatus.PENDING
+ assert entity.sync_strategy == SyncStrategy.INCREMENTAL
+ assert entity.sync_interval_minutes == 30
+
+ def test_pharmacy_status_enum(self):
+ assert PharmacyStatus.ACTIVE.value == "active"
+ assert PharmacyStatus.INACTIVE.value == "inactive"
+ assert PharmacyStatus.SUSPENDED.value == "suspended"
+ assert PharmacyStatus.PENDING.value == "pending"
+
+
+class TestMedicationEntity:
+ def test_create_medication(self):
+ entity = MedicationEntity(
+ nregistro="12345",
+ name="Ibuprofeno 400mg",
+ active_ingredient="Ibuprofeno",
+ dosage="400mg",
+ form="Comprimidos",
+ prescription_required=False,
+ )
+ assert entity.nregistro == "12345"
+ assert entity.name == "Ibuprofeno 400mg"
+ assert entity.prescription_required is False
+
+
+class TestConnectorEntity:
+ def test_create_connector(self):
+ entity = ConnectorEntity(
+ erp_system_id=uuid4(),
+ pharmacy_id=uuid4(),
+ name="Farmatic Connector",
+ connector_type=ERPType.FARMATIC,
+ )
+ assert entity.connector_type == ERPType.FARMATIC
+ assert entity.version == "1.0.0"
+ assert entity.status == ConnectorStatus.PENDING
+
+
+class TestReservationEntity:
+ def test_create_reservation(self):
+ entity = ReservationEntity(
+ pharmacy_id=uuid4(),
+ medication_id=uuid4(),
+ quantity=5,
+ idempotency_key="unique-key-123",
+ )
+ assert entity.quantity == 5
+ assert entity.status == ReservationStatus.PENDING
+ assert entity.idempotency_key == "unique-key-123"
+
+
+class TestAuditLogEntity:
+ def test_create_audit_log(self):
+ entity = AuditLogEntity(
+ actor_id=uuid4(),
+ actor_type="technical_user",
+ action="pharmacy.create",
+ resource_type="pharmacy",
+ resource_id="PH-001",
+ ip_address="192.168.1.1",
+ )
+ assert entity.action == "pharmacy.create"
+ assert entity.resource_type == "pharmacy"
+
+ def test_enums(self):
+ assert ERPType.FARMATIC.value == "farmatic"
+ assert ERPType.NIXFARMA.value == "nixfarma"
+ assert ERPType.UNYCOP.value == "unycop"
+ assert ERPType.MOCK.value == "mock"
+ assert SyncStrategy.FULL.value == "full"
+ assert SyncStrategy.HYBRID.value == "hybrid"
+ assert UserRole.SUPERADMIN.value == "superadmin"
diff --git a/pip-platform/tests/unit/test_exceptions.py b/pip-platform/tests/unit/test_exceptions.py
new file mode 100644
index 0000000..532dddd
--- /dev/null
+++ b/pip-platform/tests/unit/test_exceptions.py
@@ -0,0 +1,66 @@
+import pytest
+
+from src.core.exceptions import (
+ PIPException,
+ NotFoundException,
+ ConflictException,
+ ValidationException,
+ UnauthorizedException,
+ ForbiddenException,
+ RateLimitException,
+ ServiceUnavailableException,
+ ConnectorException,
+ SyncException,
+)
+
+
+class TestPIPException:
+ def test_base_exception(self):
+ exc = PIPException("Something failed", code="TEST_ERROR", status_code=500)
+ assert exc.message == "Something failed"
+ assert exc.code == "TEST_ERROR"
+ assert exc.status_code == 500
+ assert "timestamp" in exc.to_dict()["error"]
+
+ def test_not_found(self):
+ exc = NotFoundException("Pharmacy", "PH-001")
+ assert exc.status_code == 404
+ assert exc.code == "NOT_FOUND"
+ d = exc.to_dict()
+ assert "Pharmacy" in d["error"]["message"]
+
+ def test_conflict(self):
+ exc = ConflictException("Duplicate")
+ assert exc.status_code == 409
+
+ def test_validation(self):
+ exc = ValidationException("Invalid input")
+ assert exc.status_code == 422
+
+ def test_unauthorized(self):
+ exc = UnauthorizedException()
+ assert exc.status_code == 401
+
+ def test_forbidden(self):
+ exc = ForbiddenException(required_role="admin")
+ assert exc.status_code == 403
+ assert exc.details["required_role"] == "admin"
+
+ def test_rate_limit(self):
+ exc = RateLimitException()
+ assert exc.status_code == 429
+
+ def test_service_unavailable(self):
+ exc = ServiceUnavailableException("redis")
+ assert exc.status_code == 503
+ assert "redis" in exc.details["service"]
+
+ def test_connector_error(self):
+ exc = ConnectorException("farmatic", "Connection refused")
+ assert exc.status_code == 502
+ assert exc.details["connector_type"] == "farmatic"
+
+ def test_sync_error(self):
+ exc = SyncException("pharmacy-1", "Timeout")
+ assert exc.status_code == 502
+ assert exc.details["pharmacy_id"] == "pharmacy-1"
diff --git a/pip-platform/tests/unit/test_mock_connector.py b/pip-platform/tests/unit/test_mock_connector.py
new file mode 100644
index 0000000..1aa3e17
--- /dev/null
+++ b/pip-platform/tests/unit/test_mock_connector.py
@@ -0,0 +1,138 @@
+import asyncio
+from uuid import UUID, uuid4
+
+import pytest
+
+from src.connectors.mock.mock_connector import MockConnector
+from src.connectors.base import ConnectorResult
+from src.core.exceptions import ConnectorException
+
+
+@pytest.fixture
+def mock_connector() -> MockConnector:
+ return MockConnector()
+
+
+@pytest.fixture
+def pharmacy_id() -> UUID:
+ return uuid4()
+
+
+@pytest.fixture
+def medication_id() -> UUID:
+ return uuid4()
+
+
+class TestMockConnectorConfiguration:
+ def test_default_configuration(self, mock_connector: MockConnector):
+ assert mock_connector._simulate_latency is True
+ assert mock_connector._error_rate == 0.0
+
+ def test_configure_latency(self, mock_connector: MockConnector):
+ mock_connector.configure({"simulate_latency": False, "latency_range_ms": [5, 10]})
+ assert mock_connector._simulate_latency is False
+ assert mock_connector._latency_range_ms == (5, 10)
+
+ def test_configure_error_rate(self, mock_connector: MockConnector):
+ mock_connector.configure({"error_rate": 0.5})
+ assert mock_connector._error_rate == 0.5
+
+
+class TestMockConnectorGetStock:
+ @pytest.mark.asyncio
+ async def test_get_stock_all(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ result = await mock_connector.get_stock(pharmacy_id)
+ assert result.success is True
+ assert "items" in result.data
+ assert result.data["total"] == 10
+
+ @pytest.mark.asyncio
+ async def test_get_stock_by_nregistro(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ result = await mock_connector.get_stock(pharmacy_id, nregistro="00001")
+ assert result.success is True
+ assert result.data["nregistro"] == "00001"
+ assert result.data["name"] == "Ibuprofeno 600mg"
+
+ @pytest.mark.asyncio
+ async def test_get_stock_not_found(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ result = await mock_connector.get_stock(pharmacy_id, nregistro="99999")
+ assert result.success is False
+ assert "not found" in result.error
+
+
+class TestMockConnectorReserve:
+ @pytest.mark.asyncio
+ async def test_reserve(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ result = await mock_connector.reserve(pharmacy_id, medication_id, 5)
+ assert result.success is True
+ assert result.data["status"] == "confirmed"
+ assert result.data["quantity"] == 5
+
+ @pytest.mark.asyncio
+ async def test_reserve_idempotency(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ key = "idem-key-123"
+ result1 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key)
+ result2 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key)
+ assert result1.success is True
+ assert result2.success is True
+
+
+class TestMockConnectorCancelReservation:
+ @pytest.mark.asyncio
+ async def test_cancel_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ reserve_result = await mock_connector.reserve(pharmacy_id, uuid4(), 2)
+ reservation_id = reserve_result.data["reservation_id"]
+ result = await mock_connector.cancel_reservation(pharmacy_id, reservation_id)
+ assert result.success is True
+ assert result.data["status"] == "cancelled"
+
+ @pytest.mark.asyncio
+ async def test_cancel_nonexistent_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ result = await mock_connector.cancel_reservation(pharmacy_id, "nonexistent-id")
+ assert result.success is False
+
+
+class TestMockConnectorPharmacyInfo:
+ @pytest.mark.asyncio
+ async def test_pharmacy_information(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False})
+ result = await mock_connector.pharmacy_information(pharmacy_id)
+ assert result.success is True
+ assert "FARM-001" in result.data["code"]
+
+
+class TestMockConnectorSynchronize:
+ @pytest.mark.asyncio
+ async def test_synchronize(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ mock_connector.configure({"simulate_latency": False, "error_rate": 0.0})
+ result = await mock_connector.synchronize(pharmacy_id, strategy="full")
+ assert result.success is True
+ assert result.data["items_total"] == 10
+ assert result.data["items_failed"] == 0
+
+
+class TestMockConnectorHeartbeat:
+ @pytest.mark.asyncio
+ async def test_heartbeat(self, mock_connector: MockConnector, pharmacy_id: UUID):
+ result = await mock_connector.heartbeat(pharmacy_id)
+ assert result.success is True
+ assert result.data["status"] == "healthy"
+ assert result.data["connector_type"] == "mock"
+
+
+class TestMockConnectorErrorSimulation:
+ def test_high_error_rate_triggers_exception(self, mock_connector: MockConnector):
+ mock_connector.configure({"error_rate": 1.0})
+ with pytest.raises(ConnectorException):
+ mock_connector._maybe_error()
+
+ def test_zero_error_rate_no_exception(self, mock_connector: MockConnector):
+ mock_connector.configure({"error_rate": 0.0})
+ mock_connector._maybe_error()