Compare commits
23 Commits
not-workin
...
c1652798b4
| Author | SHA1 | Date | |
|---|---|---|---|
| c1652798b4 | |||
| ec4d512136 | |||
| abf47e0003 | |||
| 0438ba3ae5 | |||
| c3f9539988 | |||
| 1d82230564 | |||
| 15d5d31d06 | |||
| ccc53b7d4f | |||
| 8e12b3bcdf | |||
| 0d23521cac | |||
| 2189e58cc6 | |||
| 87f43f2239 | |||
| 0ddfed4f07 | |||
| e7daf79394 | |||
| 7bb94da45b | |||
| d28609ee95 | |||
| 8364ad0671 | |||
| 797b10e4ad | |||
| ec77b76abb | |||
| bb269d84a5 | |||
| 46eb980e24 | |||
| 85a4db1630 | |||
| 701a10e886 |
@@ -125,13 +125,23 @@ jobs:
|
|||||||
|
|
||||||
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
- name: Write env to server
|
- name: Write env and compose to server
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# 1. Start with the static env file content
|
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV)
|
||||||
|
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then
|
||||||
|
DEPLOY_TAG="prod"
|
||||||
|
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
|
||||||
|
DEPLOY_TAG="int"
|
||||||
|
else
|
||||||
|
DEPLOY_TAG="dev"
|
||||||
|
fi
|
||||||
|
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
# 2. Start with the static env file content
|
||||||
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
|
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
|
||||||
|
|
||||||
# 2. Determine DB credentials
|
# 3. Determine DB credentials
|
||||||
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
||||||
DB_URL="${{ secrets.DB_URL_PROD }}"
|
DB_URL="${{ secrets.DB_URL_PROD }}"
|
||||||
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
|
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
|
||||||
@@ -146,17 +156,24 @@ jobs:
|
|||||||
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
|
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Append DB credentials
|
# 4. Append DB and Docker credentials (quoted)
|
||||||
printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
|
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \
|
||||||
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
|
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
|
||||||
|
|
||||||
|
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
|
||||||
|
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
|
||||||
|
|
||||||
# 4. Debug: print content (for debug purposes)
|
# 5. Debug: print content (for debug purposes)
|
||||||
echo "Preparing to send env file with variables:"
|
echo "Preparing to send env file with variables:"
|
||||||
grep -v "PASSWORD" /tmp/full_env.env || true
|
grep -v "PASSWORD" /tmp/full_env.env || true
|
||||||
|
|
||||||
# 5. Send to server
|
# 5. Send env to server
|
||||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
||||||
|
|
||||||
|
# 6. Send docker-compose.deploy.yml to server
|
||||||
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
|
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,3 +41,8 @@ target/
|
|||||||
build/
|
build/
|
||||||
.gradle/
|
.gradle/
|
||||||
.mvn/
|
.mvn/
|
||||||
|
|
||||||
|
./storage_orders
|
||||||
|
./storage_quotes
|
||||||
|
storage_orders
|
||||||
|
storage_quotes
|
||||||
@@ -11,7 +11,7 @@ RUN ./gradlew bootJar -x test --no-daemon
|
|||||||
# Stage 2: Runtime Environment
|
# Stage 2: Runtime Environment
|
||||||
FROM eclipse-temurin:21-jre-jammy
|
FROM eclipse-temurin:21-jre-jammy
|
||||||
|
|
||||||
# Install system dependencies for OrcaSlicer
|
# Install system dependencies for OrcaSlicer (same as before)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
wget \
|
wget \
|
||||||
p7zip-full \
|
p7zip-full \
|
||||||
@@ -20,14 +20,6 @@ RUN apt-get update && apt-get install -y \
|
|||||||
libgtk-3-0 \
|
libgtk-3-0 \
|
||||||
libdbus-1-3 \
|
libdbus-1-3 \
|
||||||
libwebkit2gtk-4.0-37 \
|
libwebkit2gtk-4.0-37 \
|
||||||
libx11-xcb1 \
|
|
||||||
libxcb-dri3-0 \
|
|
||||||
libxtst6 \
|
|
||||||
libnss3 \
|
|
||||||
libatk-bridge2.0-0 \
|
|
||||||
libxss1 \
|
|
||||||
libasound2 \
|
|
||||||
libgbm1 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install OrcaSlicer
|
# Install OrcaSlicer
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies {
|
|||||||
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
|
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
|
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,25 @@ echo "----------------------------------------------------------------"
|
|||||||
echo "Starting Backend Application"
|
echo "Starting Backend Application"
|
||||||
echo "DB_URL: $DB_URL"
|
echo "DB_URL: $DB_URL"
|
||||||
echo "DB_USERNAME: $DB_USERNAME"
|
echo "DB_USERNAME: $DB_USERNAME"
|
||||||
|
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
|
||||||
echo "SLICER_PATH: $SLICER_PATH"
|
echo "SLICER_PATH: $SLICER_PATH"
|
||||||
echo "--- ALL ENV VARS ---"
|
echo "--- ALL ENV VARS ---"
|
||||||
env
|
env
|
||||||
echo "----------------------------------------------------------------"
|
echo "----------------------------------------------------------------"
|
||||||
|
|
||||||
# Exec java with explicit properties from env
|
# Determine which environment variables to use for database connection
|
||||||
exec java -jar app.jar \
|
# This allows compatibility with different docker-compose configurations
|
||||||
--spring.datasource.url="${DB_URL}" \
|
FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}"
|
||||||
--spring.datasource.username="${DB_USERNAME}" \
|
FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}"
|
||||||
--spring.datasource.password="${DB_PASSWORD}"
|
FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}"
|
||||||
|
|
||||||
|
if [ -n "$FINAL_DB_URL" ]; then
|
||||||
|
echo "Using database URL: $FINAL_DB_URL"
|
||||||
|
exec java -jar app.jar \
|
||||||
|
--spring.datasource.url="${FINAL_DB_URL}" \
|
||||||
|
--spring.datasource.username="${FINAL_DB_USER}" \
|
||||||
|
--spring.datasource.password="${FINAL_DB_PASS}"
|
||||||
|
else
|
||||||
|
echo "No database URL specified in environment, relying on application.properties defaults."
|
||||||
|
exec java -jar app.jar
|
||||||
|
fi
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -139,4 +139,4 @@
|
|||||||
"layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 E-[retraction_length] F1800\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400 P300\nM971 S11 C10 O0\nG92 E0\nG1 E[retraction_length] F300\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change",
|
"layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 E-[retraction_length] F1800\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400 P300\nM971 S11 C10 O0\nG92 E0\nG1 E[retraction_length] F300\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change",
|
||||||
"change_filament_gcode": "M620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_extruder] == 0 || z_hop_types[current_extruder] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\n\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\n\n{if next_extruder < 255}\nM400\n\nG92 E0\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM109 S[nozzle_temperature_range_high]\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\nG1 X80 F15000\nG1 X60 F15000\nG1 X80 F15000\nG1 X60 F15000; shake to put down garbage\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A",
|
"change_filament_gcode": "M620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_extruder] == 0 || z_hop_types[current_extruder] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\n\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\n\n{if next_extruder < 255}\nM400\n\nG92 E0\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM109 S[nozzle_temperature_range_high]\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\nG1 X80 F15000\nG1 X60 F15000\nG1 X80 F15000\nG1 X60 F15000; shake to put down garbage\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A",
|
||||||
"machine_pause_gcode": "M400 U1"
|
"machine_pause_gcode": "M400 U1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package com.printcalculator;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@EnableAsync
|
||||||
public class BackendApplication {
|
public class BackendApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -25,15 +25,17 @@ public class CustomQuoteRequestController {
|
|||||||
|
|
||||||
private final CustomQuoteRequestRepository requestRepo;
|
private final CustomQuoteRequestRepository requestRepo;
|
||||||
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
|
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
|
||||||
|
private final com.printcalculator.service.ClamAVService clamAVService;
|
||||||
|
|
||||||
private final com.printcalculator.service.StorageService storageService;
|
// TODO: Inject Storage Service
|
||||||
|
private static final String STORAGE_ROOT = "storage_requests";
|
||||||
|
|
||||||
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
|
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
|
||||||
CustomQuoteRequestAttachmentRepository attachmentRepo,
|
CustomQuoteRequestAttachmentRepository attachmentRepo,
|
||||||
com.printcalculator.service.StorageService storageService) {
|
com.printcalculator.service.ClamAVService clamAVService) {
|
||||||
this.requestRepo = requestRepo;
|
this.requestRepo = requestRepo;
|
||||||
this.attachmentRepo = attachmentRepo;
|
this.attachmentRepo = attachmentRepo;
|
||||||
this.storageService = storageService;
|
this.clamAVService = clamAVService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Create Custom Quote Request
|
// 1. Create Custom Quote Request
|
||||||
@@ -69,6 +71,9 @@ public class CustomQuoteRequestController {
|
|||||||
for (MultipartFile file : files) {
|
for (MultipartFile file : files) {
|
||||||
if (file.isEmpty()) continue;
|
if (file.isEmpty()) continue;
|
||||||
|
|
||||||
|
// Scan for virus
|
||||||
|
clamAVService.scan(file.getInputStream());
|
||||||
|
|
||||||
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
|
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
|
||||||
attachment.setRequest(request);
|
attachment.setRequest(request);
|
||||||
attachment.setOriginalFilename(file.getOriginalFilename());
|
attachment.setOriginalFilename(file.getOriginalFilename());
|
||||||
@@ -92,8 +97,10 @@ public class CustomQuoteRequestController {
|
|||||||
attachment.setStoredRelativePath(relativePath);
|
attachment.setStoredRelativePath(relativePath);
|
||||||
attachmentRepo.save(attachment);
|
attachmentRepo.save(attachment);
|
||||||
|
|
||||||
// Save file to disk via StorageService
|
// Save file to disk
|
||||||
storageService.store(file, Paths.get(relativePath));
|
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
|
||||||
|
Files.createDirectories(absolutePath.getParent());
|
||||||
|
Files.copy(file.getInputStream(), absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/dev/email")
|
||||||
|
@Profile("local")
|
||||||
|
public class DevEmailTestController {
|
||||||
|
|
||||||
|
private final TemplateEngine templateEngine;
|
||||||
|
|
||||||
|
public DevEmailTestController(TemplateEngine templateEngine) {
|
||||||
|
this.templateEngine = templateEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/test-template")
|
||||||
|
public ResponseEntity<String> testTemplate() {
|
||||||
|
Context context = new Context();
|
||||||
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
templateData.put("customerName", "Mario Rossi");
|
||||||
|
templateData.put("orderId", orderId);
|
||||||
|
templateData.put("orderNumber", orderId.toString().split("-")[0]);
|
||||||
|
templateData.put("orderDetailsUrl", "https://tuosito.it/ordine/" + orderId);
|
||||||
|
templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
|
||||||
|
templateData.put("totalCost", "45.50");
|
||||||
|
|
||||||
|
context.setVariables(templateData);
|
||||||
|
String html = templateEngine.process("email/order-confirmation", context);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
.body(html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,20 +64,6 @@ public class OptionsController {
|
|||||||
})
|
})
|
||||||
.filter(m -> m != null)
|
.filter(m -> m != null)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
// Sort: PLA first, then PETG, then others alphabetically
|
|
||||||
materialOptions.sort((a, b) -> {
|
|
||||||
String codeA = a.code();
|
|
||||||
String codeB = b.code();
|
|
||||||
|
|
||||||
if (codeA.equals("pla_basic")) return -1;
|
|
||||||
if (codeB.equals("pla_basic")) return 1;
|
|
||||||
|
|
||||||
if (codeA.equals("petg_basic")) return -1;
|
|
||||||
if (codeB.equals("petg_basic")) return 1;
|
|
||||||
|
|
||||||
return codeA.compareTo(codeB);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Qualities (Static as per user request)
|
// 2. Qualities (Static as per user request)
|
||||||
List<OptionsResponse.QualityOption> qualities = List.of(
|
List<OptionsResponse.QualityOption> qualities = List.of(
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import com.printcalculator.entity.*;
|
|||||||
import com.printcalculator.repository.*;
|
import com.printcalculator.repository.*;
|
||||||
import com.printcalculator.service.InvoicePdfRenderingService;
|
import com.printcalculator.service.InvoicePdfRenderingService;
|
||||||
import com.printcalculator.service.OrderService;
|
import com.printcalculator.service.OrderService;
|
||||||
|
import com.printcalculator.service.PaymentService;
|
||||||
import com.printcalculator.service.QrBillService;
|
import com.printcalculator.service.QrBillService;
|
||||||
import com.printcalculator.service.StorageService;
|
import com.printcalculator.service.StorageService;
|
||||||
|
import com.printcalculator.service.TwintPaymentService;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -24,7 +26,9 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/orders")
|
@RequestMapping("/api/orders")
|
||||||
@@ -39,6 +43,9 @@ public class OrderController {
|
|||||||
private final StorageService storageService;
|
private final StorageService storageService;
|
||||||
private final InvoicePdfRenderingService invoiceService;
|
private final InvoicePdfRenderingService invoiceService;
|
||||||
private final QrBillService qrBillService;
|
private final QrBillService qrBillService;
|
||||||
|
private final TwintPaymentService twintPaymentService;
|
||||||
|
private final PaymentService paymentService;
|
||||||
|
private final PaymentRepository paymentRepo;
|
||||||
|
|
||||||
|
|
||||||
public OrderController(OrderService orderService,
|
public OrderController(OrderService orderService,
|
||||||
@@ -49,7 +56,10 @@ public class OrderController {
|
|||||||
CustomerRepository customerRepo,
|
CustomerRepository customerRepo,
|
||||||
StorageService storageService,
|
StorageService storageService,
|
||||||
InvoicePdfRenderingService invoiceService,
|
InvoicePdfRenderingService invoiceService,
|
||||||
QrBillService qrBillService) {
|
QrBillService qrBillService,
|
||||||
|
TwintPaymentService twintPaymentService,
|
||||||
|
PaymentService paymentService,
|
||||||
|
PaymentRepository paymentRepo) {
|
||||||
this.orderService = orderService;
|
this.orderService = orderService;
|
||||||
this.orderRepo = orderRepo;
|
this.orderRepo = orderRepo;
|
||||||
this.orderItemRepo = orderItemRepo;
|
this.orderItemRepo = orderItemRepo;
|
||||||
@@ -59,6 +69,9 @@ public class OrderController {
|
|||||||
this.storageService = storageService;
|
this.storageService = storageService;
|
||||||
this.invoiceService = invoiceService;
|
this.invoiceService = invoiceService;
|
||||||
this.qrBillService = qrBillService;
|
this.qrBillService = qrBillService;
|
||||||
|
this.twintPaymentService = twintPaymentService;
|
||||||
|
this.paymentService = paymentService;
|
||||||
|
this.paymentRepo = paymentRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +129,17 @@ public class OrderController {
|
|||||||
.orElse(ResponseEntity.notFound().build());
|
.orElse(ResponseEntity.notFound().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderId}/payments/report")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<OrderDto> reportPayment(
|
||||||
|
@PathVariable UUID orderId,
|
||||||
|
@RequestBody Map<String, String> payload
|
||||||
|
) {
|
||||||
|
String method = payload.get("method");
|
||||||
|
paymentService.reportPayment(orderId, method);
|
||||||
|
return getOrder(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{orderId}/invoice")
|
@GetMapping("/{orderId}/invoice")
|
||||||
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
|
||||||
Order order = orderRepo.findById(orderId)
|
Order order = orderRepo.findById(orderId)
|
||||||
@@ -129,7 +153,7 @@ public class OrderController {
|
|||||||
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||||
vars.put("sellerEmail", "info@3dfab.ch");
|
vars.put("sellerEmail", "info@3dfab.ch");
|
||||||
|
|
||||||
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
|
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
|
||||||
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
|
||||||
@@ -181,10 +205,55 @@ public class OrderController {
|
|||||||
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
|
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"")
|
||||||
.contentType(MediaType.APPLICATION_PDF)
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
.body(pdf);
|
.body(pdf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}/twint")
|
||||||
|
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
|
||||||
|
if (!orderRepo.existsById(orderId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] qrPng = twintPaymentService.generateQrPng(360);
|
||||||
|
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
|
||||||
|
|
||||||
|
Map<String, String> data = new HashMap<>();
|
||||||
|
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl());
|
||||||
|
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
|
||||||
|
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
|
||||||
|
data.put("qrImageDataUri", qrDataUri);
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}/twint/open")
|
||||||
|
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
|
||||||
|
if (!orderRepo.existsById(orderId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.status(302)
|
||||||
|
.location(URI.create(twintPaymentService.getTwintPaymentUrl()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{orderId}/twint/qr")
|
||||||
|
public ResponseEntity<byte[]> getTwintQr(
|
||||||
|
@PathVariable UUID orderId,
|
||||||
|
@RequestParam(defaultValue = "320") int size
|
||||||
|
) {
|
||||||
|
if (!orderRepo.existsById(orderId)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
int normalizedSize = Math.max(200, Math.min(size, 600));
|
||||||
|
byte[] png = twintPaymentService.generateQrPng(normalizedSize);
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.IMAGE_PNG)
|
||||||
|
.body(png);
|
||||||
|
}
|
||||||
|
|
||||||
private String getExtension(String filename) {
|
private String getExtension(String filename) {
|
||||||
if (filename == null) return "stl";
|
if (filename == null) return "stl";
|
||||||
@@ -198,7 +267,14 @@ public class OrderController {
|
|||||||
private OrderDto convertToDto(Order order, List<OrderItem> items) {
|
private OrderDto convertToDto(Order order, List<OrderItem> items) {
|
||||||
OrderDto dto = new OrderDto();
|
OrderDto dto = new OrderDto();
|
||||||
dto.setId(order.getId());
|
dto.setId(order.getId());
|
||||||
|
dto.setOrderNumber(getDisplayOrderNumber(order));
|
||||||
dto.setStatus(order.getStatus());
|
dto.setStatus(order.getStatus());
|
||||||
|
|
||||||
|
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
|
||||||
|
dto.setPaymentStatus(p.getStatus());
|
||||||
|
dto.setPaymentMethod(p.getMethod());
|
||||||
|
});
|
||||||
|
|
||||||
dto.setCustomerEmail(order.getCustomerEmail());
|
dto.setCustomerEmail(order.getCustomerEmail());
|
||||||
dto.setCustomerPhone(order.getCustomerPhone());
|
dto.setCustomerPhone(order.getCustomerPhone());
|
||||||
dto.setBillingCustomerType(order.getBillingCustomerType());
|
dto.setBillingCustomerType(order.getBillingCustomerType());
|
||||||
@@ -255,4 +331,12 @@ public class OrderController {
|
|||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getDisplayOrderNumber(Order order) {
|
||||||
|
String orderNumber = order.getOrderNumber();
|
||||||
|
if (orderNumber != null && !orderNumber.isBlank()) {
|
||||||
|
return orderNumber;
|
||||||
|
}
|
||||||
|
return order.getId() != null ? order.getId().toString() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
import com.printcalculator.entity.PrinterMachine;
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
import com.printcalculator.exception.ModelTooLargeException;
|
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
import com.printcalculator.model.StlBounds;
|
|
||||||
import com.printcalculator.repository.PrinterMachineRepository;
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
import com.printcalculator.service.ProfileManager;
|
|
||||||
import com.printcalculator.service.SlicerService;
|
import com.printcalculator.service.SlicerService;
|
||||||
import com.printcalculator.service.StlService;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -19,29 +15,24 @@ import java.util.HashMap;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
public class QuoteController {
|
public class QuoteController {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(QuoteController.class.getName());
|
|
||||||
|
|
||||||
private final SlicerService slicerService;
|
private final SlicerService slicerService;
|
||||||
private final StlService stlService;
|
|
||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
private final PrinterMachineRepository machineRepo;
|
private final PrinterMachineRepository machineRepo;
|
||||||
private final ProfileManager profileManager;
|
private final com.printcalculator.service.ClamAVService clamAVService;
|
||||||
|
|
||||||
// Defaults (using aliases defined in ProfileManager)
|
// Defaults (using aliases defined in ProfileManager)
|
||||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
private static final String DEFAULT_PROCESS = "standard";
|
private static final String DEFAULT_PROCESS = "standard";
|
||||||
|
|
||||||
public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
|
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) {
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
this.stlService = stlService;
|
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.machineRepo = machineRepo;
|
this.machineRepo = machineRepo;
|
||||||
this.profileManager = profileManager;
|
this.clamAVService = clamAVService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/quote")
|
@PostMapping("/api/quote")
|
||||||
@@ -55,7 +46,7 @@ public class QuoteController {
|
|||||||
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
|
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
|
||||||
@RequestParam(value = "layer_height", required = false) Double layerHeight,
|
@RequestParam(value = "layer_height", required = false) Double layerHeight,
|
||||||
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
|
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
|
||||||
@RequestParam(value = "support_enabled", required = false, defaultValue = "false") Boolean supportEnabled
|
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
|
|
||||||
// ... process selection logic ...
|
// ... process selection logic ...
|
||||||
@@ -83,9 +74,6 @@ public class QuoteController {
|
|||||||
}
|
}
|
||||||
if (supportEnabled != null) {
|
if (supportEnabled != null) {
|
||||||
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
|
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
|
||||||
if (supportEnabled) {
|
|
||||||
processOverrides.put("support_threshold_angle", "45");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nozzleDiameter != null) {
|
if (nozzleDiameter != null) {
|
||||||
@@ -95,7 +83,7 @@ public class QuoteController {
|
|||||||
// For now, we trust the override key works on the base profile.
|
// For now, we trust the override key works on the base profile.
|
||||||
}
|
}
|
||||||
|
|
||||||
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides, nozzleDiameter);
|
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/calculate/stl")
|
@PostMapping("/calculate/stl")
|
||||||
@@ -103,91 +91,42 @@ public class QuoteController {
|
|||||||
@RequestParam("file") MultipartFile file
|
@RequestParam("file") MultipartFile file
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
// Legacy endpoint uses defaults
|
// Legacy endpoint uses defaults
|
||||||
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null, null);
|
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
|
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
|
||||||
Map<String, String> machineOverrides,
|
Map<String, String> machineOverrides,
|
||||||
Map<String, String> processOverrides,
|
Map<String, String> processOverrides) throws IOException {
|
||||||
Double nozzleDiameter) throws IOException {
|
|
||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan for virus
|
||||||
|
clamAVService.scan(file.getInputStream());
|
||||||
|
|
||||||
// Fetch Default Active Machine
|
// Fetch Default Active Machine
|
||||||
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||||
.orElseThrow(() -> new IOException("No active printer found in database"));
|
.orElseThrow(() -> new IOException("No active printer found in database"));
|
||||||
|
|
||||||
// Save uploaded file temporarily
|
// Save uploaded file temporarily
|
||||||
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
|
||||||
com.printcalculator.model.StlShiftResult shift = null;
|
|
||||||
try {
|
try {
|
||||||
file.transferTo(tempInput.toFile());
|
file.transferTo(tempInput.toFile());
|
||||||
|
|
||||||
// Use profile from machine or fallback
|
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
|
||||||
String slicerMachineProfile = machine.getSlicerMachineProfile();
|
|
||||||
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
|
|
||||||
slicerMachineProfile = "bambu_a1";
|
|
||||||
}
|
|
||||||
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
|
|
||||||
|
|
||||||
// Validate model size against machine volume
|
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
||||||
StlBounds bounds = validateModelSize(tempInput.toFile(), machine);
|
|
||||||
|
|
||||||
// Auto-center if needed
|
|
||||||
shift = stlService.shiftToFitIfNeeded(
|
|
||||||
tempInput.toFile(),
|
|
||||||
bounds,
|
|
||||||
machine.getBuildVolumeXMm(),
|
|
||||||
machine.getBuildVolumeYMm(),
|
|
||||||
machine.getBuildVolumeZMm()
|
|
||||||
);
|
|
||||||
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : tempInput.toFile();
|
|
||||||
if (shift.shifted()) {
|
|
||||||
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
|
|
||||||
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
|
|
||||||
}
|
|
||||||
|
|
||||||
PrintStats stats = slicerService.slice(sliceInput, slicerMachineProfile, filament, process, machineOverrides, processOverrides);
|
|
||||||
|
|
||||||
// Calculate Quote (Pass machine display name for pricing lookup)
|
// Calculate Quote (Pass machine display name for pricing lookup)
|
||||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
|
||||||
|
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
} finally {
|
} finally {
|
||||||
Files.deleteIfExists(tempInput);
|
Files.deleteIfExists(tempInput);
|
||||||
if (shift != null && shift.shifted()) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(shift.shiftedPath());
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
|
|
||||||
StlBounds bounds = stlService.readBounds(stlFile);
|
|
||||||
double x = bounds.sizeX();
|
|
||||||
double y = bounds.sizeY();
|
|
||||||
double z = bounds.sizeZ();
|
|
||||||
|
|
||||||
int bx = machine.getBuildVolumeXMm();
|
|
||||||
int by = machine.getBuildVolumeYMm();
|
|
||||||
int bz = machine.getBuildVolumeZMm();
|
|
||||||
|
|
||||||
logger.info(String.format(
|
|
||||||
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
|
|
||||||
bounds.minX(), bounds.minY(), bounds.minZ(),
|
|
||||||
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
|
|
||||||
x, y, z, bx, by, bz
|
|
||||||
));
|
|
||||||
|
|
||||||
double eps = 0.01;
|
|
||||||
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|
|
||||||
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
|
|
||||||
|
|
||||||
if (!fits) {
|
|
||||||
throw new ModelTooLargeException(x, y, z, bx, by, bz);
|
|
||||||
}
|
|
||||||
return bounds;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ package com.printcalculator.controller;
|
|||||||
import com.printcalculator.entity.PrinterMachine;
|
import com.printcalculator.entity.PrinterMachine;
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
import com.printcalculator.entity.QuoteSession;
|
import com.printcalculator.entity.QuoteSession;
|
||||||
import com.printcalculator.exception.ModelTooLargeException;
|
|
||||||
import com.printcalculator.model.PrintStats;
|
import com.printcalculator.model.PrintStats;
|
||||||
import com.printcalculator.model.QuoteResult;
|
import com.printcalculator.model.QuoteResult;
|
||||||
import com.printcalculator.model.StlBounds;
|
|
||||||
import com.printcalculator.repository.PrinterMachineRepository;
|
import com.printcalculator.repository.PrinterMachineRepository;
|
||||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
import com.printcalculator.service.QuoteCalculator;
|
||||||
import com.printcalculator.service.ProfileManager;
|
|
||||||
import com.printcalculator.service.SlicerService;
|
import com.printcalculator.service.SlicerService;
|
||||||
import com.printcalculator.service.StlService;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -32,24 +28,19 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/quote-sessions")
|
@RequestMapping("/api/quote-sessions")
|
||||||
|
|
||||||
public class QuoteSessionController {
|
public class QuoteSessionController {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
|
|
||||||
|
|
||||||
private final QuoteSessionRepository sessionRepo;
|
private final QuoteSessionRepository sessionRepo;
|
||||||
private final QuoteLineItemRepository lineItemRepo;
|
private final QuoteLineItemRepository lineItemRepo;
|
||||||
private final SlicerService slicerService;
|
private final SlicerService slicerService;
|
||||||
private final StlService stlService;
|
|
||||||
private final QuoteCalculator quoteCalculator;
|
private final QuoteCalculator quoteCalculator;
|
||||||
private final ProfileManager profileManager;
|
|
||||||
private final PrinterMachineRepository machineRepo;
|
private final PrinterMachineRepository machineRepo;
|
||||||
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
|
||||||
private final com.printcalculator.service.StorageService storageService;
|
private final com.printcalculator.service.ClamAVService clamAVService;
|
||||||
|
|
||||||
// Defaults
|
// Defaults
|
||||||
private static final String DEFAULT_FILAMENT = "pla_basic";
|
private static final String DEFAULT_FILAMENT = "pla_basic";
|
||||||
@@ -58,21 +49,17 @@ public class QuoteSessionController {
|
|||||||
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
public QuoteSessionController(QuoteSessionRepository sessionRepo,
|
||||||
QuoteLineItemRepository lineItemRepo,
|
QuoteLineItemRepository lineItemRepo,
|
||||||
SlicerService slicerService,
|
SlicerService slicerService,
|
||||||
StlService stlService,
|
|
||||||
QuoteCalculator quoteCalculator,
|
QuoteCalculator quoteCalculator,
|
||||||
ProfileManager profileManager,
|
|
||||||
PrinterMachineRepository machineRepo,
|
PrinterMachineRepository machineRepo,
|
||||||
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
|
||||||
com.printcalculator.service.StorageService storageService) {
|
com.printcalculator.service.ClamAVService clamAVService) {
|
||||||
this.sessionRepo = sessionRepo;
|
this.sessionRepo = sessionRepo;
|
||||||
this.lineItemRepo = lineItemRepo;
|
this.lineItemRepo = lineItemRepo;
|
||||||
this.slicerService = slicerService;
|
this.slicerService = slicerService;
|
||||||
this.stlService = stlService;
|
|
||||||
this.quoteCalculator = quoteCalculator;
|
this.quoteCalculator = quoteCalculator;
|
||||||
this.profileManager = profileManager;
|
|
||||||
this.machineRepo = machineRepo;
|
this.machineRepo = machineRepo;
|
||||||
this.pricingRepo = pricingRepo;
|
this.pricingRepo = pricingRepo;
|
||||||
this.storageService = storageService;
|
this.clamAVService = clamAVService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Start a new empty session
|
// 1. Start a new empty session
|
||||||
@@ -115,8 +102,13 @@ public class QuoteSessionController {
|
|||||||
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
|
||||||
if (file.isEmpty()) throw new IOException("File is empty");
|
if (file.isEmpty()) throw new IOException("File is empty");
|
||||||
|
|
||||||
|
// Scan for virus
|
||||||
|
clamAVService.scan(file.getInputStream());
|
||||||
|
|
||||||
// 1. Define Persistent Storage Path
|
// 1. Define Persistent Storage Path
|
||||||
// Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
|
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
|
||||||
|
String storageDir = "storage_quotes/" + session.getId();
|
||||||
|
Files.createDirectories(Paths.get(storageDir));
|
||||||
|
|
||||||
String originalFilename = file.getOriginalFilename();
|
String originalFilename = file.getOriginalFilename();
|
||||||
String ext = originalFilename != null && originalFilename.contains(".")
|
String ext = originalFilename != null && originalFilename.contains(".")
|
||||||
@@ -124,15 +116,11 @@ public class QuoteSessionController {
|
|||||||
: ".stl";
|
: ".stl";
|
||||||
|
|
||||||
String storedFilename = UUID.randomUUID() + ext;
|
String storedFilename = UUID.randomUUID() + ext;
|
||||||
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
|
Path persistentPath = Paths.get(storageDir, storedFilename);
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
storageService.store(file, relativePath);
|
Files.copy(file.getInputStream(), persistentPath);
|
||||||
|
|
||||||
// Resolve absolute path for slicing and storage usage
|
|
||||||
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
|
|
||||||
|
|
||||||
com.printcalculator.model.StlShiftResult shift = null;
|
|
||||||
try {
|
try {
|
||||||
// Apply Basic/Advanced Logic
|
// Apply Basic/Advanced Logic
|
||||||
applyPrintSettings(settings);
|
applyPrintSettings(settings);
|
||||||
@@ -141,33 +129,13 @@ public class QuoteSessionController {
|
|||||||
// 1. Pick Machine (default to first active or specific)
|
// 1. Pick Machine (default to first active or specific)
|
||||||
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
|
||||||
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
.orElseThrow(() -> new RuntimeException("No active printer found"));
|
||||||
|
|
||||||
// 2. Validate model size against machine volume
|
|
||||||
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
|
|
||||||
|
|
||||||
// 2b. Auto-center if needed (keeps the stored STL unchanged)
|
|
||||||
shift = stlService.shiftToFitIfNeeded(
|
|
||||||
persistentPath.toFile(),
|
|
||||||
bounds,
|
|
||||||
machine.getBuildVolumeXMm(),
|
|
||||||
machine.getBuildVolumeYMm(),
|
|
||||||
machine.getBuildVolumeZMm()
|
|
||||||
);
|
|
||||||
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : persistentPath.toFile();
|
|
||||||
if (shift.shifted()) {
|
|
||||||
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
|
|
||||||
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Pick Profiles
|
// 2. Pick Profiles
|
||||||
String machineProfile = machine.getSlicerMachineProfile();
|
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
||||||
if (machineProfile == null || machineProfile.isBlank()) {
|
// If the display name doesn't match the json profile name, we might need a mapping key in DB.
|
||||||
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
|
// For now assuming display name works or we use a tough default
|
||||||
}
|
machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists.
|
||||||
if (machineProfile == null || machineProfile.isBlank()) {
|
// Ideally: machine.getSlicerProfileName();
|
||||||
machineProfile = "bambu_a1"; // final fallback (alias handled in ProfileManager)
|
|
||||||
}
|
|
||||||
machineProfile = profileManager.resolveMachineProfileName(machineProfile, settings.getNozzleDiameter());
|
|
||||||
|
|
||||||
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
|
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
|
||||||
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
|
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
|
||||||
@@ -176,24 +144,7 @@ public class QuoteSessionController {
|
|||||||
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
|
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
|
||||||
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
|
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
|
||||||
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
|
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
|
||||||
|
|
||||||
// Update Session Material
|
|
||||||
session.setMaterialCode(settings.getMaterial());
|
|
||||||
} else {
|
|
||||||
// Fallback if null?
|
|
||||||
session.setMaterialCode("pla_basic");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Session Settings for Persistence
|
|
||||||
if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
|
|
||||||
if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
|
|
||||||
if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
|
|
||||||
if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
|
|
||||||
if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
|
|
||||||
if (settings.getNotes() != null) session.setNotes(settings.getNotes());
|
|
||||||
|
|
||||||
// Save session updates
|
|
||||||
sessionRepo.save(session);
|
|
||||||
|
|
||||||
String processProfile = "0.20mm Standard @BBL A1";
|
String processProfile = "0.20mm Standard @BBL A1";
|
||||||
// Mapping quality to process
|
// Mapping quality to process
|
||||||
@@ -206,40 +157,26 @@ public class QuoteSessionController {
|
|||||||
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
|
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build overrides map from settings
|
|
||||||
// Build overrides map from settings
|
// Build overrides map from settings
|
||||||
Map<String, String> processOverrides = new HashMap<>();
|
Map<String, String> processOverrides = new HashMap<>();
|
||||||
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
|
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
|
||||||
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
|
||||||
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
|
||||||
if (settings.getSupportsEnabled() != null) {
|
|
||||||
processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
|
|
||||||
// If enabled, use a more permissive threshold (45 deg) by default
|
|
||||||
// to avoid expensive supports on things that don't strictly need them
|
|
||||||
if (settings.getSupportsEnabled()) {
|
|
||||||
processOverrides.put("support_threshold_angle", "45");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, String> machineOverrides = new HashMap<>();
|
// 3. Slice (Use persistent path)
|
||||||
if (settings.getNozzleDiameter() != null) {
|
|
||||||
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Slice (Use persistent path)
|
|
||||||
PrintStats stats = slicerService.slice(
|
PrintStats stats = slicerService.slice(
|
||||||
sliceInput,
|
persistentPath.toFile(),
|
||||||
machineProfile,
|
machineProfile,
|
||||||
filamentProfile,
|
filamentProfile,
|
||||||
processProfile,
|
processProfile,
|
||||||
machineOverrides, // machine overrides
|
null, // machine overrides
|
||||||
processOverrides
|
processOverrides
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Calculate Quote
|
// 4. Calculate Quote
|
||||||
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
|
||||||
|
|
||||||
// 6. Create Line Item
|
// 5. Create Line Item
|
||||||
QuoteLineItem item = new QuoteLineItem();
|
QuoteLineItem item = new QuoteLineItem();
|
||||||
item.setQuoteSession(session);
|
item.setQuoteSession(session);
|
||||||
item.setOriginalFilename(file.getOriginalFilename());
|
item.setOriginalFilename(file.getOriginalFilename());
|
||||||
@@ -248,8 +185,8 @@ public class QuoteSessionController {
|
|||||||
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
|
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
|
||||||
item.setStatus("READY"); // or CALCULATED
|
item.setStatus("READY"); // or CALCULATED
|
||||||
|
|
||||||
item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
|
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
|
||||||
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
|
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
|
||||||
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
|
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
|
||||||
|
|
||||||
// Store breakdown
|
// Store breakdown
|
||||||
@@ -259,10 +196,14 @@ public class QuoteSessionController {
|
|||||||
breakdown.put("setup_fee", result.getSetupCost());
|
breakdown.put("setup_fee", result.getSetupCost());
|
||||||
item.setPricingBreakdown(breakdown);
|
item.setPricingBreakdown(breakdown);
|
||||||
|
|
||||||
// Dimensions from STL
|
// Dimensions
|
||||||
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
|
// Cannot get bb from GCodeParser yet?
|
||||||
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
|
// If GCodeParser doesn't return size, we might defaults or 0.
|
||||||
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
|
// Stats has filament used.
|
||||||
|
// Let's set dummy for now or upgrade parser later.
|
||||||
|
item.setBoundingBoxXMm(BigDecimal.ZERO);
|
||||||
|
item.setBoundingBoxYMm(BigDecimal.ZERO);
|
||||||
|
item.setBoundingBoxZMm(BigDecimal.ZERO);
|
||||||
|
|
||||||
item.setCreatedAt(OffsetDateTime.now());
|
item.setCreatedAt(OffsetDateTime.now());
|
||||||
item.setUpdatedAt(OffsetDateTime.now());
|
item.setUpdatedAt(OffsetDateTime.now());
|
||||||
@@ -271,46 +212,11 @@ public class QuoteSessionController {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Cleanup if failed
|
// Cleanup if failed
|
||||||
try {
|
Files.deleteIfExists(persistentPath);
|
||||||
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
|
||||||
if (shift != null && shift.shifted()) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(shift.shiftedPath());
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
|
|
||||||
StlBounds bounds = stlService.readBounds(stlFile);
|
|
||||||
double x = bounds.sizeX();
|
|
||||||
double y = bounds.sizeY();
|
|
||||||
double z = bounds.sizeZ();
|
|
||||||
|
|
||||||
int bx = machine.getBuildVolumeXMm();
|
|
||||||
int by = machine.getBuildVolumeYMm();
|
|
||||||
int bz = machine.getBuildVolumeZMm();
|
|
||||||
|
|
||||||
logger.info(String.format(
|
|
||||||
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
|
|
||||||
bounds.minX(), bounds.minY(), bounds.minZ(),
|
|
||||||
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
|
|
||||||
x, y, z, bx, by, bz
|
|
||||||
));
|
|
||||||
|
|
||||||
double eps = 0.01;
|
|
||||||
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|
|
||||||
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
|
|
||||||
|
|
||||||
if (!fits) {
|
|
||||||
throw new ModelTooLargeException(x, y, z, bx, by, bz);
|
|
||||||
}
|
|
||||||
return bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
|
||||||
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
|
||||||
// Set defaults based on Quality
|
// Set defaults based on Quality
|
||||||
@@ -321,30 +227,24 @@ public class QuoteSessionController {
|
|||||||
settings.setLayerHeight(0.28);
|
settings.setLayerHeight(0.28);
|
||||||
settings.setInfillDensity(15.0);
|
settings.setInfillDensity(15.0);
|
||||||
settings.setInfillPattern("grid");
|
settings.setInfillPattern("grid");
|
||||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
|
||||||
break;
|
break;
|
||||||
case "high":
|
case "high":
|
||||||
settings.setLayerHeight(0.12);
|
settings.setLayerHeight(0.12);
|
||||||
settings.setInfillDensity(20.0);
|
settings.setInfillDensity(20.0);
|
||||||
settings.setInfillPattern("gyroid");
|
settings.setInfillPattern("gyroid");
|
||||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
|
||||||
break;
|
break;
|
||||||
case "standard":
|
case "standard":
|
||||||
default:
|
default:
|
||||||
settings.setLayerHeight(0.20);
|
settings.setLayerHeight(0.20);
|
||||||
settings.setInfillDensity(20.0);
|
settings.setInfillDensity(20.0);
|
||||||
settings.setInfillPattern("grid");
|
settings.setInfillPattern("grid");
|
||||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
|
|
||||||
} else {
|
} else {
|
||||||
// ADVANCED Mode: Use values from Frontend, set defaults if missing
|
// ADVANCED Mode: Use values from Frontend, set defaults if missing
|
||||||
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
|
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
|
||||||
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
|
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
|
||||||
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
|
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
|
||||||
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
|
|
||||||
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,24 +336,6 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Path path = Paths.get(item.getStoredPath());
|
Path path = Paths.get(item.getStoredPath());
|
||||||
// Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative.
|
|
||||||
// But loadAsResource expects relative path?
|
|
||||||
// Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path).
|
|
||||||
// If path is absolute, resolve might fail or behave weirdly.
|
|
||||||
// But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString());
|
|
||||||
// If we want to use storageService.loadAsResource, we need the relative path.
|
|
||||||
// Or we just access the file directly if we trust the absolute path.
|
|
||||||
// But we want to use StorageService abstraction.
|
|
||||||
|
|
||||||
// Option 1: Reconstruct relative path.
|
|
||||||
// We know structure: quotes/{sessionId}/{filename}...
|
|
||||||
// But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily?
|
|
||||||
// QuoteLineItem doesn't seem to have storedFilename field, only storedPath.
|
|
||||||
|
|
||||||
// If we trust the file is on disk, we can use UrlResource directly here as before,
|
|
||||||
// relying on the fact that storedPath is the absolute path to the file.
|
|
||||||
// But we should verify it exists.
|
|
||||||
|
|
||||||
if (!Files.exists(path)) {
|
if (!Files.exists(path)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public class OrderDto {
|
public class OrderDto {
|
||||||
private UUID id;
|
private UUID id;
|
||||||
|
private String orderNumber;
|
||||||
private String status;
|
private String status;
|
||||||
|
private String paymentStatus;
|
||||||
|
private String paymentMethod;
|
||||||
private String customerEmail;
|
private String customerEmail;
|
||||||
private String customerPhone;
|
private String customerPhone;
|
||||||
private String billingCustomerType;
|
private String billingCustomerType;
|
||||||
@@ -27,9 +30,18 @@ public class OrderDto {
|
|||||||
public UUID getId() { return id; }
|
public UUID getId() { return id; }
|
||||||
public void setId(UUID id) { this.id = id; }
|
public void setId(UUID id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getOrderNumber() { return orderNumber; }
|
||||||
|
public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
|
||||||
|
|
||||||
public String getStatus() { return status; }
|
public String getStatus() { return status; }
|
||||||
public void setStatus(String status) { this.status = status; }
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public String getPaymentStatus() { return paymentStatus; }
|
||||||
|
public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; }
|
||||||
|
|
||||||
|
public String getPaymentMethod() { return paymentMethod; }
|
||||||
|
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
|
||||||
|
|
||||||
public String getCustomerEmail() { return customerEmail; }
|
public String getCustomerEmail() { return customerEmail; }
|
||||||
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
|
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public class PrintSettingsDto {
|
|||||||
private Double layerHeight;
|
private Double layerHeight;
|
||||||
private Double infillDensity;
|
private Double infillDensity;
|
||||||
private String infillPattern;
|
private String infillPattern;
|
||||||
private Boolean supportsEnabled = true;
|
private Boolean supportsEnabled;
|
||||||
private Double nozzleDiameter;
|
|
||||||
private String notes;
|
private String notes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,16 @@ public class Order {
|
|||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
public String getOrderNumber() {
|
||||||
|
if (id == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String rawId = id.toString();
|
||||||
|
int dashIndex = rawId.indexOf('-');
|
||||||
|
return dashIndex > 0 ? rawId.substring(0, dashIndex) : rawId;
|
||||||
|
}
|
||||||
|
|
||||||
public QuoteSession getSourceQuoteSession() {
|
public QuoteSession getSourceQuoteSession() {
|
||||||
return sourceQuoteSession;
|
return sourceQuoteSession;
|
||||||
}
|
}
|
||||||
@@ -410,5 +420,4 @@ public class Order {
|
|||||||
this.paidAt = paidAt;
|
this.paidAt = paidAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ public class OrderItem {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
private void onCreate() {
|
||||||
|
if (createdAt == null) {
|
||||||
|
createdAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
if (quantity == null) {
|
||||||
|
quantity = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public UUID getId() {
|
public UUID getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -195,4 +205,4 @@ public class OrderItem {
|
|||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ public class Payment {
|
|||||||
@Column(name = "initiated_at", nullable = false)
|
@Column(name = "initiated_at", nullable = false)
|
||||||
private OffsetDateTime initiatedAt;
|
private OffsetDateTime initiatedAt;
|
||||||
|
|
||||||
|
@Column(name = "reported_at")
|
||||||
|
private OffsetDateTime reportedAt;
|
||||||
|
|
||||||
@Column(name = "received_at")
|
@Column(name = "received_at")
|
||||||
private OffsetDateTime receivedAt;
|
private OffsetDateTime receivedAt;
|
||||||
|
|
||||||
@@ -135,6 +138,14 @@ public class Payment {
|
|||||||
this.initiatedAt = initiatedAt;
|
this.initiatedAt = initiatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getReportedAt() {
|
||||||
|
return reportedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportedAt(OffsetDateTime reportedAt) {
|
||||||
|
this.reportedAt = reportedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public OffsetDateTime getReceivedAt() {
|
public OffsetDateTime getReceivedAt() {
|
||||||
return receivedAt;
|
return receivedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ public class PrinterMachine {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
@Column(name = "slicer_machine_profile")
|
|
||||||
private String slicerMachineProfile;
|
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -60,14 +57,6 @@ public class PrinterMachine {
|
|||||||
this.printerDisplayName = printerDisplayName;
|
this.printerDisplayName = printerDisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getSlicerMachineProfile() {
|
|
||||||
return slicerMachineProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSlicerMachineProfile(String slicerMachineProfile) {
|
|
||||||
this.slicerMachineProfile = slicerMachineProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getBuildVolumeXMm() {
|
public Integer getBuildVolumeXMm() {
|
||||||
return buildVolumeXMm;
|
return buildVolumeXMm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.printcalculator.event;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class OrderCreatedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final Order order;
|
||||||
|
|
||||||
|
public OrderCreatedEvent(Object source, Order order) {
|
||||||
|
super(source);
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.printcalculator.event;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.entity.Payment;
|
||||||
|
import org.springframework.context.ApplicationEvent;
|
||||||
|
|
||||||
|
public class PaymentReportedEvent extends ApplicationEvent {
|
||||||
|
|
||||||
|
private final Order order;
|
||||||
|
private final Payment payment;
|
||||||
|
|
||||||
|
public PaymentReportedEvent(Object source, Order order, Payment payment) {
|
||||||
|
super(source);
|
||||||
|
this.order = order;
|
||||||
|
this.payment = payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order getOrder() {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Payment getPayment() {
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.printcalculator.event.listener;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.entity.Payment;
|
||||||
|
import com.printcalculator.event.OrderCreatedEvent;
|
||||||
|
import com.printcalculator.event.PaymentReportedEvent;
|
||||||
|
import com.printcalculator.service.email.EmailNotificationService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OrderEmailListener {
|
||||||
|
|
||||||
|
private final EmailNotificationService emailNotificationService;
|
||||||
|
|
||||||
|
@Value("${app.mail.admin.enabled:true}")
|
||||||
|
private boolean adminMailEnabled;
|
||||||
|
|
||||||
|
@Value("${app.mail.admin.address:}")
|
||||||
|
private String adminMailAddress;
|
||||||
|
|
||||||
|
@Value("${app.frontend.base-url:http://localhost:4200}")
|
||||||
|
private String frontendBaseUrl;
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
|
||||||
|
Order order = event.getOrder();
|
||||||
|
log.info("Processing OrderCreatedEvent for order id: {}", order.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendCustomerConfirmationEmail(order);
|
||||||
|
|
||||||
|
if (adminMailEnabled && adminMailAddress != null && !adminMailAddress.isEmpty()) {
|
||||||
|
sendAdminNotificationEmail(order);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to process email notifications for order id: {}", order.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@EventListener
|
||||||
|
public void handlePaymentReportedEvent(PaymentReportedEvent event) {
|
||||||
|
Order order = event.getOrder();
|
||||||
|
log.info("Processing PaymentReportedEvent for order id: {}", order.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendPaymentReportedEmail(order);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to send payment reported email for order id: {}", order.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendCustomerConfirmationEmail(Order order) {
|
||||||
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
|
templateData.put("customerName", order.getCustomer().getFirstName());
|
||||||
|
templateData.put("orderId", order.getId());
|
||||||
|
templateData.put("orderNumber", getDisplayOrderNumber(order));
|
||||||
|
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
|
||||||
|
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
|
||||||
|
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
|
||||||
|
|
||||||
|
emailNotificationService.sendEmail(
|
||||||
|
order.getCustomer().getEmail(),
|
||||||
|
"Conferma Ordine #" + getDisplayOrderNumber(order) + " - 3D-Fab",
|
||||||
|
"order-confirmation",
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendPaymentReportedEmail(Order order) {
|
||||||
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
|
templateData.put("customerName", order.getCustomer().getFirstName());
|
||||||
|
templateData.put("orderId", order.getId());
|
||||||
|
templateData.put("orderNumber", getDisplayOrderNumber(order));
|
||||||
|
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
|
||||||
|
|
||||||
|
emailNotificationService.sendEmail(
|
||||||
|
order.getCustomer().getEmail(),
|
||||||
|
"Stiamo verificando il tuo pagamento (Ordine #" + getDisplayOrderNumber(order) + ")",
|
||||||
|
"payment-reported",
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendAdminNotificationEmail(Order order) {
|
||||||
|
Map<String, Object> templateData = new HashMap<>();
|
||||||
|
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
|
||||||
|
templateData.put("orderId", order.getId());
|
||||||
|
templateData.put("orderNumber", getDisplayOrderNumber(order));
|
||||||
|
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
|
||||||
|
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
|
||||||
|
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
|
||||||
|
|
||||||
|
// Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro
|
||||||
|
emailNotificationService.sendEmail(
|
||||||
|
adminMailAddress,
|
||||||
|
"Nuovo Ordine Ricevuto #" + getDisplayOrderNumber(order) + " - " + order.getCustomer().getLastName(),
|
||||||
|
"order-confirmation",
|
||||||
|
templateData
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getDisplayOrderNumber(Order order) {
|
||||||
|
String orderNumber = order.getOrderNumber();
|
||||||
|
if (orderNumber != null && !orderNumber.isBlank()) {
|
||||||
|
return orderNumber;
|
||||||
|
}
|
||||||
|
return order.getId() != null ? order.getId().toString() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildOrderDetailsUrl(Order order) {
|
||||||
|
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
|
||||||
|
return baseUrl + "/ordine/" + order.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +1,27 @@
|
|||||||
package com.printcalculator.exception;
|
package com.printcalculator.exception;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
|
|
||||||
@ControllerAdvice
|
@ControllerAdvice
|
||||||
@Slf4j
|
|
||||||
public class GlobalExceptionHandler {
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
@ExceptionHandler(StorageException.class)
|
@ExceptionHandler(VirusDetectedException.class)
|
||||||
public ResponseEntity<?> handleStorageException(StorageException exc) {
|
public ResponseEntity<Object> handleVirusDetectedException(
|
||||||
// Log the full exception for internal debugging
|
VirusDetectedException ex, WebRequest request) {
|
||||||
log.error("Storage Exception occurred", exc);
|
|
||||||
|
|
||||||
Map<String, String> response = new HashMap<>();
|
|
||||||
|
|
||||||
// Check for specific virus case
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
|
body.put("timestamp", LocalDateTime.now());
|
||||||
response.put("error", "Security Violation");
|
body.put("message", ex.getMessage());
|
||||||
// Safe message for client
|
body.put("error", "Virus Detected");
|
||||||
response.put("message", "File rejected by security policy.");
|
|
||||||
response.put("code", "VIRUS_DETECTED");
|
|
||||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic fallback for other storage errors to avoid leaking internal paths/details
|
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
|
||||||
response.put("error", "Storage Operation Failed");
|
|
||||||
response.put("message", "Unable to process the file upload.");
|
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
|
||||||
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
|
|
||||||
Map<String, String> response = new HashMap<>();
|
|
||||||
response.put("error", "File too large");
|
|
||||||
response.put("message", "The uploaded file exceeds the maximum allowed size.");
|
|
||||||
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(ModelTooLargeException.class)
|
|
||||||
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
|
|
||||||
Map<String, String> response = new HashMap<>();
|
|
||||||
response.put("error", "Model too large");
|
|
||||||
response.put("code", "MODEL_TOO_LARGE");
|
|
||||||
response.put("message", String.format(
|
|
||||||
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
|
|
||||||
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
|
|
||||||
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
|
|
||||||
));
|
|
||||||
response.put("model_x_mm", formatMm(exc.getModelX()));
|
|
||||||
response.put("model_y_mm", formatMm(exc.getModelY()));
|
|
||||||
response.put("model_z_mm", formatMm(exc.getModelZ()));
|
|
||||||
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
|
|
||||||
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
|
|
||||||
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
|
|
||||||
return ResponseEntity.unprocessableEntity().body(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatMm(double value) {
|
|
||||||
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.printcalculator.exception;
|
|
||||||
|
|
||||||
public class ModelTooLargeException extends RuntimeException {
|
|
||||||
private final double modelX;
|
|
||||||
private final double modelY;
|
|
||||||
private final double modelZ;
|
|
||||||
private final int buildX;
|
|
||||||
private final int buildY;
|
|
||||||
private final int buildZ;
|
|
||||||
|
|
||||||
public ModelTooLargeException(double modelX, double modelY, double modelZ,
|
|
||||||
int buildX, int buildY, int buildZ) {
|
|
||||||
super("Model size exceeds build volume");
|
|
||||||
this.modelX = modelX;
|
|
||||||
this.modelY = modelY;
|
|
||||||
this.modelZ = modelZ;
|
|
||||||
this.buildX = buildX;
|
|
||||||
this.buildY = buildY;
|
|
||||||
this.buildZ = buildZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getModelX() {
|
|
||||||
return modelX;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getModelY() {
|
|
||||||
return modelY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getModelZ() {
|
|
||||||
return modelZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getBuildX() {
|
|
||||||
return buildX;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getBuildY() {
|
|
||||||
return buildY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getBuildZ() {
|
|
||||||
return buildZ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.printcalculator.exception;
|
||||||
|
|
||||||
|
public class VirusDetectedException extends RuntimeException {
|
||||||
|
public VirusDetectedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,8 @@
|
|||||||
package com.printcalculator.model;
|
package com.printcalculator.model;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
public record PrintStats(
|
||||||
import lombok.Builder;
|
long printTimeSeconds,
|
||||||
import lombok.Data;
|
String printTimeFormatted,
|
||||||
import lombok.NoArgsConstructor;
|
double filamentWeightGrams,
|
||||||
|
double filamentLengthMm
|
||||||
@Data
|
) {}
|
||||||
@AllArgsConstructor
|
|
||||||
@NoArgsConstructor
|
|
||||||
@Builder
|
|
||||||
public class PrintStats {
|
|
||||||
private long printTimeSeconds;
|
|
||||||
private String printTimeFormatted;
|
|
||||||
private double filamentWeightGrams;
|
|
||||||
private double filamentLengthMm;
|
|
||||||
|
|
||||||
// Breakdown if available
|
|
||||||
private Double modelWeightGrams;
|
|
||||||
private Double supportWeightGrams;
|
|
||||||
|
|
||||||
// Legacy constructor for compatibility
|
|
||||||
public PrintStats(long printTimeSeconds, String printTimeFormatted, double filamentWeightGrams, double filamentLengthMm) {
|
|
||||||
this.printTimeSeconds = printTimeSeconds;
|
|
||||||
this.printTimeFormatted = printTimeFormatted;
|
|
||||||
this.filamentWeightGrams = filamentWeightGrams;
|
|
||||||
this.filamentLengthMm = filamentLengthMm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
package com.printcalculator.model;
|
|
||||||
|
|
||||||
public record StlBounds(double minX, double minY, double minZ,
|
|
||||||
double maxX, double maxY, double maxZ) {
|
|
||||||
public double sizeX() {
|
|
||||||
return maxX - minX;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double sizeY() {
|
|
||||||
return maxY - minY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double sizeZ() {
|
|
||||||
return maxZ - minZ;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.printcalculator.model;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
public record StlShiftResult(Path shiftedPath,
|
|
||||||
double offsetX,
|
|
||||||
double offsetY,
|
|
||||||
double offsetZ,
|
|
||||||
boolean shifted) {
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,9 @@ package com.printcalculator.repository;
|
|||||||
import com.printcalculator.entity.Payment;
|
import com.printcalculator.entity.Payment;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
|
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
|
||||||
|
Optional<Payment> findByOrder_Id(UUID orderId);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.printcalculator.service;
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.exception.VirusDetectedException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -22,18 +23,15 @@ public class ClamAVService {
|
|||||||
public ClamAVService(
|
public ClamAVService(
|
||||||
@Value("${clamav.host:clamav}") String host,
|
@Value("${clamav.host:clamav}") String host,
|
||||||
@Value("${clamav.port:3310}") int port,
|
@Value("${clamav.port:3310}") int port,
|
||||||
@Value("${clamav.enabled:false}") boolean enabled
|
@Value("${clamav.enabled:true}") boolean enabled
|
||||||
) {
|
) {
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
if (!enabled) {
|
|
||||||
logger.info("ClamAV is DISABLED");
|
|
||||||
this.clamavClient = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.info("Initializing ClamAV client at {}:{}", host, port);
|
|
||||||
ClamavClient client = null;
|
ClamavClient client = null;
|
||||||
try {
|
try {
|
||||||
client = new ClamavClient(host, port);
|
if (enabled) {
|
||||||
|
logger.info("Initializing ClamAV client at {}:{}", host, port);
|
||||||
|
client = new ClamavClient(host, port);
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Failed to initialize ClamAV client: " + e.getMessage());
|
logger.error("Failed to initialize ClamAV client: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -51,11 +49,13 @@ public class ClamAVService {
|
|||||||
} else if (result instanceof ScanResult.VirusFound) {
|
} else if (result instanceof ScanResult.VirusFound) {
|
||||||
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
|
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
|
||||||
logger.warn("VIRUS DETECTED: {}", viruses);
|
logger.warn("VIRUS DETECTED: {}", viruses);
|
||||||
return false;
|
throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses);
|
||||||
} else {
|
} else {
|
||||||
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
|
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} catch (VirusDetectedException e) {
|
||||||
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
|
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -26,15 +26,13 @@ public class GCodeParser {
|
|||||||
private static final Pattern TIME_PATTERN = Pattern.compile(
|
private static final Pattern TIME_PATTERN = Pattern.compile(
|
||||||
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
|
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
|
||||||
Pattern.CASE_INSENSITIVE);
|
Pattern.CASE_INSENSITIVE);
|
||||||
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*([^;\\(\\n\\r]+)(?:\\s*\\(([^,]+) model,\\s*([^ ]+) support\\))?");
|
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)");
|
||||||
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
|
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
|
||||||
|
|
||||||
public PrintStats parse(File gcodeFile) throws IOException {
|
public PrintStats parse(File gcodeFile) throws IOException {
|
||||||
long seconds = 0;
|
long seconds = 0;
|
||||||
double weightG = 0;
|
double weightG = 0;
|
||||||
double lengthMm = 0;
|
double lengthMm = 0;
|
||||||
Double modelWeightG = null;
|
|
||||||
Double supportWeightG = null;
|
|
||||||
String timeFormatted = "";
|
String timeFormatted = "";
|
||||||
|
|
||||||
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
|
||||||
@@ -80,14 +78,7 @@ public class GCodeParser {
|
|||||||
if (weightMatcher.find()) {
|
if (weightMatcher.find()) {
|
||||||
try {
|
try {
|
||||||
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
weightG = Double.parseDouble(weightMatcher.group(1).trim());
|
||||||
System.out.println("GCodeParser: Found total weight: " + weightG + "g");
|
System.out.println("GCodeParser: Found weight: " + weightG + "g");
|
||||||
|
|
||||||
// Check if we have groups 2 and 3 for breakdown
|
|
||||||
if (weightMatcher.groupCount() >= 3 && weightMatcher.group(2) != null) {
|
|
||||||
modelWeightG = Double.parseDouble(weightMatcher.group(2).trim());
|
|
||||||
supportWeightG = Double.parseDouble(weightMatcher.group(3).trim());
|
|
||||||
System.out.println("GCodeParser: Found breakdown - Model: " + modelWeightG + "g, Support: " + supportWeightG + "g");
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException ignored) {}
|
} catch (NumberFormatException ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +92,7 @@ public class GCodeParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return PrintStats.builder()
|
return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
|
||||||
.printTimeSeconds(seconds)
|
|
||||||
.printTimeFormatted(timeFormatted)
|
|
||||||
.filamentWeightGrams(weightG)
|
|
||||||
.filamentLengthMm(lengthMm)
|
|
||||||
.modelWeightGrams(modelWeightG)
|
|
||||||
.supportWeightGrams(supportWeightG)
|
|
||||||
.build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private long parseTimeString(String timeStr) {
|
private long parseTimeString(String timeStr) {
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import com.printcalculator.repository.OrderItemRepository;
|
|||||||
import com.printcalculator.repository.OrderRepository;
|
import com.printcalculator.repository.OrderRepository;
|
||||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
import com.printcalculator.repository.QuoteLineItemRepository;
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
import com.printcalculator.repository.QuoteSessionRepository;
|
||||||
|
import com.printcalculator.event.OrderCreatedEvent;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
@@ -34,6 +35,8 @@ public class OrderService {
|
|||||||
private final StorageService storageService;
|
private final StorageService storageService;
|
||||||
private final InvoicePdfRenderingService invoiceService;
|
private final InvoicePdfRenderingService invoiceService;
|
||||||
private final QrBillService qrBillService;
|
private final QrBillService qrBillService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final PaymentService paymentService;
|
||||||
|
|
||||||
public OrderService(OrderRepository orderRepo,
|
public OrderService(OrderRepository orderRepo,
|
||||||
OrderItemRepository orderItemRepo,
|
OrderItemRepository orderItemRepo,
|
||||||
@@ -42,7 +45,9 @@ public class OrderService {
|
|||||||
CustomerRepository customerRepo,
|
CustomerRepository customerRepo,
|
||||||
StorageService storageService,
|
StorageService storageService,
|
||||||
InvoicePdfRenderingService invoiceService,
|
InvoicePdfRenderingService invoiceService,
|
||||||
QrBillService qrBillService) {
|
QrBillService qrBillService,
|
||||||
|
ApplicationEventPublisher eventPublisher,
|
||||||
|
PaymentService paymentService) {
|
||||||
this.orderRepo = orderRepo;
|
this.orderRepo = orderRepo;
|
||||||
this.orderItemRepo = orderItemRepo;
|
this.orderItemRepo = orderItemRepo;
|
||||||
this.quoteSessionRepo = quoteSessionRepo;
|
this.quoteSessionRepo = quoteSessionRepo;
|
||||||
@@ -51,6 +56,8 @@ public class OrderService {
|
|||||||
this.storageService = storageService;
|
this.storageService = storageService;
|
||||||
this.invoiceService = invoiceService;
|
this.invoiceService = invoiceService;
|
||||||
this.qrBillService = qrBillService;
|
this.qrBillService = qrBillService;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
|
this.paymentService = paymentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -195,7 +202,14 @@ public class OrderService {
|
|||||||
// Generate Invoice and QR Bill
|
// Generate Invoice and QR Bill
|
||||||
generateAndSaveDocuments(order, savedItems);
|
generateAndSaveDocuments(order, savedItems);
|
||||||
|
|
||||||
return orderRepo.save(order);
|
Order savedOrder = orderRepo.save(order);
|
||||||
|
|
||||||
|
// ALWAYS initialize payment as PENDING
|
||||||
|
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
|
||||||
|
|
||||||
|
return savedOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
|
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
|
||||||
@@ -223,7 +237,7 @@ public class OrderService {
|
|||||||
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
|
||||||
vars.put("sellerEmail", "info@3dfab.ch");
|
vars.put("sellerEmail", "info@3dfab.ch");
|
||||||
|
|
||||||
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
|
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
|
||||||
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
|
||||||
@@ -297,4 +311,12 @@ public class OrderService {
|
|||||||
}
|
}
|
||||||
return "stl";
|
return "stl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getDisplayOrderNumber(Order order) {
|
||||||
|
String orderNumber = order.getOrderNumber();
|
||||||
|
if (orderNumber != null && !orderNumber.isBlank()) {
|
||||||
|
return orderNumber;
|
||||||
|
}
|
||||||
|
return order.getId() != null ? order.getId().toString() : "unknown";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.entity.Payment;
|
||||||
|
import com.printcalculator.event.PaymentReportedEvent;
|
||||||
|
import com.printcalculator.repository.OrderRepository;
|
||||||
|
import com.printcalculator.repository.PaymentRepository;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final PaymentRepository paymentRepo;
|
||||||
|
private final OrderRepository orderRepo;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
public PaymentService(PaymentRepository paymentRepo,
|
||||||
|
OrderRepository orderRepo,
|
||||||
|
ApplicationEventPublisher eventPublisher) {
|
||||||
|
this.paymentRepo = paymentRepo;
|
||||||
|
this.orderRepo = orderRepo;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Payment getOrCreatePaymentForOrder(Order order, String defaultMethod) {
|
||||||
|
Optional<Payment> existing = paymentRepo.findByOrder_Id(order.getId());
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
return existing.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Payment payment = new Payment();
|
||||||
|
payment.setOrder(order);
|
||||||
|
payment.setMethod(defaultMethod != null ? defaultMethod : "OTHER");
|
||||||
|
payment.setStatus("PENDING");
|
||||||
|
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
|
||||||
|
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
|
||||||
|
payment.setInitiatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
return paymentRepo.save(payment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Payment reportPayment(UUID orderId, String method) {
|
||||||
|
Order order = orderRepo.findById(orderId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
|
||||||
|
|
||||||
|
Payment payment = paymentRepo.findByOrder_Id(orderId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("No active payment found for order " + orderId));
|
||||||
|
|
||||||
|
if (!"PENDING".equals(payment.getStatus())) {
|
||||||
|
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
payment.setStatus("REPORTED");
|
||||||
|
payment.setReportedAt(OffsetDateTime.now());
|
||||||
|
if (method != null && !method.isBlank()) {
|
||||||
|
payment.setMethod(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
payment = paymentRepo.save(payment);
|
||||||
|
|
||||||
|
eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment));
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,19 +10,23 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.math.BigDecimal;
|
import java.util.List;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ProfileManager {
|
public class ProfileManager {
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
|
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
|
||||||
private final String profilesRoot;
|
private final String profilesRoot;
|
||||||
|
private final Path resolvedProfilesRoot;
|
||||||
private final ObjectMapper mapper;
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
private final Map<String, String> profileAliases;
|
private final Map<String, String> profileAliases;
|
||||||
@@ -32,6 +36,8 @@ public class ProfileManager {
|
|||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.profileAliases = new HashMap<>();
|
this.profileAliases = new HashMap<>();
|
||||||
initializeAliases();
|
initializeAliases();
|
||||||
|
this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot);
|
||||||
|
logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeAliases() {
|
private void initializeAliases() {
|
||||||
@@ -55,42 +61,82 @@ public class ProfileManager {
|
|||||||
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
|
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
|
||||||
Path profilePath = findProfileFile(profileName, type);
|
Path profilePath = findProfileFile(profileName, type);
|
||||||
if (profilePath == null) {
|
if (profilePath == null) {
|
||||||
throw new IOException("Profile not found: " + profileName);
|
throw new IOException("Profile not found: " + profileName + " (root=" + resolvedProfilesRoot + ")");
|
||||||
}
|
}
|
||||||
|
logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
|
||||||
return resolveInheritance(profilePath);
|
return resolveInheritance(profilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
|
|
||||||
String resolvedName = profileAliases.getOrDefault(machineName, machineName);
|
|
||||||
if (nozzleDiameter == null) return resolvedName;
|
|
||||||
|
|
||||||
String base = resolvedName.replaceAll("\\s*\\d+(?:\\.\\d+)?\\s*nozzle$", "").trim();
|
|
||||||
String formatted = BigDecimal.valueOf(nozzleDiameter).stripTrailingZeros().toPlainString();
|
|
||||||
String candidate = base + " " + formatted + " nozzle";
|
|
||||||
|
|
||||||
Path exists = findProfileFile(candidate, "machine");
|
|
||||||
return exists != null ? candidate : resolvedName;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path findProfileFile(String name, String type) {
|
private Path findProfileFile(String name, String type) {
|
||||||
|
if (!Files.isDirectory(resolvedProfilesRoot)) {
|
||||||
|
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Check aliases first
|
// Check aliases first
|
||||||
String resolvedName = profileAliases.getOrDefault(name, name);
|
String resolvedName = profileAliases.getOrDefault(name, name);
|
||||||
|
|
||||||
// Simple search: look for name.json in the profiles_root recursively
|
// Look for name.json under the expected type directory first to avoid
|
||||||
// Type could be "machine", "process", "filament" to narrow down, but for now global search
|
// collisions across vendors/profile families with same filename.
|
||||||
String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json";
|
String filename = toJsonFilename(resolvedName);
|
||||||
|
|
||||||
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
|
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
|
||||||
Optional<Path> found = stream
|
List<Path> candidates = stream
|
||||||
.filter(p -> p.getFileName().toString().equals(filename))
|
.filter(p -> p.getFileName().toString().equals(filename))
|
||||||
.findFirst();
|
.sorted()
|
||||||
return found.orElse(null);
|
.toList();
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) {
|
||||||
|
Optional<Path> typed = candidates.stream()
|
||||||
|
.filter(p -> pathContainsSegment(p, type))
|
||||||
|
.findFirst();
|
||||||
|
if (typed.isPresent()) {
|
||||||
|
return typed.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.get(0);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.severe("Error searching for profile: " + e.getMessage());
|
logger.severe("Error searching for profile: " + e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Path resolveProfilesRoot(String configuredRoot) {
|
||||||
|
Set<Path> candidates = new LinkedHashSet<>();
|
||||||
|
Path cwd = Paths.get("").toAbsolutePath().normalize();
|
||||||
|
|
||||||
|
if (configuredRoot != null && !configuredRoot.isBlank()) {
|
||||||
|
Path configured = Paths.get(configuredRoot);
|
||||||
|
candidates.add(configured.toAbsolutePath().normalize());
|
||||||
|
if (!configured.isAbsolute()) {
|
||||||
|
candidates.add(cwd.resolve(configuredRoot).normalize());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.add(cwd.resolve("profiles").normalize());
|
||||||
|
candidates.add(cwd.resolve("backend/profiles").normalize());
|
||||||
|
candidates.add(Paths.get("/app/profiles").toAbsolutePath().normalize());
|
||||||
|
|
||||||
|
List<String> checkedPaths = new ArrayList<>();
|
||||||
|
for (Path candidate : candidates) {
|
||||||
|
checkedPaths.add(candidate.toString());
|
||||||
|
if (Files.isDirectory(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warning("No profiles directory found. Checked: " + String.join(", ", checkedPaths));
|
||||||
|
if (configuredRoot != null && !configuredRoot.isBlank()) {
|
||||||
|
return Paths.get(configuredRoot).toAbsolutePath().normalize();
|
||||||
|
}
|
||||||
|
return cwd.resolve("profiles").normalize();
|
||||||
|
}
|
||||||
|
|
||||||
private ObjectNode resolveInheritance(Path currentPath) throws IOException {
|
private ObjectNode resolveInheritance(Path currentPath) throws IOException {
|
||||||
// 1. Load current
|
// 1. Load current
|
||||||
JsonNode currentNode = mapper.readTree(currentPath.toFile());
|
JsonNode currentNode = mapper.readTree(currentPath.toFile());
|
||||||
@@ -98,14 +144,20 @@ public class ProfileManager {
|
|||||||
// 2. Check inherits
|
// 2. Check inherits
|
||||||
if (currentNode.has("inherits")) {
|
if (currentNode.has("inherits")) {
|
||||||
String parentName = currentNode.get("inherits").asText();
|
String parentName = currentNode.get("inherits").asText();
|
||||||
// Try to find parent in same directory or standard search
|
// Try local directory first with explicit .json filename.
|
||||||
Path parentPath = currentPath.getParent().resolve(parentName);
|
String parentFilename = toJsonFilename(parentName);
|
||||||
|
Path parentPath = currentPath.getParent().resolve(parentFilename);
|
||||||
if (!Files.exists(parentPath)) {
|
if (!Files.exists(parentPath)) {
|
||||||
// If not in same dir, search globally
|
// Fallback to the same profile type directory before global.
|
||||||
|
String inferredType = inferTypeFromPath(currentPath);
|
||||||
|
parentPath = findProfileFile(parentName, inferredType);
|
||||||
|
}
|
||||||
|
if (parentPath == null || !Files.exists(parentPath)) {
|
||||||
parentPath = findProfileFile(parentName, "any");
|
parentPath = findProfileFile(parentName, "any");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentPath != null && Files.exists(parentPath)) {
|
if (parentPath != null && Files.exists(parentPath)) {
|
||||||
|
logger.info("Resolved inherits '" + parentName + "' for " + currentPath + " -> " + parentPath);
|
||||||
// Recursive call
|
// Recursive call
|
||||||
ObjectNode parentNode = resolveInheritance(parentPath);
|
ObjectNode parentNode = resolveInheritance(parentPath);
|
||||||
// Merge current into parent (child overrides parent)
|
// Merge current into parent (child overrides parent)
|
||||||
@@ -136,4 +188,30 @@ public class ProfileManager {
|
|||||||
mainNode.set(fieldName, jsonNode);
|
mainNode.set(fieldName, jsonNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String toJsonFilename(String name) {
|
||||||
|
return name.endsWith(".json") ? name : name + ".json";
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean pathContainsSegment(Path path, String segment) {
|
||||||
|
String normalized = path.toString().replace('\\', '/');
|
||||||
|
String needle = "/" + segment + "/";
|
||||||
|
return normalized.contains(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String inferTypeFromPath(Path path) {
|
||||||
|
if (path == null) {
|
||||||
|
return "any";
|
||||||
|
}
|
||||||
|
if (pathContainsSegment(path, "machine")) {
|
||||||
|
return "machine";
|
||||||
|
}
|
||||||
|
if (pathContainsSegment(path, "process")) {
|
||||||
|
return "process";
|
||||||
|
}
|
||||||
|
if (pathContainsSegment(path, "filament")) {
|
||||||
|
return "filament";
|
||||||
|
}
|
||||||
|
return "any";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ public class QrBillService {
|
|||||||
|
|
||||||
// Reference
|
// Reference
|
||||||
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
|
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
|
||||||
bill.setUnstructuredMessage("Order " + order.getId());
|
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
|
||||||
|
bill.setUnstructuredMessage("Order " + orderRef);
|
||||||
|
|
||||||
return bill;
|
return bill;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,21 +76,11 @@ public class QuoteCalculator {
|
|||||||
// --- CALCULATIONS ---
|
// --- CALCULATIONS ---
|
||||||
|
|
||||||
// Material Cost: (weight / 1000) * costPerKg
|
// Material Cost: (weight / 1000) * costPerKg
|
||||||
// DISCOUNTED Support material to avoid penalizing users for default supports
|
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
||||||
BigDecimal weightToCharge;
|
|
||||||
if (stats.getModelWeightGrams() != null && stats.getSupportWeightGrams() != null) {
|
|
||||||
// Charge 100% for model + 20% for support
|
|
||||||
weightToCharge = BigDecimal.valueOf(stats.getModelWeightGrams())
|
|
||||||
.add(BigDecimal.valueOf(stats.getSupportWeightGrams()).multiply(BigDecimal.valueOf(0.2)));
|
|
||||||
} else {
|
|
||||||
weightToCharge = BigDecimal.valueOf(stats.getFilamentWeightGrams());
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
|
|
||||||
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
|
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
|
||||||
|
|
||||||
// Machine Cost: Tiered
|
// Machine Cost: Tiered
|
||||||
BigDecimal totalHours = BigDecimal.valueOf(stats.getPrintTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
|
||||||
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
|
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
|
||||||
|
|
||||||
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
// Energy Cost: (watts / 1000) * hours * costPerKwh
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SlicerService {
|
public class SlicerService {
|
||||||
@@ -41,15 +41,27 @@ public class SlicerService {
|
|||||||
|
|
||||||
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
|
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
|
||||||
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
|
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
|
||||||
|
// 1. Prepare Profiles
|
||||||
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
|
||||||
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
|
||||||
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
|
||||||
|
|
||||||
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
|
logger.info("Slicer profiles: machine='" + machineName + "', filament='" + filamentName + "', process='" + processName + "'");
|
||||||
if (processOverrides != null) processOverrides.forEach(processProfile::put);
|
logger.info("Machine limits: printable_area=" + machineProfile.path("printable_area")
|
||||||
|
+ ", printable_height=" + machineProfile.path("printable_height")
|
||||||
|
+ ", bed_exclude_area=" + machineProfile.path("bed_exclude_area")
|
||||||
|
+ ", head_wrap_detect_zone=" + machineProfile.path("head_wrap_detect_zone"));
|
||||||
|
|
||||||
|
// Apply Overrides
|
||||||
|
if (machineOverrides != null) {
|
||||||
|
machineOverrides.forEach(machineProfile::put);
|
||||||
|
}
|
||||||
|
if (processOverrides != null) {
|
||||||
|
processOverrides.forEach(processProfile::put);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Temp Dir
|
||||||
Path tempDir = Files.createTempDirectory("slicer_job_");
|
Path tempDir = Files.createTempDirectory("slicer_job_");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File mFile = tempDir.resolve("machine.json").toFile();
|
File mFile = tempDir.resolve("machine.json").toFile();
|
||||||
File fFile = tempDir.resolve("filament.json").toFile();
|
File fFile = tempDir.resolve("filament.json").toFile();
|
||||||
@@ -59,61 +71,110 @@ public class SlicerService {
|
|||||||
mapper.writeValue(fFile, filamentProfile);
|
mapper.writeValue(fFile, filamentProfile);
|
||||||
mapper.writeValue(pFile, processProfile);
|
mapper.writeValue(pFile, processProfile);
|
||||||
|
|
||||||
List<String> command = new ArrayList<>();
|
String basename = inputStl.getName();
|
||||||
command.add(slicerPath);
|
if (basename.toLowerCase().endsWith(".stl")) {
|
||||||
|
basename = basename.substring(0, basename.length() - 4);
|
||||||
command.add("--load-settings");
|
|
||||||
command.add(mFile.getAbsolutePath());
|
|
||||||
command.add("--load-settings");
|
|
||||||
command.add(pFile.getAbsolutePath());
|
|
||||||
command.add("--load-filaments");
|
|
||||||
command.add(fFile.getAbsolutePath());
|
|
||||||
|
|
||||||
command.add("--ensure-on-bed");
|
|
||||||
command.add("--arrange");
|
|
||||||
command.add("1");
|
|
||||||
command.add("--outputdir");
|
|
||||||
command.add(tempDir.toAbsolutePath().toString());
|
|
||||||
|
|
||||||
command.add("--slice");
|
|
||||||
command.add("0");
|
|
||||||
|
|
||||||
command.add(inputStl.getAbsolutePath());
|
|
||||||
|
|
||||||
logger.info("Executing Slicer: " + String.join(" ", command));
|
|
||||||
|
|
||||||
runSlicerCommand(command, tempDir);
|
|
||||||
|
|
||||||
try (Stream<Path> s = Files.list(tempDir)) {
|
|
||||||
Optional<Path> found = s.filter(p -> p.toString().endsWith(".gcode")).findFirst();
|
|
||||||
if (found.isPresent()) return gCodeParser.parse(found.get().toFile());
|
|
||||||
else throw new IOException("No GCode found in " + tempDir);
|
|
||||||
}
|
}
|
||||||
|
Path slicerLogPath = tempDir.resolve("orcaslicer.log");
|
||||||
|
|
||||||
|
// 3. Run slicer. Retry with arrange only for out-of-volume style failures.
|
||||||
|
for (boolean useArrange : new boolean[]{false, true}) {
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(slicerPath);
|
||||||
|
command.add("--load-settings");
|
||||||
|
command.add(mFile.getAbsolutePath());
|
||||||
|
command.add("--load-settings");
|
||||||
|
command.add(pFile.getAbsolutePath());
|
||||||
|
command.add("--load-filaments");
|
||||||
|
command.add(fFile.getAbsolutePath());
|
||||||
|
command.add("--ensure-on-bed");
|
||||||
|
if (useArrange) {
|
||||||
|
command.add("--arrange");
|
||||||
|
command.add("1");
|
||||||
|
}
|
||||||
|
command.add("--slice");
|
||||||
|
command.add("0");
|
||||||
|
command.add("--outputdir");
|
||||||
|
command.add(tempDir.toAbsolutePath().toString());
|
||||||
|
command.add(inputStl.getAbsolutePath());
|
||||||
|
|
||||||
|
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
|
||||||
|
|
||||||
|
Files.deleteIfExists(slicerLogPath);
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(tempDir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
pb.redirectOutput(slicerLogPath.toFile());
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
|
||||||
|
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new IOException("Slicer timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
String error = "";
|
||||||
|
if (Files.exists(slicerLogPath)) {
|
||||||
|
error = Files.readString(slicerLogPath, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
if (!useArrange && isOutOfVolumeError(error)) {
|
||||||
|
logger.warning("Slicer reported model out of printable area, retrying with arrange.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
|
||||||
|
}
|
||||||
|
|
||||||
|
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
|
||||||
|
if (!gcodeFile.exists()) {
|
||||||
|
File alt = tempDir.resolve("plate_1.gcode").toFile();
|
||||||
|
if (alt.exists()) {
|
||||||
|
gcodeFile = alt;
|
||||||
|
} else {
|
||||||
|
throw new IOException("GCode output not found in " + tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gCodeParser.parse(gcodeFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IOException("Slicer failed after retry");
|
||||||
|
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
throw new IOException(e);
|
throw new IOException("Interrupted during slicing", e);
|
||||||
|
} finally {
|
||||||
|
deleteRecursively(tempDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
|
private void deleteRecursively(Path path) {
|
||||||
ProcessBuilder pb = new ProcessBuilder(command);
|
if (path == null || !Files.exists(path)) {
|
||||||
pb.directory(tempDir.toFile());
|
return;
|
||||||
|
|
||||||
Map<String, String> env = pb.environment();
|
|
||||||
env.put("HOME", "/tmp");
|
|
||||||
env.put("QT_QPA_PLATFORM", "offscreen");
|
|
||||||
|
|
||||||
Process process = pb.start();
|
|
||||||
if (!process.waitFor(5, TimeUnit.MINUTES)) {
|
|
||||||
process.destroy();
|
|
||||||
throw new IOException("Slicer timeout");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.exitValue() != 0) {
|
try (var walk = Files.walk(path)) {
|
||||||
String out = new String(process.getInputStream().readAllBytes());
|
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||||
String err = new String(process.getErrorStream().readAllBytes());
|
try {
|
||||||
throw new IOException("Slicer failed with exit code " + process.exitValue() + "\nERR: " + err + "\nOUT: " + out);
|
Files.deleteIfExists(p);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("Failed to delete temp path " + p + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warning("Failed to walk temp directory " + path + ": " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isOutOfVolumeError(String errorLog) {
|
||||||
|
if (errorLog == null || errorLog.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = errorLog.toLowerCase();
|
||||||
|
return normalized.contains("nothing to be sliced")
|
||||||
|
|| normalized.contains("no object is fully inside the print volume")
|
||||||
|
|| normalized.contains("calc_exclude_triangles");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
package com.printcalculator.service;
|
|
||||||
|
|
||||||
import com.printcalculator.model.StlBounds;
|
|
||||||
import com.printcalculator.model.StlShiftResult;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.BufferedWriter;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.io.RandomAccessFile;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class StlService {
|
|
||||||
|
|
||||||
public StlBounds readBounds(File stlFile) throws IOException {
|
|
||||||
long size = stlFile.length();
|
|
||||||
if (size >= 84 && isBinaryStl(stlFile, size)) {
|
|
||||||
return readBinaryBounds(stlFile);
|
|
||||||
}
|
|
||||||
return readAsciiBounds(stlFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
public StlShiftResult shiftToFitIfNeeded(File stlFile, StlBounds bounds,
|
|
||||||
int bedX, int bedY, int bedZ) throws IOException {
|
|
||||||
double sizeX = bounds.sizeX();
|
|
||||||
double sizeY = bounds.sizeY();
|
|
||||||
double sizeZ = bounds.sizeZ();
|
|
||||||
|
|
||||||
double targetMinX = (bedX - sizeX) / 2.0;
|
|
||||||
double targetMinY = (bedY - sizeY) / 2.0;
|
|
||||||
double targetMinZ = 0.0;
|
|
||||||
|
|
||||||
double offsetX = targetMinX - bounds.minX();
|
|
||||||
double offsetY = targetMinY - bounds.minY();
|
|
||||||
double offsetZ = targetMinZ - bounds.minZ();
|
|
||||||
|
|
||||||
boolean needsShift = Math.abs(offsetX) > 1e-6 || Math.abs(offsetY) > 1e-6 || Math.abs(offsetZ) > 1e-6;
|
|
||||||
if (!needsShift) {
|
|
||||||
return new StlShiftResult(null, offsetX, offsetY, offsetZ, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path shiftedPath = Files.createTempFile("stl_shifted_", ".stl");
|
|
||||||
writeShifted(stlFile, shiftedPath.toFile(), offsetX, offsetY, offsetZ);
|
|
||||||
return new StlShiftResult(shiftedPath, offsetX, offsetY, offsetZ, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isBinaryStl(File stlFile, long size) throws IOException {
|
|
||||||
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
|
|
||||||
raf.seek(80);
|
|
||||||
long triangleCount = readLEUInt32(raf);
|
|
||||||
long expected = 84L + triangleCount * 50L;
|
|
||||||
return expected == size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private StlBounds readBinaryBounds(File stlFile) throws IOException {
|
|
||||||
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
|
|
||||||
raf.seek(80);
|
|
||||||
long triangleCount = readLEUInt32(raf);
|
|
||||||
raf.seek(84);
|
|
||||||
|
|
||||||
BoundsAccumulator acc = new BoundsAccumulator();
|
|
||||||
for (long i = 0; i < triangleCount; i++) {
|
|
||||||
// skip normal
|
|
||||||
readLEFloat(raf);
|
|
||||||
readLEFloat(raf);
|
|
||||||
readLEFloat(raf);
|
|
||||||
// 3 vertices
|
|
||||||
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
|
||||||
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
|
||||||
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
|
|
||||||
// skip attribute byte count
|
|
||||||
raf.skipBytes(2);
|
|
||||||
}
|
|
||||||
return acc.toBounds();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private StlBounds readAsciiBounds(File stlFile) throws IOException {
|
|
||||||
BoundsAccumulator acc = new BoundsAccumulator();
|
|
||||||
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
line = line.trim();
|
|
||||||
if (!line.startsWith("vertex")) continue;
|
|
||||||
String[] parts = line.split("\\s+");
|
|
||||||
if (parts.length < 4) continue;
|
|
||||||
double x = Double.parseDouble(parts[1]);
|
|
||||||
double y = Double.parseDouble(parts[2]);
|
|
||||||
double z = Double.parseDouble(parts[3]);
|
|
||||||
acc.accept(x, y, z);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc.toBounds();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeShifted(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
|
|
||||||
long size = input.length();
|
|
||||||
if (size >= 84 && isBinaryStl(input, size)) {
|
|
||||||
writeShiftedBinary(input, output, offsetX, offsetY, offsetZ);
|
|
||||||
} else {
|
|
||||||
writeShiftedAscii(input, output, offsetX, offsetY, offsetZ);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeShiftedAscii(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
|
|
||||||
try (BufferedReader reader = Files.newBufferedReader(input.toPath(), StandardCharsets.US_ASCII);
|
|
||||||
BufferedWriter writer = Files.newBufferedWriter(output.toPath(), StandardCharsets.US_ASCII)) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
String trimmed = line.trim();
|
|
||||||
if (!trimmed.startsWith("vertex")) {
|
|
||||||
writer.write(line);
|
|
||||||
writer.newLine();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String[] parts = trimmed.split("\\s+");
|
|
||||||
if (parts.length < 4) {
|
|
||||||
writer.write(line);
|
|
||||||
writer.newLine();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
double x = Double.parseDouble(parts[1]) + offsetX;
|
|
||||||
double y = Double.parseDouble(parts[2]) + offsetY;
|
|
||||||
double z = Double.parseDouble(parts[3]) + offsetZ;
|
|
||||||
int idx = line.indexOf("vertex");
|
|
||||||
String indent = idx > 0 ? line.substring(0, idx) : "";
|
|
||||||
writer.write(indent + String.format(Locale.US, "vertex %.6f %.6f %.6f", x, y, z));
|
|
||||||
writer.newLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeShiftedBinary(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
|
|
||||||
try (RandomAccessFile raf = new RandomAccessFile(input, "r");
|
|
||||||
OutputStream out = new FileOutputStream(output)) {
|
|
||||||
byte[] header = new byte[80];
|
|
||||||
raf.readFully(header);
|
|
||||||
out.write(header);
|
|
||||||
|
|
||||||
long triangleCount = readLEUInt32(raf);
|
|
||||||
writeLEUInt32(out, triangleCount);
|
|
||||||
|
|
||||||
for (long i = 0; i < triangleCount; i++) {
|
|
||||||
// normal
|
|
||||||
writeLEFloat(out, readLEFloat(raf));
|
|
||||||
writeLEFloat(out, readLEFloat(raf));
|
|
||||||
writeLEFloat(out, readLEFloat(raf));
|
|
||||||
|
|
||||||
// vertices
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
|
|
||||||
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
|
|
||||||
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
|
|
||||||
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
|
|
||||||
|
|
||||||
// attribute byte count
|
|
||||||
int b1 = raf.read();
|
|
||||||
int b2 = raf.read();
|
|
||||||
if ((b1 | b2) < 0) throw new IOException("Unexpected EOF while reading STL");
|
|
||||||
out.write(b1);
|
|
||||||
out.write(b2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private long readLEUInt32(RandomAccessFile raf) throws IOException {
|
|
||||||
int b1 = raf.read();
|
|
||||||
int b2 = raf.read();
|
|
||||||
int b3 = raf.read();
|
|
||||||
int b4 = raf.read();
|
|
||||||
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
|
|
||||||
return ((long) b1 & 0xFF)
|
|
||||||
| (((long) b2 & 0xFF) << 8)
|
|
||||||
| (((long) b3 & 0xFF) << 16)
|
|
||||||
| (((long) b4 & 0xFF) << 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int readLEInt(RandomAccessFile raf) throws IOException {
|
|
||||||
int b1 = raf.read();
|
|
||||||
int b2 = raf.read();
|
|
||||||
int b3 = raf.read();
|
|
||||||
int b4 = raf.read();
|
|
||||||
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
|
|
||||||
return (b1 & 0xFF)
|
|
||||||
| ((b2 & 0xFF) << 8)
|
|
||||||
| ((b3 & 0xFF) << 16)
|
|
||||||
| ((b4 & 0xFF) << 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
private float readLEFloat(RandomAccessFile raf) throws IOException {
|
|
||||||
return Float.intBitsToFloat(readLEInt(raf));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeLEUInt32(OutputStream out, long value) throws IOException {
|
|
||||||
out.write((int) (value & 0xFF));
|
|
||||||
out.write((int) ((value >> 8) & 0xFF));
|
|
||||||
out.write((int) ((value >> 16) & 0xFF));
|
|
||||||
out.write((int) ((value >> 24) & 0xFF));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeLEFloat(OutputStream out, float value) throws IOException {
|
|
||||||
int bits = Float.floatToIntBits(value);
|
|
||||||
out.write(bits & 0xFF);
|
|
||||||
out.write((bits >> 8) & 0xFF);
|
|
||||||
out.write((bits >> 16) & 0xFF);
|
|
||||||
out.write((bits >> 24) & 0xFF);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class BoundsAccumulator {
|
|
||||||
private boolean hasPoint = false;
|
|
||||||
private double minX;
|
|
||||||
private double minY;
|
|
||||||
private double minZ;
|
|
||||||
private double maxX;
|
|
||||||
private double maxY;
|
|
||||||
private double maxZ;
|
|
||||||
|
|
||||||
void accept(double x, double y, double z) {
|
|
||||||
if (!hasPoint) {
|
|
||||||
minX = maxX = x;
|
|
||||||
minY = maxY = y;
|
|
||||||
minZ = maxZ = z;
|
|
||||||
hasPoint = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (x < minX) minX = x;
|
|
||||||
if (y < minY) minY = y;
|
|
||||||
if (z < minZ) minZ = z;
|
|
||||||
if (x > maxX) maxX = x;
|
|
||||||
if (y > maxY) maxY = y;
|
|
||||||
if (z > maxZ) maxZ = z;
|
|
||||||
}
|
|
||||||
|
|
||||||
StlBounds toBounds() throws IOException {
|
|
||||||
if (!hasPoint) {
|
|
||||||
throw new IOException("STL appears to contain no vertices");
|
|
||||||
}
|
|
||||||
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.printcalculator.service;
|
||||||
|
|
||||||
|
import io.nayuki.qrcodegen.QrCode;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TwintPaymentService {
|
||||||
|
|
||||||
|
private final String twintPaymentUrl;
|
||||||
|
|
||||||
|
public TwintPaymentService(
|
||||||
|
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}")
|
||||||
|
String twintPaymentUrl
|
||||||
|
) {
|
||||||
|
this.twintPaymentUrl = twintPaymentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTwintPaymentUrl() {
|
||||||
|
return twintPaymentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] generateQrPng(int sizePx) {
|
||||||
|
try {
|
||||||
|
// Use High Error Correction for financial QR codes
|
||||||
|
QrCode qrCode = QrCode.encodeText(twintPaymentUrl, QrCode.Ecc.HIGH);
|
||||||
|
|
||||||
|
// Standard QR quiet zone is 4 modules
|
||||||
|
int borderModules = 4;
|
||||||
|
int fullModules = qrCode.size + borderModules * 2;
|
||||||
|
int scale = Math.max(1, sizePx / fullModules);
|
||||||
|
int imageSize = fullModules * scale;
|
||||||
|
|
||||||
|
BufferedImage image = new BufferedImage(imageSize, imageSize, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D graphics = image.createGraphics();
|
||||||
|
try {
|
||||||
|
graphics.setColor(Color.WHITE);
|
||||||
|
graphics.fillRect(0, 0, imageSize, imageSize);
|
||||||
|
graphics.setColor(Color.BLACK);
|
||||||
|
|
||||||
|
for (int y = 0; y < qrCode.size; y++) {
|
||||||
|
for (int x = 0; x < qrCode.size; x++) {
|
||||||
|
if (qrCode.getModule(x, y)) {
|
||||||
|
int px = (x + borderModules) * scale;
|
||||||
|
int py = (y + borderModules) * scale;
|
||||||
|
graphics.fillRect(px, py, scale, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
graphics.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(image, "png", outputStream);
|
||||||
|
return outputStream.toByteArray();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new IllegalStateException("Unable to generate TWINT QR image.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.printcalculator.service.email;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface EmailNotificationService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an HTML email using a Thymeleaf template.
|
||||||
|
*
|
||||||
|
* @param to The recipient email address.
|
||||||
|
* @param subject The subject of the email.
|
||||||
|
* @param templateName The name of the Thymeleaf template (e.g., "order-confirmation").
|
||||||
|
* @param contextData The data to populate the template with.
|
||||||
|
*/
|
||||||
|
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.printcalculator.service.email;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SmtpEmailNotificationService implements EmailNotificationService {
|
||||||
|
|
||||||
|
private final JavaMailSender emailSender;
|
||||||
|
private final TemplateEngine templateEngine;
|
||||||
|
|
||||||
|
@Value("${app.mail.from}")
|
||||||
|
private String fromAddress;
|
||||||
|
|
||||||
|
@Value("${app.mail.enabled:true}")
|
||||||
|
private boolean mailEnabled;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
|
||||||
|
if (!mailEnabled) {
|
||||||
|
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Preparing to send email to {} with template {}", to, templateName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Context context = new Context();
|
||||||
|
context.setVariables(contextData);
|
||||||
|
|
||||||
|
String process = templateEngine.process("email/" + templateName, context);
|
||||||
|
MimeMessage mimeMessage = emailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
|
||||||
|
|
||||||
|
helper.setFrom(fromAddress);
|
||||||
|
helper.setTo(to);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(process, true); // true indicates HTML format
|
||||||
|
|
||||||
|
emailSender.send(mimeMessage);
|
||||||
|
log.info("Email successfully sent to {}", to);
|
||||||
|
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
log.error("Failed to send email to {}", to, e);
|
||||||
|
// Non blocco l'ordine se l'email fallisce, ma loggo l'errore adeguatamente.
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Unexpected error while sending email to {}", to, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
backend/src/main/resources/application-local.properties
Normal file
2
backend/src/main/resources/application-local.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
app.mail.enabled=false
|
||||||
|
app.mail.admin.enabled=false
|
||||||
@@ -24,3 +24,20 @@ clamav.host=${CLAMAV_HOST:clamav}
|
|||||||
clamav.port=${CLAMAV_PORT:3310}
|
clamav.port=${CLAMAV_PORT:3310}
|
||||||
clamav.enabled=${CLAMAV_ENABLED:false}
|
clamav.enabled=${CLAMAV_ENABLED:false}
|
||||||
|
|
||||||
|
# TWINT Configuration
|
||||||
|
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
|
||||||
|
|
||||||
|
# Mail Configuration
|
||||||
|
spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
|
||||||
|
spring.mail.port=${MAIL_PORT:587}
|
||||||
|
spring.mail.username=${MAIL_USERNAME:info@3d-fab.ch}
|
||||||
|
spring.mail.password=${MAIL_PASSWORD:ht*44k+Tq39R+R-O}
|
||||||
|
spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false}
|
||||||
|
spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
|
||||||
|
|
||||||
|
# Application Mail Settings
|
||||||
|
app.mail.enabled=${APP_MAIL_ENABLED:true}
|
||||||
|
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
|
||||||
|
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
|
||||||
|
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
|
||||||
|
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Conferma Ordine</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eeeeee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
color: #555555;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.order-details {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.order-details th {
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 20px;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #999999;
|
||||||
|
margin-top: 30px;
|
||||||
|
border-top: 1px solid #eeeeee;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Grazie per il tuo ordine #<span th:text="${orderNumber}">00000000</span></h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Ciao <span th:text="${customerName}">Cliente</span>,</p>
|
||||||
|
<p>Abbiamo ricevuto il tuo ordine e stiamo iniziando a elaborarlo. Ecco un riepilogo dei dettagli:</p>
|
||||||
|
|
||||||
|
<div class="order-details">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Numero Ordine:</th>
|
||||||
|
<td th:text="${orderNumber}">00000000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Data:</th>
|
||||||
|
<td th:text="${orderDate}">01/01/2026</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Costo totale:</th>
|
||||||
|
<td th:text="${totalCost} + ' CHF'">0.00 CHF</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Clicca qui per i dettagli:
|
||||||
|
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://tuosito.it/ordine/00000000-0000-0000-0000-000000000000</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Se hai domande o dubbi, non esitare a contattarci.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>© 2026 3D-Fab. Tutti i diritti riservati.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,81 +3,356 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<style>
|
<style>
|
||||||
@page { size: A4; margin: 18mm 15mm; }
|
@page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; }
|
||||||
body { font-family: sans-serif; font-size: 10.5pt; }
|
@page qrpage { size: A4; margin: 0; }
|
||||||
.header { display: flex; justify-content: space-between; }
|
|
||||||
.addresses { margin-top: 10mm; display: flex; justify-content: space-between; }
|
body {
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
|
page: invoice;
|
||||||
th, td { padding: 6px; border-bottom: 1px solid #ccc; }
|
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
th { text-align: left; }
|
font-size: 9.5pt;
|
||||||
.totals { margin-top: 6mm; width: 40%; margin-left: auto; }
|
margin: 0;
|
||||||
.totals td { border: none; }
|
padding: 0;
|
||||||
.page-break { page-break-before: always; }
|
background: #fff;
|
||||||
|
color: #191919;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-page {
|
||||||
|
page: invoice;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-layout {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-layout td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1.5mm 0;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-subtitle {
|
||||||
|
color: #4b4b4b;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seller-block {
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1.45;
|
||||||
|
width: 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seller-name {
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-layout {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-layout td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-details {
|
||||||
|
width: 60%;
|
||||||
|
padding-right: 5mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-box {
|
||||||
|
width: 40%;
|
||||||
|
background: #f7f7f7;
|
||||||
|
border: 1px solid #e2e2e2;
|
||||||
|
padding: 3mm 3.2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-title {
|
||||||
|
font-size: 8.8pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
color: #5a5a5a;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
padding: 1.1mm 0;
|
||||||
|
border-bottom: 1px solid #ececec;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-label {
|
||||||
|
color: #636363;
|
||||||
|
width: 56%;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 3mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-value {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin-top: 3mm;
|
||||||
|
border-top: 1px solid #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th,
|
||||||
|
.line-items td {
|
||||||
|
border-bottom: 1px solid #dedede;
|
||||||
|
padding: 2.4mm 2mm;
|
||||||
|
vertical-align: top;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f2f2f2;
|
||||||
|
color: #2c2c2c;
|
||||||
|
font-size: 9pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(1),
|
||||||
|
.line-items td:nth-child(1) {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(2),
|
||||||
|
.line-items td:nth-child(2) {
|
||||||
|
width: 10%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(3),
|
||||||
|
.line-items td:nth-child(3) {
|
||||||
|
width: 20%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-items th:nth-child(4),
|
||||||
|
.line-items td:nth-child(4) {
|
||||||
|
width: 20%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-layout {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-layout td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
width: 58%;
|
||||||
|
padding-right: 5mm;
|
||||||
|
color: #383838;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes .section-caption {
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 1.2mm 0;
|
||||||
|
color: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals {
|
||||||
|
width: 42%;
|
||||||
|
margin-left: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals td {
|
||||||
|
border: none;
|
||||||
|
padding: 1.3mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-label {
|
||||||
|
text-align: left;
|
||||||
|
color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-value {
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-strong td {
|
||||||
|
font-size: 10.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
padding-top: 2mm;
|
||||||
|
border-top: 1px solid #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-row td {
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
border-top: 1px solid #cfcfcf;
|
||||||
|
padding-top: 2.2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-only-page {
|
||||||
|
page: qrpage;
|
||||||
|
position: relative;
|
||||||
|
width: 210mm;
|
||||||
|
height: 297mm;
|
||||||
|
background: #fff;
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-bill-bottom {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 210mm;
|
||||||
|
height: 105mm;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-bill-bottom svg {
|
||||||
|
width: 210mm !important;
|
||||||
|
height: 105mm !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="invoice-page">
|
||||||
|
|
||||||
<div class="header">
|
<table class="top-layout">
|
||||||
<div>
|
<tr>
|
||||||
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
|
<td>
|
||||||
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
|
<div class="doc-title">Conferma ordine</div>
|
||||||
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
|
<div class="doc-subtitle">Ricevuta semplificata</div>
|
||||||
<div th:text="${sellerEmail}">email@example.com</div>
|
</td>
|
||||||
</div>
|
<td class="seller-block">
|
||||||
|
<div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div>
|
||||||
|
<div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
|
||||||
|
<div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
|
||||||
|
<div th:text="${sellerEmail}">info@3dfab.ch</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div>
|
<table class="meta-layout">
|
||||||
<div><strong>Fattura</strong></div>
|
<tr>
|
||||||
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
|
<td class="order-details">
|
||||||
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
|
<table class="details-table">
|
||||||
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
|
<tr>
|
||||||
</div>
|
<td class="details-label">Data ordine / fattura</td>
|
||||||
</div>
|
<td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="details-label">Numero documento</td>
|
||||||
|
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="details-label">Data di scadenza</td>
|
||||||
|
<td class="details-value" th:text="${dueDate}">2026-02-20</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="details-label">Valuta</td>
|
||||||
|
<td class="details-value">CHF</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td class="customer-box">
|
||||||
|
<div class="box-title">Cliente</div>
|
||||||
|
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
||||||
|
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
||||||
|
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<div class="addresses">
|
<table class="line-items">
|
||||||
<div>
|
|
||||||
<div><strong>Fatturare a</strong></div>
|
|
||||||
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
|
||||||
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
|
||||||
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Descrizione</th>
|
<th>Descrizione</th>
|
||||||
<th style="text-align:right;">Qtà</th>
|
<th>Qtà</th>
|
||||||
<th style="text-align:right;">Prezzo</th>
|
<th>Prezzo unit.</th>
|
||||||
<th style="text-align:right;">Totale</th>
|
<th>Totale</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr th:each="lineItem : ${invoiceLineItems}">
|
<tr th:each="lineItem : ${invoiceLineItems}">
|
||||||
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
|
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
|
||||||
<td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
|
<td th:text="${lineItem.quantity}">1</td>
|
||||||
<td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
|
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
|
||||||
<td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
|
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<table class="totals">
|
<table class="summary-layout">
|
||||||
<tr>
|
<tr>
|
||||||
<td>Subtotale</td>
|
<td class="notes">
|
||||||
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
|
<div class="section-caption">Informazioni</div>
|
||||||
</tr>
|
<div th:text="${paymentTermsText}">
|
||||||
<tr>
|
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
|
||||||
<td><strong>Totale</strong></td>
|
</div>
|
||||||
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
|
<div style="margin-top: 2.5mm;">
|
||||||
|
Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<table class="totals">
|
||||||
|
<tr>
|
||||||
|
<td class="totals-label">Subtotale</td>
|
||||||
|
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="total-strong">
|
||||||
|
<td class="totals-label">Totale ordine</td>
|
||||||
|
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="due-row">
|
||||||
|
<td class="totals-label">Importo dovuto</td>
|
||||||
|
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
|
|
||||||
Pagamento entro 7 giorni. Grazie.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="page-break-before: always;"></div>
|
<div class="qr-only-page">
|
||||||
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
|
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.printcalculator;
|
|
||||||
|
|
||||||
import com.printcalculator.controller.QuoteSessionController;
|
|
||||||
import com.printcalculator.dto.PrintSettingsDto;
|
|
||||||
import com.printcalculator.entity.QuoteSession;
|
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
|
||||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
|
||||||
import com.printcalculator.repository.PrinterMachineRepository;
|
|
||||||
import com.printcalculator.service.SlicerService;
|
|
||||||
import com.printcalculator.service.QuoteCalculator;
|
|
||||||
import com.printcalculator.service.StorageService;
|
|
||||||
import com.printcalculator.service.StlService;
|
|
||||||
import com.printcalculator.service.ProfileManager;
|
|
||||||
import com.printcalculator.model.PrintStats;
|
|
||||||
import com.printcalculator.model.QuoteResult;
|
|
||||||
import com.printcalculator.entity.PrinterMachine;
|
|
||||||
import com.printcalculator.model.StlBounds;
|
|
||||||
import com.printcalculator.model.StlShiftResult;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
|
||||||
|
|
||||||
@WebMvcTest(QuoteSessionController.class)
|
|
||||||
public class ManualSessionPersistenceTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private QuoteSessionController controller;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private QuoteSessionRepository sessionRepo;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private QuoteLineItemRepository lineItemRepo; // Mock this too
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private SlicerService slicerService;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private StorageService storageService;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private StlService stlService;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private ProfileManager profileManager;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private QuoteCalculator quoteCalculator;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private PrinterMachineRepository machineRepo;
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Add this if needed by controller
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testSettingsPersistence() throws Exception {
|
|
||||||
// Prepare
|
|
||||||
UUID sessionId = UUID.randomUUID();
|
|
||||||
QuoteSession session = new QuoteSession();
|
|
||||||
session.setId(sessionId);
|
|
||||||
session.setMaterialCode("pla_basic"); // Initial state
|
|
||||||
|
|
||||||
when(sessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
|
|
||||||
when(sessionRepo.save(any(QuoteSession.class))).thenAnswer(i -> i.getArguments()[0]);
|
|
||||||
when(lineItemRepo.save(any(QuoteLineItem.class))).thenAnswer(i -> i.getArguments()[0]);
|
|
||||||
|
|
||||||
// 2. Add Item with Custom Settings
|
|
||||||
PrintSettingsDto settings = new PrintSettingsDto();
|
|
||||||
settings.setComplexityMode("ADVANCED");
|
|
||||||
settings.setMaterial("petg_basic");
|
|
||||||
settings.setLayerHeight(0.12);
|
|
||||||
settings.setInfillDensity(50.0);
|
|
||||||
settings.setInfillPattern("gyroid");
|
|
||||||
settings.setSupportsEnabled(true);
|
|
||||||
settings.setNozzleDiameter(0.6);
|
|
||||||
settings.setNotes("Test Notes");
|
|
||||||
|
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "test.stl", "application/octet-stream", "dummy content".getBytes());
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
when(machineRepo.findFirstByIsActiveTrue()).thenReturn(Optional.of(new PrinterMachine(){{
|
|
||||||
setPrinterDisplayName("TestPrinter");
|
|
||||||
setSlicerMachineProfile("TestProfile");
|
|
||||||
setBuildVolumeXMm(256);
|
|
||||||
setBuildVolumeYMm(256);
|
|
||||||
setBuildVolumeZMm(256);
|
|
||||||
}}));
|
|
||||||
when(slicerService.slice(any(), any(), any(), any(), any(), any())).thenReturn(new PrintStats(100, "1m", 10.0, 100));
|
|
||||||
when(quoteCalculator.calculate(any(), any(), any())).thenReturn(
|
|
||||||
new QuoteResult(10.0, "CHF", new PrintStats(100, "1m", 10.0, 100), 0.0)
|
|
||||||
);
|
|
||||||
when(stlService.readBounds(any())).thenReturn(new StlBounds(0, 0, 0, 10, 10, 10));
|
|
||||||
when(stlService.shiftToFitIfNeeded(any(), any(), anyInt(), anyInt(), anyInt()))
|
|
||||||
.thenReturn(new StlShiftResult(null, 0, 0, 0, false));
|
|
||||||
when(profileManager.resolveMachineProfileName(any(), any())).thenAnswer(i -> i.getArguments()[0]);
|
|
||||||
when(storageService.loadAsResource(any())).thenReturn(new org.springframework.core.io.ByteArrayResource("dummy".getBytes()){
|
|
||||||
@Override
|
|
||||||
public File getFile() { return new File("dummy"); }
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.addItemToExistingSession(sessionId, settings, file);
|
|
||||||
|
|
||||||
// 3. Verify Session Updated via Save Call capture
|
|
||||||
ArgumentCaptor<QuoteSession> captor = ArgumentCaptor.forClass(QuoteSession.class);
|
|
||||||
verify(sessionRepo).save(captor.capture());
|
|
||||||
|
|
||||||
QuoteSession updatedSession = captor.getValue();
|
|
||||||
|
|
||||||
assertEquals("petg_basic", updatedSession.getMaterialCode());
|
|
||||||
assertEquals(0, BigDecimal.valueOf(0.12).compareTo(updatedSession.getLayerHeightMm()));
|
|
||||||
assertEquals(50, updatedSession.getInfillPercent());
|
|
||||||
assertEquals("gyroid", updatedSession.getInfillPattern());
|
|
||||||
assertTrue(updatedSession.getSupportsEnabled());
|
|
||||||
assertEquals(0, BigDecimal.valueOf(0.6).compareTo(updatedSession.getNozzleDiameterMm()));
|
|
||||||
assertEquals("Test Notes", updatedSession.getNotes());
|
|
||||||
|
|
||||||
System.out.println("Verification Passed: Settings were persisted to Session.");
|
|
||||||
}
|
|
||||||
@org.springframework.boot.test.context.TestConfiguration
|
|
||||||
static class TestConfig {
|
|
||||||
@org.springframework.context.annotation.Bean
|
|
||||||
public org.springframework.transaction.PlatformTransactionManager transactionManager() {
|
|
||||||
return org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.printcalculator.config;
|
|
||||||
|
|
||||||
import com.printcalculator.service.ClamAVService;
|
|
||||||
import org.springframework.boot.test.context.TestConfiguration;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Primary;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
|
|
||||||
@TestConfiguration
|
|
||||||
public class TestConfig {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@Primary
|
|
||||||
public ClamAVService mockClamAVService() {
|
|
||||||
return new ClamAVService("localhost", 3310, true) {
|
|
||||||
@Override
|
|
||||||
public boolean scan(InputStream inputStream) {
|
|
||||||
return true; // Always clean for tests
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
package com.printcalculator.controller;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.printcalculator.dto.CreateOrderRequest;
|
|
||||||
import com.printcalculator.dto.CustomerDto;
|
|
||||||
import com.printcalculator.dto.AddressDto;
|
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
|
||||||
import com.printcalculator.entity.QuoteSession;
|
|
||||||
import com.printcalculator.repository.OrderRepository;
|
|
||||||
import com.printcalculator.repository.QuoteLineItemRepository;
|
|
||||||
import com.printcalculator.repository.QuoteSessionRepository;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
import org.springframework.util.FileSystemUtils;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import com.printcalculator.service.ClamAVService;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
@AutoConfigureMockMvc
|
|
||||||
@org.springframework.test.context.TestPropertySource(properties = {
|
|
||||||
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL",
|
|
||||||
"spring.datasource.driverClassName=org.h2.Driver",
|
|
||||||
"spring.datasource.username=sa",
|
|
||||||
"spring.datasource.password=",
|
|
||||||
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
|
|
||||||
"spring.jpa.hibernate.ddl-auto=create-drop"
|
|
||||||
})
|
|
||||||
class OrderIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean
|
|
||||||
private ClamAVService clamAVService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private MockMvc mockMvc;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private QuoteSessionRepository sessionRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private QuoteLineItemRepository lineItemRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private OrderRepository orderRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
private UUID sessionId;
|
|
||||||
private UUID lineItemId;
|
|
||||||
private final String TEST_FILENAME = "test_model.stl";
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() throws Exception {
|
|
||||||
// Mock ClamAV to always return true (safe)
|
|
||||||
when(clamAVService.scan(any())).thenReturn(true);
|
|
||||||
|
|
||||||
// 1. Create Quote Session
|
|
||||||
QuoteSession session = new QuoteSession();
|
|
||||||
session.setStatus("ACTIVE");
|
|
||||||
session.setMaterialCode("PLA");
|
|
||||||
session.setPricingVersion("v1");
|
|
||||||
session.setCreatedAt(OffsetDateTime.now());
|
|
||||||
session.setExpiresAt(OffsetDateTime.now().plusDays(7));
|
|
||||||
session.setSetupCostChf(BigDecimal.valueOf(5.00));
|
|
||||||
session.setSupportsEnabled(false);
|
|
||||||
session = sessionRepository.save(session);
|
|
||||||
this.sessionId = session.getId();
|
|
||||||
|
|
||||||
// 2. Create Dummy File on Disk (storage_quotes)
|
|
||||||
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
|
|
||||||
Files.createDirectories(sessionDir);
|
|
||||||
Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl");
|
|
||||||
Files.writeString(filePath, "dummy content");
|
|
||||||
|
|
||||||
// 3. Create Quote Line Item
|
|
||||||
QuoteLineItem item = new QuoteLineItem();
|
|
||||||
item.setQuoteSession(session);
|
|
||||||
item.setStatus("READY");
|
|
||||||
item.setOriginalFilename(TEST_FILENAME);
|
|
||||||
item.setStoredPath(filePath.toString());
|
|
||||||
item.setQuantity(2);
|
|
||||||
item.setPrintTimeSeconds(120);
|
|
||||||
item.setMaterialGrams(BigDecimal.valueOf(10.5));
|
|
||||||
item.setUnitPriceChf(BigDecimal.valueOf(10.00));
|
|
||||||
item.setCreatedAt(OffsetDateTime.now());
|
|
||||||
item.setUpdatedAt(OffsetDateTime.now());
|
|
||||||
item = lineItemRepository.save(item);
|
|
||||||
this.lineItemId = item.getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() throws Exception {
|
|
||||||
// Cleanup generated files
|
|
||||||
FileSystemUtils.deleteRecursively(Paths.get("storage_quotes"));
|
|
||||||
FileSystemUtils.deleteRecursively(Paths.get("storage_orders"));
|
|
||||||
|
|
||||||
// Clean DB
|
|
||||||
orderRepository.deleteAll();
|
|
||||||
lineItemRepository.deleteAll();
|
|
||||||
sessionRepository.deleteAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception {
|
|
||||||
// Prepare Request
|
|
||||||
CreateOrderRequest request = new CreateOrderRequest();
|
|
||||||
|
|
||||||
CustomerDto customer = new CustomerDto();
|
|
||||||
customer.setEmail("integration@test.com");
|
|
||||||
customer.setCustomerType("PRIVATE");
|
|
||||||
request.setCustomer(customer);
|
|
||||||
|
|
||||||
AddressDto billing = new AddressDto();
|
|
||||||
billing.setFirstName("John");
|
|
||||||
billing.setLastName("Doe");
|
|
||||||
billing.setAddressLine1("Street 1");
|
|
||||||
billing.setCity("City");
|
|
||||||
billing.setZip("1000");
|
|
||||||
billing.setCountryCode("CH");
|
|
||||||
request.setBillingAddress(billing);
|
|
||||||
|
|
||||||
request.setShippingSameAsBilling(true);
|
|
||||||
|
|
||||||
// Execute Request
|
|
||||||
mockMvc.perform(post("/api/orders/from-quote/" + sessionId)
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
// Verify Session Status
|
|
||||||
QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow();
|
|
||||||
assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED");
|
|
||||||
assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set");
|
|
||||||
|
|
||||||
UUID orderId = updatedSession.getConvertedOrderId();
|
|
||||||
|
|
||||||
// Verify File Copy
|
|
||||||
Path orderStorageDir = Paths.get("storage_orders");
|
|
||||||
// We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename
|
|
||||||
// Since we don't know OrderItemId easily without querying DB, let's walk the dir.
|
|
||||||
|
|
||||||
try (var stream = Files.walk(orderStorageDir)) {
|
|
||||||
boolean fileFound = stream
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.anyMatch(path -> {
|
|
||||||
try {
|
|
||||||
return Files.readString(path).equals("dummy content");
|
|
||||||
} catch (Exception e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
assertTrue(fileFound, "The file should have been copied to storage_orders with correct content");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.printcalculator.event.listener;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.Customer;
|
||||||
|
import com.printcalculator.entity.Order;
|
||||||
|
import com.printcalculator.event.OrderCreatedEvent;
|
||||||
|
import com.printcalculator.service.email.EmailNotificationService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Captor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class OrderEmailListenerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EmailNotificationService emailNotificationService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private OrderEmailListener orderEmailListener;
|
||||||
|
|
||||||
|
@Captor
|
||||||
|
private ArgumentCaptor<Map<String, Object>> templateDataCaptor;
|
||||||
|
|
||||||
|
private Order order;
|
||||||
|
private OrderCreatedEvent event;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
Customer customer = new Customer();
|
||||||
|
customer.setFirstName("John");
|
||||||
|
customer.setLastName("Doe");
|
||||||
|
customer.setEmail("john.doe@test.com");
|
||||||
|
|
||||||
|
order = new Order();
|
||||||
|
order.setId(UUID.randomUUID());
|
||||||
|
order.setCustomer(customer);
|
||||||
|
order.setCreatedAt(OffsetDateTime.parse("2026-02-21T10:00:00Z"));
|
||||||
|
order.setTotalChf(new BigDecimal("150.50"));
|
||||||
|
|
||||||
|
event = new OrderCreatedEvent(this, order);
|
||||||
|
|
||||||
|
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
|
||||||
|
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
|
||||||
|
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleOrderCreatedEvent_ShouldSendCustomerAndAdminEmails() {
|
||||||
|
// Act
|
||||||
|
orderEmailListener.handleOrderCreatedEvent(event);
|
||||||
|
|
||||||
|
// Assert Customer Email
|
||||||
|
verify(emailNotificationService, times(1)).sendEmail(
|
||||||
|
eq("john.doe@test.com"),
|
||||||
|
eq("Conferma Ordine #" + order.getOrderNumber() + " - 3D-Fab"),
|
||||||
|
eq("order-confirmation"),
|
||||||
|
templateDataCaptor.capture()
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> customerData = templateDataCaptor.getAllValues().get(0);
|
||||||
|
assertEquals("John", customerData.get("customerName"));
|
||||||
|
assertEquals(order.getId(), customerData.get("orderId"));
|
||||||
|
assertEquals(order.getOrderNumber(), customerData.get("orderNumber"));
|
||||||
|
assertEquals("https://tuosito.it/ordine/" + order.getId(), customerData.get("orderDetailsUrl"));
|
||||||
|
assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate"));
|
||||||
|
assertEquals("150.50", customerData.get("totalCost"));
|
||||||
|
|
||||||
|
// Assert Admin Email
|
||||||
|
verify(emailNotificationService, times(1)).sendEmail(
|
||||||
|
eq("admin@printcalculator.local"),
|
||||||
|
eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - Doe"),
|
||||||
|
eq("order-confirmation"),
|
||||||
|
templateDataCaptor.capture()
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> adminData = templateDataCaptor.getAllValues().get(1);
|
||||||
|
assertEquals("John Doe", adminData.get("customerName"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleOrderCreatedEvent_WithAdminDisabled_ShouldOnlySendCustomerEmail() {
|
||||||
|
// Arrange
|
||||||
|
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", false);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
orderEmailListener.handleOrderCreatedEvent(event);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
verify(emailNotificationService, times(1)).sendEmail(
|
||||||
|
eq("john.doe@test.com"),
|
||||||
|
anyString(),
|
||||||
|
anyString(),
|
||||||
|
any()
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(emailNotificationService, never()).sendEmail(
|
||||||
|
eq("admin@printcalculator.local"),
|
||||||
|
anyString(),
|
||||||
|
anyString(),
|
||||||
|
any()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleOrderCreatedEvent_ExceptionHandling_ShouldNotPropagate() {
|
||||||
|
// Arrange
|
||||||
|
doThrow(new RuntimeException("Simulated Mail Failure"))
|
||||||
|
.when(emailNotificationService).sendEmail(anyString(), anyString(), anyString(), any());
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
// Event listener shouldn't throw exception back, thus passing the test.
|
||||||
|
orderEmailListener.handleOrderCreatedEvent(event);
|
||||||
|
|
||||||
|
verify(emailNotificationService, times(1)).sendEmail(anyString(), anyString(), anyString(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,10 @@ class GCodeParserTest {
|
|||||||
PrintStats stats = parser.parse(tempFile);
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertEquals(3723L, stats.getPrintTimeSeconds()); // 3600 + 120 + 3
|
assertEquals(3723, stats.printTimeSeconds()); // 3600 + 120 + 3
|
||||||
assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
|
assertEquals("1h 2m 3s", stats.printTimeFormatted());
|
||||||
assertEquals(10.5, stats.getFilamentWeightGrams(), 0.001);
|
assertEquals(10.5, stats.filamentWeightGrams(), 0.001);
|
||||||
assertEquals(3000.0, stats.getFilamentLengthMm(), 0.001);
|
assertEquals(3000.0, stats.filamentLengthMm(), 0.001);
|
||||||
|
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,8 @@ class GCodeParserTest {
|
|||||||
GCodeParser parser = new GCodeParser();
|
GCodeParser parser = new GCodeParser();
|
||||||
PrintStats stats = parser.parse(tempFile);
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
assertEquals(750L, stats.getPrintTimeSeconds()); // 12*60 + 30
|
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
|
||||||
assertEquals(5.0, stats.getFilamentWeightGrams(), 0.001);
|
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
|
||||||
|
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
}
|
}
|
||||||
@@ -69,8 +69,8 @@ class GCodeParserTest {
|
|||||||
GCodeParser parser = new GCodeParser();
|
GCodeParser parser = new GCodeParser();
|
||||||
PrintStats stats = parser.parse(tempFile);
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
assertEquals(3723L, stats.getPrintTimeSeconds());
|
assertEquals(3723L, stats.printTimeSeconds());
|
||||||
assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
|
assertEquals("1h 2m 3s", stats.printTimeFormatted());
|
||||||
|
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
}
|
}
|
||||||
@@ -87,8 +87,8 @@ class GCodeParserTest {
|
|||||||
GCodeParser parser = new GCodeParser();
|
GCodeParser parser = new GCodeParser();
|
||||||
PrintStats stats = parser.parse(tempFile);
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
assertEquals(3723L, stats.getPrintTimeSeconds());
|
assertEquals(3723L, stats.printTimeSeconds());
|
||||||
assertEquals("01:02:03", stats.getPrintTimeFormatted());
|
assertEquals("01:02:03", stats.printTimeFormatted());
|
||||||
|
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
}
|
}
|
||||||
@@ -105,8 +105,8 @@ class GCodeParserTest {
|
|||||||
GCodeParser parser = new GCodeParser();
|
GCodeParser parser = new GCodeParser();
|
||||||
PrintStats stats = parser.parse(tempFile);
|
PrintStats stats = parser.parse(tempFile);
|
||||||
|
|
||||||
assertEquals(321L, stats.getPrintTimeSeconds());
|
assertEquals(321L, stats.printTimeSeconds());
|
||||||
assertEquals("5m 21s", stats.getPrintTimeFormatted());
|
assertEquals("5m 21s", stats.printTimeFormatted());
|
||||||
|
|
||||||
tempFile.delete();
|
tempFile.delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
package com.printcalculator.service;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
||||||
import com.printcalculator.model.PrintStats;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.MockitoAnnotations;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
|
|
||||||
class SlicerServiceTest {
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private ProfileManager profileManager;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private GCodeParser gCodeParser;
|
|
||||||
|
|
||||||
private ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
private SlicerService slicerService;
|
|
||||||
|
|
||||||
@TempDir
|
|
||||||
Path tempDir;
|
|
||||||
|
|
||||||
// Captured execution details
|
|
||||||
private List<String> lastCommand;
|
|
||||||
private Path lastTempDir;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() throws IOException {
|
|
||||||
MockitoAnnotations.openMocks(this);
|
|
||||||
|
|
||||||
// Subclass to override runSlicerCommand
|
|
||||||
slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) {
|
|
||||||
@Override
|
|
||||||
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
|
|
||||||
lastCommand = command;
|
|
||||||
lastTempDir = tempDir;
|
|
||||||
// Don't run actual process.
|
|
||||||
// Simulate GCode output creation for the parser to find?
|
|
||||||
// Or just let it fail at parser step since we only care about JSON generation here?
|
|
||||||
// For a full test, we should create a dummy GCode file.
|
|
||||||
|
|
||||||
File stl = new File(command.get(command.size() - 1));
|
|
||||||
String basename = stl.getName().replace(".stl", "");
|
|
||||||
Files.createFile(tempDir.resolve(basename + ".gcode"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock Profile Responses
|
|
||||||
ObjectNode emptyNode = mapper.createObjectNode();
|
|
||||||
when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy());
|
|
||||||
when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy());
|
|
||||||
when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy());
|
|
||||||
|
|
||||||
// Mock Parser
|
|
||||||
when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException {
|
|
||||||
File dummyStl = tempDir.resolve("test.stl").toFile();
|
|
||||||
Files.createFile(dummyStl.toPath());
|
|
||||||
|
|
||||||
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null);
|
|
||||||
|
|
||||||
assertNotNull(lastTempDir);
|
|
||||||
assertTrue(Files.exists(lastTempDir.resolve("process.json")));
|
|
||||||
assertTrue(Files.exists(lastTempDir.resolve("machine.json")));
|
|
||||||
assertTrue(Files.exists(lastTempDir.resolve("filament.json")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException {
|
|
||||||
File dummyStl = tempDir.resolve("test.stl").toFile();
|
|
||||||
Files.createFile(dummyStl.toPath());
|
|
||||||
|
|
||||||
Map<String, String> processOverrides = new HashMap<>();
|
|
||||||
processOverrides.put("layer_height", "0.12");
|
|
||||||
|
|
||||||
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
|
|
||||||
|
|
||||||
File processJsonFile = lastTempDir.resolve("process.json").toFile();
|
|
||||||
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
|
|
||||||
|
|
||||||
assertTrue(processJson.has("layer_height"));
|
|
||||||
assertEquals("0.12", processJson.get("layer_height").asText());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException {
|
|
||||||
File dummyStl = tempDir.resolve("test.stl").toFile();
|
|
||||||
Files.createFile(dummyStl.toPath());
|
|
||||||
|
|
||||||
Map<String, String> processOverrides = new HashMap<>();
|
|
||||||
processOverrides.put("sparse_infill_density", "25%");
|
|
||||||
processOverrides.put("enable_support", "1");
|
|
||||||
|
|
||||||
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
|
|
||||||
|
|
||||||
File processJsonFile = lastTempDir.resolve("process.json").toFile();
|
|
||||||
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
|
|
||||||
|
|
||||||
assertEquals("25%", processJson.get("sparse_infill_density").asText());
|
|
||||||
assertEquals("1", processJson.get("enable_support").asText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.printcalculator.service.email;
|
||||||
|
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.thymeleaf.TemplateEngine;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SmtpEmailNotificationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JavaMailSender emailSender;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private TemplateEngine templateEngine;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private MimeMessage mimeMessage;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private SmtpEmailNotificationService emailNotificationService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(emailNotificationService, "fromAddress", "noreply@test.com");
|
||||||
|
ReflectionTestUtils.setField(emailNotificationService, "mailEnabled", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendEmail_Success() {
|
||||||
|
// Arrange
|
||||||
|
String to = "user@test.com";
|
||||||
|
String subject = "Test Subject";
|
||||||
|
String templateName = "test-template";
|
||||||
|
Map<String, Object> contextData = new HashMap<>();
|
||||||
|
contextData.put("key", "value");
|
||||||
|
|
||||||
|
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenReturn("<html>Test</html>");
|
||||||
|
when(emailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
emailNotificationService.sendEmail(to, subject, templateName, contextData);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
verify(templateEngine, times(1)).process(eq("email/" + templateName), any(Context.class));
|
||||||
|
verify(emailSender, times(1)).createMimeMessage();
|
||||||
|
verify(emailSender, times(1)).send(mimeMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendEmail_Exception_ShouldNotThrow() {
|
||||||
|
// Arrange
|
||||||
|
String to = "user@test.com";
|
||||||
|
String subject = "Test Subject";
|
||||||
|
String templateName = "test-template";
|
||||||
|
Map<String, Object> contextData = new HashMap<>();
|
||||||
|
|
||||||
|
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenThrow(new RuntimeException("Template error"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
// We expect the exception to be caught and logged, not propagated
|
||||||
|
assertDoesNotThrow(() -> emailNotificationService.sendEmail(to, subject, templateName, contextData));
|
||||||
|
|
||||||
|
verify(emailSender, never()).createMimeMessage();
|
||||||
|
verify(emailSender, never()).send(any(MimeMessage.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
db.sql
4
db.sql
@@ -12,7 +12,6 @@ create table printer_machine
|
|||||||
fleet_weight numeric(6, 3) not null default 1.000,
|
fleet_weight numeric(6, 3) not null default 1.000,
|
||||||
|
|
||||||
is_active boolean not null default true,
|
is_active boolean not null default true,
|
||||||
slicer_machine_profile varchar(255),
|
|
||||||
created_at timestamptz not null default now()
|
created_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -554,7 +553,7 @@ CREATE TABLE IF NOT EXISTS payments
|
|||||||
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
|
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
|
||||||
|
|
||||||
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
|
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
|
||||||
status text NOT NULL CHECK (status IN ('PENDING', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
|
status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
|
||||||
|
|
||||||
currency char(3) NOT NULL DEFAULT 'CHF',
|
currency char(3) NOT NULL DEFAULT 'CHF',
|
||||||
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
|
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
|
||||||
@@ -565,6 +564,7 @@ CREATE TABLE IF NOT EXISTS payments
|
|||||||
|
|
||||||
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
|
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
|
||||||
initiated_at timestamptz NOT NULL DEFAULT now(),
|
initiated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
reported_at timestamptz,
|
||||||
received_at timestamptz
|
received_at timestamptz
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ TAG=dev
|
|||||||
BACKEND_PORT=18002
|
BACKEND_PORT=18002
|
||||||
FRONTEND_PORT=18082
|
FRONTEND_PORT=18082
|
||||||
|
|
||||||
|
CLAMAV_HOST=192.168.1.147
|
||||||
|
CLAMAV_PORT=3310
|
||||||
|
CLAMAV_ENABLED=true
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ TAG=int
|
|||||||
BACKEND_PORT=18001
|
BACKEND_PORT=18001
|
||||||
FRONTEND_PORT=18081
|
FRONTEND_PORT=18081
|
||||||
|
|
||||||
|
CLAMAV_HOST=192.168.1.147
|
||||||
|
CLAMAV_PORT=3310
|
||||||
|
CLAMAV_ENABLED=true
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,7 @@ TAG=prod
|
|||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
FRONTEND_PORT=80
|
FRONTEND_PORT=80
|
||||||
|
|
||||||
|
CLAMAV_HOST=192.168.1.147
|
||||||
|
CLAMAV_PORT=3310
|
||||||
|
CLAMAV_ENABLED=true
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
# L'immagine usa il tag specificato nel file .env o passato da riga di comando
|
# L'immagine usa il tag specificato nel file .env o passato da riga di comando
|
||||||
@@ -7,26 +5,39 @@ services:
|
|||||||
container_name: print-calculator-backend-${ENV}
|
container_name: print-calculator-backend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT}:8000"
|
- "${BACKEND_PORT}:8000"
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
|
- SPRING_PROFILES_ACTIVE=${ENV}
|
||||||
- DB_URL=${DB_URL}
|
- DB_URL=${DB_URL}
|
||||||
- DB_USERNAME=${DB_USERNAME}
|
- DB_USERNAME=${DB_USERNAME}
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
- CLAMAV_HOST=${CLAMAV_HOST}
|
||||||
|
- CLAMAV_PORT=${CLAMAV_PORT}
|
||||||
|
- CLAMAV_ENABLED=${CLAMAV_ENABLED}
|
||||||
|
- MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com}
|
||||||
|
- MAIL_PORT=${MAIL_PORT:-587}
|
||||||
|
- MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch}
|
||||||
|
- MAIL_PASSWORD=${MAIL_PASSWORD:-}
|
||||||
|
- MAIL_SMTP_AUTH=${MAIL_SMTP_AUTH:-true}
|
||||||
|
- MAIL_SMTP_STARTTLS=${MAIL_SMTP_STARTTLS:-true}
|
||||||
|
- APP_MAIL_FROM=${APP_MAIL_FROM:-info@3d-fab.ch}
|
||||||
|
- APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true}
|
||||||
|
- APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch}
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
- CLAMAV_HOST=host.docker.internal
|
|
||||||
- CLAMAV_PORT=3310
|
|
||||||
- STORAGE_LOCATION=/app/storage
|
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
volumes:
|
||||||
- backend_profiles_${ENV}:/app/profiles
|
- backend_profiles_${ENV}:/app/profiles
|
||||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes
|
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes
|
||||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders
|
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders
|
||||||
|
- /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
|
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
|
||||||
container_name: print-calculator-frontend-${ENV}
|
container_name: print-calculator-frontend-${ENV}
|
||||||
@@ -35,6 +46,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_profiles_prod:
|
backend_profiles_prod:
|
||||||
|
|||||||
@@ -1,48 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
|
||||||
platform: linux/amd64
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
platforms:
|
|
||||||
- linux/amd64
|
|
||||||
container_name: print-calculator-backend
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
- DB_URL=jdbc:postgresql://db:5432/printcalc
|
|
||||||
- DB_USERNAME=printcalc
|
|
||||||
- DB_PASSWORD=printcalc_secret
|
|
||||||
- SPRING_PROFILES_ACTIVE=local
|
|
||||||
- TEMP_DIR=/app/temp
|
|
||||||
- PROFILES_DIR=/app/profiles
|
|
||||||
- CLAMAV_HOST=clamav
|
|
||||||
- CLAMAV_PORT=3310
|
|
||||||
- STORAGE_LOCATION=/app/storage
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
- clamav
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
clamav:
|
|
||||||
platform: linux/amd64
|
|
||||||
image: clamav/clamav:latest
|
|
||||||
container_name: print-calculator-clamav
|
|
||||||
ports:
|
|
||||||
- "3310:3310"
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.dev
|
|
||||||
container_name: print-calculator-frontend
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
- db
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: print-calculator-db
|
container_name: print-calculator-db
|
||||||
@@ -56,5 +12,16 @@ services:
|
|||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
clamav:
|
||||||
|
platform: linux/amd64
|
||||||
|
image: clamav/clamav:latest
|
||||||
|
container_name: print-calculator-clamav
|
||||||
|
ports:
|
||||||
|
- "3310:3310"
|
||||||
|
volumes:
|
||||||
|
- clamav_db:/var/lib/clamav
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
clamav_db:
|
||||||
|
|||||||
568
frontend/package-lock.json
generated
568
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,14 @@ export const routes: Routes = [
|
|||||||
path: 'payment/:orderId',
|
path: 'payment/:orderId',
|
||||||
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
|
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ordine/:orderId',
|
||||||
|
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'order-confirmed/:orderId',
|
||||||
|
loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
||||||
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
||||||
|
|
||||||
@if (error() === 'VIRUS_DETECTED') {
|
@if (error()) {
|
||||||
<app-alert type="error">{{ 'CALC.ERROR_VIRUS_DETECTED' | translate }}</app-alert>
|
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
|
||||||
} @else if (error()) {
|
|
||||||
<app-alert type="error">{{ 'CALC.ERROR_' + error() | translate }}</app-alert>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,12 +19,12 @@
|
|||||||
<div class="mode-selector">
|
<div class="mode-selector">
|
||||||
<div class="mode-option"
|
<div class="mode-option"
|
||||||
[class.active]="mode() === 'easy'"
|
[class.active]="mode() === 'easy'"
|
||||||
(click)="setMode('easy')">
|
(click)="mode.set('easy')">
|
||||||
{{ 'CALC.MODE_EASY' | translate }}
|
{{ 'CALC.MODE_EASY' | translate }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mode-option"
|
<div class="mode-option"
|
||||||
[class.active]="mode() === 'advanced'"
|
[class.active]="mode() === 'advanced'"
|
||||||
(click)="setMode('advanced')">
|
(click)="mode.set('advanced')">
|
||||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +35,6 @@
|
|||||||
[loading]="loading()"
|
[loading]="loading()"
|
||||||
[uploadProgress]="uploadProgress()"
|
[uploadProgress]="uploadProgress()"
|
||||||
(submitRequest)="onCalculate($event)"
|
(submitRequest)="onCalculate($event)"
|
||||||
(itemRemoved)="onItemRemoved($event)"
|
|
||||||
></app-upload-form>
|
></app-upload-form>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,25 +42,15 @@
|
|||||||
<!-- Right Column: Result or Info -->
|
<!-- Right Column: Result or Info -->
|
||||||
<div class="col-result" #resultCol>
|
<div class="col-result" #resultCol>
|
||||||
|
|
||||||
@if (loading() && !result()) {
|
@if (loading()) {
|
||||||
<!-- Initial Loading State (before first result) -->
|
|
||||||
<app-card class="loading-state">
|
<app-card class="loading-state">
|
||||||
<div class="loader-content">
|
<div class="loader-content">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<h3 class="loading-title">Analisi in corso...</h3>
|
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
|
||||||
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
|
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
} @else if (result()) {
|
} @else if (result()) {
|
||||||
<!-- Result State (Active or Finished) -->
|
|
||||||
@if (loading()) {
|
|
||||||
<!-- Small loader indicator when refining results -->
|
|
||||||
<div class="analyzing-bar">
|
|
||||||
<div class="spinner-small"></div>
|
|
||||||
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<app-quote-result
|
<app-quote-result
|
||||||
[result]="result()!"
|
[result]="result()!"
|
||||||
(consult)="onConsult()"
|
(consult)="onConsult()"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
uploadProgress = signal(0);
|
uploadProgress = signal(0);
|
||||||
result = signal<QuoteResult | null>(null);
|
result = signal<QuoteResult | null>(null);
|
||||||
error = signal<string | null>(null);
|
error = signal<boolean>(false);
|
||||||
|
|
||||||
orderSuccess = signal(false);
|
orderSuccess = signal(false);
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
|
|
||||||
this.route.queryParams.subscribe(params => {
|
this.route.queryParams.subscribe(params => {
|
||||||
const sessionId = params['session'];
|
const sessionId = params['session'];
|
||||||
if (sessionId && sessionId !== this.result()?.sessionId) {
|
if (sessionId) {
|
||||||
this.loadSession(sessionId);
|
this.loadSession(sessionId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Failed to load session', err);
|
console.error('Failed to load session', err);
|
||||||
this.error.set('Failed to load session');
|
this.error.set(true);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -106,14 +106,14 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
forkJoin(downloads).subscribe({
|
forkJoin(downloads).subscribe({
|
||||||
next: (results: any[]) => {
|
next: (results: any[]) => {
|
||||||
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
|
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
|
||||||
const colors = items.map(i => i.colorCode || 'Black');
|
|
||||||
|
|
||||||
if (this.uploadForm) {
|
if (this.uploadForm) {
|
||||||
this.uploadForm.setFiles(files, colors);
|
this.uploadForm.setFiles(files);
|
||||||
this.uploadForm.patchSettings(session);
|
this.uploadForm.patchSettings(session);
|
||||||
|
|
||||||
// Also restore colors?
|
// Also restore colors?
|
||||||
// setFiles inits with correct colors now.
|
// setFiles inits with 'Black'. We need to update them if they differ.
|
||||||
|
// items has colorCode.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.uploadForm) {
|
if (this.uploadForm) {
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
@@ -122,11 +122,7 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
if (item.colorCode) {
|
if (item.colorCode) {
|
||||||
this.uploadForm.updateItemColor(index, item.colorCode);
|
this.uploadForm.updateItemColor(index, item.colorCode);
|
||||||
}
|
}
|
||||||
if (item.quantity) {
|
|
||||||
this.uploadForm.updateItemQuantityAtIndex(index, item.quantity);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
this.uploadForm.updateItemIdsByIndex(items.map(i => i.id));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -145,7 +141,7 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
this.currentRequest = req;
|
this.currentRequest = req;
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.uploadProgress.set(0);
|
this.uploadProgress.set(0);
|
||||||
this.error.set(null);
|
this.error.set(false);
|
||||||
this.result.set(null);
|
this.result.set(null);
|
||||||
this.orderSuccess.set(false);
|
this.orderSuccess.set(false);
|
||||||
|
|
||||||
@@ -161,45 +157,26 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
if (typeof event === 'number') {
|
if (typeof event === 'number') {
|
||||||
this.uploadProgress.set(event);
|
this.uploadProgress.set(event);
|
||||||
} else {
|
} else {
|
||||||
// It's the result (partial or final)
|
// It's the result
|
||||||
const res = event as QuoteResult;
|
const res = event as QuoteResult;
|
||||||
this.result.set(res);
|
this.result.set(res);
|
||||||
|
this.loading.set(false);
|
||||||
// Show result immediately if not already showing
|
this.uploadProgress.set(100);
|
||||||
if (this.step() !== 'quote') {
|
this.step.set('quote');
|
||||||
this.step.set('quote');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync IDs back to upload form for future updates
|
|
||||||
if (this.uploadForm) {
|
|
||||||
this.uploadForm.updateItemIdsByIndex(res.items.map(i => i.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL with session ID without reloading
|
// Update URL with session ID without reloading
|
||||||
if (res.sessionId) {
|
if (res.sessionId) {
|
||||||
// Check if we need to update URL to avoid redundant navigations
|
this.router.navigate([], {
|
||||||
const currentSession = this.route.snapshot.queryParamMap.get('session');
|
relativeTo: this.route,
|
||||||
if (currentSession !== res.sessionId) {
|
queryParams: { session: res.sessionId },
|
||||||
this.router.navigate([], {
|
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
|
||||||
relativeTo: this.route,
|
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
||||||
queryParams: { session: res.sessionId },
|
});
|
||||||
queryParamsHandling: 'merge',
|
|
||||||
replaceUrl: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
complete: () => {
|
error: () => {
|
||||||
this.loading.set(false);
|
this.error.set(true);
|
||||||
this.uploadProgress.set(100);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
if (typeof err === 'string') {
|
|
||||||
this.error.set(err);
|
|
||||||
} else {
|
|
||||||
this.error.set('GENERIC');
|
|
||||||
}
|
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,10 +196,10 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
this.step.set('quote');
|
this.step.set('quote');
|
||||||
}
|
}
|
||||||
|
|
||||||
onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) {
|
onItemChange(event: {id?: string, fileName: string, quantity: number}) {
|
||||||
// 1. Update local form for consistency (UI feedback)
|
// 1. Update local form for consistency (UI feedback)
|
||||||
if (this.uploadForm) {
|
if (this.uploadForm) {
|
||||||
this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity);
|
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update backend session if ID exists
|
// 2. Update backend session if ID exists
|
||||||
@@ -234,43 +211,6 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onItemRemoved(event: {index: number, id?: string}) {
|
|
||||||
// 1. Update local result if exists to keep UI in sync
|
|
||||||
const currentRes = this.result();
|
|
||||||
if (currentRes) {
|
|
||||||
const updatedItems = [...currentRes.items];
|
|
||||||
updatedItems.splice(event.index, 1);
|
|
||||||
|
|
||||||
// Recalculate totals locally for immediate feedback
|
|
||||||
let totalTime = 0;
|
|
||||||
let totalWeight = 0;
|
|
||||||
let itemsPrice = 0;
|
|
||||||
|
|
||||||
updatedItems.forEach(i => {
|
|
||||||
totalTime += i.unitTime * i.quantity;
|
|
||||||
totalWeight += i.unitWeight * i.quantity;
|
|
||||||
itemsPrice += i.unitPrice * i.quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.result.set({
|
|
||||||
...currentRes,
|
|
||||||
items: updatedItems,
|
|
||||||
totalPrice: Math.round((itemsPrice + currentRes.setupCost) * 100) / 100,
|
|
||||||
totalTimeHours: Math.floor(totalTime / 3600),
|
|
||||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
|
||||||
totalWeight: Math.ceil(totalWeight)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Delete from backend if ID exists
|
|
||||||
if (event.id && currentRes?.sessionId) {
|
|
||||||
this.estimator.deleteLineItem(currentRes.sessionId, event.id).subscribe({
|
|
||||||
next: () => console.log('Line item deleted from backend'),
|
|
||||||
error: (err) => console.error('Failed to delete line item', err)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmitOrder(orderData: any) {
|
onSubmitOrder(orderData: any) {
|
||||||
console.log('Order Submitted:', orderData);
|
console.log('Order Submitted:', orderData);
|
||||||
this.orderSuccess.set(true);
|
this.orderSuccess.set(true);
|
||||||
@@ -316,12 +256,4 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
|
|
||||||
this.router.navigate(['/contact']);
|
this.router.navigate(['/contact']);
|
||||||
}
|
}
|
||||||
|
|
||||||
setMode(mode: 'easy' | 'advanced') {
|
|
||||||
const path = mode === 'easy' ? 'basic' : 'advanced';
|
|
||||||
this.router.navigate(['../', path], {
|
|
||||||
relativeTo: this.route,
|
|
||||||
queryParamsHandling: 'merge'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setup-note">
|
<div class="setup-note">
|
||||||
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
|
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (result().notes) {
|
@if (result().notes) {
|
||||||
@@ -35,45 +35,28 @@
|
|||||||
|
|
||||||
<!-- Detailed Items List (NOW ON BOTTOM) -->
|
<!-- Detailed Items List (NOW ON BOTTOM) -->
|
||||||
<div class="items-list">
|
<div class="items-list">
|
||||||
@for (item of items(); track item; let i = $index) {
|
@for (item of items(); track item.fileName; let i = $index) {
|
||||||
<div class="item-row" [class.has-error]="item.error">
|
<div class="item-row">
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<span class="file-name">{{ item.fileName }}</span>
|
<span class="file-name">{{ item.fileName }}</span>
|
||||||
@if (item.error) {
|
<span class="file-details">
|
||||||
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
|
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
|
||||||
} @else if (item.status === 'pending') {
|
</span>
|
||||||
<span class="file-details pending">
|
|
||||||
<div class="spinner-mini"></div> Analisi...
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<span class="file-details">
|
|
||||||
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
|
|
||||||
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="item-controls">
|
<div class="item-controls">
|
||||||
@if (!item.error) {
|
<div class="qty-control">
|
||||||
<div class="qty-control">
|
<label>{{ 'CHECKOUT.QTY' | translate }}:</label>
|
||||||
<label>Qtà:</label>
|
<input
|
||||||
<input
|
type="number"
|
||||||
type="number"
|
min="1"
|
||||||
min="1"
|
[ngModel]="item.quantity"
|
||||||
[ngModel]="item.quantity"
|
(ngModelChange)="updateQuantity(i, $event)"
|
||||||
(ngModelChange)="updateQuantity(i, $event)"
|
class="qty-input">
|
||||||
class="qty-input">
|
</div>
|
||||||
</div>
|
<div class="item-price">
|
||||||
<div class="item-price">
|
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
</div>
|
||||||
</div>
|
|
||||||
} @else if (item.status === 'pending') {
|
|
||||||
<div class="item-price pending">
|
|
||||||
<div class="spinner-mini"></div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="item-price error">-</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,6 @@
|
|||||||
background: var(--color-neutral-50);
|
background: var(--color-neutral-50);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
||||||
&.has-error {
|
|
||||||
border-color: #ef4444;
|
|
||||||
background: #fef2f2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-info {
|
.item-info {
|
||||||
@@ -36,21 +31,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.file-details {
|
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.color-badge {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.file-error { font-size: 0.8rem; color: #ef4444; font-weight: 500; }
|
|
||||||
|
|
||||||
.item-controls {
|
.item-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
|
|||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
||||||
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
||||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-quote-result',
|
selector: 'app-quote-result',
|
||||||
@@ -19,13 +18,11 @@ export class QuoteResultComponent {
|
|||||||
result = input.required<QuoteResult>();
|
result = input.required<QuoteResult>();
|
||||||
consult = output<void>();
|
consult = output<void>();
|
||||||
proceed = output<void>();
|
proceed = output<void>();
|
||||||
itemChange = output<{id?: string, fileName: string, quantity: number, index: number}>();
|
itemChange = output<{id?: string, fileName: string, quantity: number}>();
|
||||||
|
|
||||||
// Local mutable state for items to handle quantity changes
|
// Local mutable state for items to handle quantity changes
|
||||||
items = signal<QuoteItem[]>([]);
|
items = signal<QuoteItem[]>([]);
|
||||||
|
|
||||||
getColorHex = getColorHex;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
// Initialize local items when result inputs change
|
// Initialize local items when result inputs change
|
||||||
@@ -47,8 +44,7 @@ export class QuoteResultComponent {
|
|||||||
this.itemChange.emit({
|
this.itemChange.emit({
|
||||||
id: this.items()[index].id,
|
id: this.items()[index].id,
|
||||||
fileName: this.items()[index].fileName,
|
fileName: this.items()[index].fileName,
|
||||||
quantity: qty,
|
quantity: qty
|
||||||
index: index
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,11 +57,9 @@ export class QuoteResultComponent {
|
|||||||
let weight = 0;
|
let weight = 0;
|
||||||
|
|
||||||
currentItems.forEach(i => {
|
currentItems.forEach(i => {
|
||||||
if (i.status === 'done' && !i.error) {
|
price += i.unitPrice * i.quantity;
|
||||||
price += i.unitPrice * i.quantity;
|
time += i.unitTime * i.quantity;
|
||||||
time += i.unitTime * i.quantity;
|
weight += i.unitWeight * i.quantity;
|
||||||
weight += i.unitWeight * i.quantity;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const hours = Math.floor(time / 3600);
|
const hours = Math.floor(time / 3600);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<!-- New File List with Details -->
|
<!-- New File List with Details -->
|
||||||
@if (items().length > 0) {
|
@if (items().length > 0) {
|
||||||
<div class="items-grid">
|
<div class="items-grid">
|
||||||
@for (item of items(); track item; let i = $index) {
|
@for (item of items(); track item.file.name; let i = $index) {
|
||||||
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
|
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
|
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-controls">
|
<div class="card-controls">
|
||||||
<div class="qty-group">
|
<div class="qty-group">
|
||||||
<label>QTÀ</label>
|
<label>{{ 'CALC.QTY_SHORT' | translate }}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="color-group">
|
<div class="color-group">
|
||||||
<label>COLORE</label>
|
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
|
||||||
<app-color-selector
|
<app-color-selector
|
||||||
[selectedColor]="item.color"
|
[selectedColor]="item.color"
|
||||||
[variants]="currentMaterialVariants()"
|
[variants]="currentMaterialVariants()"
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<app-input
|
<app-input
|
||||||
formControlName="notes"
|
formControlName="notes"
|
||||||
[label]="'CALC.NOTES' | translate"
|
[label]="'CALC.NOTES' | translate"
|
||||||
placeholder="Istruzioni specifiche..."
|
[placeholder]="'CALC.NOTES_PLACEHOLDER' | translate"
|
||||||
></app-input>
|
></app-input>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
[disabled]="items().length === 0 || loading()"
|
[disabled]="items().length === 0 || loading()"
|
||||||
[fullWidth]="true">
|
[fullWidth]="true">
|
||||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, Mat
|
|||||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||||
|
|
||||||
interface FormItem {
|
interface FormItem {
|
||||||
id?: string;
|
|
||||||
file: File;
|
file: File;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -30,7 +29,6 @@ export class UploadFormComponent implements OnInit {
|
|||||||
loading = input<boolean>(false);
|
loading = input<boolean>(false);
|
||||||
uploadProgress = input<number>(0);
|
uploadProgress = input<number>(0);
|
||||||
submitRequest = output<QuoteRequest>();
|
submitRequest = output<QuoteRequest>();
|
||||||
itemRemoved = output<{index: number, id?: string}>();
|
|
||||||
|
|
||||||
private estimator = inject(QuoteEstimatorService);
|
private estimator = inject(QuoteEstimatorService);
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
@@ -77,7 +75,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
||||||
nozzleDiameter: [0.4, Validators.required],
|
nozzleDiameter: [0.4, Validators.required],
|
||||||
infillPattern: ['grid'],
|
infillPattern: ['grid'],
|
||||||
supportEnabled: [true]
|
supportEnabled: [false]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to material changes to update variants
|
// Listen to material changes to update variants
|
||||||
@@ -114,9 +112,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
private setDefaults() {
|
private setDefaults() {
|
||||||
// Set Defaults if available
|
// Set Defaults if available
|
||||||
if (this.materials().length > 0 && !this.form.get('material')?.value) {
|
if (this.materials().length > 0 && !this.form.get('material')?.value) {
|
||||||
// Prefer PLA Basic, otherwise first available
|
this.form.get('material')?.setValue(this.materials()[0].value);
|
||||||
const pla = this.materials().find(m => m.value === 'pla_basic');
|
|
||||||
this.form.get('material')?.setValue(pla ? pla.value : this.materials()[0].value);
|
|
||||||
}
|
}
|
||||||
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
|
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
|
||||||
// Try to find 'standard' or use first
|
// Try to find 'standard' or use first
|
||||||
@@ -180,37 +176,6 @@ export class UploadFormComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItemQuantityAtIndex(index: number, quantity: number) {
|
|
||||||
this.items.update(current => {
|
|
||||||
const updated = [...current];
|
|
||||||
if (updated[index]) {
|
|
||||||
updated[index] = { ...updated[index], quantity };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItemIds(itemsWithIds: { fileName: string, id: string }[]) {
|
|
||||||
this.items.update(current => {
|
|
||||||
return current.map(item => {
|
|
||||||
const match = itemsWithIds.find(i => i.fileName === item.file.name && !i.id); // This matching is weak
|
|
||||||
// Better: matching should be based on index if we trust order
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItemIdsByIndex(ids: (string | undefined)[]) {
|
|
||||||
this.items.update(current => {
|
|
||||||
return current.map((item, i) => {
|
|
||||||
if (ids[i]) {
|
|
||||||
return { ...item, id: ids[i] };
|
|
||||||
}
|
|
||||||
return item;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
selectFile(file: File) {
|
selectFile(file: File) {
|
||||||
if (this.selectedFile() === file) {
|
if (this.selectedFile() === file) {
|
||||||
// toggle off? no, keep active
|
// toggle off? no, keep active
|
||||||
@@ -241,7 +206,11 @@ export class UploadFormComponent implements OnInit {
|
|||||||
let val = parseInt(input.value, 10);
|
let val = parseInt(input.value, 10);
|
||||||
if (isNaN(val) || val < 1) val = 1;
|
if (isNaN(val) || val < 1) val = 1;
|
||||||
|
|
||||||
this.updateItemQuantityAtIndex(index, val);
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], quantity: val };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItemColor(index: number, newColor: string) {
|
updateItemColor(index: number, newColor: string) {
|
||||||
@@ -253,7 +222,6 @@ export class UploadFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeItem(index: number) {
|
removeItem(index: number) {
|
||||||
const itemToRemove = this.items()[index];
|
|
||||||
this.items.update(current => {
|
this.items.update(current => {
|
||||||
const updated = [...current];
|
const updated = [...current];
|
||||||
const removed = updated.splice(index, 1)[0];
|
const removed = updated.splice(index, 1)[0];
|
||||||
@@ -262,15 +230,14 @@ export class UploadFormComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
this.itemRemoved.emit({ index, id: itemToRemove.id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setFiles(files: File[], colors?: string[]) {
|
setFiles(files: File[]) {
|
||||||
const validItems: FormItem[] = [];
|
const validItems: FormItem[] = [];
|
||||||
files.forEach((file, i) => {
|
for (const file of files) {
|
||||||
const color = (colors && colors[i]) ? colors[i] : 'Black';
|
// Default color is Black or derive from somewhere if possible, but here we just init
|
||||||
validItems.push({ file, quantity: 1, color: color });
|
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||||
});
|
}
|
||||||
|
|
||||||
if (validItems.length > 0) {
|
if (validItems.length > 0) {
|
||||||
this.items.set(validItems);
|
this.items.set(validItems);
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="name"
|
formControlName="name"
|
||||||
label="USER_DETAILS.NAME"
|
[label]="'USER_DETAILS.NAME' | translate"
|
||||||
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="surname"
|
formControlName="surname"
|
||||||
label="USER_DETAILS.SURNAME"
|
[label]="'USER_DETAILS.SURNAME' | translate"
|
||||||
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="email"
|
formControlName="email"
|
||||||
label="USER_DETAILS.EMAIL"
|
[label]="'USER_DETAILS.EMAIL' | translate"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
|
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="phone"
|
formControlName="phone"
|
||||||
label="USER_DETAILS.PHONE"
|
[label]="'USER_DETAILS.PHONE' | translate"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
@@ -53,8 +53,8 @@
|
|||||||
<!-- Address -->
|
<!-- Address -->
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="address"
|
formControlName="address"
|
||||||
label="USER_DETAILS.ADDRESS"
|
[label]="'USER_DETAILS.ADDRESS' | translate"
|
||||||
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="zip"
|
formControlName="zip"
|
||||||
label="USER_DETAILS.ZIP"
|
[label]="'USER_DETAILS.ZIP' | translate"
|
||||||
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<app-input
|
<app-input
|
||||||
formControlName="city"
|
formControlName="city"
|
||||||
label="USER_DETAILS.CITY"
|
[label]="'USER_DETAILS.CITY' | translate"
|
||||||
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
|
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
|
||||||
[required]="true"
|
[required]="true"
|
||||||
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
</app-input>
|
</app-input>
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ export interface QuoteItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
material?: string;
|
material?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
error?: string;
|
|
||||||
status: 'pending' | 'done' | 'error';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuoteResult {
|
export interface QuoteResult {
|
||||||
@@ -140,13 +138,6 @@ export class QuoteEstimatorService {
|
|||||||
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
|
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteLineItem(sessionId: string, lineItemId: string): Observable<any> {
|
|
||||||
const headers: any = {};
|
|
||||||
// @ts-ignore
|
|
||||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
|
||||||
return this.http.delete(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}`, { headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
createOrder(sessionId: string, orderDetails: any): Observable<any> {
|
createOrder(sessionId: string, orderDetails: any): Observable<any> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -161,6 +152,13 @@ export class QuoteEstimatorService {
|
|||||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportPayment(orderId: string, method: string): Observable<any> {
|
||||||
|
const headers: any = {};
|
||||||
|
// @ts-ignore
|
||||||
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
getOrderInvoice(orderId: string): Observable<Blob> {
|
getOrderInvoice(orderId: string): Observable<Blob> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -170,6 +168,13 @@ export class QuoteEstimatorService {
|
|||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTwintPayment(orderId: string): Observable<any> {
|
||||||
|
const headers: any = {};
|
||||||
|
// @ts-ignore
|
||||||
|
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||||
|
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||||
console.log('QuoteEstimatorService: Calculating quote...', request);
|
console.log('QuoteEstimatorService: Calculating quote...', request);
|
||||||
@@ -189,74 +194,18 @@ export class QuoteEstimatorService {
|
|||||||
const sessionId = sessionRes.id;
|
const sessionId = sessionRes.id;
|
||||||
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
||||||
|
|
||||||
// Initialize items in pending state
|
|
||||||
const currentItems: QuoteItem[] = request.items.map(item => ({
|
|
||||||
fileName: item.file.name,
|
|
||||||
unitPrice: 0,
|
|
||||||
unitTime: 0,
|
|
||||||
unitWeight: 0,
|
|
||||||
quantity: item.quantity,
|
|
||||||
status: 'pending',
|
|
||||||
color: item.color || 'White' // Default color for UI
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Emit initial state
|
|
||||||
const initialResult: QuoteResult = {
|
|
||||||
sessionId: sessionId,
|
|
||||||
items: [...currentItems],
|
|
||||||
setupCost: sessionSetupCost,
|
|
||||||
currency: 'CHF',
|
|
||||||
totalPrice: 0, // Will be calculated dynamically
|
|
||||||
totalTimeHours: 0,
|
|
||||||
totalTimeMinutes: 0,
|
|
||||||
totalWeight: 0,
|
|
||||||
notes: request.notes
|
|
||||||
};
|
|
||||||
observer.next(initialResult);
|
|
||||||
|
|
||||||
// 2. Upload files to this session
|
// 2. Upload files to this session
|
||||||
const totalItems = request.items.length;
|
const totalItems = request.items.length;
|
||||||
const allProgress: number[] = new Array(totalItems).fill(0);
|
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||||
|
const finalResponses: any[] = [];
|
||||||
let completedRequests = 0;
|
let completedRequests = 0;
|
||||||
|
|
||||||
const emitUpdate = () => {
|
const checkCompletion = () => {
|
||||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||||
observer.next(avg);
|
observer.next(avg);
|
||||||
|
|
||||||
// Helper to calculate totals for current items
|
|
||||||
let grandTotal = 0;
|
|
||||||
let totalTime = 0;
|
|
||||||
let totalWeight = 0;
|
|
||||||
let validCount = 0;
|
|
||||||
|
|
||||||
currentItems.forEach(item => {
|
|
||||||
if (item.status === 'done') {
|
|
||||||
grandTotal += item.unitPrice * item.quantity;
|
|
||||||
totalTime += item.unitTime * item.quantity;
|
|
||||||
totalWeight += item.unitWeight * item.quantity;
|
|
||||||
validCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validCount > 0) {
|
|
||||||
grandTotal += sessionSetupCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: QuoteResult = {
|
|
||||||
sessionId: sessionId,
|
|
||||||
items: [...currentItems], // Create copy to trigger change detection
|
|
||||||
setupCost: sessionSetupCost,
|
|
||||||
currency: 'CHF',
|
|
||||||
totalPrice: Math.round(grandTotal * 100) / 100,
|
|
||||||
totalTimeHours: Math.floor(totalTime / 3600),
|
|
||||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
|
||||||
totalWeight: Math.ceil(totalWeight),
|
|
||||||
notes: request.notes
|
|
||||||
};
|
|
||||||
observer.next(result);
|
|
||||||
|
|
||||||
if (completedRequests === totalItems) {
|
if (completedRequests === totalItems) {
|
||||||
observer.complete();
|
finalize(finalResponses, sessionSetupCost, sessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,42 +235,20 @@ export class QuoteEstimatorService {
|
|||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||||
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
|
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
||||||
emitUpdate();
|
checkCompletion();
|
||||||
} else if (event.type === HttpEventType.Response) {
|
} else if (event.type === HttpEventType.Response) {
|
||||||
allProgress[index] = 100;
|
allProgress[index] = 100;
|
||||||
const resBody = event.body as any;
|
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
|
||||||
|
|
||||||
// Update item in list
|
|
||||||
currentItems[index] = {
|
|
||||||
id: resBody.id,
|
|
||||||
fileName: resBody.originalFilename, // use returned filename
|
|
||||||
unitPrice: resBody.unitPriceChf || 0,
|
|
||||||
unitTime: resBody.printTimeSeconds || 0,
|
|
||||||
unitWeight: resBody.materialGrams || 0,
|
|
||||||
quantity: item.quantity, // Keep original quantity
|
|
||||||
material: request.material,
|
|
||||||
color: item.color || 'White',
|
|
||||||
status: 'done'
|
|
||||||
};
|
|
||||||
|
|
||||||
completedRequests++;
|
completedRequests++;
|
||||||
emitUpdate();
|
checkCompletion();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Item upload failed', err);
|
console.error('Item upload failed', err);
|
||||||
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
|
finalResponses[index] = { success: false, fileName: item.file.name };
|
||||||
|
|
||||||
currentItems[index] = {
|
|
||||||
...currentItems[index],
|
|
||||||
status: 'error',
|
|
||||||
error: errorMsg
|
|
||||||
};
|
|
||||||
|
|
||||||
allProgress[index] = 100; // Mark as done despite error
|
|
||||||
completedRequests++;
|
completedRequests++;
|
||||||
emitUpdate();
|
checkCompletion();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -331,6 +258,62 @@ export class QuoteEstimatorService {
|
|||||||
observer.error('Could not initialize quote session');
|
observer.error('Could not initialize quote session');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
|
||||||
|
observer.next(100);
|
||||||
|
const items: QuoteItem[] = [];
|
||||||
|
let grandTotal = 0;
|
||||||
|
let totalTime = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
let validCount = 0;
|
||||||
|
|
||||||
|
responses.forEach((res, idx) => {
|
||||||
|
if (!res || !res.success) return;
|
||||||
|
validCount++;
|
||||||
|
|
||||||
|
const unitPrice = res.unitPriceChf || 0;
|
||||||
|
const quantity = res.originalQty || 1;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: res.id,
|
||||||
|
fileName: res.fileName,
|
||||||
|
unitPrice: unitPrice,
|
||||||
|
unitTime: res.printTimeSeconds || 0,
|
||||||
|
unitWeight: res.materialGrams || 0,
|
||||||
|
quantity: quantity,
|
||||||
|
material: request.material,
|
||||||
|
color: res.originalItem.color || 'Default'
|
||||||
|
// Store ID if needed for updates? QuoteItem interface might need update
|
||||||
|
// or we map it in component
|
||||||
|
});
|
||||||
|
|
||||||
|
grandTotal += unitPrice * quantity;
|
||||||
|
totalTime += (res.printTimeSeconds || 0) * quantity;
|
||||||
|
totalWeight += (res.materialGrams || 0) * quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validCount === 0) {
|
||||||
|
observer.error('All calculations failed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grandTotal += setupCost;
|
||||||
|
|
||||||
|
const result: QuoteResult = {
|
||||||
|
sessionId: sessionId,
|
||||||
|
items,
|
||||||
|
setupCost: setupCost,
|
||||||
|
currency: 'CHF',
|
||||||
|
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||||
|
totalTimeHours: Math.floor(totalTime / 3600),
|
||||||
|
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||||
|
totalWeight: Math.ceil(totalWeight),
|
||||||
|
notes: request.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
observer.next(result);
|
||||||
|
observer.complete();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +367,7 @@ export class QuoteEstimatorService {
|
|||||||
material: session.materialCode, // Assumption: session has one material for all? or items have it?
|
material: session.materialCode, // Assumption: session has one material for all? or items have it?
|
||||||
// Backend model QuoteSession has materialCode.
|
// Backend model QuoteSession has materialCode.
|
||||||
// But line items might have different colors.
|
// But line items might have different colors.
|
||||||
color: item.colorCode,
|
color: item.colorCode
|
||||||
status: 'done'
|
|
||||||
})),
|
})),
|
||||||
setupCost: session.setupCostChf,
|
setupCost: session.setupCostChf,
|
||||||
currency: 'CHF', // Fixed for now
|
currency: 'CHF', // Fixed for now
|
||||||
|
|||||||
@@ -1,154 +1,161 @@
|
|||||||
<div class="container hero">
|
<div class="checkout-page">
|
||||||
<h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
|
<div class="container hero">
|
||||||
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
|
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="checkout-layout">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: Form -->
|
||||||
|
<div class="checkout-form-section">
|
||||||
|
<!-- Error Message -->
|
||||||
|
<div *ngIf="error" class="error-message">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
||||||
<div class="checkout-layout">
|
|
||||||
|
<!-- Contact Info Card -->
|
||||||
<!-- LEFT COLUMN: Form -->
|
<app-card class="mb-6">
|
||||||
<div class="checkout-form-section">
|
<div class="card-header-simple">
|
||||||
<!-- Error Message -->
|
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
|
||||||
<div *ngIf="error" class="error-message">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
|
||||||
|
|
||||||
<!-- Contact Info Card -->
|
|
||||||
<app-card class="mb-6">
|
|
||||||
<div class="form-row">
|
|
||||||
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
|
|
||||||
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="user-type-selector">
|
|
||||||
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
|
|
||||||
{{ 'CHECKOUT.PRIVATE' | translate }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
|
<div class="form-row">
|
||||||
{{ 'CHECKOUT.COMPANY' | translate }}
|
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? ('CHECKOUT.INVALID_EMAIL' | translate) : null"></app-input>
|
||||||
|
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</app-card>
|
||||||
|
|
||||||
<div formGroupName="billingAddress">
|
<!-- Billing Address Card -->
|
||||||
<div *ngIf="isCompany" class="company-fields">
|
<app-card class="mb-6">
|
||||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true"></app-input>
|
<div class="card-header-simple">
|
||||||
<div class="form-row no-margin">
|
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
||||||
|
</div>
|
||||||
|
<div formGroupName="billingAddress">
|
||||||
|
|
||||||
|
<!-- Private Person Fields -->
|
||||||
|
<div *ngIf="!isCompany" class="form-row">
|
||||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
||||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div *ngIf="!isCompany" class="form-row no-margin">
|
|
||||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
|
|
||||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<!-- Billing Address Card -->
|
<!-- Company Fields -->
|
||||||
<app-card class="mb-6">
|
<div *ngIf="isCompany" class="company-fields mb-4">
|
||||||
|
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
||||||
|
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Type Selector -->
|
||||||
|
<div class="user-type-selector">
|
||||||
|
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
|
||||||
|
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
|
||||||
|
</div>
|
||||||
|
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
|
||||||
|
{{ 'CONTACT.TYPE_COMPANY' | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
||||||
|
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
||||||
|
|
||||||
|
<div class="form-row three-cols">
|
||||||
|
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
|
||||||
|
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
|
||||||
|
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
|
||||||
|
<!-- Shipping Option -->
|
||||||
|
<div class="shipping-option">
|
||||||
|
<label class="checkbox-container">
|
||||||
|
<input type="checkbox" formControlName="shippingSameAsBilling">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shipping Address Card (Conditional) -->
|
||||||
|
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
|
||||||
|
<div class="card-header-simple">
|
||||||
|
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
||||||
|
</div>
|
||||||
|
<div formGroupName="shippingAddress">
|
||||||
|
<div class="form-row">
|
||||||
|
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
|
||||||
|
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="isCompany" class="company-fields">
|
||||||
|
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"></app-input>
|
||||||
|
<app-input formControlName="referencePerson" [label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
|
||||||
|
|
||||||
|
<div class="form-row three-cols">
|
||||||
|
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
|
||||||
|
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
|
||||||
|
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
|
||||||
|
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Order Summary -->
|
||||||
|
<div class="checkout-summary-section">
|
||||||
|
<app-card class="sticky-card">
|
||||||
<div class="card-header-simple">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div formGroupName="billingAddress">
|
|
||||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
<div class="summary-items" *ngIf="quoteSession() as session">
|
||||||
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
<div class="summary-item" *ngFor="let item of session.items">
|
||||||
|
<div class="item-details">
|
||||||
<div class="form-row three-cols">
|
<span class="item-name">{{ item.originalFilename }}</span>
|
||||||
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
|
<div class="item-specs">
|
||||||
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
|
<span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span>
|
||||||
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
|
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
|
||||||
|
</div>
|
||||||
|
<div class="item-specs-sub">
|
||||||
|
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="item-price">
|
||||||
|
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-totals" *ngIf="quoteSession() as session">
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
|
||||||
|
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
|
||||||
|
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
|
||||||
|
<span>{{ 9.00 | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grand-total">
|
||||||
|
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
||||||
|
<span>{{ (session.grandTotalChf + 9.00) | currency:'CHF' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Shipping Option -->
|
|
||||||
<div class="shipping-option">
|
|
||||||
<label class="checkbox-container">
|
|
||||||
<input type="checkbox" formControlName="shippingSameAsBilling">
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shipping Address Card (Conditional) -->
|
|
||||||
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
|
|
||||||
<div class="card-header-simple">
|
|
||||||
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
|
||||||
</div>
|
|
||||||
<div formGroupName="shippingAddress">
|
|
||||||
<div class="form-row">
|
|
||||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
|
|
||||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate"></app-input>
|
|
||||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
|
|
||||||
|
|
||||||
<div class="form-row three-cols">
|
|
||||||
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
|
|
||||||
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
|
|
||||||
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
|
|
||||||
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Order Summary -->
|
|
||||||
<div class="checkout-summary-section">
|
|
||||||
<app-card class="sticky-card">
|
|
||||||
<div class="card-header-simple">
|
|
||||||
<h3>{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-items" *ngIf="quoteSession() as session">
|
|
||||||
<div class="summary-item" *ngFor="let item of session.items">
|
|
||||||
<div class="item-details">
|
|
||||||
<span class="item-name">{{ item.originalFilename }}</span>
|
|
||||||
<div class="item-specs">
|
|
||||||
<span>Qty: {{ item.quantity }}</span>
|
|
||||||
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
|
|
||||||
</div>
|
|
||||||
<div class="item-specs-sub">
|
|
||||||
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="item-price">
|
|
||||||
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-totals" *ngIf="quoteSession() as session">
|
|
||||||
<div class="total-row">
|
|
||||||
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
|
|
||||||
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="total-row">
|
|
||||||
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
|
|
||||||
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="total-row">
|
|
||||||
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
|
|
||||||
<span>{{ 9.0 | currency:'CHF' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="grand-total-row">
|
|
||||||
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
|
||||||
<span>{{ (session.grandTotalChf + 9.0) | currency:'CHF' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
.hero {
|
.hero {
|
||||||
padding: var(--space-12) 0 var(--space-8);
|
padding: var(--space-8) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
h1 {
|
.section-title {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkout-layout {
|
.checkout-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 400px;
|
grid-template-columns: 1fr 420px;
|
||||||
gap: var(--space-8);
|
gap: var(--space-8);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
margin-bottom: var(--space-12);
|
margin-bottom: var(--space-12);
|
||||||
@@ -77,7 +70,7 @@
|
|||||||
background-color: var(--color-neutral-100);
|
background-color: var(--color-neutral-100);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
margin-bottom: var(--space-4);
|
margin: var(--space-6) 0;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
@@ -195,6 +188,7 @@
|
|||||||
max-height: 450px;
|
max-height: 450px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-right: var(--space-2);
|
padding-right: var(--space-2);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
@@ -260,19 +254,19 @@
|
|||||||
|
|
||||||
.summary-totals {
|
.summary-totals {
|
||||||
background: var(--color-neutral-100);
|
background: var(--color-neutral-100);
|
||||||
padding: var(--space-6);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-6);
|
||||||
|
|
||||||
.total-row {
|
.total-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grand-total-row {
|
.grand-total {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
@@ -303,4 +297,3 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mb-6 { margin-bottom: var(--space-6); }
|
.mb-6 { margin-bottom: var(--space-6); }
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
|||||||
AppCardComponent
|
AppCardComponent
|
||||||
],
|
],
|
||||||
templateUrl: './checkout.component.html',
|
templateUrl: './checkout.component.html',
|
||||||
styleUrl: './checkout.component.scss'
|
styleUrls: ['./checkout.component.scss']
|
||||||
})
|
})
|
||||||
export class CheckoutComponent implements OnInit {
|
export class CheckoutComponent implements OnInit {
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
@@ -47,6 +47,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
firstName: ['', Validators.required],
|
firstName: ['', Validators.required],
|
||||||
lastName: ['', Validators.required],
|
lastName: ['', Validators.required],
|
||||||
companyName: [''],
|
companyName: [''],
|
||||||
|
referencePerson: [''],
|
||||||
addressLine1: ['', Validators.required],
|
addressLine1: ['', Validators.required],
|
||||||
addressLine2: [''],
|
addressLine2: [''],
|
||||||
zip: ['', Validators.required],
|
zip: ['', Validators.required],
|
||||||
@@ -58,6 +59,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
firstName: [''],
|
firstName: [''],
|
||||||
lastName: [''],
|
lastName: [''],
|
||||||
companyName: [''],
|
companyName: [''],
|
||||||
|
referencePerson: [''],
|
||||||
addressLine1: [''],
|
addressLine1: [''],
|
||||||
addressLine2: [''],
|
addressLine2: [''],
|
||||||
zip: [''],
|
zip: [''],
|
||||||
@@ -75,16 +77,27 @@ export class CheckoutComponent implements OnInit {
|
|||||||
const type = isCompany ? 'BUSINESS' : 'PRIVATE';
|
const type = isCompany ? 'BUSINESS' : 'PRIVATE';
|
||||||
this.checkoutForm.patchValue({ customerType: type });
|
this.checkoutForm.patchValue({ customerType: type });
|
||||||
|
|
||||||
// Update validators based on type
|
|
||||||
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
|
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
|
||||||
const companyControl = billingGroup.get('companyName');
|
const companyControl = billingGroup.get('companyName');
|
||||||
|
const referenceControl = billingGroup.get('referencePerson');
|
||||||
|
const firstNameControl = billingGroup.get('firstName');
|
||||||
|
const lastNameControl = billingGroup.get('lastName');
|
||||||
|
|
||||||
if (isCompany) {
|
if (isCompany) {
|
||||||
companyControl?.setValidators([Validators.required]);
|
companyControl?.setValidators([Validators.required]);
|
||||||
|
referenceControl?.setValidators([Validators.required]);
|
||||||
|
firstNameControl?.clearValidators();
|
||||||
|
lastNameControl?.clearValidators();
|
||||||
} else {
|
} else {
|
||||||
companyControl?.clearValidators();
|
companyControl?.clearValidators();
|
||||||
|
referenceControl?.clearValidators();
|
||||||
|
firstNameControl?.setValidators([Validators.required]);
|
||||||
|
lastNameControl?.setValidators([Validators.required]);
|
||||||
}
|
}
|
||||||
companyControl?.updateValueAndValidity();
|
companyControl?.updateValueAndValidity();
|
||||||
|
referenceControl?.updateValueAndValidity();
|
||||||
|
firstNameControl?.updateValueAndValidity();
|
||||||
|
lastNameControl?.updateValueAndValidity();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -151,6 +164,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
firstName: formVal.billingAddress.firstName,
|
firstName: formVal.billingAddress.firstName,
|
||||||
lastName: formVal.billingAddress.lastName,
|
lastName: formVal.billingAddress.lastName,
|
||||||
companyName: formVal.billingAddress.companyName,
|
companyName: formVal.billingAddress.companyName,
|
||||||
|
contactPerson: formVal.billingAddress.referencePerson,
|
||||||
addressLine1: formVal.billingAddress.addressLine1,
|
addressLine1: formVal.billingAddress.addressLine1,
|
||||||
addressLine2: formVal.billingAddress.addressLine2,
|
addressLine2: formVal.billingAddress.addressLine2,
|
||||||
zip: formVal.billingAddress.zip,
|
zip: formVal.billingAddress.zip,
|
||||||
@@ -161,6 +175,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
firstName: formVal.shippingAddress.firstName,
|
firstName: formVal.shippingAddress.firstName,
|
||||||
lastName: formVal.shippingAddress.lastName,
|
lastName: formVal.shippingAddress.lastName,
|
||||||
companyName: formVal.shippingAddress.companyName,
|
companyName: formVal.shippingAddress.companyName,
|
||||||
|
contactPerson: formVal.shippingAddress.referencePerson,
|
||||||
addressLine1: formVal.shippingAddress.addressLine1,
|
addressLine1: formVal.shippingAddress.addressLine1,
|
||||||
addressLine2: formVal.shippingAddress.addressLine2,
|
addressLine2: formVal.shippingAddress.addressLine2,
|
||||||
zip: formVal.shippingAddress.zip,
|
zip: formVal.shippingAddress.zip,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
||||||
<textarea formControlName="message" class="form-control" rows="10"></textarea>
|
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Upload Section -->
|
<!-- File Upload Section -->
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
|
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
|
||||||
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
|
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
|
||||||
<div *ngIf="file.type !== 'image'" class="file-icon">
|
<div *ngIf="file.type !== 'image'" class="file-icon">
|
||||||
<span *ngIf="file.type === 'pdf'">PDF</span>
|
<span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
|
||||||
<span *ngIf="file.type === '3d'">3D</span>
|
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<section class="contact-hero">
|
<section class="contact-hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
||||||
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
|
<p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,18 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero-grid">
|
<div class="container hero-grid">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Stampa 3D tecnica per aziende, freelance e maker</p>
|
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p>
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1>
|
||||||
Prezzo e tempi in pochi secondi.<br>
|
|
||||||
Dal file 3D al pezzo finito.
|
|
||||||
</h1>
|
|
||||||
<p class="hero-lead">
|
<p class="hero-lead">
|
||||||
Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
|
{{ 'HOME.HERO_LEAD' | translate }}
|
||||||
</p>
|
</p>
|
||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">
|
||||||
Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo.
|
{{ 'HOME.HERO_SUBTITLE' | translate }}
|
||||||
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<app-button variant="primary" routerLink="/calculator/basic">Calcola Preventivo</app-button>
|
<app-button variant="primary" routerLink="/calculator/basic">{{ 'HOME.BTN_CALCULATE' | translate }}</app-button>
|
||||||
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
|
<app-button variant="outline" routerLink="/shop">{{ 'HOME.BTN_SHOP' | translate }}</app-button>
|
||||||
<app-button variant="text" routerLink="/contact">Parla con noi</app-button>
|
<app-button variant="text" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,31 +22,31 @@
|
|||||||
<section class="section calculator">
|
<section class="section calculator">
|
||||||
<div class="container calculator-grid">
|
<div class="container calculator-grid">
|
||||||
<div class="calculator-copy">
|
<div class="calculator-copy">
|
||||||
<h2 class="section-title">Preventivo immediato in pochi secondi</h2>
|
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
|
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }}
|
||||||
</p>
|
</p>
|
||||||
<ul class="calculator-list">
|
<ul class="calculator-list">
|
||||||
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
|
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
|
||||||
<li>Qualità: bozza, standard, alta definizione</li>
|
<li>{{ 'HOME.SEC_CALC_LIST_2' | translate }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<app-card class="quote-card">
|
<app-card class="quote-card">
|
||||||
<div class="quote-header">
|
<div class="quote-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="quote-eyebrow">Calcolo automatico</p>
|
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p>
|
||||||
<h3 class="quote-title">Prezzo e tempi in un click</h3>
|
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="quote-tag">Senza registrazione</span>
|
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="quote-steps">
|
<ul class="quote-steps">
|
||||||
<li>Carica il file 3D</li>
|
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li>
|
||||||
<li>Scegli materiale e qualità</li>
|
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li>
|
||||||
<li>Ricevi subito costo e tempo</li>
|
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="quote-actions">
|
<div class="quote-actions">
|
||||||
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">Apri calcolatore</app-button>
|
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">{{ 'HOME.BTN_OPEN_CALC' | translate }}</app-button>
|
||||||
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
|
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,9 +56,9 @@
|
|||||||
<div class="capabilities-bg"></div>
|
<div class="capabilities-bg"></div>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2 class="section-title">Cosa puoi ottenere</h2>
|
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
Produzione su misura per prototipi, piccole serie e pezzi personalizzati.
|
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="cap-cards">
|
<div class="cap-cards">
|
||||||
@@ -70,29 +66,29 @@
|
|||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<!-- <img src="..." alt="..."> -->
|
<!-- <img src="..." alt="..."> -->
|
||||||
</div>
|
</div>
|
||||||
<h3>Prototipazione veloce</h3>
|
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.</p>
|
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<!-- <img src="..." alt="..."> -->
|
<!-- <img src="..." alt="..."> -->
|
||||||
</div>
|
</div>
|
||||||
<h3>Pezzi personalizzati</h3>
|
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
|
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<!-- <img src="..." alt="..."> -->
|
<!-- <img src="..." alt="..."> -->
|
||||||
</div>
|
</div>
|
||||||
<h3>Piccole serie</h3>
|
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.</p>
|
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<!-- <img src="..." alt="..."> -->
|
<!-- <img src="..." alt="..."> -->
|
||||||
</div>
|
</div>
|
||||||
<h3>Consulenza e CAD</h3>
|
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.</p>
|
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,33 +97,32 @@
|
|||||||
<section class="section shop">
|
<section class="section shop">
|
||||||
<div class="container split">
|
<div class="container split">
|
||||||
<div class="shop-copy">
|
<div class="shop-copy">
|
||||||
<h2 class="section-title">Shop di soluzioni tecniche pronte</h2>
|
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2>
|
||||||
<p>
|
<p>
|
||||||
Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con
|
{{ 'HOME.SEC_SHOP_TEXT' | translate }}
|
||||||
funzionalità concrete.
|
|
||||||
</p>
|
</p>
|
||||||
<ul class="shop-list">
|
<ul class="shop-list">
|
||||||
<li>Accessori funzionali per officine e laboratori</li>
|
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li>
|
||||||
<li>Ricambi e componenti difficili da reperire</li>
|
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li>
|
||||||
<li>Supporti e organizzatori per migliorare i flussi di lavoro</li>
|
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="shop-actions">
|
<div class="shop-actions">
|
||||||
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
|
<app-button variant="primary" routerLink="/shop">{{ 'HOME.BTN_DISCOVER' | translate }}</app-button>
|
||||||
<app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
|
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_REQ_SOLUTION' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-cards">
|
<div class="shop-cards">
|
||||||
<app-card>
|
<app-card>
|
||||||
<h3>Best seller tecnici</h3>
|
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Soluzioni provate sul campo e già pronte alla spedizione.</p>
|
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<h3>Kit pronti all'uso</h3>
|
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Componenti compatibili e facili da montare senza sorprese.</p>
|
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<h3>Su richiesta</h3>
|
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3>
|
||||||
<p class="text-muted">Non trovi quello che serve? Lo progettiamo e lo produciamo per te.</p>
|
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,17 +131,16 @@
|
|||||||
<section class="section about">
|
<section class="section about">
|
||||||
<div class="container about-grid">
|
<div class="container about-grid">
|
||||||
<div class="about-copy">
|
<div class="about-copy">
|
||||||
<h2 class="section-title">Su di noi</h2>
|
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2>
|
||||||
<p>
|
<p>
|
||||||
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
|
{{ 'HOME.SEC_ABOUT_TEXT' | translate }}
|
||||||
alla produzione, con tempi chiari e supporto diretto.
|
|
||||||
</p>
|
</p>
|
||||||
<app-button variant="outline" routerLink="/contact">Contattaci</app-button>
|
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-media">
|
<div class="about-media">
|
||||||
<div class="about-feature-image">
|
<div class="about-feature-image">
|
||||||
<!-- Foto founders -->
|
<!-- Foto founders -->
|
||||||
<span class="text-sm">Foto Founders</span>
|
<span class="text-sm">{{ 'HOME.FOUNDERS_PHOTO' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<div class="container hero">
|
||||||
|
<h1>{{ 'ORDER_CONFIRMED.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'ORDER_CONFIRMED.SUBTITLE' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="confirmation-layout" *ngIf="order() as o">
|
||||||
|
<app-card class="status-card">
|
||||||
|
<div class="status-badge">{{ o.status === 'SHIPPED' ? ('TRACKING.STEP_SHIPPED' | translate) : ('ORDER_CONFIRMED.STATUS' | translate) }}</div>
|
||||||
|
<h2>{{ 'ORDER_CONFIRMED.HEADING' | translate }}</h2>
|
||||||
|
<p class="order-ref" *ngIf="orderNumber">
|
||||||
|
{{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="status-timeline">
|
||||||
|
<div class="timeline-step"
|
||||||
|
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
|
||||||
|
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
|
||||||
|
<div class="circle">1</div>
|
||||||
|
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-step"
|
||||||
|
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
|
||||||
|
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
|
||||||
|
<div class="circle">2</div>
|
||||||
|
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-step"
|
||||||
|
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
|
||||||
|
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
|
||||||
|
<div class="circle">3</div>
|
||||||
|
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-step"
|
||||||
|
[class.active]="o.status === 'SHIPPED'"
|
||||||
|
[class.completed]="o.status === 'COMPLETED'">
|
||||||
|
<div class="circle">4</div>
|
||||||
|
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-block">
|
||||||
|
<p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p>
|
||||||
|
<p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button (click)="goHome()">{{ 'ORDER_CONFIRMED.BACK_HOME' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
.hero {
|
||||||
|
padding: var(--space-12) 0 var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-layout {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto var(--space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef8f0;
|
||||||
|
color: #136f2d;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-ref {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-block {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-5);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
p + p {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-timeline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-border);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.circle {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
.circle {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.status-timeline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
top: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 15px;
|
||||||
|
width: 2px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-step {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
|
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-order-confirmed',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TranslateModule, AppButtonComponent, AppCardComponent],
|
||||||
|
templateUrl: './order-confirmed.component.html',
|
||||||
|
styleUrl: './order-confirmed.component.scss'
|
||||||
|
})
|
||||||
|
export class OrderConfirmedComponent implements OnInit {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private quoteService = inject(QuoteEstimatorService);
|
||||||
|
|
||||||
|
orderId: string | null = null;
|
||||||
|
orderNumber: string | null = null;
|
||||||
|
order = signal<any>(null);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
||||||
|
if (!this.orderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.orderNumber = this.extractOrderNumber(this.orderId);
|
||||||
|
this.quoteService.getOrder(this.orderId).subscribe({
|
||||||
|
next: (order) => {
|
||||||
|
this.order.set(order);
|
||||||
|
this.orderNumber = order?.orderNumber ?? this.orderNumber;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// Keep fallback derived from UUID when API is unavailable.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goHome(): void {
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractOrderNumber(orderId: string): string {
|
||||||
|
return orderId.split('-')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,14 @@
|
|||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="payment-layout" *ngIf="order() as o">
|
<div class="payment-layout" *ngIf="order() as o">
|
||||||
|
|
||||||
<div class="payment-main">
|
<div class="payment-main">
|
||||||
|
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
|
||||||
|
<div class="status-content text-center">
|
||||||
|
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
|
||||||
|
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
|
||||||
<app-card class="mb-6">
|
<app-card class="mb-6">
|
||||||
<div class="card-header-simple">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
|
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
|
||||||
@@ -14,36 +20,43 @@
|
|||||||
|
|
||||||
<div class="payment-selection">
|
<div class="payment-selection">
|
||||||
<div class="methods-grid">
|
<div class="methods-grid">
|
||||||
<div
|
<div
|
||||||
class="type-option"
|
class="type-option"
|
||||||
[class.selected]="selectedPaymentMethod === 'twint'"
|
[class.selected]="selectedPaymentMethod === 'twint'"
|
||||||
(click)="selectPayment('twint')">
|
(click)="selectPayment('twint')">
|
||||||
<span class="method-name">TWINT</span>
|
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="type-option"
|
class="type-option"
|
||||||
[class.selected]="selectedPaymentMethod === 'bill'"
|
[class.selected]="selectedPaymentMethod === 'bill'"
|
||||||
(click)="selectPayment('bill')">
|
(click)="selectPayment('bill')">
|
||||||
<span class="method-name">QR Bill / Bank Transfer</span>
|
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TWINT Details -->
|
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
|
||||||
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
|
|
||||||
<div class="details-header">
|
<div class="details-header">
|
||||||
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
|
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="qr-placeholder">
|
<div class="qr-placeholder">
|
||||||
<div class="qr-box">
|
<img
|
||||||
<span>QR CODE</span>
|
*ngIf="twintQrUrl()"
|
||||||
</div>
|
class="twint-qr"
|
||||||
|
[src]="getTwintQrUrl()"
|
||||||
|
(error)="onTwintQrError()"
|
||||||
|
alt="TWINT payment QR" />
|
||||||
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
|
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
|
||||||
|
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
|
||||||
|
<div class="twint-mobile-action">
|
||||||
|
<app-button variant="outline" (click)="openTwintPayment()" [fullWidth]="true">
|
||||||
|
{{ 'PAYMENT.TWINT_OPEN' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
|
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QR Bill Details -->
|
|
||||||
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
|
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
|
||||||
<div class="details-header">
|
<div class="details-header">
|
||||||
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
||||||
@@ -51,8 +64,9 @@
|
|||||||
<div class="bank-details">
|
<div class="bank-details">
|
||||||
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
|
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
|
||||||
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
|
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
|
||||||
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
|
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</p>
|
||||||
|
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
|
||||||
|
|
||||||
<div class="qr-bill-actions">
|
<div class="qr-bill-actions">
|
||||||
<app-button variant="outline" (click)="downloadInvoice()">
|
<app-button variant="outline" (click)="downloadInvoice()">
|
||||||
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
||||||
@@ -62,8 +76,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
|
<app-button
|
||||||
{{ 'PAYMENT.CONFIRM' | translate }}
|
(click)="completeOrder()"
|
||||||
|
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
|
||||||
|
[fullWidth]="true">
|
||||||
|
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
@@ -73,7 +90,7 @@
|
|||||||
<app-card class="sticky-card">
|
<app-card class="sticky-card">
|
||||||
<div class="card-header-simple">
|
<div class="card-header-simple">
|
||||||
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
|
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
|
||||||
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
|
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="summary-totals">
|
<div class="summary-totals">
|
||||||
@@ -96,7 +113,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="loading()" class="loading-state">
|
<div *ngIf="loading()" class="loading-state">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.hero {
|
.hero {
|
||||||
padding: var(--space-12) 0 var(--space-8);
|
padding: var(--space-12) 0 var(--space-8);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment-layout {
|
.payment-layout {
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
padding-bottom: var(--space-4);
|
padding-bottom: var(--space-4);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
border-color: var(--color-brand);
|
border-color: var(--color-brand);
|
||||||
background-color: var(--color-neutral-100);
|
background-color: var(--color-neutral-100);
|
||||||
color: #000;
|
color: #000;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +95,28 @@
|
|||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
&.text-center {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.details-header {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.details-header {
|
.details-header {
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
h4 {
|
h4 {
|
||||||
@@ -105,23 +127,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.qr-placeholder {
|
.qr-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.qr-box {
|
.twint-qr {
|
||||||
width: 180px;
|
width: 240px;
|
||||||
height: 180px;
|
height: 240px;
|
||||||
background-color: white;
|
background-color: #fff;
|
||||||
border: 2px solid var(--color-neutral-900);
|
border: 1px solid var(--color-border);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
object-fit: contain;
|
||||||
|
box-shadow: 0 6px 18px rgba(44, 37, 84, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.twint-mobile-action {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
.amount {
|
||||||
@@ -132,6 +160,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.billing-hint {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.bank-details {
|
.bank-details {
|
||||||
p {
|
p {
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
@@ -183,13 +217,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mb-6 { margin-bottom: var(--space-6); }
|
.mb-6 { margin-bottom: var(--space-6); }
|
||||||
|
|
||||||
.error-message, .loading-state {
|
.error-message,
|
||||||
|
.loading-state {
|
||||||
margin-top: var(--space-12);
|
margin-top: var(--space-12);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
|||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-payment',
|
selector: 'app-payment',
|
||||||
@@ -23,11 +24,14 @@ export class PaymentComponent implements OnInit {
|
|||||||
order = signal<any>(null);
|
order = signal<any>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
twintOpenUrl = signal<string | null>(null);
|
||||||
|
twintQrUrl = signal<string | null>(null);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
||||||
if (this.orderId) {
|
if (this.orderId) {
|
||||||
this.loadOrder();
|
this.loadOrder();
|
||||||
|
this.loadTwintPayment();
|
||||||
} else {
|
} else {
|
||||||
this.error.set('Order ID not found.');
|
this.error.set('Order ID not found.');
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
@@ -54,13 +58,16 @@ export class PaymentComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadInvoice() {
|
downloadInvoice() {
|
||||||
if (!this.orderId) return;
|
const orderId = this.orderId;
|
||||||
this.quoteService.getOrderInvoice(this.orderId).subscribe({
|
if (!orderId) return;
|
||||||
|
this.quoteService.getOrderInvoice(orderId).subscribe({
|
||||||
next: (blob) => {
|
next: (blob) => {
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `invoice-${this.orderId}.pdf`;
|
const fallbackOrderNumber = this.extractOrderNumber(orderId);
|
||||||
|
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
|
||||||
|
a.download = `invoice-${orderNumber}.pdf`;
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
@@ -68,9 +75,76 @@ export class PaymentComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadTwintPayment() {
|
||||||
|
if (!this.orderId) return;
|
||||||
|
this.quoteService.getTwintPayment(this.orderId).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
const qrPath = typeof res.qrImageUrl === 'string' ? `${res.qrImageUrl}?size=360` : null;
|
||||||
|
const qrDataUri = typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
|
||||||
|
this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl));
|
||||||
|
this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath));
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to load TWINT payment details', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openTwintPayment(): void {
|
||||||
|
const openUrl = this.twintOpenUrl();
|
||||||
|
if (typeof window !== 'undefined' && openUrl) {
|
||||||
|
window.open(openUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTwintQrUrl(): string {
|
||||||
|
return this.twintQrUrl() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onTwintQrError(): void {
|
||||||
|
this.twintQrUrl.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveApiUrl(urlOrPath: string | null | undefined): string | null {
|
||||||
|
if (!urlOrPath) return null;
|
||||||
|
if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
|
||||||
|
return urlOrPath;
|
||||||
|
}
|
||||||
|
const base = (environment.apiUrl || '').replace(/\/$/, '');
|
||||||
|
const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`;
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
completeOrder(): void {
|
completeOrder(): void {
|
||||||
// Simulate payment completion
|
if (!this.orderId || !this.selectedPaymentMethod) {
|
||||||
alert('Payment Simulated! Order marked as PAID.');
|
return;
|
||||||
this.router.navigate(['/']);
|
}
|
||||||
|
|
||||||
|
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
|
||||||
|
next: (order) => {
|
||||||
|
this.order.set(order);
|
||||||
|
// The UI will re-render and show the 'REPORTED' state.
|
||||||
|
// We stay on this page to let the user see the "In verifica"
|
||||||
|
// status along with payment instructions.
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
console.error('Failed to report payment', err);
|
||||||
|
this.error.set('Failed to report payment. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayOrderNumber(order: any): string {
|
||||||
|
if (order?.orderNumber) {
|
||||||
|
return order.orderNumber;
|
||||||
|
}
|
||||||
|
if (order?.id) {
|
||||||
|
return this.extractOrderNumber(order.id);
|
||||||
|
}
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractOrderNumber(orderId: string): string {
|
||||||
|
return orderId.split('-')[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p>Prodotto non trovato.</p>
|
<p>{{ 'SHOP.NOT_FOUND' | translate }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,12 +32,14 @@
|
|||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-brand);
|
||||||
color: var(--color-text);
|
border-width: 2px;
|
||||||
|
padding: calc(0.5rem - 1px) calc(1rem - 1px);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
font-weight: 600;
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
border-color: var(--color-brand);
|
background-color: var(--color-brand);
|
||||||
color: var(--color-neutral-900);
|
color: var(--color-neutral-900);
|
||||||
background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
private controls!: OrbitControls;
|
private controls!: OrbitControls;
|
||||||
private animationId: number | null = null;
|
private animationId: number | null = null;
|
||||||
private currentMesh: THREE.Mesh | null = null;
|
private currentMesh: THREE.Mesh | null = null;
|
||||||
|
private autoRotate = true;
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
@@ -38,14 +39,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changes['color'] && this.currentMesh && !changes['file']) {
|
if (changes['color'] && this.currentMesh && !changes['file']) {
|
||||||
// Update existing mesh color if only color changed
|
this.applyColorStyle(this.color);
|
||||||
const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
|
|
||||||
mat.color.set(this.color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (this.animationId) cancelAnimationFrame(this.animationId);
|
if (this.animationId) cancelAnimationFrame(this.animationId);
|
||||||
|
this.clearCurrentMesh();
|
||||||
|
if (this.controls) this.controls.dispose();
|
||||||
if (this.renderer) this.renderer.dispose();
|
if (this.renderer) this.renderer.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,28 +55,51 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
const height = this.rendererContainer.nativeElement.clientHeight;
|
const height = this.rendererContainer.nativeElement.clientHeight;
|
||||||
|
|
||||||
this.scene = new THREE.Scene();
|
this.scene = new THREE.Scene();
|
||||||
this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50
|
this.scene.background = new THREE.Color(0xf4f8fc);
|
||||||
|
|
||||||
// Lights
|
// Lights
|
||||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
|
||||||
this.scene.add(ambientLight);
|
this.scene.add(ambientLight);
|
||||||
|
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95);
|
||||||
directionalLight.position.set(1, 1, 1);
|
hemiLight.position.set(0, 30, 0);
|
||||||
this.scene.add(directionalLight);
|
this.scene.add(hemiLight);
|
||||||
|
|
||||||
|
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.35);
|
||||||
|
directionalLight1.position.set(6, 8, 6);
|
||||||
|
this.scene.add(directionalLight1);
|
||||||
|
|
||||||
|
const directionalLight2 = new THREE.DirectionalLight(0xe8f0ff, 0.85);
|
||||||
|
directionalLight2.position.set(-7, 4, -5);
|
||||||
|
this.scene.add(directionalLight2);
|
||||||
|
|
||||||
|
const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.55);
|
||||||
|
directionalLight3.position.set(0, 5, -9);
|
||||||
|
this.scene.add(directionalLight3);
|
||||||
|
|
||||||
// Camera
|
// Camera
|
||||||
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
|
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
|
||||||
this.camera.position.z = 100;
|
this.camera.position.z = 100;
|
||||||
|
|
||||||
// Renderer
|
// Renderer
|
||||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
this.renderer.toneMappingExposure = 1.2;
|
||||||
this.renderer.setSize(width, height);
|
this.renderer.setSize(width, height);
|
||||||
this.rendererContainer.nativeElement.appendChild(this.renderer.domElement);
|
this.rendererContainer.nativeElement.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||||
this.controls.enableDamping = true;
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.06;
|
||||||
|
this.controls.enablePan = false;
|
||||||
|
this.controls.minDistance = 10;
|
||||||
|
this.controls.maxDistance = 600;
|
||||||
|
this.controls.addEventListener('start', () => {
|
||||||
|
this.autoRotate = false;
|
||||||
|
});
|
||||||
|
|
||||||
this.animate();
|
this.animate();
|
||||||
|
|
||||||
@@ -95,24 +119,27 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
private loadFile(file: File) {
|
private loadFile(file: File) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.autoRotate = true;
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
try {
|
try {
|
||||||
const loader = new STLLoader();
|
const loader = new STLLoader();
|
||||||
const geometry = loader.parse(event.target?.result as ArrayBuffer);
|
const geometry = loader.parse(event.target?.result as ArrayBuffer);
|
||||||
|
|
||||||
if (this.currentMesh) {
|
this.clearCurrentMesh();
|
||||||
this.scene.remove(this.currentMesh);
|
|
||||||
this.currentMesh.geometry.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
geometry.computeVertexNormals();
|
||||||
color: this.color,
|
|
||||||
specular: 0x111111,
|
const material = new THREE.MeshStandardMaterial({
|
||||||
shininess: 200
|
color: this.color,
|
||||||
|
roughness: 0.42,
|
||||||
|
metalness: 0.05,
|
||||||
|
emissive: 0x000000,
|
||||||
|
emissiveIntensity: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
this.currentMesh = new THREE.Mesh(geometry, material);
|
this.currentMesh = new THREE.Mesh(geometry, material);
|
||||||
|
this.applyColorStyle(this.color);
|
||||||
|
|
||||||
// Center geometry
|
// Center geometry
|
||||||
geometry.computeBoundingBox();
|
geometry.computeBoundingBox();
|
||||||
@@ -140,9 +167,10 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
// Calculate distance towards camera (z-axis)
|
// Calculate distance towards camera (z-axis)
|
||||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
||||||
cameraZ *= 1.5; // Tighter zoom (reduced from 2.5)
|
cameraZ *= 1.72;
|
||||||
|
|
||||||
this.camera.position.z = cameraZ;
|
this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
this.camera.updateProjectionMatrix();
|
this.camera.updateProjectionMatrix();
|
||||||
this.controls.update();
|
this.controls.update();
|
||||||
|
|
||||||
@@ -157,9 +185,63 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
|
|
||||||
private animate() {
|
private animate() {
|
||||||
this.animationId = requestAnimationFrame(() => this.animate());
|
this.animationId = requestAnimationFrame(() => this.animate());
|
||||||
|
|
||||||
|
if (this.currentMesh && this.autoRotate) {
|
||||||
|
this.currentMesh.rotation.z += 0.0025;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.controls) this.controls.update();
|
if (this.controls) this.controls.update();
|
||||||
if (this.renderer && this.scene && this.camera) {
|
if (this.renderer && this.scene && this.camera) {
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clearCurrentMesh() {
|
||||||
|
if (!this.currentMesh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scene.remove(this.currentMesh);
|
||||||
|
this.currentMesh.geometry.dispose();
|
||||||
|
|
||||||
|
const meshMaterial = this.currentMesh.material;
|
||||||
|
if (Array.isArray(meshMaterial)) {
|
||||||
|
meshMaterial.forEach((m) => m.dispose());
|
||||||
|
} else {
|
||||||
|
meshMaterial.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentMesh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyColorStyle(color: string) {
|
||||||
|
if (!this.currentMesh) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const darkColor = this.isDarkColor(color);
|
||||||
|
const meshMaterial = this.currentMesh.material;
|
||||||
|
|
||||||
|
if (meshMaterial instanceof THREE.MeshStandardMaterial) {
|
||||||
|
meshMaterial.color.set(color);
|
||||||
|
if (darkColor) {
|
||||||
|
meshMaterial.emissive.set(0x2a2f36);
|
||||||
|
meshMaterial.emissiveIntensity = 0.28;
|
||||||
|
meshMaterial.roughness = 0.5;
|
||||||
|
meshMaterial.metalness = 0.03;
|
||||||
|
} else {
|
||||||
|
meshMaterial.emissive.set(0x000000);
|
||||||
|
meshMaterial.emissiveIntensity = 0;
|
||||||
|
meshMaterial.roughness = 0.42;
|
||||||
|
meshMaterial.metalness = 0.05;
|
||||||
|
}
|
||||||
|
meshMaterial.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDarkColor(color: string): boolean {
|
||||||
|
const c = new THREE.Color(color);
|
||||||
|
const luminance = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
|
||||||
|
return luminance < 0.22;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"CTA_START": "Start Now",
|
"CTA_START": "Start Now",
|
||||||
"BUSINESS": "Business",
|
"BUSINESS": "Business",
|
||||||
"PRIVATE": "Private",
|
"PRIVATE": "Private",
|
||||||
"MODE_EASY": "Easy Print",
|
"MODE_EASY": "Quick",
|
||||||
"MODE_ADVANCED": "Advanced",
|
"MODE_ADVANCED": "Advanced",
|
||||||
"UPLOAD_LABEL": "Drag your 3D file here",
|
"UPLOAD_LABEL": "Drag your 3D file here",
|
||||||
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
|
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
|
||||||
@@ -61,9 +61,6 @@
|
|||||||
"ORDER": "Order Now",
|
"ORDER": "Order Now",
|
||||||
"CONSULT": "Request Consultation",
|
"CONSULT": "Request Consultation",
|
||||||
"ERROR_GENERIC": "An error occurred while calculating the quote.",
|
"ERROR_GENERIC": "An error occurred while calculating the quote.",
|
||||||
"ERROR_UPLOAD_FAILED": "File upload failed. Please try again.",
|
|
||||||
"ERROR_VIRUS_DETECTED": "File removed (virus detected)",
|
|
||||||
"ERROR_SLICING_FAILED": "Slicing error (complex geometry?)",
|
|
||||||
"NEW_QUOTE": "Calculate New Quote",
|
"NEW_QUOTE": "Calculate New Quote",
|
||||||
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
|
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
|
||||||
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
|
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
|
||||||
@@ -158,16 +155,6 @@
|
|||||||
"CONTACT_INFO": "Contact Information",
|
"CONTACT_INFO": "Contact Information",
|
||||||
"BILLING_ADDR": "Billing Address",
|
"BILLING_ADDR": "Billing Address",
|
||||||
"SHIPPING_ADDR": "Shipping Address",
|
"SHIPPING_ADDR": "Shipping Address",
|
||||||
"SHIPPING_SAME": "Shipping address same as billing",
|
|
||||||
"ORDER_SUMMARY": "Order Summary",
|
|
||||||
"SUBTOTAL": "Subtotal",
|
|
||||||
"SETUP_FEE": "Setup Fee",
|
|
||||||
"SHIPPING": "Shipping",
|
|
||||||
"TOTAL": "Total",
|
|
||||||
"PLACE_ORDER": "Place Order",
|
|
||||||
"PROCESSING": "Processing...",
|
|
||||||
"PRIVATE": "Private",
|
|
||||||
"COMPANY": "Company",
|
|
||||||
"FIRST_NAME": "First Name",
|
"FIRST_NAME": "First Name",
|
||||||
"LAST_NAME": "Last Name",
|
"LAST_NAME": "Last Name",
|
||||||
"EMAIL": "Email",
|
"EMAIL": "Email",
|
||||||
@@ -177,17 +164,29 @@
|
|||||||
"ADDRESS_2": "Address Line 2 (Optional)",
|
"ADDRESS_2": "Address Line 2 (Optional)",
|
||||||
"ZIP": "ZIP Code",
|
"ZIP": "ZIP Code",
|
||||||
"CITY": "City",
|
"CITY": "City",
|
||||||
"COUNTRY": "Country"
|
"COUNTRY": "Country",
|
||||||
|
"SHIPPING_SAME": "Shipping address same as billing",
|
||||||
|
"PLACE_ORDER": "Place Order",
|
||||||
|
"PROCESSING": "Processing...",
|
||||||
|
"SUMMARY_TITLE": "Order Summary",
|
||||||
|
"SUBTOTAL": "Subtotal",
|
||||||
|
"SETUP_FEE": "Setup Fee",
|
||||||
|
"TOTAL": "Total",
|
||||||
|
"QTY": "Qty",
|
||||||
|
"SHIPPING": "Shipping"
|
||||||
},
|
},
|
||||||
"PAYMENT": {
|
"PAYMENT": {
|
||||||
"TITLE": "Payment",
|
"TITLE": "Payment",
|
||||||
"METHOD": "Payment Method",
|
"METHOD": "Payment Method",
|
||||||
"TWINT_TITLE": "Pay with TWINT",
|
"TWINT_TITLE": "Pay with TWINT",
|
||||||
"TWINT_DESC": "Scan the code with your TWINT app",
|
"TWINT_DESC": "Scan the code with your TWINT app",
|
||||||
|
"TWINT_OPEN": "Open directly in TWINT",
|
||||||
|
"TWINT_LINK": "Open payment link",
|
||||||
"BANK_TITLE": "Bank Transfer",
|
"BANK_TITLE": "Bank Transfer",
|
||||||
"BANK_OWNER": "Owner",
|
"BANK_OWNER": "Owner",
|
||||||
"BANK_IBAN": "IBAN",
|
"BANK_IBAN": "IBAN",
|
||||||
"BANK_REF": "Reference",
|
"BANK_REF": "Reference",
|
||||||
|
"BILLING_INFO_HINT": "Add the same information used in billing.",
|
||||||
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
||||||
"CONFIRM": "Confirm Order",
|
"CONFIRM": "Confirm Order",
|
||||||
"SUMMARY_TITLE": "Order Summary",
|
"SUMMARY_TITLE": "Order Summary",
|
||||||
@@ -195,6 +194,27 @@
|
|||||||
"SHIPPING": "Shipping",
|
"SHIPPING": "Shipping",
|
||||||
"SETUP_FEE": "Setup Fee",
|
"SETUP_FEE": "Setup Fee",
|
||||||
"TOTAL": "Total",
|
"TOTAL": "Total",
|
||||||
"LOADING": "Loading order details..."
|
"LOADING": "Loading order details...",
|
||||||
|
"METHOD_TWINT": "TWINT",
|
||||||
|
"METHOD_BANK": "Bank Transfer / QR",
|
||||||
|
"STATUS_REPORTED_TITLE": "Payment Reported",
|
||||||
|
"STATUS_REPORTED_DESC": "We are verifying your transaction. Your order will move to production as soon as the payment is confirmed.",
|
||||||
|
"IN_VERIFICATION": "Verifying Payment"
|
||||||
|
},
|
||||||
|
"TRACKING": {
|
||||||
|
"STEP_PENDING": "Pending",
|
||||||
|
"STEP_REPORTED": "Verifying",
|
||||||
|
"STEP_PRODUCTION": "Production",
|
||||||
|
"STEP_SHIPPED": "Shipped"
|
||||||
|
},
|
||||||
|
"ORDER_CONFIRMED": {
|
||||||
|
"TITLE": "Order Confirmed",
|
||||||
|
"SUBTITLE": "Payment received. Your order is now being processed.",
|
||||||
|
"STATUS": "Processing",
|
||||||
|
"HEADING": "We are preparing your order",
|
||||||
|
"ORDER_REF": "Order reference",
|
||||||
|
"PROCESSING_TEXT": "As soon as payment is confirmed, your order will move to production.",
|
||||||
|
"EMAIL_TEXT": "We will send you an email update with status and next steps.",
|
||||||
|
"BACK_HOME": "Back to Home"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,59 @@
|
|||||||
"TERMS": "Termini & Condizioni",
|
"TERMS": "Termini & Condizioni",
|
||||||
"CONTACT": "Contattaci"
|
"CONTACT": "Contattaci"
|
||||||
},
|
},
|
||||||
|
"HOME": {
|
||||||
|
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
|
||||||
|
"HERO_TITLE": "Prezzo e tempi in pochi secondi.<br>Dal file 3D al pezzo finito.",
|
||||||
|
"HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
||||||
|
"HERO_SUBTITLE": "Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo. Se devi ancora crearlo, il nostro team di design lo progetterà per te.",
|
||||||
|
"BTN_CALCULATE": "Calcola Preventivo",
|
||||||
|
"BTN_SHOP": "Vai allo shop",
|
||||||
|
"BTN_CONTACT": "Parla con noi",
|
||||||
|
"SEC_CALC_TITLE": "Preventivo immediato in pochi secondi",
|
||||||
|
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.",
|
||||||
|
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP, OBJ",
|
||||||
|
"SEC_CALC_LIST_2": "Qualità: bozza, standard, alta definizione",
|
||||||
|
"CARD_CALC_EYEBROW": "Calcolo automatico",
|
||||||
|
"CARD_CALC_TITLE": "Prezzo e tempi in un click",
|
||||||
|
"CARD_CALC_TAG": "Senza registrazione",
|
||||||
|
"CARD_CALC_STEP_1": "Carica il file 3D",
|
||||||
|
"CARD_CALC_STEP_2": "Scegli materiale e qualità",
|
||||||
|
"CARD_CALC_STEP_3": "Ricevi subito costo e tempo",
|
||||||
|
"BTN_OPEN_CALC": "Apri calcolatore",
|
||||||
|
"SEC_CAP_TITLE": "Cosa puoi ottenere",
|
||||||
|
"SEC_CAP_SUBTITLE": "Produzione su misura per prototipi, piccole serie e pezzi personalizzati.",
|
||||||
|
"CAP_1_TITLE": "Prototipazione veloce",
|
||||||
|
"CAP_1_TEXT": "Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.",
|
||||||
|
"CAP_2_TITLE": "Pezzi personalizzati",
|
||||||
|
"CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.",
|
||||||
|
"CAP_3_TITLE": "Piccole serie",
|
||||||
|
"CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.",
|
||||||
|
"CAP_4_TITLE": "Consulenza e CAD",
|
||||||
|
"CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.",
|
||||||
|
"SEC_SHOP_TITLE": "Shop di soluzioni tecniche pronte",
|
||||||
|
"SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.",
|
||||||
|
"SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori",
|
||||||
|
"SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire",
|
||||||
|
"SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro",
|
||||||
|
"BTN_DISCOVER": "Scopri i prodotti",
|
||||||
|
"BTN_REQ_SOLUTION": "Richiedi una soluzione",
|
||||||
|
"CARD_SHOP_1_TITLE": "Best seller tecnici",
|
||||||
|
"CARD_SHOP_1_TEXT": "Soluzioni provate sul campo e già pronte alla spedizione.",
|
||||||
|
"CARD_SHOP_2_TITLE": "Kit pronti all'uso",
|
||||||
|
"CARD_SHOP_2_TEXT": "Componenti compatibili e facili da montare senza sorprese.",
|
||||||
|
"CARD_SHOP_3_TITLE": "Su richiesta",
|
||||||
|
"CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.",
|
||||||
|
"SEC_ABOUT_TITLE": "Su di noi",
|
||||||
|
"SEC_ABOUT_TEXT": "3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale alla produzione, con tempi chiari e supporto diretto.",
|
||||||
|
"FOUNDERS_PHOTO": "Foto Founders"
|
||||||
|
},
|
||||||
"CALC": {
|
"CALC": {
|
||||||
"TITLE": "Calcola Preventivo 3D",
|
"TITLE": "Calcola Preventivo 3D",
|
||||||
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP...) e ricevi una stima immediata di costi e tempi di stampa.",
|
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP...) e ricevi una stima immediata di costi e tempi di stampa.",
|
||||||
"CTA_START": "Inizia Ora",
|
"CTA_START": "Inizia Ora",
|
||||||
"BUSINESS": "Aziende",
|
"BUSINESS": "Aziende",
|
||||||
"PRIVATE": "Privati",
|
"PRIVATE": "Privati",
|
||||||
"MODE_EASY": "Stampa Facile",
|
"MODE_EASY": "Base",
|
||||||
"MODE_ADVANCED": "Avanzata",
|
"MODE_ADVANCED": "Avanzata",
|
||||||
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
||||||
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
|
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
|
||||||
@@ -40,9 +86,6 @@
|
|||||||
"ORDER": "Ordina Ora",
|
"ORDER": "Ordina Ora",
|
||||||
"CONSULT": "Richiedi Consulenza",
|
"CONSULT": "Richiedi Consulenza",
|
||||||
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
|
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
|
||||||
"ERROR_UPLOAD_FAILED": "Caricamento file fallito. Riprova.",
|
|
||||||
"ERROR_VIRUS_DETECTED": "File rimosso (virus rilevato)",
|
|
||||||
"ERROR_SLICING_FAILED": "Errore slicing (geometria complessa?)",
|
|
||||||
"NEW_QUOTE": "Calcola Nuovo Preventivo",
|
"NEW_QUOTE": "Calcola Nuovo Preventivo",
|
||||||
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
|
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
|
||||||
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
|
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
|
||||||
@@ -50,13 +93,53 @@
|
|||||||
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
|
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
|
||||||
"BENEFITS_2": "Materiali selezionati e qualità controllata",
|
"BENEFITS_2": "Materiali selezionati e qualità controllata",
|
||||||
"BENEFITS_3": "Consulenza CAD se il file ha bisogno di modifiche",
|
"BENEFITS_3": "Consulenza CAD se il file ha bisogno di modifiche",
|
||||||
"ERR_FILE_REQUIRED": "Il file è obbligatorio."
|
"ERR_FILE_REQUIRED": "Il file è obbligatorio.",
|
||||||
|
"ANALYZING_TITLE": "Analisi in corso...",
|
||||||
|
"ANALYZING_TEXT": "Stiamo analizzando la geometria e calcolando il percorso utensile.",
|
||||||
|
"QTY_SHORT": "QTÀ",
|
||||||
|
"COLOR_LABEL": "COLORE",
|
||||||
|
"ADD_FILES": "Aggiungi file",
|
||||||
|
"UPLOADING": "Caricamento...",
|
||||||
|
"PROCESSING": "Elaborazione...",
|
||||||
|
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
|
||||||
|
"SETUP_NOTE": "* Include {{cost}} Costo di Setup"
|
||||||
|
},
|
||||||
|
"QUOTE": {
|
||||||
|
"PROCEED_ORDER": "Procedi con l'ordine",
|
||||||
|
"CONSULT": "Richiedi Consulenza",
|
||||||
|
"TOTAL": "Totale"
|
||||||
|
},
|
||||||
|
"USER_DETAILS": {
|
||||||
|
"TITLE": "I tuoi dati",
|
||||||
|
"NAME": "Nome",
|
||||||
|
"NAME_PLACEHOLDER": "Il tuo nome",
|
||||||
|
"SURNAME": "Cognome",
|
||||||
|
"SURNAME_PLACEHOLDER": "Il tuo cognome",
|
||||||
|
"EMAIL": "Email",
|
||||||
|
"EMAIL_PLACEHOLDER": "tua@email.com",
|
||||||
|
"PHONE": "Telefono",
|
||||||
|
"PHONE_PLACEHOLDER": "+41 ...",
|
||||||
|
"ADDRESS": "Indirizzo",
|
||||||
|
"ADDRESS_PLACEHOLDER": "Via e numero",
|
||||||
|
"ZIP": "CAP",
|
||||||
|
"ZIP_PLACEHOLDER": "0000",
|
||||||
|
"CITY": "Città",
|
||||||
|
"CITY_PLACEHOLDER": "Città",
|
||||||
|
"SUBMIT": "Procedi",
|
||||||
|
"SUMMARY_TITLE": "Riepilogo"
|
||||||
|
},
|
||||||
|
"COMMON": {
|
||||||
|
"REQUIRED": "Campo obbligatorio",
|
||||||
|
"INVALID_EMAIL": "Email non valida",
|
||||||
|
"BACK": "Indietro",
|
||||||
|
"OPTIONAL": "(Opzionale)"
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Soluzioni tecniche",
|
"TITLE": "Soluzioni tecniche",
|
||||||
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
|
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
|
||||||
"ADD_CART": "Aggiungi al Carrello",
|
"ADD_CART": "Aggiungi al Carrello",
|
||||||
"BACK": "Torna allo Shop"
|
"BACK": "Torna allo Shop",
|
||||||
|
"NOT_FOUND": "Prodotto non trovato."
|
||||||
},
|
},
|
||||||
"ABOUT": {
|
"ABOUT": {
|
||||||
"TITLE": "Chi Siamo",
|
"TITLE": "Chi Siamo",
|
||||||
@@ -129,7 +212,10 @@
|
|||||||
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
|
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
|
||||||
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
|
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
|
||||||
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
|
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
|
||||||
"SEND_ANOTHER": "Invia un altro messaggio"
|
"SEND_ANOTHER": "Invia un altro messaggio",
|
||||||
|
"HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.",
|
||||||
|
"FILE_TYPE_PDF": "PDF",
|
||||||
|
"FILE_TYPE_3D": "3D"
|
||||||
},
|
},
|
||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
@@ -137,36 +223,41 @@
|
|||||||
"CONTACT_INFO": "Informazioni di Contatto",
|
"CONTACT_INFO": "Informazioni di Contatto",
|
||||||
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
||||||
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
||||||
"SHIPPING_SAME": "Indirizzo di spedizione uguale a quello di fatturazione",
|
|
||||||
"ORDER_SUMMARY": "Riepilogo Ordine",
|
|
||||||
"SUBTOTAL": "Subtotale",
|
|
||||||
"SETUP_FEE": "Costo Setup",
|
|
||||||
"SHIPPING": "Spedizione",
|
|
||||||
"TOTAL": "Totale",
|
|
||||||
"PLACE_ORDER": "Conferma Ordine",
|
|
||||||
"PROCESSING": "Elaborazione...",
|
|
||||||
"PRIVATE": "Privato",
|
|
||||||
"COMPANY": "Azienda",
|
|
||||||
"FIRST_NAME": "Nome",
|
"FIRST_NAME": "Nome",
|
||||||
"LAST_NAME": "Cognome",
|
"LAST_NAME": "Cognome",
|
||||||
"EMAIL": "Email",
|
"EMAIL": "Email",
|
||||||
"PHONE": "Telefono",
|
"PHONE": "Telefono",
|
||||||
"COMPANY_NAME": "Nome Azienda",
|
"COMPANY_NAME": "Nome Azienda",
|
||||||
"ADDRESS_1": "Indirizzo riga 1",
|
"ADDRESS_1": "Indirizzo (Via e numero)",
|
||||||
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
|
"ADDRESS_2": "Informazioni aggiuntive (opzionale)",
|
||||||
"ZIP": "CAP",
|
"ZIP": "CAP",
|
||||||
"CITY": "Città",
|
"CITY": "Città",
|
||||||
"COUNTRY": "Paese"
|
"COUNTRY": "Paese",
|
||||||
|
"SHIPPING_SAME": "L'indirizzo di spedizione è lo stesso di quello di fatturazione",
|
||||||
|
"PLACE_ORDER": "Invia Ordine",
|
||||||
|
"PROCESSING": "Elaborazione...",
|
||||||
|
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||||
|
"SUBTOTAL": "Subtotale",
|
||||||
|
"SETUP_FEE": "Costo di Avvio",
|
||||||
|
"TOTAL": "Totale",
|
||||||
|
"QTY": "Qtà",
|
||||||
|
"SHIPPING": "Spedizione",
|
||||||
|
"INVALID_EMAIL": "Email non valida",
|
||||||
|
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
|
||||||
|
"REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)"
|
||||||
},
|
},
|
||||||
"PAYMENT": {
|
"PAYMENT": {
|
||||||
"TITLE": "Pagamento",
|
"TITLE": "Pagamento",
|
||||||
"METHOD": "Metodo di Pagamento",
|
"METHOD": "Metodo di Pagamento",
|
||||||
"TWINT_TITLE": "Paga con TWINT",
|
"TWINT_TITLE": "Paga con TWINT",
|
||||||
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
|
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
|
||||||
|
"TWINT_OPEN": "Apri direttamente in TWINT",
|
||||||
|
"TWINT_LINK": "Apri link di pagamento",
|
||||||
"BANK_TITLE": "Bonifico Bancario",
|
"BANK_TITLE": "Bonifico Bancario",
|
||||||
"BANK_OWNER": "Titolare",
|
"BANK_OWNER": "Titolare",
|
||||||
"BANK_IBAN": "IBAN",
|
"BANK_IBAN": "IBAN",
|
||||||
"BANK_REF": "Riferimento",
|
"BANK_REF": "Riferimento",
|
||||||
|
"BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.",
|
||||||
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
||||||
"CONFIRM": "Conferma Ordine",
|
"CONFIRM": "Conferma Ordine",
|
||||||
"SUMMARY_TITLE": "Riepilogo Ordine",
|
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||||
@@ -174,6 +265,27 @@
|
|||||||
"SHIPPING": "Spedizione",
|
"SHIPPING": "Spedizione",
|
||||||
"SETUP_FEE": "Costo Setup",
|
"SETUP_FEE": "Costo Setup",
|
||||||
"TOTAL": "Totale",
|
"TOTAL": "Totale",
|
||||||
"LOADING": "Caricamento dettagli ordine..."
|
"LOADING": "Caricamento dettagli ordine...",
|
||||||
|
"METHOD_TWINT": "TWINT",
|
||||||
|
"METHOD_BANK": "Fattura QR / Bonifico",
|
||||||
|
"STATUS_REPORTED_TITLE": "Abbiamo ricevuto la tua segnalazione",
|
||||||
|
"STATUS_REPORTED_DESC": "Stiamo verificando la transazione. Il tuo ordine passerà in produzione non appena l'accredito sarà confermato.",
|
||||||
|
"IN_VERIFICATION": "Pagamento in verifica"
|
||||||
|
},
|
||||||
|
"TRACKING": {
|
||||||
|
"STEP_PENDING": "In attesa",
|
||||||
|
"STEP_REPORTED": "In verifica",
|
||||||
|
"STEP_PRODUCTION": "In Produzione",
|
||||||
|
"STEP_SHIPPED": "Spedito"
|
||||||
|
},
|
||||||
|
"ORDER_CONFIRMED": {
|
||||||
|
"TITLE": "Ordine Confermato",
|
||||||
|
"SUBTITLE": "Pagamento registrato. Il tuo ordine è ora in elaborazione.",
|
||||||
|
"STATUS": "In elaborazione",
|
||||||
|
"HEADING": "Stiamo preparando il tuo ordine",
|
||||||
|
"ORDER_REF": "Riferimento ordine",
|
||||||
|
"PROCESSING_TEXT": "Non appena confermiamo il pagamento, il tuo ordine passerà in produzione.",
|
||||||
|
"EMAIL_TEXT": "Ti invieremo una email con aggiornamento stato e prossimi step.",
|
||||||
|
"BACK_HOME": "Torna alla Home"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user