132 Commits

Author SHA1 Message Date
72c0c2c098 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-06 15:14:04 +01:00
b517373538 feat(back-end and front-end): calculator improvements 2026-03-06 15:13:34 +01:00
printcalc-ci
575a540a70 style: apply prettier formatting 2026-03-05 20:56:22 +00:00
042c254691 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 14s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m7s
# Conflicts:
#	frontend/src/app/features/checkout/checkout.component.html
2026-03-05 21:46:23 +01:00
7a699d2adf feat(back-end and front-end): calculator improvements 2026-03-05 21:46:11 +01:00
printcalc-ci
811e0f441b style: apply prettier formatting 2026-03-05 17:31:15 +00:00
235fe7780d feat(back-end and front-end): calculator improvements
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 18:30:37 +01:00
93b0b55f43 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options
All checks were successful
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 32s
2026-03-05 18:30:11 +01:00
cdd0d22d9a Merge branch 'dev' into feat/calculator-options
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 17:43:57 +01:00
0a3510e996 Merge remote-tracking branch 'origin/feat/calculator-options' into feat/calculator-options 2026-03-05 17:29:11 +01:00
819ac01d44 Merge branch 'dev' into feat/calculator-options 2026-03-05 17:28:57 +01:00
printcalc-ci
aa6322e928 style: apply prettier formatting 2026-03-05 16:28:39 +00:00
a7491130fb chore(back-end and front-end): refractor and improvements calculator
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 17:28:07 +01:00
8e23bd97e6 fix(tutto rotto): dai che si fixa
Some checks failed
PR Checks / prettier-autofix (pull_request) Failing after 7s
PR Checks / test-backend (pull_request) Failing after 21s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Failing after 55s
2026-03-05 17:07:25 +01:00
71424f086e Merge branch 'fix/twint' into feat/calculator-options
# Conflicts:
#	backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java
2026-03-05 17:05:45 +01:00
b2edf5ec4c fix(tutto rotto): dai che si fixa 2026-03-05 17:05:15 +01:00
8c61990827 fix(tutto rotto): 2026-03-05 16:46:24 +01:00
54b50028b1 fix(back-end): path solver
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m1s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-backend (pull_request) Failing after 22s
PR Checks / security-sast (pull_request) Successful in 30s
2026-03-05 16:37:54 +01:00
9facf05c10 fix(back-end): twint url
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-05 15:44:03 +01:00
fe3951b6c3 feat(front-end): calculator improvements 2026-03-05 15:43:37 +01:00
1effd4926f Merge pull request 'feat/calculator-options' (#23) from feat/calculator-options into dev
All checks were successful
Build and Deploy / build-and-push (push) Successful in 45s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #23
2026-03-05 15:14:08 +01:00
printcalc-ci
d061f21d79 style: apply prettier formatting 2026-03-05 14:07:58 +00:00
266fab5e17 feat(front-end): alt improvements
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 15s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-05 15:07:05 +01:00
a4b85b01bd feat(front-end): calculator improvements 2026-03-05 15:02:26 +01:00
30e28cb019 feat(front-end): seo 2026-03-05 15:01:56 +01:00
1a36808d9f feat(front-end and back-end): new nozle option, also fix quantity reload and reorganized service in back-end 2026-03-05 15:01:40 +01:00
8a57aa78fb Merge pull request 'dev' (#22) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #22
2026-03-05 09:31:09 +01:00
de9e473cca fix(front-end): button calculator
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / deploy (push) Successful in 11s
2026-03-05 08:32:06 +01:00
a7f58175fa Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
2026-03-05 08:32:01 +01:00
460b878fbb fix(front-end): button calculator 2026-03-05 08:31:55 +01:00
4a8925df13 Merge pull request 'feat(back-end and front-end) 3d visualization for cad' (#21) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #21
2026-03-04 16:57:49 +01:00
db3619e889 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 26s
Build and Deploy / deploy (push) Successful in 13s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-04 16:54:25 +01:00
printcalc-ci
5e5a3949d4 style: apply prettier formatting 2026-03-04 15:53:21 +00:00
0ef97eeb9b feat(back-end and front-end) 3d visualization for cad
All checks were successful
Build and Deploy / test-backend (push) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 43s
Build and Deploy / deploy (push) Successful in 9s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-04 16:49:18 +01:00
6149e4ac43 Merge pull request 'dev' (#20) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 23s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #20
2026-03-04 15:33:02 +01:00
printcalc-ci
57360bacd0 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-04 14:21:33 +00:00
db3708aef6 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 15s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 12s
PR Checks / test-frontend (pull_request) Successful in 1m4s
2026-03-04 15:19:40 +01:00
2050ff35f4 feat(back-end and front-end) email
Some checks failed
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 44s
Build and Deploy / deploy (push) Successful in 9s
PR Checks / prettier-autofix (pull_request) Failing after 10s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-04 15:12:28 +01:00
038e79e52a Merge pull request 'dev' (#18) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #18
2026-03-04 15:03:12 +01:00
6f47d02813 feat(back-end and front-end) email
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 35s
Build and Deploy / build-and-push (push) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 12s
2026-03-04 14:55:51 +01:00
3916f3ace6 feat(back-end and front-end) email for request
All checks were successful
Build and Deploy / test-backend (push) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 37s
Build and Deploy / deploy (push) Successful in 9s
2026-03-04 14:45:09 +01:00
df3fecf722 Merge pull request 'dev' (#17) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #17
2026-03-04 13:54:16 +01:00
2c4fa570e1 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
PR Checks / prettier-autofix (pull_request) Successful in 11s
Build and Deploy / test-frontend (push) Successful in 1m10s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 10s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-04 13:50:55 +01:00
ab2229ec8b Merge pull request 'feat/cad-bill' (#16) from feat/cad-bill into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 44s
Build and Deploy / deploy (push) Successful in 9s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m7s
Reviewed-on: #16
2026-03-04 12:45:58 +01:00
cc36c0a18b Merge remote-tracking branch 'origin/feat/cad-bill' into feat/cad-bill
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-04 12:25:43 +01:00
47e22c5a61 feat(back-end and front-end) email for request 2026-03-04 12:25:23 +01:00
c2161ef1fc Merge branch 'dev' into feat/cad-bill
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-04 12:16:52 +01:00
printcalc-ci
8f6e74cf02 style: apply prettier formatting 2026-03-04 11:15:52 +00:00
767b65008b feat(back-end and front-end) email for request
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 18s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m8s
2026-03-04 12:15:23 +01:00
1b3f0b16ff feat(back-end and front-end) cad bill with order 2026-03-04 12:03:09 +01:00
d9931a6fae Merge pull request 'dev' (#15) from dev into main
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #15
2026-03-04 11:02:43 +01:00
179be37a36 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 12s
2026-03-04 11:00:18 +01:00
printcalc-ci
412f3ae71b style: apply prettier formatting 2026-03-04 09:59:05 +00:00
0f2f2bc7a9 fix(back-end): 3mf preview
All checks were successful
Build and Deploy / test-backend (push) Successful in 24s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 23s
Build and Deploy / deploy (push) Successful in 8s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-04 10:26:40 +01:00
685cd704e7 fix(back-end): 3mf preview
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 44s
Build and Deploy / deploy (push) Successful in 8s
2026-03-04 10:23:25 +01:00
09179ce825 fix(back-end): fix 3mf calculator
All checks were successful
Build and Deploy / test-backend (push) Successful in 42s
Build and Deploy / test-frontend (push) Successful in 1m13s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 8s
2026-03-04 09:59:25 +01:00
27d0399263 fix(back-end): fix 3mf calculator
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 1m32s
Build and Deploy / deploy (push) Successful in 11s
2026-03-04 09:52:09 +01:00
0f57034b52 fix(back-end): fix 3mf calculator
Some checks failed
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 18s
Build and Deploy / deploy (push) Has been skipped
2026-03-04 09:49:03 +01:00
db748fb649 fix(back-end): fix 3mf calculator
Some checks failed
Build and Deploy / test-backend (push) Successful in 24s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Failing after 14s
Build and Deploy / deploy (push) Has been skipped
2026-03-04 09:24:21 +01:00
6eb0629136 fix(back-end): fix 3mf calculator
Some checks failed
Build and Deploy / test-backend (push) Successful in 55s
Build and Deploy / test-frontend (push) Successful in 1m10s
Build and Deploy / build-and-push (push) Failing after 14s
Build and Deploy / deploy (push) Has been skipped
2026-03-04 09:21:07 +01:00
8bd4ea54b2 fix(back-end): fix 3mf calculator
Some checks failed
Build and Deploy / test-backend (push) Successful in 24s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Failing after 55s
Build and Deploy / deploy (push) Has been skipped
2026-03-03 18:48:59 +01:00
d951212576 Merge pull request 'dev' (#13) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #13
2026-03-03 18:28:30 +01:00
e23bca0734 fix(back-end): fix security issue
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 32s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 37s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / deploy (push) Successful in 13s
2026-03-03 18:26:03 +01:00
f5cdaf51cb fix(back-end): fix security issue
Some checks failed
Build and Deploy / test-backend (push) Successful in 25s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Failing after 29s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 37s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 12s
2026-03-03 18:19:15 +01:00
476dc5b2ce fix(back-end): add extended support for 3MF conversion
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m7s
Build and Deploy / test-backend (push) Successful in 35s
PR Checks / prettier-autofix (pull_request) Successful in 11s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / security-sast (pull_request) Failing after 30s
PR Checks / test-backend (pull_request) Successful in 24s
Build and Deploy / build-and-push (push) Successful in 1m31s
Build and Deploy / deploy (push) Successful in 11s
2026-03-03 18:13:15 +01:00
548b23317f fix(chore): translation 2026-03-03 17:20:36 +01:00
9d40e74baf Merge pull request 'fix(deploy): new test' (#14) from prova into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 32s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 13s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Reviewed-on: #14
2026-03-03 13:56:30 +01:00
b39fa0b693 Merge branch 'dev' into prova
All checks were successful
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-03 13:41:38 +01:00
printcalc-ci
f3ea2be8b0 style: apply prettier formatting 2026-03-03 12:41:15 +00:00
c66903d22e fix(deploy): new test
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-03 13:39:50 +01:00
4bc815b04d Merge pull request 'fix(deploy): prova workflow' (#12) from prova into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 42s
Build and Deploy / deploy (push) Successful in 10s
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
Reviewed-on: #12
2026-03-03 13:24:02 +01:00
127a321621 fix(deploy): fix security
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 25s
2026-03-03 13:22:45 +01:00
27af5f7ebb fix(deploy): prova workflow
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Failing after 27s
PR Checks / test-backend (pull_request) Successful in 38s
2026-03-03 13:02:24 +01:00
edef17d0ad Merge remote-tracking branch 'origin/prova' into prova
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Failing after 21s
PR Checks / test-backend (pull_request) Successful in 37s
# Conflicts:
#	.gitea/workflows/pr-checks.yaml
2026-03-03 13:00:28 +01:00
76f648e82a fix(deploy): prova workflow 2026-03-03 13:00:14 +01:00
printcalc-ci
b68d702a3d style: apply prettier formatting 2026-03-03 11:58:34 +00:00
173a6b70d2 fix(deploy): prova workflow
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Failing after 29s
PR Checks / test-backend (pull_request) Successful in 57s
2026-03-03 12:58:21 +01:00
c08add9b1e Merge remote-tracking branch 'origin/prova' into prova
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / qodana (pull_request) Failing after 8s
PR Checks / test-backend (pull_request) Successful in 39s
2026-03-03 12:55:20 +01:00
3ea8c1b2ad fix(deploy): prova workflow 2026-03-03 12:55:09 +01:00
printcalc-ci
20293cc044 style: apply prettier formatting 2026-03-03 11:46:26 +00:00
dd6f723271 fix(deploy): prova workflow
Some checks failed
PR Checks / qodana (pull_request) Failing after 32s
PR Checks / prettier-autofix (pull_request) Successful in 39s
PR Checks / test-backend (pull_request) Successful in 38s
2026-03-03 12:45:23 +01:00
c28d22ccdb fix(deploy): new worlfkow gitea
All checks were successful
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
2026-03-03 12:43:20 +01:00
3abe90d8f3 fix(deploy): new worlfkow gitea 2026-03-03 12:42:23 +01:00
04cbf00a2d Merge branch 'main' into dev
All checks were successful
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 18s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / deploy (push) Successful in 10s
Build, Test, Deploy and Analysis / qodana (push) Has been skipped
# Conflicts:
#	.gitea/workflows/cicd.yaml
2026-03-03 12:35:11 +01:00
be8e523574 fix(deploy): cicd.yaml
Some checks failed
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 12s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been skipped
Build, Test, Deploy and Analysis / deploy (pull_request) Has been skipped
Build, Test, Deploy and Analysis / test-backend (pull_request) Successful in 39s
Build, Test, Deploy and Analysis / qodana (push) Has been skipped
Build, Test, Deploy and Analysis / test-backend (push) Has been skipped
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 17s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
2026-03-03 12:33:18 +01:00
c680486157 fix(front-end): quantity changes and normalization
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 12s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 37s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 25s
Build, Test, Deploy and Analysis / deploy (push) Successful in 10s
2026-03-03 12:05:00 +01:00
90bdb5384d fix(front-end): color selector
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 17s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 41s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 23s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 11:49:40 +01:00
04ecda99ee fix(chore):
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 13s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 34s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 11:16:40 +01:00
2d1a783e9c fix(chore): prepare Qodana dirs
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 56s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 51s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 17s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
2026-03-03 10:30:43 +01:00
ef2be1a3be Merge pull request 'dev' (#10) from dev into main
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 39s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 15s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
Reviewed-on: #10
2026-03-03 10:26:24 +01:00
5c27d4d16b feat(back-end): pla tought
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 37s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 41s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 10:24:21 +01:00
654aa775db fix(back-end): implementation of security better
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 12s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 37s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 41s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 10:11:12 +01:00
9307ba6fba Merge pull request 'produzione 1' (#9) from dev into main
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 14s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / deploy (push) Successful in 7s
Reviewed-on: #9
2026-03-03 09:58:03 +01:00
c0d6b480c1 fix(back-end):
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 16s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 09:57:39 +01:00
fdb1bcb282 fix(back-end): noozle diameter
Some checks failed
Build, Test, Deploy and Analysis / test-backend (push) Successful in 54s
Build, Test, Deploy and Analysis / qodana (push) Failing after 1m12s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 37s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 11s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been cancelled
Build, Test, Deploy and Analysis / deploy (pull_request) Has been cancelled
Build, Test, Deploy and Analysis / test-backend (pull_request) Has been cancelled
2026-03-03 09:44:35 +01:00
09a4ac064f fix(back-end) security issue
Some checks failed
Build, Test, Deploy and Analysis / test-backend (pull_request) Successful in 39s
Build, Test, Deploy and Analysis / build-and-push (push) Failing after 34s
Build, Test, Deploy and Analysis / deploy (pull_request) Successful in 8s
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 11s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 40s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Successful in 20s
Build, Test, Deploy and Analysis / deploy (push) Has been skipped
2026-03-03 09:30:22 +01:00
c00ca5a32e feat(chore): added qodana analysis job
Some checks failed
Build, Test, Deploy and Analysis / test-backend (pull_request) Failing after 0s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been skipped
Build, Test, Deploy and Analysis / deploy (pull_request) Has been skipped
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 0s
Build, Test, Deploy and Analysis / qodana (push) Failing after 32s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 1m30s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 42s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
2026-03-03 09:19:05 +01:00
9955f23f31 feat(chore): added qodana analysis job
Some checks failed
Build, Test, Deploy and Analysis / qodana (pull_request) Waiting to run
Build, Test, Deploy and Analysis / qodana (push) Failing after 5m7s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 40s
Build, Test, Deploy and Analysis / test-backend (pull_request) Failing after 0s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been skipped
Build, Test, Deploy and Analysis / deploy (pull_request) Has been skipped
Build, Test, Deploy and Analysis / build-and-push (push) Failing after 0s
Build, Test, Deploy and Analysis / deploy (push) Has been skipped
2026-03-03 09:04:12 +01:00
25afb355b4 feat(front-end): make responsive back-office 2026-03-03 08:43:13 +01:00
b7c399e3cb feat(back-end): new stock db and back-office improvements 2026-03-02 20:19:19 +01:00
02e58ea00f fix(home): reordered calculator and capabilities sections
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 56s
Build, Test and Deploy / build-and-push (push) Successful in 25s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-03-02 19:44:55 +01:00
819e00e067 feat(back-end): fix order page
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 47s
Build, Test and Deploy / build-and-push (push) Successful in 25s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-27 20:22:18 +01:00
ed76b13e4c feat(back-end and front-end): back-office pazzo
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 44s
Build, Test and Deploy / build-and-push (push) Successful in 46s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 15:46:41 +01:00
47553ebb82 feat(back-end and front-end): back-office test 2026-02-27 15:07:39 +01:00
949770a741 feat(back-end and front-end): back-office 2026-02-27 15:07:32 +01:00
65e1ee3be6 feat(back-end and front-end): back-office
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 1m4s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 12:51:48 +01:00
3f938db257 feat(back-end and front-end): back-office
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 38s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-27 12:44:06 +01:00
1598f35c08 feat(back-end): email improvements
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 34s
Build, Test and Deploy / deploy (push) Has been skipped
Build, Test and Deploy / build-and-push (push) Has been skipped
2026-02-27 12:09:50 +01:00
8e9afbf260 feat(back-end): email improvements
Some checks failed
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
Build, Test and Deploy / test-backend (push) Has been cancelled
2026-02-27 12:09:37 +01:00
6e52988cdd feat(front-end): delay on update
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 36s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-27 11:50:17 +01:00
2e701d5597 feat(front-end): traslation de and fr4
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 11:37:34 +01:00
150faf3b5a feat(front-end): traslation de and fr
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 11:32:14 +01:00
64cd90eabc feat(front-end): traslation en 2026-02-27 11:10:45 +01:00
877171ceb1 feat(front-end): traslation in italian 2026-02-27 11:05:20 +01:00
521009de7c feat(front-end): gestione quantità massima e price for piece 2026-02-27 10:59:02 +01:00
8028e9c7b7 feat(back-end): model dimension 2026-02-27 10:43:59 +01:00
a85c57032d fix(front-end): calculator improvements 2026-02-27 10:43:06 +01:00
219b4e127d fix(front-end): calculator improvements in base quality settings
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 11s
2026-02-27 10:22:32 +01:00
f7ddab0e93 fix(front-end): red write and last commit for today
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-26 22:30:34 +01:00
80a41b0cc2 feat(frontend e back-end): termini e condizioni
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 38s
Build, Test and Deploy / build-and-push (push) Successful in 1m3s
Build, Test and Deploy / deploy (push) Successful in 11s
2026-02-26 22:24:51 +01:00
6f3e601f21 feat(frontend): about improvments
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-26 21:42:12 +01:00
e82862821e feat(frontend): fav icon e upload multiple files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-26 19:41:40 +01:00
b6230e69e4 feat(frontend): update images, and desing home
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 42s
Build, Test and Deploy / build-and-push (push) Successful in 42s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-26 19:09:59 +01:00
c58d674a70 feat(back-end - front end): improvements in email and invoice rendering
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 45s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-25 16:35:58 +01:00
fecb394272 fix(back-end) calculator improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 41s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-25 15:05:23 +01:00
54d12f4da0 fix(front-end): improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 41s
Build, Test and Deploy / build-and-push (push) Successful in 49s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-25 10:53:08 +01:00
4ddd33662d fix(front-end): 3d view only for stl
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-24 13:12:29 +01:00
a6eae757c5 feat(front-end): upload only supported file and add step warning message
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 40s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-24 13:06:16 +01:00
6463fac211 fix(back-end): email fix
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-24 10:47:13 +01:00
57f6301e03 feat(back-end front-end): integration with twint
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 43s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-24 10:07:04 +01:00
699a968875 feat(back-end front-end): upgrade to the order componen instead of payment and order-confirmed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 39s
Build, Test and Deploy / build-and-push (push) Successful in 1m3s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-24 08:44:42 +01:00
c1652798b4 feat(back-end front-end): new UX for
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-23 18:56:24 +01:00
300 changed files with 29216 additions and 4560 deletions

View File

@@ -1,11 +1,11 @@
name: Build, Test and Deploy name: Build and Deploy
on: on:
push: push:
branches: [main, int, dev] branches: [main, int, dev]
concurrency: concurrency:
group: print-calculator-${{ gitea.ref }} group: print-calculator-deploy-${{ gitea.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@@ -18,8 +18,9 @@ jobs:
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: '21' java-version: "21"
distribution: 'temurin' distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle - name: Run Tests with Gradle
run: | run: |
@@ -27,8 +28,55 @@ jobs:
chmod +x gradlew chmod +x gradlew
./gradlew test ./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Resolve Chrome binary
shell: bash
run: |
set -euo pipefail
if command -v chromium >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium)"
elif command -v chromium-browser >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium-browser)"
elif command -v google-chrome >/dev/null 2>&1; then
CHROME_PATH="$(command -v google-chrome)"
else
apt-get update
apt-get install -y --no-install-recommends chromium
CHROME_PATH="$(command -v chromium)"
fi
echo "CHROME_BIN=$CHROME_PATH" >> "$GITHUB_ENV"
echo "Using CHROME_BIN=$CHROME_PATH"
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless)
shell: bash
env:
CI: "true"
run: |
cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox
build-and-push: build-and-push:
needs: test-backend needs: [test-backend, test-frontend]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -106,21 +154,15 @@ jobs:
mkdir -p ~/.ssh mkdir -p ~/.ssh
chmod 700 ~/.ssh chmod 700 ~/.ssh
# 1) Prende il secret base64 e rimuove spazi/newline/CR
printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64 printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64
# 2) (debug sicuro) stampa solo la lunghezza della base64
echo "b64_len=$(wc -c < /tmp/key.b64)" echo "b64_len=$(wc -c < /tmp/key.b64)"
# 3) Decodifica in chiave privata
base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519 base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519
# 4) Rimuove eventuali CRLF dentro la chiave (se proviene da Windows)
tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean
mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519 mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
@@ -128,7 +170,6 @@ jobs:
- name: Write env and compose to server - name: Write env and compose to server
shell: bash shell: bash
run: | run: |
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV)
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then
DEPLOY_TAG="prod" DEPLOY_TAG="prod"
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
@@ -138,10 +179,8 @@ jobs:
fi fi
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]') 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
# 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 }}"
@@ -156,32 +195,28 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi fi
# 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' \ printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env "${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
# 5. Debug: print content (for debug purposes) ADMIN_TTL="${{ secrets.ADMIN_SESSION_TTL_MINUTES }}"
echo "Preparing to send env file with variables:" ADMIN_TTL="${ADMIN_TTL:-480}"
grep -v "PASSWORD" /tmp/full_env.env || true printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
# 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 }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml "setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
- name: Trigger deploy on Unraid (forced command key) - name: Trigger deploy on Unraid (forced command key)
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
# e assicuriamoci che l'input sia pulito
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}" ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"

View File

@@ -0,0 +1,185 @@
name: PR Checks
on:
pull_request:
branches: [main, int, dev]
concurrency:
group: print-calculator-pr-${{ gitea.ref }}
cancel-in-progress: true
jobs:
prettier-autofix:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Apply formatting with Prettier
shell: bash
run: |
npx --yes prettier@3.6.2 --write \
"frontend/src/**/*.{ts,html,scss,css,json}" \
".gitea/workflows/*.{yml,yaml}"
- name: Commit and push formatting changes
shell: bash
run: |
if git diff --quiet; then
echo "No formatting changes to commit."
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends jq
fi
EVENT_FILE="${GITHUB_EVENT_PATH:-}"
if [[ -z "$EVENT_FILE" || ! -f "$EVENT_FILE" ]]; then
echo "Event payload not found, skipping auto-push."
exit 0
fi
HEAD_REPO="$(jq -r '.pull_request.head.repo.full_name // empty' "$EVENT_FILE")"
BASE_REPO="$(jq -r '.repository.full_name // empty' "$EVENT_FILE")"
PR_BRANCH="$(jq -r '.pull_request.head.ref // empty' "$EVENT_FILE")"
if [[ -z "$PR_BRANCH" ]]; then
echo "PR branch not found in event payload, skipping auto-push."
exit 0
fi
if [[ -n "$HEAD_REPO" && -n "$BASE_REPO" && "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "PR from fork ($HEAD_REPO), skipping auto-push."
exit 0
fi
git config user.name "printcalc-ci"
git config user.email "ci@printcalculator.local"
git add frontend/src .gitea/workflows
git commit -m "style: apply prettier formatting"
git push origin "HEAD:${PR_BRANCH}"
security-sast:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Python and Semgrep
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-pip
python3 -m pip install --upgrade pip
python3 -m pip install semgrep
- name: Run Semgrep (SAST)
shell: bash
run: |
semgrep --version
semgrep --config auto --error \
--exclude frontend/node_modules \
--exclude backend/build \
backend/src frontend/src
- name: Install Gitleaks
shell: bash
run: |
set -euo pipefail
VERSION="8.24.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Run Gitleaks (secrets scan)
shell: bash
run: |
set +e
gitleaks detect --source . --no-git --redact --exit-code 1 \
--report-format json --report-path /tmp/gitleaks-report.json
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Gitleaks findings:"
cat /tmp/gitleaks-report.json
fi
exit $rc
test-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle
run: |
cd backend
chmod +x gradlew
./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Resolve Chrome binary
shell: bash
run: |
set -euo pipefail
if command -v chromium >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium)"
elif command -v chromium-browser >/dev/null 2>&1; then
CHROME_PATH="$(command -v chromium-browser)"
elif command -v google-chrome >/dev/null 2>&1; then
CHROME_PATH="$(command -v google-chrome)"
else
apt-get update
apt-get install -y --no-install-recommends chromium
CHROME_PATH="$(command -v chromium)"
fi
echo "CHROME_BIN=$CHROME_PATH" >> "$GITHUB_ENV"
echo "Using CHROME_BIN=$CHROME_PATH"
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless)
shell: bash
env:
CI: "true"
run: |
cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox

8
.gitignore vendored
View File

@@ -41,3 +41,11 @@ target/
build/ build/
.gradle/ .gradle/
.mvn/ .mvn/
./storage_orders
./storage_quotes
storage_orders
storage_quotes
# Qodana local reports/artifacts
backend/.qodana/

View File

@@ -10,11 +10,13 @@ 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
ARG ORCA_VERSION=2.3.1
ARG ORCA_DOWNLOAD_URL
# Install system dependencies for OrcaSlicer (same as before) # 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 \ assimp-utils \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
@@ -24,14 +26,42 @@ RUN apt-get update && apt-get install -y \
# Install OrcaSlicer # Install OrcaSlicer
WORKDIR /opt WORKDIR /opt
RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage -O OrcaSlicer.AppImage \ RUN set -eux; \
&& 7z x OrcaSlicer.AppImage -o/opt/orcaslicer \ ORCA_URL="${ORCA_DOWNLOAD_URL:-}"; \
if [ -n "${ORCA_URL}" ]; then \
wget -q "${ORCA_URL}" -O OrcaSlicer.AppImage; \
else \
CANDIDATES="\
https://github.com/OrcaSlicer/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \
https://github.com/SoftFever/OrcaSlicer/releases/download/v${ORCA_VERSION}/OrcaSlicer_Linux_AppImage_Ubuntu2204_V${ORCA_VERSION}.AppImage \
https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/OrcaSlicer_Linux_V2.2.0.AppImage"; \
ok=0; \
for url in $CANDIDATES; do \
if wget -q --spider "$url"; then \
echo "Using OrcaSlicer URL: $url"; \
wget -q "$url" -O OrcaSlicer.AppImage; \
ok=1; \
break; \
fi; \
done; \
if [ "$ok" -ne 1 ]; then \
echo "Failed to find OrcaSlicer AppImage for version ${ORCA_VERSION}" >&2; \
echo "Tried URLs:" >&2; \
for url in $CANDIDATES; do echo " - $url" >&2; done; \
exit 1; \
fi; \
fi \
&& chmod +x OrcaSlicer.AppImage \
&& rm -rf /opt/orcaslicer /opt/squashfs-root \
&& ./OrcaSlicer.AppImage --appimage-extract >/dev/null \
&& mv /opt/squashfs-root /opt/orcaslicer \
&& chmod -R +x /opt/orcaslicer \ && chmod -R +x /opt/orcaslicer \
&& rm OrcaSlicer.AppImage && rm OrcaSlicer.AppImage
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}" ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
# Set Slicer Path env variable for Java app # Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp"
WORKDIR /app WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -24,11 +24,15 @@ repositories {
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'xyz.capybara:clamav-client:2.1.2' implementation 'xyz.capybara:clamav-client:2.1.2'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
@@ -38,6 +42,15 @@ dependencies {
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' implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation platform('org.lwjgl:lwjgl-bom:3.3.4')
implementation 'org.lwjgl:lwjgl'
implementation 'org.lwjgl:lwjgl-assimp'
runtimeOnly 'org.lwjgl:lwjgl::natives-linux'
runtimeOnly 'org.lwjgl:lwjgl::natives-macos'
runtimeOnly 'org.lwjgl:lwjgl::natives-macos-arm64'
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-linux'
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos'
runtimeOnly 'org.lwjgl:lwjgl-assimp::natives-macos-arm64'

View File

@@ -5,8 +5,6 @@ echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "--- ALL ENV VARS ---"
env
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Determine which environment variables to use for database connection

48
backend/qodana.yaml Normal file
View File

@@ -0,0 +1,48 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
projectJDK: "21" #(Applied in CI/CD pipeline)
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-jvm:2025.3

View File

@@ -2,12 +2,12 @@ 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.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication @SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class})
@EnableTransactionManagement @EnableTransactionManagement
@EnableScheduling @EnableScheduling
@EnableAsync @EnableAsync

View File

@@ -0,0 +1,47 @@
package com.printcalculator.config;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/actuator/**").denyAll()
.requestMatchers("/api/admin/auth/login").permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().permitAll()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
}))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -1,21 +1,21 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.service.request.CustomQuoteRequestControllerService;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import jakarta.validation.Valid;
import com.printcalculator.repository.CustomQuoteRequestRepository;
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;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -23,105 +23,25 @@ import java.util.UUID;
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestControllerService customQuoteRequestControllerService;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
// TODO: Inject Storage Service public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) {
private static final String STORAGE_ROOT = "storage_requests"; this.customQuoteRequestControllerService = customQuoteRequestControllerService;
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.ClamAVService clamAVService) {
this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService;
} }
// 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest( public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
@RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, @Valid @RequestPart("request") QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List<MultipartFile> files @RequestPart(value = "files", required = false) List<MultipartFile> files
) throws IOException { ) throws IOException {
return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files));
// 1. Create Request
CustomQuoteRequest request = new CustomQuoteRequest();
request.setRequestType(requestDto.getRequestType());
request.setCustomerType(requestDto.getCustomerType());
request.setEmail(requestDto.getEmail());
request.setPhone(requestDto.getPhone());
request.setName(requestDto.getName());
request.setCompanyName(requestDto.getCompanyName());
request.setContactPerson(requestDto.getContactPerson());
request.setMessage(requestDto.getMessage());
request.setStatus("PENDING");
request.setCreatedAt(OffsetDateTime.now());
request.setUpdatedAt(OffsetDateTime.now());
request = requestRepo.save(request);
// 2. Handle Attachments
if (files != null && !files.isEmpty()) {
if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed.");
} }
for (MultipartFile file : files) {
if (file.isEmpty()) continue;
// Scan for virus
clamAVService.scan(file.getInputStream());
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
attachment.setRequest(request);
attachment.setOriginalFilename(file.getOriginalFilename());
attachment.setMimeType(file.getContentType());
attachment.setFileSizeBytes(file.getSize());
attachment.setCreatedAt(OffsetDateTime.now());
// Generate path
UUID fileUuid = UUID.randomUUID();
String ext = getExtension(file.getOriginalFilename());
String storedFilename = fileUuid.toString() + "." + ext;
// Note: We don't have attachment ID yet.
// We'll save attachment first to get ID.
attachment.setStoredFilename(storedFilename);
attachment.setStoredRelativePath("PENDING");
attachment = attachmentRepo.save(attachment);
String relativePath = "quote-requests/" + request.getId() + "/attachments/" + attachment.getId() + "/" + storedFilename;
attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment);
// Save file to disk
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
}
}
return ResponseEntity.ok(request);
}
// 2. Get Request
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) { public ResponseEntity<CustomQuoteRequest> getCustomQuoteRequest(@PathVariable UUID id) {
return requestRepo.findById(id) return customQuoteRequestControllerService.getCustomQuoteRequest(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
// Helper
private String getExtension(String filename) {
if (filename == null) return "dat";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
}
return "dat";
}
} }

View File

@@ -1,47 +0,0 @@
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);
}
}

View File

@@ -3,18 +3,33 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse; import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.*; // This line replaces specific entity imports import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.ProfileManager;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList; import java.math.BigDecimal;
import java.util.Comparator; import java.util.Comparator;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -22,76 +37,117 @@ public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo; private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo; private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo; private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo;
private final PrinterMachineProfileRepository printerMachineProfileRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver;
private final ProfileManager profileManager;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public OptionsController(FilamentMaterialTypeRepository materialRepo, public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo, FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo, NozzleOptionRepository nozzleRepo,
NozzleOptionRepository nozzleRepo) { PrinterMachineRepository printerMachineRepo,
PrinterMachineProfileRepository printerMachineProfileRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver,
ProfileManager profileManager,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.materialRepo = materialRepo; this.materialRepo = materialRepo;
this.variantRepo = variantRepo; this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo; this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo;
this.printerMachineProfileRepo = printerMachineProfileRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver;
this.profileManager = profileManager;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@GetMapping("/api/calculator/options") @GetMapping("/api/calculator/options")
public ResponseEntity<OptionsResponse> getOptions() { @Transactional(readOnly = true)
// 1. Materials & Variants public ResponseEntity<OptionsResponse> getOptions(
@RequestParam(value = "printerMachineId", required = false) Long printerMachineId,
@RequestParam(value = "nozzleDiameter", required = false) Double nozzleDiameter
) {
List<FilamentMaterialType> types = materialRepo.findAll(); List<FilamentMaterialType> types = materialRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findAll(); List<FilamentVariant> allVariants = variantRepo.findByIsActiveTrue().stream()
.sorted(Comparator
.comparing((FilamentVariant v) -> safeMaterialCode(v.getFilamentMaterialType()), String.CASE_INSENSITIVE_ORDER)
.thenComparing(v -> safeString(v.getVariantDisplayName()), String.CASE_INSENSITIVE_ORDER))
.toList();
Set<Long> compatibleMaterialTypeIds = resolveCompatibleMaterialTypeIds(printerMachineId, nozzleDiameter);
List<OptionsResponse.MaterialOption> materialOptions = types.stream() List<OptionsResponse.MaterialOption> materialOptions = types.stream()
.sorted(Comparator.comparing(t -> safeString(t.getMaterialCode()), String.CASE_INSENSITIVE_ORDER))
.map(type -> { .map(type -> {
if (!compatibleMaterialTypeIds.isEmpty() && !compatibleMaterialTypeIds.contains(type.getId())) {
return null;
}
List<OptionsResponse.VariantOption> variants = allVariants.stream() List<OptionsResponse.VariantOption> variants = allVariants.stream()
.filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive()) .filter(v -> v.getFilamentMaterialType() != null
&& v.getFilamentMaterialType().getId().equals(type.getId()))
.map(v -> new OptionsResponse.VariantOption( .map(v -> new OptionsResponse.VariantOption(
v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
getColorHex(v.getColorName()), // Need helper or store hex in DB resolveHexColor(v),
v.getStockSpools().doubleValue() <= 0 v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
toStockFilamentGrams(v),
v.getStockSpools() == null || v.getStockSpools().doubleValue() <= 0
)) ))
.collect(Collectors.toList()); .collect(Collectors.toList());
// Only include material if it has active variants if (variants.isEmpty()) {
if (variants.isEmpty()) return null; return null;
}
return new OptionsResponse.MaterialOption( return new OptionsResponse.MaterialOption(
type.getMaterialCode(), type.getMaterialCode(),
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"), type.getMaterialCode() + (Boolean.TRUE.equals(type.getIsFlexible()) ? " (Flexible)" : " (Standard)"),
variants variants
); );
}) })
.filter(m -> m != null) .filter(m -> m != null)
.collect(Collectors.toList()); .toList();
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of( List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"), new OptionsResponse.QualityOption("draft", "Draft"),
new OptionsResponse.QualityOption("standard", "Standard"), new OptionsResponse.QualityOption("standard", "Standard"),
new OptionsResponse.QualityOption("extra_fine", "High Definition") new OptionsResponse.QualityOption("extra_fine", "High Definition")
); );
// 3. Infill Patterns (Static as per user request)
List<OptionsResponse.InfillPatternOption> patterns = List.of( List<OptionsResponse.InfillPatternOption> patterns = List.of(
new OptionsResponse.InfillPatternOption("grid", "Grid"), new OptionsResponse.InfillPatternOption("grid", "Grid"),
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"), new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
// 4. Layer Heights PrinterMachine targetMachine = resolveMachine(printerMachineId);
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> l.getIsActive()) Set<BigDecimal> supportedMachineNozzles = targetMachine != null
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) ? printerMachineProfileRepo.findByPrinterMachineAndIsActiveTrue(targetMachine).stream()
.map(l -> new OptionsResponse.LayerHeightOptionDTO( .map(PrinterMachineProfile::getNozzleDiameterMm)
l.getLayerHeightMm().doubleValue(), .filter(v -> v != null)
String.format("%.2f mm", l.getLayerHeightMm()) .map(nozzleLayerHeightPolicyService::normalizeNozzle)
)) .collect(Collectors.toCollection(LinkedHashSet::new))
.collect(Collectors.toList()); : Set.of();
boolean restrictNozzlesByMachineProfile = !supportedMachineNozzles.isEmpty();
// 5. Nozzles
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> n.getIsActive()) .filter(n -> Boolean.TRUE.equals(n.getIsActive()))
.filter(n -> {
if (!restrictNozzlesByMachineProfile) {
return true;
}
BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(n.getNozzleDiameterMm());
return normalized != null && supportedMachineNozzles.contains(normalized);
})
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO( .map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(), n.getNozzleDiameterMm().doubleValue(),
@@ -100,13 +156,207 @@ public class OptionsController {
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)") : " (Standard)")
)) ))
.collect(Collectors.toList()); .toList();
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); Map<BigDecimal, List<BigDecimal>> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle();
Set<BigDecimal> visibleNozzlesFromOptions = nozzles.stream()
.map(OptionsResponse.NozzleOptionDTO::value)
.map(BigDecimal::valueOf)
.map(nozzleLayerHeightPolicyService::normalizeNozzle)
.filter(v -> v != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
Map<BigDecimal, List<BigDecimal>> effectiveRulesByNozzle = new LinkedHashMap<>();
for (BigDecimal nozzle : visibleNozzlesFromOptions) {
List<BigDecimal> policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
List<BigDecimal> compatibleProcessLayers = resolveCompatibleProcessLayers(targetMachine, nozzle);
List<BigDecimal> effective = mergePolicyAndProcessLayers(policyLayers, compatibleProcessLayers);
if (!effective.isEmpty()) {
effectiveRulesByNozzle.put(nozzle, effective);
}
}
if (effectiveRulesByNozzle.isEmpty()) {
for (BigDecimal nozzle : visibleNozzlesFromOptions) {
List<BigDecimal> policyLayers = rulesByNozzle.getOrDefault(nozzle, List.of());
if (!policyLayers.isEmpty()) {
effectiveRulesByNozzle.put(nozzle, policyLayers);
}
}
} }
// Temporary helper until we add hex to DB Set<BigDecimal> visibleNozzles = new LinkedHashSet<>(effectiveRulesByNozzle.keySet());
nozzles = nozzles.stream()
.filter(option -> {
BigDecimal normalized = nozzleLayerHeightPolicyService.normalizeNozzle(
BigDecimal.valueOf(option.value())
);
return normalized != null && visibleNozzles.contains(normalized);
})
.toList();
BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
if (!visibleNozzles.isEmpty() && !visibleNozzles.contains(selectedNozzle)) {
selectedNozzle = visibleNozzles.iterator().next();
}
List<OptionsResponse.LayerHeightOptionDTO> layers = toLayerDtos(
effectiveRulesByNozzle.getOrDefault(selectedNozzle, List.of())
);
if (layers.isEmpty()) {
if (!visibleNozzles.isEmpty()) {
BigDecimal fallbackNozzle = visibleNozzles.iterator().next();
layers = toLayerDtos(effectiveRulesByNozzle.getOrDefault(fallbackNozzle, List.of()));
}
if (layers.isEmpty()) {
layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of());
}
}
List<OptionsResponse.NozzleLayerHeightOptionsDTO> layerHeightsByNozzle = effectiveRulesByNozzle.entrySet().stream()
.map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO(
entry.getKey().doubleValue(),
toLayerDtos(entry.getValue())
))
.toList();
return ResponseEntity.ok(new OptionsResponse(
materialOptions,
qualities,
patterns,
layers,
nozzles,
layerHeightsByNozzle
));
}
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
PrinterMachine machine = resolveMachine(printerMachineId);
if (machine == null) {
return Set.of();
}
BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle)
.orElse(null);
if (machineProfile == null) {
return Set.of();
}
List<MaterialOrcaProfileMap> maps = materialOrcaMapRepo.findByPrinterMachineProfileAndIsActiveTrue(machineProfile);
return maps.stream()
.map(MaterialOrcaProfileMap::getFilamentMaterialType)
.filter(m -> m != null && m.getId() != null)
.map(FilamentMaterialType::getId)
.collect(Collectors.toSet());
}
private PrinterMachine resolveMachine(Long printerMachineId) {
PrinterMachine machine = null;
if (printerMachineId != null) {
machine = printerMachineRepo.findById(printerMachineId).orElse(null);
}
if (machine == null) {
machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
}
return machine;
}
private List<OptionsResponse.LayerHeightOptionDTO> toLayerDtos(List<BigDecimal> layers) {
return layers.stream()
.sorted(Comparator.naturalOrder())
.map(layer -> new OptionsResponse.LayerHeightOptionDTO(
layer.doubleValue(),
String.format("%.2f mm", layer)
))
.toList();
}
private List<BigDecimal> resolveCompatibleProcessLayers(PrinterMachine machine, BigDecimal nozzle) {
if (machine == null || nozzle == null) {
return List.of();
}
PrinterMachineProfile profile = orcaProfileResolver.resolveMachineProfile(machine, nozzle).orElse(null);
if (profile == null || profile.getOrcaMachineProfileName() == null) {
return List.of();
}
return profileManager.findCompatibleProcessLayers(profile.getOrcaMachineProfileName());
}
private List<BigDecimal> mergePolicyAndProcessLayers(List<BigDecimal> policyLayers,
List<BigDecimal> processLayers) {
if ((processLayers == null || processLayers.isEmpty())
&& (policyLayers == null || policyLayers.isEmpty())) {
return List.of();
}
if (processLayers == null || processLayers.isEmpty()) {
return policyLayers != null ? policyLayers : List.of();
}
if (policyLayers == null || policyLayers.isEmpty()) {
return processLayers;
}
Set<BigDecimal> allowedByPolicy = policyLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<BigDecimal> intersection = processLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null && allowedByPolicy.contains(v))
.collect(Collectors.toCollection(ArrayList::new));
if (!intersection.isEmpty()) {
return intersection;
}
return processLayers.stream()
.map(nozzleLayerHeightPolicyService::normalizeLayer)
.filter(v -> v != null)
.collect(Collectors.toCollection(ArrayList::new));
}
private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex();
}
return getColorHex(variant.getColorName());
}
private double toStockFilamentGrams(FilamentVariant variant) {
if (variant.getStockSpools() == null || variant.getSpoolNetKg() == null) {
return 0d;
}
return variant.getStockSpools()
.multiply(variant.getSpoolNetKg())
.multiply(BigDecimal.valueOf(1000))
.doubleValue();
}
private String safeMaterialCode(FilamentMaterialType type) {
if (type == null || type.getMaterialCode() == null) {
return "";
}
return type.getMaterialCode();
}
private String safeString(String value) {
return value == null ? "" : value;
}
// Temporary helper for legacy values where color hex is not yet set in DB
private String getColorHex(String colorName) { private String getColorHex(String colorName) {
if (colorName == null) {
return "#9e9e9e";
}
String lower = colorName.toLowerCase(); String lower = colorName.toLowerCase();
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a"; if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5"; if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
@@ -120,6 +370,6 @@ public class OptionsController {
} }
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2"; if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d"; if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
return "#9e9e9e"; // Default grey return "#9e9e9e";
} }
} }

View File

@@ -1,83 +1,42 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.*; import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.entity.*; import com.printcalculator.dto.OrderDto;
import com.printcalculator.repository.*; import com.printcalculator.service.order.OrderControllerService;
import com.printcalculator.service.InvoicePdfRenderingService; import jakarta.validation.Valid;
import com.printcalculator.service.OrderService;
import com.printcalculator.service.QrBillService;
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;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
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.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.UUID;
import java.util.Base64;
import java.util.stream.Collectors;
import java.net.URI;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
public class OrderController { public class OrderController {
private final OrderService orderService; private final OrderControllerService orderControllerService;
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final CustomerRepository customerRepo;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
public OrderController(OrderControllerService orderControllerService) {
public OrderController(OrderService orderService, this.orderControllerService = orderControllerService;
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo,
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
TwintPaymentService twintPaymentService) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.customerRepo = customerRepo;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
} }
// 1. Create Order from Quote
@PostMapping("/from-quote/{quoteSessionId}") @PostMapping("/from-quote/{quoteSessionId}")
@Transactional @Transactional
public ResponseEntity<OrderDto> createOrderFromQuote( public ResponseEntity<OrderDto> createOrderFromQuote(
@PathVariable UUID quoteSessionId, @PathVariable UUID quoteSessionId,
@RequestBody com.printcalculator.dto.CreateOrderRequest request @Valid @RequestBody CreateOrderRequest request
) { ) {
Order order = orderService.createOrderFromQuote(quoteSessionId, request); return ResponseEntity.ok(orderControllerService.createOrderFromQuote(quoteSessionId, request));
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return ResponseEntity.ok(convertToDto(order, items));
} }
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -87,137 +46,49 @@ public class OrderController {
@PathVariable UUID orderItemId, @PathVariable UUID orderItemId,
@RequestParam("file") MultipartFile file @RequestParam("file") MultipartFile file
) throws IOException { ) throws IOException {
boolean uploaded = orderControllerService.uploadOrderItemFile(orderId, orderItemId, file);
OrderItem item = orderItemRepo.findById(orderItemId) if (!uploaded) {
.orElseThrow(() -> new RuntimeException("OrderItem not found"));
if (!item.getOrder().getId().equals(orderId)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID().toString() + "." + ext;
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
item.setStoredRelativePath(relativePath);
item.setStoredFilename(storedFilename);
}
storageService.store(file, Paths.get(relativePath));
item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType());
orderItemRepo.save(item);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping("/{orderId}") @GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) { public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return orderRepo.findById(orderId) return orderControllerService.getOrder(orderId)
.map(o -> { .map(ResponseEntity::ok)
List<OrderItem> items = orderItemRepo.findByOrder_Id(o.getId());
return ResponseEntity.ok(convertToDto(o, items));
})
.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
) {
return orderControllerService.reportPayment(orderId, payload.get("method"))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return orderControllerService.getConfirmation(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) return ResponseEntity.notFound().build();
.orElseThrow(() -> new RuntimeException("Order not found"));
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
} }
@GetMapping("/{orderId}/twint") @GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) { return orderControllerService.getTwintPayment(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") @GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) { return orderControllerService.openTwintPayment(orderId);
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl()))
.build();
} }
@GetMapping("/{orderId}/twint/qr") @GetMapping("/{orderId}/twint/qr")
@@ -225,94 +96,6 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size @RequestParam(defaultValue = "320") int size
) { ) {
if (!orderRepo.existsById(orderId)) { return orderControllerService.getTwintQr(orderId, size);
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) {
if (filename == null) return "stl";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
}
return "stl";
}
private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!order.getShippingSameAsBilling()) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).collect(Collectors.toList());
dto.setItems(itemDtos);
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";
}
} }

View File

@@ -4,17 +4,23 @@ import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService;
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;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.HashMap;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
public class QuoteController { public class QuoteController {
@@ -22,17 +28,23 @@ public class QuoteController {
private final SlicerService slicerService; private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final ClamAVService clamAVService;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
// 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, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { public QuoteController(SlicerService slicerService,
QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
ClamAVService clamAVService,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -69,15 +81,27 @@ public class QuoteController {
if (infillPattern != null && !infillPattern.isEmpty()) { if (infillPattern != null && !infillPattern.isEmpty()) {
processOverrides.put("sparse_infill_pattern", infillPattern); processOverrides.put("sparse_infill_pattern", infillPattern);
} }
BigDecimal normalizedNozzle = nozzleLayerHeightPolicyService.resolveNozzle(
nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
);
if (layerHeight != null) { if (layerHeight != null) {
processOverrides.put("layer_height", String.valueOf(layerHeight)); BigDecimal normalizedLayer = nozzleLayerHeightPolicyService.normalizeLayer(BigDecimal.valueOf(layerHeight));
if (!nozzleLayerHeightPolicyService.isAllowed(normalizedNozzle, normalizedLayer)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Layer height " + normalizedLayer.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + normalizedNozzle.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(normalizedNozzle)
);
}
processOverrides.put("layer_height", normalizedLayer.stripTrailingZeros().toPlainString());
} }
if (supportEnabled != null) { if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0"); processOverrides.put("enable_support", supportEnabled ? "1" : "0");
} }
if (nozzleDiameter != null) { if (nozzleDiameter != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter)); machineOverrides.put("nozzle_diameter", normalizedNozzle.stripTrailingZeros().toPlainString());
// Also need to ensure the printer profile is compatible or just override? // Also need to ensure the printer profile is compatible or just override?
// Usually nozzle diameter changes require a different printer profile or deep overrides. // Usually nozzle diameter changes require a different printer profile or deep overrides.
// For now, we trust the override key works on the base profile. // For now, we trust the override key works on the base profile.

View File

@@ -1,285 +1,122 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
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.SlicerService; import com.printcalculator.service.QuoteSessionTotalsService;
import com.printcalculator.service.quote.QuoteSessionItemService;
import com.printcalculator.service.quote.QuoteSessionResponseAssembler;
import com.printcalculator.service.quote.QuoteStorageService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException; import java.io.IOException;
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.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/quote-sessions") @RequestMapping("/api/quote-sessions")
public class QuoteSessionController { public class QuoteSessionController {
private final QuoteSessionRepository sessionRepo; private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo; private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final QuoteSessionTotalsService quoteSessionTotalsService;
private final QuoteSessionItemService quoteSessionItemService;
// Defaults private final QuoteStorageService quoteStorageService;
private static final String DEFAULT_FILAMENT = "pla_basic"; private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
private static final String DEFAULT_PROCESS = "standard";
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService) { QuoteSessionTotalsService quoteSessionTotalsService,
QuoteSessionItemService quoteSessionItemService,
QuoteStorageService quoteStorageService,
QuoteSessionResponseAssembler quoteSessionResponseAssembler) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService; this.quoteSessionTotalsService = quoteSessionTotalsService;
this.quoteSessionItemService = quoteSessionItemService;
this.quoteStorageService = quoteStorageService;
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
} }
// 1. Start a new empty session
@PostMapping(value = "") @PostMapping(value = "")
@Transactional @Transactional
public ResponseEntity<QuoteSession> createSession() { public ResponseEntity<QuoteSession> createSession() {
QuoteSession session = new QuoteSession(); QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE"); session.setStatus("ACTIVE");
session.setPricingVersion("v1"); session.setPricingVersion("v1");
// Default material/settings will be set when items are added or updated? session.setMaterialCode("PLA");
// For now set safe defaults
session.setMaterialCode("pla_basic");
session.setSupportsEnabled(false); session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now()); session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(30)); session.setExpiresAt(OffsetDateTime.now().plusDays(30));
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO); session.setSetupCostChf(quoteCalculator.calculateSessionSetupFee(policy));
session = sessionRepo.save(session); session = sessionRepo.save(session);
return ResponseEntity.ok(session); return ResponseEntity.ok(session);
} }
// 2. Add item to existing session
@PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<QuoteLineItem> addItemToExistingSession( public ResponseEntity<QuoteLineItem> addItemToExistingSession(@PathVariable UUID id,
@PathVariable UUID id, @RequestPart("settings") PrintSettingsDto settings,
@RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings, @RequestPart("file") MultipartFile file) throws IOException {
@RequestPart("file") MultipartFile file
) throws IOException {
QuoteSession session = sessionRepo.findById(id) QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found")); .orElseThrow(() -> new RuntimeException("Session not found"));
QuoteLineItem item = addItemToSession(session, file, settings); QuoteLineItem item = quoteSessionItemService.addItemToSession(session, file, settings);
return ResponseEntity.ok(item); return ResponseEntity.ok(item);
} }
// Helper to add item
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) throw new IOException("File is empty");
// Scan for virus
clamAVService.scan(file.getInputStream());
// 1. Define Persistent Storage Path
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
String storageDir = "storage_quotes/" + session.getId();
Files.createDirectories(Paths.get(storageDir));
String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf("."))
: ".stl";
String storedFilename = UUID.randomUUID() + ext;
Path persistentPath = Paths.get(storageDir, storedFilename);
// Save file
Files.copy(file.getInputStream(), persistentPath);
try {
// Apply Basic/Advanced Logic
applyPrintSettings(settings);
// REAL SLICING
// 1. Pick Machine (default to first active or specific)
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
// 2. Pick Profiles
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
// If the display name doesn't match the json profile name, we might need a mapping key in DB.
// 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.
// Ideally: machine.getSlicerProfileName();
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
if (settings.getMaterial() != null) {
if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA";
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("abs")) filamentProfile = "Generic ABS";
}
String processProfile = "0.20mm Standard @BBL A1";
// Mapping quality to process
// "standard" -> "0.20mm Standard @BBL A1"
// "draft" -> "0.28mm Extra Draft @BBL A1"
// "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca)
// Let's use robust defaults or simple overrides
if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1";
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
}
// Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>();
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.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
// 3. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
persistentPath.toFile(),
machineProfile,
filamentProfile,
processProfile,
null, // machine overrides
processOverrides
);
// 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 5. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(persistentPath.toString()); // SAVE PATH
item.setQuantity(1);
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
// Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown);
// Dimensions
// Cannot get bb from GCodeParser yet?
// If GCodeParser doesn't return size, we might defaults or 0.
// 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.setUpdatedAt(OffsetDateTime.now());
return lineItemRepo.save(item);
} catch (Exception e) {
// Cleanup if failed
Files.deleteIfExists(persistentPath);
throw e;
}
}
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
switch (quality) {
case "draft":
settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
break;
case "high":
settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid");
break;
case "standard":
default:
settings.setLayerHeight(0.20);
settings.setInfillDensity(20.0);
settings.setInfillPattern("grid");
break;
}
} else {
// ADVANCED Mode: Use values from Frontend, set defaults if missing
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
}
}
// 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}") @PatchMapping("/line-items/{lineItemId}")
@Transactional @Transactional
public ResponseEntity<QuoteLineItem> updateLineItem( public ResponseEntity<QuoteLineItem> updateLineItem(@PathVariable UUID lineItemId,
@PathVariable UUID lineItemId, @RequestBody Map<String, Object> updates) {
@RequestBody Map<String, Object> updates
) {
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
if (updates.containsKey("quantity")) { QuoteSession session = item.getQuoteSession();
item.setQuantity((Integer) updates.get("quantity")); if ("CONVERTED".equals(session.getStatus())) {
} throw new ResponseStatusException(BAD_REQUEST, "Cannot modify a converted session");
if (updates.containsKey("color_code")) {
item.setColorCode((String) updates.get("color_code"));
} }
// Recalculate price if needed? if (updates.containsKey("quantity")) {
// For now, unit price is fixed in mock. Total is calculated on GET. item.setQuantity(parsePositiveQuantity(updates.get("quantity")));
}
if (updates.containsKey("color_code")) {
Object colorValue = updates.get("color_code");
if (colorValue != null) {
item.setColorCode(String.valueOf(colorValue));
}
}
item.setUpdatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now());
return ResponseEntity.ok(lineItemRepo.save(item)); return ResponseEntity.ok(lineItemRepo.save(item));
} }
// 4. Delete Line Item
@DeleteMapping("/{sessionId}/line-items/{lineItemId}") @DeleteMapping("/{sessionId}/line-items/{lineItemId}")
@Transactional @Transactional
public ResponseEntity<Void> deleteLineItem( public ResponseEntity<Void> deleteLineItem(@PathVariable UUID sessionId,
@PathVariable UUID sessionId, @PathVariable UUID lineItemId) {
@PathVariable UUID lineItemId
) {
// Verify item belongs to session?
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
@@ -291,39 +128,21 @@ public class QuoteSessionController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// 5. Get Session (Session + Items + Total)
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) { public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id) QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found")); .orElseThrow(() -> new RuntimeException("Session not found"));
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id); List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
// Calculate Totals return ResponseEntity.ok(quoteSessionResponseAssembler.assemble(session, items, totals));
BigDecimal itemsTotal = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
itemsTotal = itemsTotal.add(lineTotal);
} }
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal grandTotal = itemsTotal.add(setupFee);
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", items);
response.put("itemsTotalChf", itemsTotal);
response.put("grandTotalChf", grandTotal);
return ResponseEntity.ok(response);
}
// 6. Download Line Item Content
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content")
public ResponseEntity<org.springframework.core.io.Resource> downloadLineItemContent( public ResponseEntity<Resource> downloadLineItemContent(@PathVariable UUID sessionId,
@PathVariable UUID sessionId, @PathVariable UUID lineItemId,
@PathVariable UUID lineItemId @RequestParam(name = "preview", required = false, defaultValue = "false") boolean preview)
) throws IOException { throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId) QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found")); .orElseThrow(() -> new RuntimeException("Item not found"));
@@ -331,20 +150,93 @@ public class QuoteSessionController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (item.getStoredPath() == null) { String targetStoredPath = item.getStoredPath();
if (preview) {
String convertedPath = quoteStorageService.extractConvertedStoredPath(item);
if (convertedPath != null && !convertedPath.isBlank()) {
targetStoredPath = convertedPath;
}
}
if (targetStoredPath == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
Path path = Paths.get(item.getStoredPath()); java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
if (!Files.exists(path)) { if (path == null || !java.nio.file.Files.exists(path)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri()); Resource resource = new UrlResource(path.toUri());
String downloadName = preview ? path.getFileName().toString() : item.getOriginalFilename();
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) .contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"") .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + downloadName + "\"")
.body(resource); .body(resource);
} }
@GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
public ResponseEntity<Resource> downloadLineItemStlPreview(@PathVariable UUID sessionId,
@PathVariable UUID lineItemId)
throws IOException {
QuoteLineItem item = lineItemRepo.findById(lineItemId)
.orElseThrow(() -> new RuntimeException("Item not found"));
if (!item.getQuoteSession().getId().equals(sessionId)) {
return ResponseEntity.badRequest().build();
}
if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) {
return ResponseEntity.notFound().build();
}
String targetStoredPath = item.getStoredPath();
if (targetStoredPath == null || targetStoredPath.isBlank()) {
return ResponseEntity.notFound().build();
}
java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
if (path == null || !java.nio.file.Files.exists(path)) {
return ResponseEntity.notFound().build();
}
if (!"stl".equals(quoteStorageService.getSafeExtension(path.getFileName().toString(), ""))) {
return ResponseEntity.notFound().build();
}
Resource resource = new UrlResource(path.toUri());
String downloadName = path.getFileName().toString();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("model/stl"))
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + downloadName + "\"")
.body(resource);
}
private int parsePositiveQuantity(Object raw) {
if (raw == null) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity is required");
}
int quantity;
if (raw instanceof Number number) {
double numericValue = number.doubleValue();
if (!Double.isFinite(numericValue)) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be a finite number");
}
quantity = (int) Math.floor(numericValue);
} else {
try {
quantity = Integer.parseInt(String.valueOf(raw).trim());
} catch (NumberFormatException ex) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be an integer");
}
}
if (quantity < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be >= 1");
}
return quantity;
}
} }

View File

@@ -0,0 +1,83 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminLoginRequest;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.OptionalLong;
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private final AdminSessionService adminSessionService;
private final AdminLoginThrottleService adminLoginThrottleService;
public AdminAuthController(
AdminSessionService adminSessionService,
AdminLoginThrottleService adminLoginThrottleService
) {
this.adminSessionService = adminSessionService;
this.adminLoginThrottleService = adminLoginThrottleService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(
@Valid @RequestBody AdminLoginRequest request,
HttpServletRequest httpRequest,
HttpServletResponse response
) {
String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest);
OptionalLong remainingLock = adminLoginThrottleService.getRemainingLockSeconds(clientKey);
if (remainingLock.isPresent()) {
long retryAfter = remainingLock.getAsLong();
return ResponseEntity.status(429)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
if (!adminSessionService.isPasswordValid(request.getPassword())) {
long retryAfter = adminLoginThrottleService.registerFailure(clientKey);
return ResponseEntity.status(401)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
adminLoginThrottleService.reset(clientKey);
String token = adminSessionService.createSessionToken();
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());
return ResponseEntity.ok(Map.of(
"authenticated", true,
"expiresInMinutes", adminSessionService.getSessionTtlMinutes()
));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString());
return ResponseEntity.ok(Map.of("authenticated", false));
}
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me() {
return ResponseEntity.ok(Map.of("authenticated", true));
}
}

View File

@@ -0,0 +1,82 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.service.admin.AdminFilamentControllerService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin/filaments")
@Transactional(readOnly = true)
public class AdminFilamentController {
private final AdminFilamentControllerService adminFilamentControllerService;
public AdminFilamentController(AdminFilamentControllerService adminFilamentControllerService) {
this.adminFilamentControllerService = adminFilamentControllerService;
}
@GetMapping("/materials")
public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() {
return ResponseEntity.ok(adminFilamentControllerService.getMaterials());
}
@GetMapping("/variants")
public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() {
return ResponseEntity.ok(adminFilamentControllerService.getVariants());
}
@PostMapping("/materials")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial(
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.createMaterial(payload));
}
@PutMapping("/materials/{materialTypeId}")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> updateMaterial(
@PathVariable Long materialTypeId,
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.updateMaterial(materialTypeId, payload));
}
@PostMapping("/variants")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> createVariant(
@RequestBody AdminUpsertFilamentVariantRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.createVariant(payload));
}
@PutMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> updateVariant(
@PathVariable Long variantId,
@RequestBody AdminUpsertFilamentVariantRequest payload
) {
return ResponseEntity.ok(adminFilamentControllerService.updateVariant(variantId, payload));
}
@DeleteMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) {
adminFilamentControllerService.deleteVariant(variantId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,93 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminCadInvoiceDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.service.admin.AdminOperationsControllerService;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin")
@Transactional(readOnly = true)
public class AdminOperationsController {
private final AdminOperationsControllerService adminOperationsControllerService;
public AdminOperationsController(AdminOperationsControllerService adminOperationsControllerService) {
this.adminOperationsControllerService = adminOperationsControllerService;
}
@GetMapping("/filament-stock")
public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() {
return ResponseEntity.ok(adminOperationsControllerService.getFilamentStock());
}
@GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
return ResponseEntity.ok(adminOperationsControllerService.getContactRequests());
}
@GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) {
return ResponseEntity.ok(adminOperationsControllerService.getContactRequestDetail(requestId));
}
@PatchMapping("/contact-requests/{requestId}/status")
@Transactional
public ResponseEntity<AdminContactRequestDetailDto> updateContactRequestStatus(
@PathVariable UUID requestId,
@RequestBody AdminUpdateContactRequestStatusRequest payload
) {
return ResponseEntity.ok(adminOperationsControllerService.updateContactRequestStatus(requestId, payload));
}
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
public ResponseEntity<Resource> downloadContactRequestAttachment(
@PathVariable UUID requestId,
@PathVariable UUID attachmentId
) {
return adminOperationsControllerService.downloadContactRequestAttachment(requestId, attachmentId);
}
@GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
return ResponseEntity.ok(adminOperationsControllerService.getQuoteSessions());
}
@GetMapping("/cad-invoices")
public ResponseEntity<List<AdminCadInvoiceDto>> getCadInvoices() {
return ResponseEntity.ok(adminOperationsControllerService.getCadInvoices());
}
@PostMapping("/cad-invoices")
@Transactional
public ResponseEntity<AdminCadInvoiceDto> createOrUpdateCadInvoice(
@RequestBody AdminCadInvoiceCreateRequest payload
) {
return ResponseEntity.ok(adminOperationsControllerService.createOrUpdateCadInvoice(payload));
}
@DeleteMapping("/sessions/{sessionId}")
@Transactional
public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) {
adminOperationsControllerService.deleteQuoteSession(sessionId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,76 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.service.order.AdminOrderControllerService;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/orders")
@Transactional(readOnly = true)
public class AdminOrderController {
private final AdminOrderControllerService adminOrderControllerService;
public AdminOrderController(AdminOrderControllerService adminOrderControllerService) {
this.adminOrderControllerService = adminOrderControllerService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
return ResponseEntity.ok(adminOrderControllerService.listOrders());
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(adminOrderControllerService.getOrder(orderId));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> updatePaymentMethod(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
return ResponseEntity.ok(adminOrderControllerService.updatePaymentMethod(orderId, payload));
}
@PostMapping("/{orderId}/status")
@Transactional
public ResponseEntity<OrderDto> updateOrderStatus(
@PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload
) {
return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload));
}
@GetMapping("/{orderId}/items/{orderItemId}/file")
public ResponseEntity<Resource> downloadOrderItemFile(
@PathVariable UUID orderId,
@PathVariable UUID orderItemId
) {
return adminOrderControllerService.downloadOrderItemFile(orderId, orderItemId);
}
@GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return adminOrderControllerService.downloadOrderConfirmation(orderId);
}
@GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return adminOrderControllerService.downloadOrderInvoice(orderId);
}
}

View File

@@ -0,0 +1,52 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public class AdminCadInvoiceCreateRequest {
private UUID sessionId;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private String notes;
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}

View File

@@ -0,0 +1,143 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminCadInvoiceDto {
private UUID sessionId;
private String sessionStatus;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
private BigDecimal printItemsTotalChf;
private BigDecimal setupCostChf;
private BigDecimal shippingCostChf;
private BigDecimal grandTotalChf;
private UUID convertedOrderId;
private String convertedOrderStatus;
private String checkoutPath;
private String notes;
private OffsetDateTime createdAt;
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public String getSessionStatus() {
return sessionStatus;
}
public void setSessionStatus(String sessionStatus) {
this.sessionStatus = sessionStatus;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
public BigDecimal getPrintItemsTotalChf() {
return printItemsTotalChf;
}
public void setPrintItemsTotalChf(BigDecimal printItemsTotalChf) {
this.printItemsTotalChf = printItemsTotalChf;
}
public BigDecimal getSetupCostChf() {
return setupCostChf;
}
public void setSetupCostChf(BigDecimal setupCostChf) {
this.setupCostChf = setupCostChf;
}
public BigDecimal getShippingCostChf() {
return shippingCostChf;
}
public void setShippingCostChf(BigDecimal shippingCostChf) {
this.shippingCostChf = shippingCostChf;
}
public BigDecimal getGrandTotalChf() {
return grandTotalChf;
}
public void setGrandTotalChf(BigDecimal grandTotalChf) {
this.grandTotalChf = grandTotalChf;
}
public UUID getConvertedOrderId() {
return convertedOrderId;
}
public void setConvertedOrderId(UUID convertedOrderId) {
this.convertedOrderId = convertedOrderId;
}
public String getConvertedOrderStatus() {
return convertedOrderStatus;
}
public void setConvertedOrderStatus(String convertedOrderStatus) {
this.convertedOrderStatus = convertedOrderStatus;
}
public String getCheckoutPath() {
return checkoutPath;
}
public void setCheckoutPath(String checkoutPath) {
this.checkoutPath = checkoutPath;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,52 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestAttachmentDto {
private UUID id;
private String originalFilename;
private String mimeType;
private Long fileSizeBytes;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,125 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminContactRequestDetailDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String contactPerson;
private String message;
private String status;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private List<AdminContactRequestAttachmentDto> attachments;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getRequestType() {
return requestType;
}
public void setRequestType(String requestType) {
this.requestType = requestType;
}
public String getCustomerType() {
return customerType;
}
public void setCustomerType(String customerType) {
this.customerType = customerType;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getContactPerson() {
return contactPerson;
}
public void setContactPerson(String contactPerson) {
this.contactPerson = contactPerson;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public List<AdminContactRequestAttachmentDto> getAttachments() {
return attachments;
}
public void setAttachments(List<AdminContactRequestAttachmentDto> attachments) {
this.attachments = attachments;
}
}

View File

@@ -0,0 +1,88 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String status;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getRequestType() {
return requestType;
}
public void setRequestType(String requestType) {
this.requestType = requestType;
}
public String getCustomerType() {
return customerType;
}
public void setCustomerType(String customerType) {
this.customerType = customerType;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,49 @@
package com.printcalculator.dto;
public class AdminFilamentMaterialTypeDto {
private Long id;
private String materialCode;
private Boolean isFlexible;
private Boolean isTechnical;
private String technicalTypeLabel;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -0,0 +1,87 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminFilamentStockDto {
private Long filamentVariantId;
private String materialCode;
private String variantDisplayName;
private String colorName;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean active;
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
public void setStockKg(BigDecimal stockKg) {
this.stockKg = stockKg;
}
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
}

View File

@@ -0,0 +1,187 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public class AdminFilamentVariantDto {
private Long id;
private Long materialTypeId;
private String materialCode;
private Boolean materialIsFlexible;
private Boolean materialIsTechnical;
private String materialTechnicalTypeLabel;
private String variantDisplayName;
private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte;
private Boolean isSpecial;
private BigDecimal costChfPerKg;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean isActive;
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getMaterialTypeId() {
return materialTypeId;
}
public void setMaterialTypeId(Long materialTypeId) {
this.materialTypeId = materialTypeId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getMaterialIsFlexible() {
return materialIsFlexible;
}
public void setMaterialIsFlexible(Boolean materialIsFlexible) {
this.materialIsFlexible = materialIsFlexible;
}
public Boolean getMaterialIsTechnical() {
return materialIsTechnical;
}
public void setMaterialIsTechnical(Boolean materialIsTechnical) {
this.materialIsTechnical = materialIsTechnical;
}
public String getMaterialTechnicalTypeLabel() {
return materialTechnicalTypeLabel;
}
public void setMaterialTechnicalTypeLabel(String materialTechnicalTypeLabel) {
this.materialTechnicalTypeLabel = materialTechnicalTypeLabel;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
public void setStockKg(BigDecimal stockKg) {
this.stockKg = stockKg;
}
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,17 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.NotBlank;
public class AdminLoginRequest {
@NotBlank
private String password;
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.dto;
public class AdminOrderStatusUpdateRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,98 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.math.BigDecimal;
import java.util.UUID;
public class AdminQuoteSessionDto {
private UUID id;
private String status;
private String materialCode;
private OffsetDateTime createdAt;
private OffsetDateTime expiresAt;
private UUID convertedOrderId;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(OffsetDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public UUID getConvertedOrderId() {
return convertedOrderId;
}
public void setConvertedOrderId(UUID convertedOrderId) {
this.convertedOrderId = convertedOrderId;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.dto;
public class AdminUpdateContactRequestStatusRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,40 @@
package com.printcalculator.dto;
public class AdminUpsertFilamentMaterialTypeRequest {
private String materialCode;
private Boolean isFlexible;
private Boolean isTechnical;
private String technicalTypeLabel;
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -0,0 +1,114 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId;
private String variantDisplayName;
private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte;
private Boolean isSpecial;
private BigDecimal costChfPerKg;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private Boolean isActive;
public Long getMaterialTypeId() {
return materialTypeId;
}
public void setMaterialTypeId(Long materialTypeId) {
this.materialTypeId = materialTypeId;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -1,11 +1,19 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data; import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
@Data @Data
public class CreateOrderRequest { public class CreateOrderRequest {
private CustomerDto customer; private CustomerDto customer;
private AddressDto billingAddress; private AddressDto billingAddress;
private AddressDto shippingAddress; private AddressDto shippingAddress;
private String language;
private boolean shippingSameAsBilling; private boolean shippingSameAsBilling;
@AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.")
private boolean acceptTerms;
@AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.")
private boolean acceptPrivacy;
} }

View File

@@ -7,12 +7,23 @@ public record OptionsResponse(
List<QualityOption> qualities, List<QualityOption> qualities,
List<InfillPatternOption> infillPatterns, List<InfillPatternOption> infillPatterns,
List<LayerHeightOptionDTO> layerHeights, List<LayerHeightOptionDTO> layerHeights,
List<NozzleOptionDTO> nozzleDiameters List<NozzleOptionDTO> nozzleDiameters,
List<NozzleLayerHeightOptionsDTO> layerHeightsByNozzle
) { ) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {} public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {} public record VariantOption(
Long id,
String name,
String colorName,
String hexColor,
String finishType,
Double stockSpools,
Double stockFilamentGrams,
boolean isOutOfStock
) {}
public record QualityOption(String id, String label) {} public record QualityOption(String id, String label) {}
public record InfillPatternOption(String id, String label) {} public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {} public record LayerHeightOptionDTO(double value, String label) {}
public record NozzleOptionDTO(double value, String label) {} public record NozzleOptionDTO(double value, String label) {}
public record NozzleLayerHeightOptionsDTO(double nozzleDiameter, List<LayerHeightOptionDTO> layerHeights) {}
} }

View File

@@ -9,8 +9,11 @@ public class OrderDto {
private UUID id; private UUID id;
private String orderNumber; 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 preferredLanguage;
private String billingCustomerType; private String billingCustomerType;
private AddressDto billingAddress; private AddressDto billingAddress;
private AddressDto shippingAddress; private AddressDto shippingAddress;
@@ -20,8 +23,19 @@ public class OrderDto {
private BigDecimal shippingCostChf; private BigDecimal shippingCostChf;
private BigDecimal discountChf; private BigDecimal discountChf;
private BigDecimal subtotalChf; private BigDecimal subtotalChf;
private Boolean isCadOrder;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
private BigDecimal totalChf; private BigDecimal totalChf;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
private String printMaterialCode;
private BigDecimal printNozzleDiameterMm;
private BigDecimal printLayerHeightMm;
private String printInfillPattern;
private Integer printInfillPercent;
private Boolean printSupportsEnabled;
private List<OrderItemDto> items; private List<OrderItemDto> items;
// Getters and Setters // Getters and Setters
@@ -34,12 +48,21 @@ public class OrderDto {
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; }
public String getCustomerPhone() { return customerPhone; } public String getCustomerPhone() { return customerPhone; }
public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; } public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; }
public String getPreferredLanguage() { return preferredLanguage; }
public void setPreferredLanguage(String preferredLanguage) { this.preferredLanguage = preferredLanguage; }
public String getBillingCustomerType() { return billingCustomerType; } public String getBillingCustomerType() { return billingCustomerType; }
public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; } public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; }
@@ -67,12 +90,45 @@ public class OrderDto {
public BigDecimal getSubtotalChf() { return subtotalChf; } public BigDecimal getSubtotalChf() { return subtotalChf; }
public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; } public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; }
public Boolean getIsCadOrder() { return isCadOrder; }
public void setIsCadOrder(Boolean isCadOrder) { this.isCadOrder = isCadOrder; }
public UUID getSourceRequestId() { return sourceRequestId; }
public void setSourceRequestId(UUID sourceRequestId) { this.sourceRequestId = sourceRequestId; }
public BigDecimal getCadHours() { return cadHours; }
public void setCadHours(BigDecimal cadHours) { this.cadHours = cadHours; }
public BigDecimal getCadHourlyRateChf() { return cadHourlyRateChf; }
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) { this.cadHourlyRateChf = cadHourlyRateChf; }
public BigDecimal getCadTotalChf() { return cadTotalChf; }
public void setCadTotalChf(BigDecimal cadTotalChf) { this.cadTotalChf = cadTotalChf; }
public BigDecimal getTotalChf() { return totalChf; } public BigDecimal getTotalChf() { return totalChf; }
public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; } public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; }
public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public String getPrintMaterialCode() { return printMaterialCode; }
public void setPrintMaterialCode(String printMaterialCode) { this.printMaterialCode = printMaterialCode; }
public BigDecimal getPrintNozzleDiameterMm() { return printNozzleDiameterMm; }
public void setPrintNozzleDiameterMm(BigDecimal printNozzleDiameterMm) { this.printNozzleDiameterMm = printNozzleDiameterMm; }
public BigDecimal getPrintLayerHeightMm() { return printLayerHeightMm; }
public void setPrintLayerHeightMm(BigDecimal printLayerHeightMm) { this.printLayerHeightMm = printLayerHeightMm; }
public String getPrintInfillPattern() { return printInfillPattern; }
public void setPrintInfillPattern(String printInfillPattern) { this.printInfillPattern = printInfillPattern; }
public Integer getPrintInfillPercent() { return printInfillPercent; }
public void setPrintInfillPercent(Integer printInfillPercent) { this.printInfillPercent = printInfillPercent; }
public Boolean getPrintSupportsEnabled() { return printSupportsEnabled; }
public void setPrintSupportsEnabled(Boolean printSupportsEnabled) { this.printSupportsEnabled = printSupportsEnabled; }
public List<OrderItemDto> getItems() { return items; } public List<OrderItemDto> getItems() { return items; }
public void setItems(List<OrderItemDto> items) { this.items = items; } public void setItems(List<OrderItemDto> items) { this.items = items; }
} }

View File

@@ -8,6 +8,16 @@ public class OrderItemDto {
private String originalFilename; private String originalFilename;
private String materialCode; private String materialCode;
private String colorCode; private String colorCode;
private Long filamentVariantId;
private String filamentVariantDisplayName;
private String filamentColorName;
private String filamentColorHex;
private String quality;
private BigDecimal nozzleDiameterMm;
private BigDecimal layerHeightMm;
private Integer infillPercent;
private String infillPattern;
private Boolean supportsEnabled;
private Integer quantity; private Integer quantity;
private Integer printTimeSeconds; private Integer printTimeSeconds;
private BigDecimal materialGrams; private BigDecimal materialGrams;
@@ -27,6 +37,36 @@ public class OrderItemDto {
public String getColorCode() { return colorCode; } public String getColorCode() { return colorCode; }
public void setColorCode(String colorCode) { this.colorCode = colorCode; } public void setColorCode(String colorCode) { this.colorCode = colorCode; }
public Long getFilamentVariantId() { return filamentVariantId; }
public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; }
public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; }
public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; }
public String getFilamentColorName() { return filamentColorName; }
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; }
public String getFilamentColorHex() { return filamentColorHex; }
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }
public String getQuality() { return quality; }
public void setQuality(String quality) { this.quality = quality; }
public BigDecimal getNozzleDiameterMm() { return nozzleDiameterMm; }
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { this.nozzleDiameterMm = nozzleDiameterMm; }
public BigDecimal getLayerHeightMm() { return layerHeightMm; }
public void setLayerHeightMm(BigDecimal layerHeightMm) { this.layerHeightMm = layerHeightMm; }
public Integer getInfillPercent() { return infillPercent; }
public void setInfillPercent(Integer infillPercent) { this.infillPercent = infillPercent; }
public String getInfillPattern() { return infillPattern; }
public void setInfillPattern(String infillPattern) { this.infillPattern = infillPattern; }
public Boolean getSupportsEnabled() { return supportsEnabled; }
public void setSupportsEnabled(Boolean supportsEnabled) { this.supportsEnabled = supportsEnabled; }
public Integer getQuantity() { return quantity; } public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; } public void setQuantity(Integer quantity) { this.quantity = quantity; }

View File

@@ -1,23 +1,157 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data;
@Data
public class PrintSettingsDto { public class PrintSettingsDto {
// Mode: "BASIC" or "ADVANCED" // Mode: "BASIC" or "ADVANCED"
private String complexityMode; private String complexityMode;
// Common // Common
private String material; // e.g. "PLA", "PETG" private String material; // e.g. "PLA", "PLA TOUGH", "PETG"
private String color; // e.g. "White", "#FFFFFF" private String color; // e.g. "White", "#FFFFFF"
private Integer quantity;
private Long filamentVariantId;
private Long printerMachineId;
// Basic Mode // Basic Mode
private String quality; // "draft", "standard", "high" private String quality; // "draft", "standard", "high"
// Advanced Mode (Optional in Basic) // Advanced Mode (Optional in Basic)
private Double nozzleDiameter;
private Double layerHeight; private Double layerHeight;
private Double infillDensity; private Double infillDensity;
private String infillPattern; private String infillPattern;
private Boolean supportsEnabled; private Boolean supportsEnabled;
private String notes; private String notes;
// Dimensions
private Double boundingBoxX;
private Double boundingBoxY;
private Double boundingBoxZ;
public String getComplexityMode() {
return complexityMode;
}
public void setComplexityMode(String complexityMode) {
this.complexityMode = complexityMode;
}
public String getMaterial() {
return material;
}
public void setMaterial(String material) {
this.material = material;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public Long getPrinterMachineId() {
return printerMachineId;
}
public void setPrinterMachineId(Long printerMachineId) {
this.printerMachineId = printerMachineId;
}
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public Double getNozzleDiameter() {
return nozzleDiameter;
}
public void setNozzleDiameter(Double nozzleDiameter) {
this.nozzleDiameter = nozzleDiameter;
}
public Double getLayerHeight() {
return layerHeight;
}
public void setLayerHeight(Double layerHeight) {
this.layerHeight = layerHeight;
}
public Double getInfillDensity() {
return infillDensity;
}
public void setInfillDensity(Double infillDensity) {
this.infillDensity = infillDensity;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public Double getBoundingBoxX() {
return boundingBoxX;
}
public void setBoundingBoxX(Double boundingBoxX) {
this.boundingBoxX = boundingBoxX;
}
public Double getBoundingBoxY() {
return boundingBoxY;
}
public void setBoundingBoxY(Double boundingBoxY) {
this.boundingBoxY = boundingBoxY;
}
public Double getBoundingBoxZ() {
return boundingBoxZ;
}
public void setBoundingBoxZ(Double boundingBoxZ) {
this.boundingBoxZ = boundingBoxZ;
}
} }

View File

@@ -1,15 +1,23 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import lombok.Data; import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
@Data @Data
public class QuoteRequestDto { public class QuoteRequestDto {
private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE" private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE"
private String customerType; // "PRIVATE" or "BUSINESS" private String customerType; // "PRIVATE" or "BUSINESS"
private String language; // "it" | "en" | "de" | "fr"
private String email; private String email;
private String phone; private String phone;
private String name; private String name;
private String companyName; private String companyName;
private String contactPerson; private String contactPerson;
private String message; private String message;
@AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.")
private boolean acceptTerms;
@AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.")
private boolean acceptPrivacy;
} }

View File

@@ -24,6 +24,16 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; private String colorName;
@Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex;
@ColumnDefault("'GLOSSY'")
@Column(name = "finish_type", length = Integer.MAX_VALUE)
private String finishType;
@Column(name = "brand", length = Integer.MAX_VALUE)
private String brand;
@ColumnDefault("false") @ColumnDefault("false")
@Column(name = "is_matte", nullable = false) @Column(name = "is_matte", nullable = false)
private Boolean isMatte; private Boolean isMatte;
@@ -83,6 +93,30 @@ public class FilamentVariant {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() { public Boolean getIsMatte() {
return isMatte; return isMatte;
} }

View File

@@ -0,0 +1,72 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "filament_variant_orca_override", uniqueConstraints = {
@UniqueConstraint(name = "ux_filament_variant_orca_override_variant_machine", columnNames = {
"filament_variant_id", "printer_machine_profile_id"
})
})
public class FilamentVariantOrcaOverride {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_variant_orca_override_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_variant_id", nullable = false)
private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_profile_id", nullable = false)
private PrinterMachineProfile printerMachineProfile;
@Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaFilamentProfileName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public PrinterMachineProfile getPrinterMachineProfile() {
return printerMachineProfile;
}
public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) {
this.printerMachineProfile = printerMachineProfile;
}
public String getOrcaFilamentProfileName() {
return orcaFilamentProfileName;
}
public void setOrcaFilamentProfileName(String orcaFilamentProfileName) {
this.orcaFilamentProfileName = orcaFilamentProfileName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,72 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "material_orca_profile_map", uniqueConstraints = {
@UniqueConstraint(name = "ux_material_orca_profile_map_machine_material", columnNames = {
"printer_machine_profile_id", "filament_material_type_id"
})
})
public class MaterialOrcaProfileMap {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "material_orca_profile_map_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_profile_id", nullable = false)
private PrinterMachineProfile printerMachineProfile;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_material_type_id", nullable = false)
private FilamentMaterialType filamentMaterialType;
@Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaFilamentProfileName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PrinterMachineProfile getPrinterMachineProfile() {
return printerMachineProfile;
}
public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) {
this.printerMachineProfile = printerMachineProfile;
}
public FilamentMaterialType getFilamentMaterialType() {
return filamentMaterialType;
}
public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) {
this.filamentMaterialType = filamentMaterialType;
}
public String getOrcaFilamentProfileName() {
return orcaFilamentProfileName;
}
public void setOrcaFilamentProfileName(String orcaFilamentProfileName) {
this.orcaFilamentProfileName = orcaFilamentProfileName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,63 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(
name = "nozzle_layer_height_option",
uniqueConstraints = @UniqueConstraint(
name = "ux_nozzle_layer_height_option_nozzle_layer",
columnNames = {"nozzle_diameter_mm", "layer_height_mm"}
)
)
public class NozzleLayerHeightOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "nozzle_layer_height_option_id", nullable = false)
private Long id;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -95,6 +95,10 @@ public class Order {
@Column(name = "shipping_country_code", length = 2) @Column(name = "shipping_country_code", length = 2)
private String shippingCountryCode; private String shippingCountryCode;
@ColumnDefault("'it'")
@Column(name = "preferred_language", length = 2)
private String preferredLanguage;
@ColumnDefault("'CHF'") @ColumnDefault("'CHF'")
@Column(name = "currency", nullable = false, length = 3) @Column(name = "currency", nullable = false, length = 3)
private String currency; private String currency;
@@ -115,6 +119,23 @@ public class Order {
@Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2) @Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal subtotalChf; private BigDecimal subtotalChf;
@ColumnDefault("false")
@Column(name = "is_cad_order", nullable = false)
private Boolean isCadOrder;
@Column(name = "source_request_id")
private UUID sourceRequestId;
@Column(name = "cad_hours", precision = 10, scale = 2)
private BigDecimal cadHours;
@Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2)
private BigDecimal cadHourlyRateChf;
@ColumnDefault("0.00")
@Column(name = "cad_total_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal cadTotalChf;
@ColumnDefault("0.00") @ColumnDefault("0.00")
@Column(name = "total_chf", nullable = false, precision = 12, scale = 2) @Column(name = "total_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal totalChf; private BigDecimal totalChf;
@@ -356,6 +377,14 @@ public class Order {
this.currency = currency; this.currency = currency;
} }
public String getPreferredLanguage() {
return preferredLanguage;
}
public void setPreferredLanguage(String preferredLanguage) {
this.preferredLanguage = preferredLanguage;
}
public BigDecimal getSetupCostChf() { public BigDecimal getSetupCostChf() {
return setupCostChf; return setupCostChf;
} }
@@ -388,6 +417,46 @@ public class Order {
this.subtotalChf = subtotalChf; this.subtotalChf = subtotalChf;
} }
public Boolean getIsCadOrder() {
return isCadOrder;
}
public void setIsCadOrder(Boolean isCadOrder) {
this.isCadOrder = isCadOrder;
}
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
public BigDecimal getTotalChf() { public BigDecimal getTotalChf() {
return totalChf; return totalChf;
} }

View File

@@ -44,6 +44,28 @@ public class OrderItem {
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode; private String materialCode;
@Column(name = "quality", length = Integer.MAX_VALUE)
private String quality;
@Column(name = "nozzle_diameter_mm", precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_percent")
private Integer infillPercent;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "supports_enabled")
private Boolean supportsEnabled;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant;
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@@ -57,6 +79,15 @@ public class OrderItem {
@Column(name = "material_grams", precision = 12, scale = 2) @Column(name = "material_grams", precision = 12, scale = 2)
private BigDecimal materialGrams; private BigDecimal materialGrams;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm;
@Column(name = "bounding_box_y_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxYMm;
@Column(name = "bounding_box_z_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxZMm;
@Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2) @Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal unitPriceChf; private BigDecimal unitPriceChf;
@@ -149,6 +180,62 @@ public class OrderItem {
this.materialCode = materialCode; this.materialCode = materialCode;
} }
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getColorCode() { public String getColorCode() {
return colorCode; return colorCode;
} }
@@ -181,6 +268,30 @@ public class OrderItem {
this.materialGrams = materialGrams; this.materialGrams = materialGrams;
} }
public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm;
}
public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) {
this.boundingBoxXMm = boundingBoxXMm;
}
public BigDecimal getBoundingBoxYMm() {
return boundingBoxYMm;
}
public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) {
this.boundingBoxYMm = boundingBoxYMm;
}
public BigDecimal getBoundingBoxZMm() {
return boundingBoxZMm;
}
public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) {
this.boundingBoxZMm = boundingBoxZMm;
}
public BigDecimal getUnitPriceChf() { public BigDecimal getUnitPriceChf() {
return unitPriceChf; return unitPriceChf;
} }

View File

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

View File

@@ -0,0 +1,85 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "printer_machine_profile", uniqueConstraints = {
@UniqueConstraint(name = "ux_printer_machine_profile_machine_nozzle", columnNames = {
"printer_machine_id", "nozzle_diameter_mm"
})
})
public class PrinterMachineProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "printer_machine_profile_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_id", nullable = false)
private PrinterMachine printerMachine;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "orca_machine_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaMachineProfileName;
@ColumnDefault("false")
@Column(name = "is_default", nullable = false)
private Boolean isDefault;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PrinterMachine getPrinterMachine() {
return printerMachine;
}
public void setPrinterMachine(PrinterMachine printerMachine) {
this.printerMachine = printerMachine;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public String getOrcaMachineProfileName() {
return orcaMachineProfileName;
}
public void setOrcaMachineProfileName(String orcaMachineProfileName) {
this.orcaMachineProfileName = orcaMachineProfileName;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean isDefault) {
this.isDefault = isDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -40,6 +40,32 @@ public class QuoteLineItem {
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private FilamentVariant filamentVariant;
@Column(name = "material_code", length = Integer.MAX_VALUE)
private String materialCode;
@Column(name = "quality", length = Integer.MAX_VALUE)
private String quality;
@Column(name = "nozzle_diameter_mm", precision = 5, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "layer_height_mm", precision = 6, scale = 3)
private BigDecimal layerHeightMm;
@Column(name = "infill_percent")
private Integer infillPercent;
@Column(name = "infill_pattern", length = Integer.MAX_VALUE)
private String infillPattern;
@Column(name = "supports_enabled")
private Boolean supportsEnabled;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3) @Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm; private BigDecimal boundingBoxXMm;
@@ -124,6 +150,70 @@ public class QuoteLineItem {
this.colorCode = colorCode; this.colorCode = colorCode;
} }
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getQuality() {
return quality;
}
public void setQuality(String quality) {
this.quality = quality;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public Integer getInfillPercent() {
return infillPercent;
}
public void setInfillPercent(Integer infillPercent) {
this.infillPercent = infillPercent;
}
public String getInfillPattern() {
return infillPattern;
}
public void setInfillPattern(String infillPattern) {
this.infillPattern = infillPattern;
}
public Boolean getSupportsEnabled() {
return supportsEnabled;
}
public void setSupportsEnabled(Boolean supportsEnabled) {
this.supportsEnabled = supportsEnabled;
}
public BigDecimal getBoundingBoxXMm() { public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm; return boundingBoxXMm;
} }

View File

@@ -61,6 +61,15 @@ public class QuoteSession {
@Column(name = "converted_order_id") @Column(name = "converted_order_id")
private UUID convertedOrderId; private UUID convertedOrderId;
@Column(name = "source_request_id")
private UUID sourceRequestId;
@Column(name = "cad_hours", precision = 10, scale = 2)
private BigDecimal cadHours;
@Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2)
private BigDecimal cadHourlyRateChf;
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -173,4 +182,28 @@ public class QuoteSession {
this.convertedOrderId = convertedOrderId; this.convertedOrderId = convertedOrderId;
} }
public UUID getSourceRequestId() {
return sourceRequestId;
}
public void setSourceRequestId(UUID sourceRequestId) {
this.sourceRequestId = sourceRequestId;
}
public BigDecimal getCadHours() {
return cadHours;
}
public void setCadHours(BigDecimal cadHours) {
this.cadHours = cadHours;
}
public BigDecimal getCadHourlyRateChf() {
return cadHourlyRateChf;
}
public void setCadHourlyRateChf(BigDecimal cadHourlyRateChf) {
this.cadHourlyRateChf = cadHourlyRateChf;
}
} }

View File

@@ -0,0 +1,16 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class OrderShippedEvent extends ApplicationEvent {
private final Order order;
public OrderShippedEvent(Object source, Order order) {
super(source);
this.order = order;
}
}

View File

@@ -0,0 +1,24 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import org.springframework.context.ApplicationEvent;
public class PaymentConfirmedEvent extends ApplicationEvent {
private final Order order;
private final Payment payment;
public PaymentConfirmedEvent(Object source, Order order, Payment payment) {
super(source);
this.order = order;
this.payment = payment;
}
public Order getOrder() {
return order;
}
public Payment getPayment() {
return payment;
}
}

View File

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

View File

@@ -1,7 +1,16 @@
package com.printcalculator.event.listener; package com.printcalculator.event.listener;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import com.printcalculator.service.email.EmailNotificationService; import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -10,16 +19,29 @@ import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.text.NumberFormat;
import java.time.Year;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Currency;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.nio.file.Paths;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class OrderEmailListener { public class OrderEmailListener {
private static final String DEFAULT_LANGUAGE = "it";
private final EmailNotificationService emailNotificationService; private final EmailNotificationService emailNotificationService;
private final InvoicePdfRenderingService invoicePdfRenderingService;
private final OrderItemRepository orderItemRepository;
private final QrBillService qrBillService;
private final StorageService storageService;
@Value("${app.mail.admin.enabled:true}") @Value("${app.mail.admin.enabled:true}")
private boolean adminMailEnabled; private boolean adminMailEnabled;
@@ -47,41 +69,404 @@ public class OrderEmailListener {
} }
} }
@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);
}
}
@Async
@EventListener
public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) {
Order order = event.getOrder();
Payment payment = event.getPayment();
log.info("Processing PaymentConfirmedEvent for order id: {}", order.getId());
try {
sendPaidInvoiceEmail(order, payment);
} catch (Exception e) {
log.error("Failed to send paid invoice email for order id: {}", order.getId(), e);
}
}
@Async
@EventListener
public void handleOrderShippedEvent(OrderShippedEvent event) {
Order order = event.getOrder();
log.info("Processing OrderShippedEvent for order id: {}", order.getId());
try {
sendOrderShippedEmail(order);
} catch (Exception e) {
log.error("Failed to send order shipped email for order id: {}", order.getId(), e);
}
}
private void sendCustomerConfirmationEmail(Order order) { private void sendCustomerConfirmationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>(); String language = resolveLanguage(order.getPreferredLanguage());
templateData.put("customerName", order.getCustomer().getFirstName()); String orderNumber = getDisplayOrderNumber(order);
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order)); Map<String, Object> templateData = buildBaseTemplateData(order, language);
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order)); String subject = applyOrderConfirmationTexts(templateData, language, orderNumber);
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); byte[] confirmationPdf = loadOrGenerateConfirmationPdf(order);
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
emailNotificationService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
subject,
"order-confirmation",
templateData,
buildConfirmationAttachmentName(language, orderNumber),
confirmationPdf
);
}
private void sendPaymentReportedEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyPaymentReportedTexts(templateData, language, orderNumber);
emailNotificationService.sendEmail( emailNotificationService.sendEmail(
order.getCustomer().getEmail(), order.getCustomer().getEmail(),
"Conferma Ordine #" + getDisplayOrderNumber(order) + " - 3D-Fab", subject,
"order-confirmation", "payment-reported",
templateData
);
}
private void sendPaidInvoiceEmail(Order order, Payment payment) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyPaymentConfirmedTexts(templateData, language, orderNumber);
byte[] pdf = null;
try {
List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
pdf = invoicePdfRenderingService.generateDocumentPdf(order, items, false, qrBillService, payment);
} catch (Exception e) {
log.error("Failed to generate PDF for paid invoice email: {}", e.getMessage(), e);
}
emailNotificationService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
subject,
"payment-confirmed",
templateData,
buildPaidInvoiceAttachmentName(language, orderNumber),
pdf
);
}
private void sendOrderShippedEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyOrderShippedTexts(templateData, language, orderNumber);
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
subject,
"order-shipped",
templateData templateData
); );
} }
private void sendAdminNotificationEmail(Order order) { private void sendAdminNotificationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>(); String orderNumber = getDisplayOrderNumber(order);
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName()); Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
templateData.put("orderId", order.getId()); templateData.put("customerName", buildCustomerFullName(order));
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order)); templateData.put("emailTitle", "Nuovo ordine ricevuto");
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))); templateData.put("headlineText", "Nuovo ordine #" + orderNumber);
templateData.put("totalCost", String.format("%.2f", order.getTotalChf())); templateData.put("greetingText", "Ciao team,");
templateData.put("introText", "Un nuovo ordine e' stato creato dal cliente.");
templateData.put("detailsTitleText", "Dettagli ordine");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelDate", "Data");
templateData.put("labelTotal", "Totale");
templateData.put("orderDetailsCtaText", "Apri dettaglio ordine");
templateData.put("attachmentHintText", "La conferma cliente e il QR bill sono stati salvati nella cartella documenti dell'ordine.");
templateData.put("supportText", "Controlla i dettagli e procedi con la gestione operativa.");
templateData.put("footerText", "Notifica automatica sistema ordini.");
// Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro
emailNotificationService.sendEmail( emailNotificationService.sendEmail(
adminMailAddress, adminMailAddress,
"Nuovo Ordine Ricevuto #" + getDisplayOrderNumber(order) + " - " + order.getCustomer().getLastName(), "Nuovo Ordine Ricevuto #" + orderNumber + " - " + buildCustomerFullName(order),
"order-confirmation", "order-confirmation",
templateData templateData
); );
} }
private Map<String, Object> buildBaseTemplateData(Order order, String language) {
Locale locale = localeForLanguage(language);
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
currencyFormatter.setCurrency(Currency.getInstance("CHF"));
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", buildCustomerFirstName(order, language));
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order, language));
templateData.put(
"orderDate",
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
);
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("currentYear", Year.now().getValue());
return templateData;
}
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Order Confirmation");
templateData.put("headlineText", "Thank you for your order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "We received your order and started processing it.");
templateData.put("detailsTitleText", "Order details");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelDate", "Date");
templateData.put("labelTotal", "Total");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("attachmentHintText", "Attached you can find the order confirmation PDF with the QR bill.");
templateData.put("supportText", "If you have questions, reply to this email and we will help you.");
templateData.put("footerText", "Automated message from 3D-Fab.");
yield "Order Confirmation #" + orderNumber + " - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Bestellbestaetigung");
templateData.put("headlineText", "Danke fuer Ihre Bestellung #" + orderNumber);
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Wir haben Ihre Bestellung erhalten und mit der Bearbeitung begonnen.");
templateData.put("detailsTitleText", "Bestelldetails");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelDate", "Datum");
templateData.put("labelTotal", "Gesamtbetrag");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("attachmentHintText", "Im Anhang finden Sie die Bestellbestaetigung mit QR-Rechnung.");
templateData.put("supportText", "Bei Fragen antworten Sie einfach auf diese E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
yield "Bestellbestaetigung #" + orderNumber + " - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Confirmation de commande");
templateData.put("headlineText", "Merci pour votre commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Nous avons recu votre commande et commence son traitement.");
templateData.put("detailsTitleText", "Details de commande");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelDate", "Date");
templateData.put("labelTotal", "Total");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("attachmentHintText", "Vous trouverez en piece jointe la confirmation de commande avec la facture QR.");
templateData.put("supportText", "Si vous avez des questions, repondez a cet email.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
yield "Confirmation de commande #" + orderNumber + " - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Conferma ordine");
templateData.put("headlineText", "Grazie per il tuo ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Abbiamo ricevuto il tuo ordine e iniziato l'elaborazione.");
templateData.put("detailsTitleText", "Dettagli ordine");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelDate", "Data");
templateData.put("labelTotal", "Totale");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("attachmentHintText", "In allegato trovi la conferma ordine in PDF con QR bill.");
templateData.put("supportText", "Se hai domande, rispondi a questa email e ti aiutiamo subito.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
yield "Conferma Ordine #" + orderNumber + " - 3D-Fab";
}
};
}
private String applyPaymentReportedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Payment Reported");
templateData.put("headlineText", "Payment reported for order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "We received your payment report and our team is now verifying it.");
templateData.put("statusText", "Current status: Payment under verification.");
templateData.put("orderDetailsCtaText", "Check order status");
templateData.put("supportText", "You will receive another email as soon as the payment is confirmed.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "We are verifying your payment (Order #" + orderNumber + ")";
}
case "de" -> {
templateData.put("emailTitle", "Zahlung gemeldet");
templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " gemeldet");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Wir haben Ihre Zahlungsmitteilung erhalten und pruefen sie aktuell.");
templateData.put("statusText", "Aktueller Status: Zahlung in Pruefung.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Sobald die Zahlung bestaetigt ist, erhalten Sie eine weitere E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Wir pruefen Ihre Zahlung (Bestellung #" + orderNumber + ")";
}
case "fr" -> {
templateData.put("emailTitle", "Paiement signale");
templateData.put("headlineText", "Paiement signale pour la commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Nous avons recu votre signalement de paiement et nous le verifions.");
templateData.put("statusText", "Statut actuel: Paiement en verification.");
templateData.put("orderDetailsCtaText", "Consulter le statut de la commande");
templateData.put("supportText", "Vous recevrez un nouvel email des que le paiement sera confirme.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Nous verifions votre paiement (Commande #" + orderNumber + ")";
}
default -> {
templateData.put("emailTitle", "Pagamento segnalato");
templateData.put("headlineText", "Pagamento segnalato per ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Abbiamo ricevuto la tua segnalazione di pagamento e la stiamo verificando.");
templateData.put("statusText", "Stato attuale: pagamento in verifica.");
templateData.put("orderDetailsCtaText", "Controlla lo stato ordine");
templateData.put("supportText", "Riceverai una nuova email non appena il pagamento sara' confermato.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Stiamo verificando il tuo pagamento (Ordine #" + orderNumber + ")";
}
};
}
private String applyPaymentConfirmedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Payment Confirmed");
templateData.put("headlineText", "Payment confirmed for order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "Your payment has been confirmed and the order moved into production.");
templateData.put("statusText", "Current status: In production.");
templateData.put("attachmentHintText", "The paid invoice PDF is attached to this email.");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("supportText", "We will notify you again when the shipment is ready.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "Payment confirmed (Order #" + orderNumber + ") - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Zahlung bestaetigt");
templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " bestaetigt");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Ihre Zahlung wurde bestaetigt und die Bestellung ist jetzt in Produktion.");
templateData.put("statusText", "Aktueller Status: In Produktion.");
templateData.put("attachmentHintText", "Die bezahlte Rechnung als PDF ist dieser E-Mail beigefuegt.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Wir informieren Sie erneut, sobald der Versand bereit ist.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Zahlung bestaetigt (Bestellung #" + orderNumber + ") - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Paiement confirme");
templateData.put("headlineText", "Paiement confirme pour la commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Votre paiement est confirme et la commande est passe en production.");
templateData.put("statusText", "Statut actuel: En production.");
templateData.put("attachmentHintText", "La facture payee en PDF est jointe a cet email.");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("supportText", "Nous vous informerons a nouveau des que l'expedition sera prete.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Paiement confirme (Commande #" + orderNumber + ") - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Pagamento confermato");
templateData.put("headlineText", "Pagamento confermato per ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Il tuo pagamento e' stato confermato e l'ordine e' entrato in produzione.");
templateData.put("statusText", "Stato attuale: in produzione.");
templateData.put("attachmentHintText", "In allegato trovi la fattura saldata in PDF.");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("supportText", "Ti aggiorneremo di nuovo quando la spedizione sara' pronta.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Pagamento confermato (Ordine #" + orderNumber + ") - 3D-Fab";
}
};
}
private String applyOrderShippedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Order Shipped");
templateData.put("headlineText", "Your order #" + orderNumber + " has been shipped");
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "Good news: your package has left our workshop and is on its way.");
templateData.put("statusText", "Current status: Shipped.");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("supportText", "If you need assistance, reply to this email.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "Your order has been shipped (Order #" + orderNumber + ") - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Bestellung versandt");
templateData.put("headlineText", "Ihre Bestellung #" + orderNumber + " wurde versandt");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Gute Nachricht: Ihr Paket hat unsere Werkstatt verlassen und ist unterwegs.");
templateData.put("statusText", "Aktueller Status: Versandt.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Wenn Sie Hilfe benoetigen, antworten Sie auf diese E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Ihre Bestellung wurde versandt (Bestellung #" + orderNumber + ") - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Commande expediee");
templateData.put("headlineText", "Votre commande #" + orderNumber + " a ete expediee");
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Bonne nouvelle: votre colis a quitte notre atelier et est en route.");
templateData.put("statusText", "Statut actuel: Expediee.");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Votre commande a ete expediee (Commande #" + orderNumber + ") - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Ordine spedito");
templateData.put("headlineText", "Il tuo ordine #" + orderNumber + " e' stato spedito");
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Buone notizie: il tuo pacco e' partito dal nostro laboratorio ed e' in viaggio.");
templateData.put("statusText", "Stato attuale: spedito.");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("supportText", "Se hai bisogno di assistenza, rispondi a questa email.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Il tuo ordine e' stato spedito (Ordine #" + orderNumber + ") - 3D-Fab";
}
};
}
private String getDisplayOrderNumber(Order order) { private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber(); String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) { if (orderNumber != null && !orderNumber.isBlank()) {
@@ -90,8 +475,108 @@ public class OrderEmailListener {
return order.getId() != null ? order.getId().toString() : "unknown"; return order.getId() != null ? order.getId().toString() : "unknown";
} }
private String buildOrderDetailsUrl(Order order) { private String buildOrderDetailsUrl(Order order, String language) {
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", ""); String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
return baseUrl + "/ordine/" + order.getId(); return baseUrl + "/" + language + "/co/" + order.getId();
}
private String buildConfirmationAttachmentName(String language, String orderNumber) {
return switch (language) {
case "en" -> "Order-Confirmation-" + orderNumber + ".pdf";
case "de" -> "Bestellbestaetigung-" + orderNumber + ".pdf";
case "fr" -> "Confirmation-Commande-" + orderNumber + ".pdf";
default -> "Conferma-Ordine-" + orderNumber + ".pdf";
};
}
private String buildPaidInvoiceAttachmentName(String language, String orderNumber) {
return switch (language) {
case "en" -> "Paid-Invoice-" + orderNumber + ".pdf";
case "de" -> "Bezahlte-Rechnung-" + orderNumber + ".pdf";
case "fr" -> "Facture-Payee-" + orderNumber + ".pdf";
default -> "Fattura-Pagata-" + orderNumber + ".pdf";
};
}
private byte[] loadOrGenerateConfirmationPdf(Order order) {
byte[] stored = loadStoredConfirmationPdf(order);
if (stored != null) {
return stored;
}
try {
List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
return invoicePdfRenderingService.generateDocumentPdf(order, items, true, qrBillService, null);
} catch (Exception e) {
log.error("Failed to generate fallback confirmation PDF for order id: {}", order.getId(), e);
return null;
}
}
private byte[] loadStoredConfirmationPdf(Order order) {
String relativePath = buildConfirmationPdfRelativePath(order);
try {
return storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes();
} catch (Exception e) {
log.warn("Confirmation PDF not found for order id {} at {}", order.getId(), relativePath);
return null;
}
}
private String buildConfirmationPdfRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf";
}
private String buildCustomerFirstName(Order order, String language) {
if (order.getCustomer() != null && order.getCustomer().getFirstName() != null && !order.getCustomer().getFirstName().isBlank()) {
return order.getCustomer().getFirstName();
}
if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()) {
return order.getBillingFirstName();
}
return switch (language) {
case "en" -> "Customer";
case "de" -> "Kunde";
case "fr" -> "Client";
default -> "Cliente";
};
}
private String buildCustomerFullName(Order order) {
String firstName = order.getCustomer() != null ? order.getCustomer().getFirstName() : null;
String lastName = order.getCustomer() != null ? order.getCustomer().getLastName() : null;
if (firstName != null && !firstName.isBlank() && lastName != null && !lastName.isBlank()) {
return firstName + " " + lastName;
}
if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()
&& order.getBillingLastName() != null && !order.getBillingLastName().isBlank()) {
return order.getBillingFirstName() + " " + order.getBillingLastName();
}
return "Cliente";
}
private Locale localeForLanguage(String language) {
return switch (language) {
case "en" -> Locale.ENGLISH;
case "de" -> Locale.GERMAN;
case "fr" -> Locale.FRENCH;
default -> Locale.ITALIAN;
};
}
private String resolveLanguage(String language) {
if (language == null || language.isBlank()) {
return DEFAULT_LANGUAGE;
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (normalized.length() > 2) {
normalized = normalized.substring(0, 2);
}
return switch (normalized) {
case "it", "en", "de", "fr" -> normalized;
default -> DEFAULT_LANGUAGE;
};
} }
} }

View File

@@ -2,17 +2,35 @@ package com.printcalculator.exception;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
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.context.request.WebRequest; import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
@ControllerAdvice @ControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(ModelProcessingException.class)
public ResponseEntity<Object> handleModelProcessingException(
ModelProcessingException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("status", HttpStatus.UNPROCESSABLE_ENTITY.value());
body.put("error", "Unprocessable Entity");
body.put("code", ex.getCode());
body.put("message", ex.getMessage());
body.put("path", extractPath(request));
return new ResponseEntity<>(body, HttpStatus.UNPROCESSABLE_ENTITY);
}
@ExceptionHandler(VirusDetectedException.class) @ExceptionHandler(VirusDetectedException.class)
public ResponseEntity<Object> handleVirusDetectedException( public ResponseEntity<Object> handleVirusDetectedException(
VirusDetectedException ex, WebRequest request) { VirusDetectedException ex, WebRequest request) {
@@ -24,4 +42,42 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
} }
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleValidationException(
MethodArgumentNotValidException ex, WebRequest request) {
List<String> details = new ArrayList<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
details.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", "Dati non validi.");
body.put("error", "Validation Error");
body.put("details", details);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgumentException(
IllegalArgumentException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
body.put("error", "Bad Request");
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
private String extractPath(WebRequest request) {
String raw = request.getDescription(false);
if (raw == null) {
return "";
}
return raw.startsWith("uri=") ? raw.substring(4) : raw;
}
} }

View File

@@ -0,0 +1,21 @@
package com.printcalculator.exception;
import java.io.IOException;
public class ModelProcessingException extends IOException {
private final String code;
public ModelProcessingException(String code, String message) {
super(message);
this.code = code;
}
public ModelProcessingException(String code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.model;
public record ModelDimensions(
double xMm,
double yMm,
double zMm
) {}

View File

@@ -4,13 +4,10 @@ public class QuoteResult {
private double totalPrice; private double totalPrice;
private String currency; private String currency;
private PrintStats stats; private PrintStats stats;
private double setupCost; public QuoteResult(double totalPrice, String currency, PrintStats stats) {
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
this.totalPrice = totalPrice; this.totalPrice = totalPrice;
this.currency = currency; this.currency = currency;
this.stats = stats; this.stats = stats;
this.setupCost = setupCost;
} }
public double getTotalPrice() { public double getTotalPrice() {
@@ -24,8 +21,4 @@ public class QuoteResult {
public PrintStats getStats() { public PrintStats getStats() {
return stats; return stats;
} }
public double getSetupCost() {
return setupCost;
}
} }

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> { public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> {
List<CustomQuoteRequestAttachment> findByRequest_IdOrderByCreatedAtAsc(UUID requestId);
} }

View File

@@ -0,0 +1,15 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantOrcaOverride;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FilamentVariantOrcaOverrideRepository extends JpaRepository<FilamentVariantOrcaOverride, Long> {
Optional<FilamentVariantOrcaOverride> findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(
FilamentVariant filamentVariant,
PrinterMachineProfile printerMachineProfile
);
}

View File

@@ -2,12 +2,18 @@ package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.FilamentVariant;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentMaterialType;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> { public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
@EntityGraph(attributePaths = {"filamentMaterialType"})
List<FilamentVariant> findByIsActiveTrue();
// We try to match by color name if possible, or get first active // We try to match by color name if possible, or get first active
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName); Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
Optional<FilamentVariant> findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName);
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type); Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
} }

View File

@@ -0,0 +1,20 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface MaterialOrcaProfileMapRepository extends JpaRepository<MaterialOrcaProfileMap, Long> {
Optional<MaterialOrcaProfileMap> findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
PrinterMachineProfile printerMachineProfile,
FilamentMaterialType filamentMaterialType
);
@EntityGraph(attributePaths = {"filamentMaterialType"})
List<MaterialOrcaProfileMap> findByPrinterMachineProfileAndIsActiveTrue(PrinterMachineProfile printerMachineProfile);
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.repository;
import com.printcalculator.entity.NozzleLayerHeightOption;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NozzleLayerHeightOptionRepository extends JpaRepository<NozzleLayerHeightOption, Long> {
List<NozzleLayerHeightOption> findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
}

View File

@@ -3,5 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.NozzleOption;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.Optional;
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> { public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
Optional<NozzleOption> findFirstByNozzleDiameterMmAndIsActiveTrue(BigDecimal nozzleDiameterMm);
} }

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> { public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
List<OrderItem> findByOrder_Id(UUID orderId); List<OrderItem> findByOrder_Id(UUID orderId);
boolean existsByFilamentVariant_Id(Long filamentVariantId);
} }

View File

@@ -3,7 +3,11 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> { public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findAllByOrderByCreatedAtDesc();
boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId);
} }

View File

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

View File

@@ -0,0 +1,15 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
public interface PrinterMachineProfileRepository extends JpaRepository<PrinterMachineProfile, Long> {
Optional<PrinterMachineProfile> findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm);
Optional<PrinterMachineProfile> findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(PrinterMachine printerMachine);
List<PrinterMachineProfile> findByPrinterMachineAndIsActiveTrue(PrinterMachine printerMachine);
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> { public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
boolean existsByFilamentVariant_Id(Long filamentVariantId);
} }

View File

@@ -8,4 +8,6 @@ import java.util.UUID;
public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> { public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> {
List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff); List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff);
List<QuoteSession> findByStatusInOrderByCreatedAtDesc(List<String> statuses);
} }

View File

@@ -0,0 +1,101 @@
package com.printcalculator.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.OptionalLong;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AdminLoginThrottleService {
private static final long BASE_DELAY_SECONDS = 2L;
private static final long MAX_DELAY_SECONDS = 3601L;
private final ConcurrentHashMap<String, LoginAttemptState> attemptsByClient = new ConcurrentHashMap<>();
private final boolean trustProxyHeaders;
public AdminLoginThrottleService(
@Value("${admin.auth.trust-proxy-headers:false}") boolean trustProxyHeaders
) {
this.trustProxyHeaders = trustProxyHeaders;
}
public OptionalLong getRemainingLockSeconds(String clientKey) {
LoginAttemptState state = attemptsByClient.get(clientKey);
if (state == null) {
return OptionalLong.empty();
}
long now = Instant.now().getEpochSecond();
long remaining = state.blockedUntilEpochSeconds - now;
if (remaining <= 0) {
attemptsByClient.remove(clientKey, state);
return OptionalLong.empty();
}
return OptionalLong.of(remaining);
}
public long registerFailure(String clientKey) {
long now = Instant.now().getEpochSecond();
LoginAttemptState state = attemptsByClient.compute(clientKey, (key, current) -> {
int nextFailures = current == null ? 1 : current.failures + 1;
long delay = calculateDelaySeconds(nextFailures);
return new LoginAttemptState(nextFailures, now + delay);
});
return calculateDelaySeconds(state.failures);
}
public void reset(String clientKey) {
attemptsByClient.remove(clientKey);
}
public String resolveClientKey(HttpServletRequest request) {
if (trustProxyHeaders) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
String[] parts = forwardedFor.split(",");
if (parts.length > 0 && !parts[0].trim().isEmpty()) {
return parts[0].trim();
}
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
}
String remoteAddress = request.getRemoteAddr();
if (remoteAddress != null && !remoteAddress.isBlank()) {
return remoteAddress.trim();
}
return "unknown";
}
private long calculateDelaySeconds(int failures) {
long delay = BASE_DELAY_SECONDS;
for (int i = 1; i < failures; i++) {
if (delay >= MAX_DELAY_SECONDS) {
return MAX_DELAY_SECONDS;
}
delay *= 2;
}
return Math.min(delay, MAX_DELAY_SECONDS);
}
private static class LoginAttemptState {
private final int failures;
private final long blockedUntilEpochSeconds;
private LoginAttemptState(int failures, long blockedUntilEpochSeconds) {
this.failures = failures;
this.blockedUntilEpochSeconds = blockedUntilEpochSeconds;
}
}
}

View File

@@ -0,0 +1,71 @@
package com.printcalculator.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component
public class AdminSessionAuthenticationFilter extends OncePerRequestFilter {
private final AdminSessionService adminSessionService;
public AdminSessionAuthenticationFilter(AdminSessionService adminSessionService) {
this.adminSessionService = adminSessionService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = resolvePath(request);
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (!path.startsWith("/api/admin/")) {
return true;
}
return "/api/admin/auth/login".equals(path);
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Optional<String> token = adminSessionService.extractTokenFromCookies(request);
Optional<AdminSessionService.AdminSessionPayload> payload = token.flatMap(adminSessionService::validateSessionToken);
if (payload.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
return;
}
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated(
"admin",
null,
Collections.emptyList()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private String resolvePath(HttpServletRequest request) {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
return path.substring(contextPath.length());
}
return path;
}
}

View File

@@ -0,0 +1,203 @@
package com.printcalculator.security;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
@Service
public class AdminSessionService {
public static final String COOKIE_NAME = "admin_session";
private static final String COOKIE_PATH = "/api/admin";
private static final String HMAC_ALGORITHM = "HmacSHA256";
private final ObjectMapper objectMapper;
private final String adminPassword;
private final byte[] sessionSecret;
private final long sessionTtlMinutes;
public AdminSessionService(
ObjectMapper objectMapper,
@Value("${admin.password}") String adminPassword,
@Value("${admin.session.secret}") String sessionSecret,
@Value("${admin.session.ttl-minutes}") long sessionTtlMinutes
) {
this.objectMapper = objectMapper;
this.adminPassword = adminPassword;
this.sessionSecret = sessionSecret.getBytes(StandardCharsets.UTF_8);
this.sessionTtlMinutes = sessionTtlMinutes;
validateConfiguration(adminPassword, sessionSecret, sessionTtlMinutes);
}
public boolean isPasswordValid(String candidatePassword) {
if (candidatePassword == null) {
return false;
}
return MessageDigest.isEqual(
adminPassword.getBytes(StandardCharsets.UTF_8),
candidatePassword.getBytes(StandardCharsets.UTF_8)
);
}
public String createSessionToken() {
Instant now = Instant.now();
AdminSessionPayload payload = new AdminSessionPayload(
now.getEpochSecond(),
now.plus(Duration.ofMinutes(sessionTtlMinutes)).getEpochSecond(),
UUID.randomUUID().toString()
);
try {
String payloadJson = objectMapper.writeValueAsString(payload);
String encodedPayload = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8));
String signature = base64UrlEncode(sign(encodedPayload));
return encodedPayload + "." + signature;
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot create admin session token", e);
}
}
public Optional<AdminSessionPayload> validateSessionToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
String[] parts = token.split("\\.");
if (parts.length != 2) {
return Optional.empty();
}
String encodedPayload = parts[0];
String encodedSignature = parts[1];
byte[] providedSignature;
try {
providedSignature = base64UrlDecode(encodedSignature);
} catch (IllegalArgumentException e) {
return Optional.empty();
}
byte[] expectedSignature = sign(encodedPayload);
if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {
return Optional.empty();
}
try {
byte[] decodedPayload = base64UrlDecode(encodedPayload);
AdminSessionPayload payload = objectMapper.readValue(decodedPayload, AdminSessionPayload.class);
if (payload.exp <= Instant.now().getEpochSecond()) {
return Optional.empty();
}
return Optional.of(payload);
} catch (IllegalArgumentException | IOException e) {
return Optional.empty();
}
}
public Optional<String> extractTokenFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (COOKIE_NAME.equals(cookie.getName())) {
return Optional.ofNullable(cookie.getValue());
}
}
return Optional.empty();
}
public ResponseCookie buildLoginCookie(String token) {
return ResponseCookie.from(COOKIE_NAME, token)
.path(COOKIE_PATH)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ofMinutes(sessionTtlMinutes))
.build();
}
public ResponseCookie buildLogoutCookie() {
return ResponseCookie.from(COOKIE_NAME, "")
.path(COOKIE_PATH)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ZERO)
.build();
}
public long getSessionTtlMinutes() {
return sessionTtlMinutes;
}
private byte[] sign(String encodedPayload) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(sessionSecret, HMAC_ALGORITHM));
return mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Cannot sign admin session token", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
private byte[] base64UrlDecode(String data) {
return Base64.getUrlDecoder().decode(data);
}
private void validateConfiguration(String password, String secret, long ttlMinutes) {
if (password == null || password.isBlank()) {
throw new IllegalStateException("ADMIN_PASSWORD must be configured and non-empty");
}
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be configured and non-empty");
}
if (secret.length() < 32) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be at least 32 characters long");
}
if (ttlMinutes <= 0) {
throw new IllegalStateException("ADMIN_SESSION_TTL_MINUTES must be > 0");
}
}
public static class AdminSessionPayload {
@JsonProperty("iat")
public long iat;
@JsonProperty("exp")
public long exp;
@JsonProperty("nonce")
public String nonce;
public AdminSessionPayload() {
}
public AdminSessionPayload(long iat, long exp, String nonce) {
this.iat = iat;
this.exp = exp;
this.nonce = nonce;
}
}
}

View File

@@ -1,48 +0,0 @@
package com.printcalculator.service;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
@Service
public class InvoicePdfRenderingService {
private final TemplateEngine thymeleafTemplateEngine;
public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) {
this.thymeleafTemplateEngine = thymeleafTemplateEngine;
}
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables, String qrBillSvg) {
try {
Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY);
thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables);
thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg);
String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData);
String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm();
ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream();
PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder();
openHtmlToPdfRendererBuilder.useFastMode();
openHtmlToPdfRendererBuilder.useSVGDrawer(new BatikSVGDrawer());
openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources);
openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream);
openHtmlToPdfRendererBuilder.run();
return generatedPdfByteArrayOutputStream.toByteArray();
} catch (Exception pdfGenerationException) {
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
}
}
}

View File

@@ -0,0 +1,143 @@
package com.printcalculator.service;
import com.printcalculator.entity.NozzleLayerHeightOption;
import com.printcalculator.repository.NozzleLayerHeightOptionRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class NozzleLayerHeightPolicyService {
private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP);
private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP);
private final NozzleLayerHeightOptionRepository ruleRepo;
public NozzleLayerHeightPolicyService(NozzleLayerHeightOptionRepository ruleRepo) {
this.ruleRepo = ruleRepo;
}
public Map<BigDecimal, List<BigDecimal>> getActiveRulesByNozzle() {
List<NozzleLayerHeightOption> rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
if (rules.isEmpty()) {
return fallbackRules();
}
Map<BigDecimal, List<BigDecimal>> byNozzle = new LinkedHashMap<>();
for (NozzleLayerHeightOption rule : rules) {
BigDecimal nozzle = normalizeNozzle(rule.getNozzleDiameterMm());
BigDecimal layer = normalizeLayer(rule.getLayerHeightMm());
if (nozzle == null || layer == null) {
continue;
}
byNozzle.computeIfAbsent(nozzle, ignored -> new ArrayList<>()).add(layer);
}
byNozzle.values().forEach(this::sortAndDeduplicate);
return byNozzle;
}
public BigDecimal normalizeNozzle(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal normalizeLayer(BigDecimal value) {
if (value == null) {
return null;
}
return value.setScale(3, RoundingMode.HALF_UP);
}
public BigDecimal resolveNozzle(BigDecimal requestedNozzle) {
return normalizeNozzle(requestedNozzle != null ? requestedNozzle : DEFAULT_NOZZLE);
}
public BigDecimal resolveLayer(BigDecimal requestedLayer, BigDecimal nozzleDiameter) {
if (requestedLayer != null) {
return normalizeLayer(requestedLayer);
}
return defaultLayerForNozzle(nozzleDiameter);
}
public List<BigDecimal> allowedLayersForNozzle(BigDecimal nozzleDiameter) {
BigDecimal nozzle = resolveNozzle(nozzleDiameter);
List<BigDecimal> allowed = getActiveRulesByNozzle().get(nozzle);
return allowed != null ? allowed : List.of();
}
public boolean isAllowed(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
BigDecimal layer = normalizeLayer(layerHeight);
if (layer == null) {
return false;
}
return allowedLayersForNozzle(nozzleDiameter)
.stream()
.anyMatch(allowed -> allowed.compareTo(layer) == 0);
}
public BigDecimal defaultLayerForNozzle(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return DEFAULT_LAYER;
}
BigDecimal preferred = normalizeLayer(DEFAULT_LAYER);
for (BigDecimal candidate : allowed) {
if (candidate.compareTo(preferred) == 0) {
return candidate;
}
}
return allowed.get(0);
}
public String allowedLayersLabel(BigDecimal nozzleDiameter) {
List<BigDecimal> allowed = allowedLayersForNozzle(nozzleDiameter);
if (allowed.isEmpty()) {
return "none";
}
return allowed.stream()
.map(value -> String.format(Locale.ROOT, "%.2f", value))
.reduce((a, b) -> a + ", " + b)
.orElse("none");
}
private void sortAndDeduplicate(List<BigDecimal> values) {
values.sort(Comparator.naturalOrder());
for (int i = values.size() - 1; i > 0; i--) {
if (values.get(i).compareTo(values.get(i - 1)) == 0) {
values.remove(i);
}
}
}
private Map<BigDecimal, List<BigDecimal>> fallbackRules() {
Map<BigDecimal, List<BigDecimal>> fallback = new LinkedHashMap<>();
fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12));
fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28));
fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36));
fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56));
return fallback;
}
private BigDecimal scaleNozzle(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
}
private List<BigDecimal> scaleLayers(double... values) {
List<BigDecimal> scaled = new ArrayList<>();
for (double value : values) {
scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP));
}
return scaled;
}
}

View File

@@ -0,0 +1,152 @@
package com.printcalculator.service;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantOrcaOverride;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentVariantOrcaOverrideRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Optional;
@Service
public class OrcaProfileResolver {
private final PrinterMachineProfileRepository machineProfileRepo;
private final MaterialOrcaProfileMapRepository materialMapRepo;
private final FilamentVariantOrcaOverrideRepository variantOverrideRepo;
public OrcaProfileResolver(
PrinterMachineProfileRepository machineProfileRepo,
MaterialOrcaProfileMapRepository materialMapRepo,
FilamentVariantOrcaOverrideRepository variantOverrideRepo
) {
this.machineProfileRepo = machineProfileRepo;
this.materialMapRepo = materialMapRepo;
this.variantOverrideRepo = variantOverrideRepo;
}
public ResolvedProfiles resolve(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm, FilamentVariant variant) {
Optional<PrinterMachineProfile> machineProfileOpt = resolveMachineProfile(printerMachine, nozzleDiameterMm);
String machineProfileName = machineProfileOpt
.map(PrinterMachineProfile::getOrcaMachineProfileName)
.orElseGet(() -> fallbackMachineProfile(printerMachine, nozzleDiameterMm));
String filamentProfileName = machineProfileOpt
.map(machineProfile -> resolveFilamentProfileWithMachineProfile(machineProfile, variant)
.orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType())))
.orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType()));
return new ResolvedProfiles(machineProfileName, filamentProfileName, machineProfileOpt.orElse(null));
}
public Optional<PrinterMachineProfile> resolveMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null) {
return Optional.empty();
}
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle != null) {
Optional<PrinterMachineProfile> exact = machineProfileRepo
.findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(machine, normalizedNozzle);
if (exact.isPresent()) {
return exact;
}
}
Optional<PrinterMachineProfile> defaultProfile = machineProfileRepo
.findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(machine);
if (defaultProfile.isPresent()) {
return defaultProfile;
}
return machineProfileRepo.findByPrinterMachineAndIsActiveTrue(machine)
.stream()
.findFirst();
}
private Optional<String> resolveFilamentProfileWithMachineProfile(PrinterMachineProfile machineProfile, FilamentVariant variant) {
if (machineProfile == null || variant == null) {
return Optional.empty();
}
Optional<FilamentVariantOrcaOverride> override = variantOverrideRepo
.findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(variant, machineProfile);
if (override.isPresent()) {
return Optional.ofNullable(override.get().getOrcaFilamentProfileName());
}
Optional<MaterialOrcaProfileMap> map = materialMapRepo
.findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
machineProfile,
variant.getFilamentMaterialType()
);
return map.map(MaterialOrcaProfileMap::getOrcaFilamentProfileName);
}
private String fallbackMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null || machine.getPrinterDisplayName() == null || machine.getPrinterDisplayName().isBlank()) {
return "Bambu Lab A1 0.4 nozzle";
}
String displayName = machine.getPrinterDisplayName();
if (displayName.toLowerCase().contains("bambulab a1") || displayName.toLowerCase().contains("bambu lab a1")) {
String nozzleForProfile = formatNozzleForProfileName(nozzleDiameterMm);
if (nozzleForProfile == null) {
return "Bambu Lab A1 0.4 nozzle";
}
return "Bambu Lab A1 " + nozzleForProfile + " nozzle";
}
return displayName;
}
private String fallbackFilamentProfile(FilamentMaterialType materialType) {
String materialCode = materialType != null && materialType.getMaterialCode() != null
? materialType.getMaterialCode().trim().toUpperCase()
: "PLA";
return switch (materialCode) {
case "PLA TOUGH" -> "Bambu PLA Tough @BBL A1";
case "PETG" -> "Generic PETG";
case "TPU" -> "Generic TPU";
case "PC" -> "Generic PC";
case "ABS" -> "Generic ABS";
default -> "Generic PLA";
};
}
private BigDecimal normalizeNozzle(BigDecimal nozzleDiameterMm) {
if (nozzleDiameterMm == null) {
return null;
}
return nozzleDiameterMm.setScale(2, RoundingMode.HALF_UP);
}
private String formatNozzleForProfileName(BigDecimal nozzleDiameterMm) {
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle == null) {
return null;
}
BigDecimal stripped = normalizedNozzle.stripTrailingZeros();
if (stripped.scale() < 0) {
stripped = stripped.setScale(0);
}
return stripped.toPlainString();
}
public record ResolvedProfiles(
String machineProfileName,
String filamentProfileName,
PrinterMachineProfile machineProfile
) {}
}

View File

@@ -1,6 +1,5 @@
package com.printcalculator.service; package com.printcalculator.service;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.CreateOrderRequest; import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.entity.*; import com.printcalculator.entity.*;
import com.printcalculator.repository.CustomerRepository; import com.printcalculator.repository.CustomerRepository;
@@ -9,23 +8,27 @@ 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 com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher; 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 java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets; import java.math.RoundingMode;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -36,6 +39,8 @@ public class OrderService {
private final InvoicePdfRenderingService invoiceService; private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final PaymentService paymentService;
private final QuoteSessionTotalsService quoteSessionTotalsService;
public OrderService(OrderRepository orderRepo, public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -45,7 +50,9 @@ public class OrderService {
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService,
ApplicationEventPublisher eventPublisher) { ApplicationEventPublisher eventPublisher,
PaymentService paymentService,
QuoteSessionTotalsService quoteSessionTotalsService) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
@@ -55,10 +62,16 @@ public class OrderService {
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.paymentService = paymentService;
this.quoteSessionTotalsService = quoteSessionTotalsService;
} }
@Transactional @Transactional
public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) { public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
if (!request.isAcceptTerms() || !request.isAcceptPrivacy()) {
throw new IllegalArgumentException("Accettazione Termini e Privacy obbligatoria.");
}
QuoteSession session = quoteSessionRepo.findById(quoteSessionId) QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
.orElseThrow(() -> new RuntimeException("Quote Session not found")); .orElseThrow(() -> new RuntimeException("Quote Session not found"));
@@ -78,6 +91,14 @@ public class OrderService {
customer.setPhone(request.getCustomer().getPhone()); customer.setPhone(request.getCustomer().getPhone());
customer.setCustomerType(request.getCustomer().getCustomerType()); customer.setCustomerType(request.getCustomer().getCustomerType());
if (request.getBillingAddress() != null) {
customer.setFirstName(request.getBillingAddress().getFirstName());
customer.setLastName(request.getBillingAddress().getLastName());
customer.setCompanyName(request.getBillingAddress().getCompanyName());
customer.setContactPerson(request.getBillingAddress().getContactPerson());
}
customer.setUpdatedAt(OffsetDateTime.now()); customer.setUpdatedAt(OffsetDateTime.now());
customerRepo.save(customer); customerRepo.save(customer);
@@ -89,6 +110,7 @@ public class OrderService {
order.setStatus("PENDING_PAYMENT"); order.setStatus("PENDING_PAYMENT");
order.setCreatedAt(OffsetDateTime.now()); order.setCreatedAt(OffsetDateTime.now());
order.setUpdatedAt(OffsetDateTime.now()); order.setUpdatedAt(OffsetDateTime.now());
order.setPreferredLanguage(normalizeLanguage(request.getLanguage()));
order.setCurrency("CHF"); order.setCurrency("CHF");
order.setBillingCustomerType(request.getCustomer().getCustomerType()); order.setBillingCustomerType(request.getCustomer().getCustomerType());
@@ -128,13 +150,20 @@ public class OrderService {
} }
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, quoteItems);
BigDecimal cadTotal = totals.cadTotalChf();
BigDecimal subtotal = BigDecimal.ZERO; BigDecimal subtotal = BigDecimal.ZERO;
order.setSubtotalChf(BigDecimal.ZERO); order.setSubtotalChf(BigDecimal.ZERO);
order.setTotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO);
order.setDiscountChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO);
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); order.setSetupCostChf(totals.setupCostChf());
order.setShippingCostChf(BigDecimal.valueOf(9.00)); order.setShippingCostChf(totals.shippingCostChf());
order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus()));
order.setSourceRequestId(session.getSourceRequestId());
order.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO);
order.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO);
order.setCadTotalChf(cadTotal);
order = orderRepo.save(order); order = orderRepo.save(order);
@@ -144,14 +173,40 @@ public class OrderService {
OrderItem oItem = new OrderItem(); OrderItem oItem = new OrderItem();
oItem.setOrder(order); oItem.setOrder(order);
oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setQuantity(qItem.getQuantity()); int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1;
oItem.setQuantity(quantity);
oItem.setColorCode(qItem.getColorCode()); oItem.setColorCode(qItem.getColorCode());
oItem.setFilamentVariant(qItem.getFilamentVariant());
if (qItem.getFilamentVariant() != null
&& qItem.getFilamentVariant().getFilamentMaterialType() != null
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
} else {
oItem.setMaterialCode(session.getMaterialCode()); oItem.setMaterialCode(session.getMaterialCode());
}
oItem.setQuality(qItem.getQuality());
oItem.setNozzleDiameterMm(qItem.getNozzleDiameterMm());
oItem.setLayerHeightMm(qItem.getLayerHeightMm());
oItem.setInfillPercent(qItem.getInfillPercent());
oItem.setInfillPattern(qItem.getInfillPattern());
oItem.setSupportsEnabled(qItem.getSupportsEnabled());
oItem.setUnitPriceChf(qItem.getUnitPriceChf()); BigDecimal distributedUnitPrice = qItem.getUnitPriceChf() != null ? qItem.getUnitPriceChf() : BigDecimal.ZERO;
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity()))); if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
distributedUnitPrice = distributedUnitPrice.add(unitMachineCost);
}
oItem.setUnitPriceChf(distributedUnitPrice);
oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(quantity)));
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
oItem.setMaterialGrams(qItem.getMaterialGrams()); oItem.setMaterialGrams(qItem.getMaterialGrams());
oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm());
oItem.setBoundingBoxYMm(qItem.getBoundingBoxYMm());
oItem.setBoundingBoxZMm(qItem.getBoundingBoxZMm());
UUID fileUuid = UUID.randomUUID(); UUID fileUuid = UUID.randomUUID();
String ext = getExtension(qItem.getOriginalFilename()); String ext = getExtension(qItem.getOriginalFilename());
@@ -167,16 +222,15 @@ public class OrderService {
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath); oItem.setStoredRelativePath(relativePath);
if (qItem.getStoredPath() != null) { Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
if (sourcePath == null || !Files.exists(sourcePath)) {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
}
try { try {
Path sourcePath = Paths.get(qItem.getStoredPath());
if (Files.exists(sourcePath)) {
storageService.store(sourcePath, Paths.get(relativePath)); storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath)); oItem.setFileSizeBytes(Files.size(sourcePath));
}
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
}
} }
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
@@ -184,12 +238,12 @@ public class OrderService {
subtotal = subtotal.add(oItem.getLineTotalChf()); subtotal = subtotal.add(oItem.getLineTotalChf());
} }
order.setSubtotalChf(subtotal); order.setSubtotalChf(subtotal.add(cadTotal));
if (order.getShippingCostChf() == null) {
order.setShippingCostChf(BigDecimal.valueOf(9.00));
}
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); BigDecimal total = order.getSubtotalChf()
.add(order.getSetupCostChf())
.add(order.getShippingCostChf())
.subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
order.setTotalChf(total); order.setTotalChf(total);
session.setConvertedOrderId(order.getId()); session.setConvertedOrderId(order.getId());
@@ -201,6 +255,9 @@ public class OrderService {
Order savedOrder = orderRepo.save(order); Order savedOrder = orderRepo.save(order);
// ALWAYS initialize payment as PENDING
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder)); eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return savedOrder; return savedOrder;
@@ -208,74 +265,13 @@ public class OrderService {
private void generateAndSaveDocuments(Order order, List<OrderItem> items) { private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
try { try {
// 1. Generate QR Bill // 1. Generate and save the raw QR Bill for internal traceability.
byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order); byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order);
String qrBillSvg = new String(qrBillSvgBytes, StandardCharsets.UTF_8); saveFileBytes(qrBillSvgBytes, buildQrBillSvgRelativePath(order));
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page // 2. Generate and save the same confirmation PDF served by /api/orders/{id}/confirmation.
if (qrBillSvg.contains("<?xml")) { byte[] confirmationPdfBytes = invoiceService.generateDocumentPdf(order, items, true, qrBillService, null);
int svgStartIndex = qrBillSvg.indexOf("<svg"); saveFileBytes(confirmationPdfBytes, buildConfirmationPdfRelativePath(order));
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
// Save QR Bill SVG
String qrRelativePath = "orders/" + order.getId() + "/documents/qr-bill.svg";
saveFileBytes(qrBillSvgBytes, qrRelativePath);
// 2. Prepare Invoice Variables
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = "BUSINESS".equals(order.getBillingCustomerType())
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
// 3. Generate PDF
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
// Save PDF
String pdfRelativePath = "orders/" + order.getId() + "/documents/invoice-" + order.getId() + ".pdf";
saveFileBytes(pdfBytes, pdfRelativePath);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
@@ -306,6 +302,23 @@ public class OrderService {
return "stl"; return "stl";
} }
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private String getDisplayOrderNumber(Order order) { private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber(); String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) { if (orderNumber != null && !orderNumber.isBlank()) {
@@ -313,4 +326,28 @@ public class OrderService {
} }
return order.getId() != null ? order.getId().toString() : "unknown"; return order.getId() != null ? order.getId().toString() : "unknown";
} }
private String buildQrBillSvgRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/qr-bill.svg";
}
private String buildConfirmationPdfRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf";
}
private String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (normalized.length() > 2) {
normalized = normalized.substring(0, 2);
}
return switch (normalized) {
case "it", "en", "de", "fr" -> normalized;
default -> "it";
};
}
} }

View File

@@ -7,9 +7,14 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
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.Comparator;
import java.util.Collections;
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;
@@ -17,21 +22,31 @@ import java.util.stream.Stream;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@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 static final Pattern LAYER_MM_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)?)mm\\b", Pattern.CASE_INSENSITIVE);
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;
private volatile List<ProcessProfileMeta> cachedProcessProfiles;
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) { public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
this.profilesRoot = profilesRoot; this.profilesRoot = profilesRoot;
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() {
@@ -40,6 +55,7 @@ public class ProfileManager {
// Material Aliases // Material Aliases
profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1"); profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1");
profileAliases.put("pla_tough", "Bambu PLA Tough @BBL A1");
profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1"); profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1");
profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1"); profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1");
@@ -55,13 +71,73 @@ 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); logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
return resolveInheritance(profilePath); return resolveInheritance(profilePath);
} }
public List<BigDecimal> findCompatibleProcessLayers(String machineProfileName) {
if (machineProfileName == null || machineProfileName.isBlank()) {
return List.of();
}
Set<BigDecimal> layers = new LinkedHashSet<>();
for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) {
if (meta.compatiblePrinters().contains(machineProfileName) && meta.layerHeightMm() != null) {
layers.add(meta.layerHeightMm());
}
}
if (layers.isEmpty()) {
return List.of();
}
List<BigDecimal> sorted = new ArrayList<>(layers);
sorted.sort(Comparator.naturalOrder());
return sorted;
}
public Optional<String> findCompatibleProcessProfileName(String machineProfileName,
BigDecimal layerHeightMm,
String qualityHint) {
if (machineProfileName == null || machineProfileName.isBlank() || layerHeightMm == null) {
return Optional.empty();
}
BigDecimal normalizedLayer = layerHeightMm.setScale(3, RoundingMode.HALF_UP);
String normalizedQuality = String.valueOf(qualityHint == null ? "" : qualityHint)
.trim()
.toLowerCase(Locale.ROOT);
List<ProcessProfileMeta> candidates = new ArrayList<>();
for (ProcessProfileMeta meta : getOrLoadProcessProfiles()) {
if (!meta.compatiblePrinters().contains(machineProfileName)) {
continue;
}
if (meta.layerHeightMm() == null || meta.layerHeightMm().compareTo(normalizedLayer) != 0) {
continue;
}
candidates.add(meta);
}
if (candidates.isEmpty()) {
return Optional.empty();
}
candidates.sort(Comparator
.comparingInt((ProcessProfileMeta meta) -> scoreProcessForQuality(meta.name(), normalizedQuality))
.reversed()
.thenComparing(ProcessProfileMeta::name, String.CASE_INSENSITIVE_ORDER));
return Optional.ofNullable(candidates.get(0).name());
}
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);
@@ -69,7 +145,7 @@ public class ProfileManager {
// collisions across vendors/profile families with same filename. // collisions across vendors/profile families with same filename.
String filename = toJsonFilename(resolvedName); String filename = toJsonFilename(resolvedName);
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) { try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
List<Path> candidates = stream List<Path> candidates = stream
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.sorted() .sorted()
@@ -95,6 +171,37 @@ public class ProfileManager {
} }
} }
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());
@@ -172,4 +279,125 @@ public class ProfileManager {
} }
return "any"; return "any";
} }
private List<ProcessProfileMeta> getOrLoadProcessProfiles() {
List<ProcessProfileMeta> cached = cachedProcessProfiles;
if (cached != null) {
return cached;
}
synchronized (this) {
if (cachedProcessProfiles != null) {
return cachedProcessProfiles;
}
List<ProcessProfileMeta> loaded = new ArrayList<>();
if (!Files.isDirectory(resolvedProfilesRoot)) {
cachedProcessProfiles = Collections.emptyList();
return cachedProcessProfiles;
}
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
List<Path> processFiles = stream
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json"))
.filter(path -> pathContainsSegment(path, "process"))
.sorted()
.toList();
for (Path processFile : processFiles) {
try {
JsonNode node = mapper.readTree(processFile.toFile());
if (!"process".equalsIgnoreCase(node.path("type").asText())) {
continue;
}
String name = node.path("name").asText("");
if (name.isBlank()) {
continue;
}
BigDecimal layer = extractLayerHeightFromProfileName(name);
if (layer == null) {
continue;
}
Set<String> compatiblePrinters = new LinkedHashSet<>();
JsonNode compatibleNode = node.path("compatible_printers");
if (compatibleNode.isArray()) {
compatibleNode.forEach(value -> {
String printer = value.asText("").trim();
if (!printer.isBlank()) {
compatiblePrinters.add(printer);
}
});
}
if (compatiblePrinters.isEmpty()) {
continue;
}
loaded.add(new ProcessProfileMeta(name, layer, compatiblePrinters));
} catch (Exception ignored) {
// Ignore malformed or non-process JSON files.
}
}
} catch (IOException e) {
logger.warning("Failed to scan process profiles: " + e.getMessage());
}
cachedProcessProfiles = List.copyOf(loaded);
return cachedProcessProfiles;
}
}
private BigDecimal extractLayerHeightFromProfileName(String profileName) {
if (profileName == null) {
return null;
}
Matcher matcher = LAYER_MM_PATTERN.matcher(profileName.trim());
if (!matcher.find()) {
return null;
}
try {
return new BigDecimal(matcher.group(1)).setScale(3, RoundingMode.HALF_UP);
} catch (NumberFormatException ex) {
return null;
}
}
private int scoreProcessForQuality(String processName, String qualityHint) {
String normalizedName = String.valueOf(processName == null ? "" : processName)
.toLowerCase(Locale.ROOT);
if (qualityHint == null || qualityHint.isBlank()) {
return 0;
}
return switch (qualityHint) {
case "draft" -> {
if (normalizedName.contains("extra draft")) yield 30;
if (normalizedName.contains("draft")) yield 20;
if (normalizedName.contains("standard")) yield 10;
yield 0;
}
case "extra_fine", "high", "high_definition" -> {
if (normalizedName.contains("extra fine")) yield 30;
if (normalizedName.contains("high quality")) yield 25;
if (normalizedName.contains("fine")) yield 20;
if (normalizedName.contains("standard")) yield 5;
yield 0;
}
default -> {
if (normalizedName.contains("standard")) yield 30;
if (normalizedName.contains("optimal")) yield 25;
if (normalizedName.contains("strength")) yield 20;
if (normalizedName.contains("high quality")) yield 10;
if (normalizedName.contains("draft")) yield 5;
yield 0;
}
};
}
private record ProcessProfileMeta(String name, BigDecimal layerHeightMm, Set<String> compatiblePrinters) {
}
} }

View File

@@ -21,6 +21,8 @@ import java.util.List;
@Service @Service
public class QuoteCalculator { public class QuoteCalculator {
private static final BigDecimal SETUP_FEE_DOUBLE_THRESHOLD_CHF = BigDecimal.TEN;
private static final BigDecimal SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD = BigDecimal.valueOf(2);
private final PricingPolicyRepository pricingRepo; private final PricingPolicyRepository pricingRepo;
private final PricingPolicyMachineHourTierRepository tierRepo; private final PricingPolicyMachineHourTierRepository tierRepo;
@@ -60,44 +62,70 @@ public class QuoteCalculator {
.orElseThrow(() -> new RuntimeException("No active printer found")); .orElseThrow(() -> new RuntimeException("No active printer found"));
} }
// 3. Fetch Filament Info
// filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA"
// We try to extract material code (PLA, PETG)
String materialCode = detectMaterialCode(filamentProfileName); String materialCode = detectMaterialCode(filamentProfileName);
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode) FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
.orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode)); .orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode));
// Try to find specific variant (e.g. by color if we could parse it)
// For now, get default/first active variant for this material
FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode)); .orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode));
return calculate(stats, machine, policy, variant);
}
// --- CALCULATIONS --- public QuoteResult calculate(PrintStats stats, String machineName, FilamentVariant variant) {
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
if (policy == null) {
throw new RuntimeException("No active pricing policy found");
}
// Material Cost: (weight / 1000) * costPerKg PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); if (machine == null) {
machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
return calculate(stats, machine, policy, variant);
}
private QuoteResult calculate(PrintStats stats, PrinterMachine machine, PricingPolicy policy, FilamentVariant variant) {
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams())
.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: Tiered BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds())
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
// Energy Cost: (watts / 1000) * hours * costPerKwh BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts())
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); .divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(totalHours); BigDecimal kwh = kw.multiply(totalHours);
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh()); BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
// Subtotal (Costs + Fixed Fees) BigDecimal subtotal = materialCost.add(energyCost);
BigDecimal fixedFee = policy.getFixedJobFeeChf(); BigDecimal markupFactor = BigDecimal.ONE.add(
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee); policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
);
subtotal = subtotal.multiply(markupFactor);
// Markup return new QuoteResult(subtotal.doubleValue(), "CHF", stats);
// Markup is percentage (e.g. 20.0) }
public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) {
BigDecimal rawCost = calculateMachineCost(policy, hours);
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)); BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP); return rawCost.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
}
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue()); public BigDecimal calculateSessionSetupFee(PricingPolicy policy) {
if (policy == null || policy.getFixedJobFeeChf() == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
BigDecimal baseSetupFee = policy.getFixedJobFeeChf();
if (baseSetupFee.compareTo(SETUP_FEE_DOUBLE_THRESHOLD_CHF) < 0) {
return baseSetupFee
.multiply(SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD)
.setScale(2, RoundingMode.HALF_UP);
}
return baseSetupFee.setScale(2, RoundingMode.HALF_UP);
} }
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) { private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
@@ -147,6 +175,7 @@ public class QuoteCalculator {
private String detectMaterialCode(String profileName) { private String detectMaterialCode(String profileName) {
String lower = profileName.toLowerCase(); String lower = profileName.toLowerCase();
if (lower.contains("pla tough") || lower.contains("pla_tough")) return "PLA TOUGH";
if (lower.contains("petg")) return "PETG"; if (lower.contains("petg")) return "PETG";
if (lower.contains("tpu")) return "TPU"; if (lower.contains("tpu")) return "TPU";
if (lower.contains("abs")) return "ABS"; if (lower.contains("abs")) return "ABS";

View File

@@ -0,0 +1,167 @@
package com.printcalculator.service;
import com.printcalculator.entity.PricingPolicy;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashSet;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
@Service
public class QuoteSessionTotalsService {
private final PricingPolicyRepository pricingRepo;
private final QuoteCalculator quoteCalculator;
private final NozzleOptionRepository nozzleOptionRepo;
public QuoteSessionTotalsService(PricingPolicyRepository pricingRepo,
QuoteCalculator quoteCalculator,
NozzleOptionRepository nozzleOptionRepo) {
this.pricingRepo = pricingRepo;
this.quoteCalculator = quoteCalculator;
this.nozzleOptionRepo = nozzleOptionRepo;
}
public QuoteSessionTotals compute(QuoteSession session, List<QuoteLineItem> items) {
BigDecimal printItemsBaseTotal = BigDecimal.ZERO;
BigDecimal totalSeconds = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
int quantity = normalizeQuantity(item.getQuantity());
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
printItemsBaseTotal = printItemsBaseTotal.add(unitPrice.multiply(BigDecimal.valueOf(quantity)));
if (item.getPrintTimeSeconds() != null && item.getPrintTimeSeconds() > 0) {
totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity)));
}
}
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
BigDecimal printItemsTotal = printItemsBaseTotal.add(globalMachineCost);
BigDecimal cadTotal = calculateCadTotal(session);
BigDecimal itemsTotal = printItemsTotal.add(cadTotal);
BigDecimal baseSetupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal nozzleChangeCost = calculateNozzleChangeCost(items);
BigDecimal setupFee = baseSetupFee.add(nozzleChangeCost).setScale(2, RoundingMode.HALF_UP);
BigDecimal shippingCost = calculateShippingCost(items);
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCost);
return new QuoteSessionTotals(
printItemsTotal,
globalMachineCost,
cadTotal,
itemsTotal,
baseSetupFee.setScale(2, RoundingMode.HALF_UP),
nozzleChangeCost,
setupFee,
shippingCost,
grandTotal,
totalSeconds
);
}
public BigDecimal calculateCadTotal(QuoteSession session) {
if (session == null) {
return BigDecimal.ZERO;
}
BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO;
BigDecimal cadRate = session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO;
if (cadHours.compareTo(BigDecimal.ZERO) <= 0 || cadRate.compareTo(BigDecimal.ZERO) <= 0) {
return BigDecimal.ZERO;
}
return cadHours.multiply(cadRate).setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal calculateShippingCost(List<QuoteLineItem> items) {
if (items == null || items.isEmpty()) {
return BigDecimal.ZERO;
}
boolean exceedsBaseSize = false;
for (QuoteLineItem item : items) {
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO;
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO;
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
BigDecimal[] dims = {x, y, z};
Arrays.sort(dims);
if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0
|| dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0
|| dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) {
exceedsBaseSize = true;
break;
}
}
int totalQuantity = items.stream().mapToInt(i -> normalizeQuantity(i.getQuantity())).sum();
if (totalQuantity <= 0) {
return BigDecimal.ZERO;
}
if (exceedsBaseSize) {
return totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00);
}
return BigDecimal.valueOf(2.00);
}
private BigDecimal calculateNozzleChangeCost(List<QuoteLineItem> items) {
if (items == null || items.isEmpty()) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
Set<BigDecimal> uniqueNozzles = new LinkedHashSet<>();
for (QuoteLineItem item : items) {
if (item == null || item.getNozzleDiameterMm() == null) {
continue;
}
uniqueNozzles.add(item.getNozzleDiameterMm().setScale(2, RoundingMode.HALF_UP));
}
BigDecimal totalFee = BigDecimal.ZERO;
for (BigDecimal nozzle : uniqueNozzles) {
BigDecimal nozzleFee = nozzleOptionRepo
.findFirstByNozzleDiameterMmAndIsActiveTrue(nozzle)
.map(option -> option.getExtraNozzleChangeFeeChf() != null
? option.getExtraNozzleChangeFeeChf()
: BigDecimal.ZERO)
.orElse(BigDecimal.ZERO);
if (nozzleFee.compareTo(BigDecimal.ZERO) > 0) {
totalFee = totalFee.add(nozzleFee);
}
}
return totalFee.setScale(2, RoundingMode.HALF_UP);
}
private int normalizeQuantity(Integer quantity) {
if (quantity == null || quantity < 1) {
return 1;
}
return quantity;
}
public record QuoteSessionTotals(
BigDecimal printItemsTotalChf,
BigDecimal globalMachineCostChf,
BigDecimal cadTotalChf,
BigDecimal itemsTotalChf,
BigDecimal baseSetupCostChf,
BigDecimal nozzleChangeCostChf,
BigDecimal setupCostChf,
BigDecimal shippingCostChf,
BigDecimal grandTotalChf,
BigDecimal totalPrintSeconds
) {}
}

View File

@@ -45,9 +45,8 @@ public class SessionCleanupService {
// "rimangono in memoria... cancella quelle vecchie di 7 giorni". // "rimangono in memoria... cancella quelle vecchie di 7 giorni".
// Implementation plan said: status != 'ORDERED'. // Implementation plan said: status != 'ORDERED'.
// User specified statuses: ACTIVE, EXPIRED, CONVERTED. // CAD_ACTIVE sessions are managed manually from back-office and must be preserved.
// We should NOT delete sessions that have been converted to an order. if ("CONVERTED".equals(session.getStatus()) || "CAD_ACTIVE".equals(session.getStatus())) {
if ("CONVERTED".equals(session.getStatus())) {
continue; continue;
} }

View File

@@ -2,38 +2,77 @@ package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.exception.ModelProcessingException;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import org.lwjgl.PointerBuffer;
import org.lwjgl.assimp.AIFace;
import org.lwjgl.assimp.AIMesh;
import org.lwjgl.assimp.AIScene;
import org.lwjgl.assimp.AIVector3D;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.BufferedWriter;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.IntBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.lwjgl.assimp.Assimp.aiGetErrorString;
import static org.lwjgl.assimp.Assimp.aiImportFile;
import static org.lwjgl.assimp.Assimp.aiProcess_JoinIdenticalVertices;
import static org.lwjgl.assimp.Assimp.aiProcess_PreTransformVertices;
import static org.lwjgl.assimp.Assimp.aiProcess_SortByPType;
import static org.lwjgl.assimp.Assimp.aiProcess_Triangulate;
import static org.lwjgl.assimp.Assimp.aiReleaseImport;
@Service @Service
public class SlicerService { public class SlicerService {
private static final Logger logger = Logger.getLogger(SlicerService.class.getName()); private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
private static final Pattern SIZE_X_PATTERN = Pattern.compile("(?m)^\\s*size_x\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private final String slicerPath; private final String trustedSlicerPath;
private final String trustedAssimpPath;
private final ProfileManager profileManager; private final ProfileManager profileManager;
private final GCodeParser gCodeParser; private final GCodeParser gCodeParser;
private final ObjectMapper mapper; private final ObjectMapper mapper;
public SlicerService( public SlicerService(
@Value("${slicer.path}") String slicerPath, @Value("${slicer.path}") String slicerPath,
@Value("${assimp.path:assimp}") String assimpPath,
ProfileManager profileManager, ProfileManager profileManager,
GCodeParser gCodeParser, GCodeParser gCodeParser,
ObjectMapper mapper) { ObjectMapper mapper) {
this.slicerPath = slicerPath; this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
this.trustedAssimpPath = normalizeExecutablePath(assimpPath);
this.profileManager = profileManager; this.profileManager = profileManager;
this.gCodeParser = gCodeParser; this.gCodeParser = gCodeParser;
this.mapper = mapper; this.mapper = mapper;
@@ -76,17 +115,25 @@ public class SlicerService {
basename = basename.substring(0, basename.length() - 4); basename = basename.substring(0, basename.length() - 4);
} }
Path slicerLogPath = tempDir.resolve("orcaslicer.log"); Path slicerLogPath = tempDir.resolve("orcaslicer.log");
String machineProfilePath = requireSafeArgument(mFile.getAbsolutePath(), "machine profile path");
String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path");
String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path");
String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path");
String inputModelPath = requireSafeArgument(inputStl.getAbsolutePath(), "input model path");
List<String> slicerInputPaths = resolveSlicerInputPaths(inputStl, inputModelPath, tempDir);
// 3. Run slicer. Retry with arrange only for out-of-volume style failures. // 3. Run slicer. Retry with arrange only for out-of-volume style failures.
for (boolean useArrange : new boolean[]{false, true}) { for (boolean useArrange : new boolean[]{false, true}) {
List<String> command = new ArrayList<>(); // Build process arguments explicitly to avoid shell interpretation and command injection.
command.add(slicerPath); ProcessBuilder pb = new ProcessBuilder();
List<String> command = pb.command();
command.add(trustedSlicerPath);
command.add("--load-settings"); command.add("--load-settings");
command.add(mFile.getAbsolutePath()); command.add(machineProfilePath);
command.add("--load-settings"); command.add("--load-settings");
command.add(pFile.getAbsolutePath()); command.add(processProfilePath);
command.add("--load-filaments"); command.add("--load-filaments");
command.add(fFile.getAbsolutePath()); command.add(filamentProfilePath);
command.add("--ensure-on-bed"); command.add("--ensure-on-bed");
if (useArrange) { if (useArrange) {
command.add("--arrange"); command.add("--arrange");
@@ -95,13 +142,12 @@ public class SlicerService {
command.add("--slice"); command.add("--slice");
command.add("0"); command.add("0");
command.add("--outputdir"); command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString()); command.add(outputDirPath);
command.add(inputStl.getAbsolutePath()); command.addAll(slicerInputPaths);
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command)); logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
Files.deleteIfExists(slicerLogPath); Files.deleteIfExists(slicerLogPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile()); pb.directory(tempDir.toFile());
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
pb.redirectOutput(slicerLogPath.toFile()); pb.redirectOutput(slicerLogPath.toFile());
@@ -111,7 +157,10 @@ public class SlicerService {
if (!finished) { if (!finished) {
process.destroyForcibly(); process.destroyForcibly();
throw new IOException("Slicer timed out"); throw new ModelProcessingException(
"SLICER_TIMEOUT",
"Model processing timed out. Try another format or contact us directly via Request Consultation."
);
} }
if (process.exitValue() != 0) { if (process.exitValue() != 0) {
@@ -123,7 +172,11 @@ public class SlicerService {
logger.warning("Slicer reported model out of printable area, retrying with arrange."); logger.warning("Slicer reported model out of printable area, retrying with arrange.");
continue; continue;
} }
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); logger.warning("Slicer failed with exit code " + process.exitValue() + ". Log: " + error);
throw new ModelProcessingException(
"SLICER_EXECUTION_FAILED",
"Unable to process this model. Try another format or contact us directly via Request Consultation."
);
} }
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile(); File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
@@ -132,14 +185,20 @@ public class SlicerService {
if (alt.exists()) { if (alt.exists()) {
gcodeFile = alt; gcodeFile = alt;
} else { } else {
throw new IOException("GCode output not found in " + tempDir); throw new ModelProcessingException(
"SLICER_OUTPUT_MISSING",
"Unable to generate slicing output for this model. Try another format or contact us directly via Request Consultation."
);
} }
} }
return gCodeParser.parse(gcodeFile); return gCodeParser.parse(gcodeFile);
} }
throw new IOException("Slicer failed after retry"); throw new ModelProcessingException(
"SLICER_FAILED_AFTER_RETRY",
"Unable to process this model. Try another format or contact us directly via Request Consultation."
);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -149,6 +208,89 @@ public class SlicerService {
} }
} }
public Optional<ModelDimensions> inspectModelDimensions(File inputModel) {
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("slicer_info_");
Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path");
ProcessBuilder pb = new ProcessBuilder();
List<String> infoCommand = pb.command();
infoCommand.add(trustedSlicerPath);
infoCommand.add("--info");
infoCommand.add(inputModelPath);
pb.directory(tempDir.toFile());
pb.redirectErrorStream(true);
pb.redirectOutput(infoLogPath.toFile());
Process process = pb.start();
boolean finished = process.waitFor(2, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
logger.warning("Model info extraction timed out for " + inputModel.getName());
return Optional.empty();
}
String output = Files.exists(infoLogPath)
? Files.readString(infoLogPath, StandardCharsets.UTF_8)
: "";
if (process.exitValue() != 0) {
logger.warning("OrcaSlicer --info failed (exit " + process.exitValue() + ") for "
+ inputModel.getName() + ": " + output);
return Optional.empty();
}
Optional<ModelDimensions> parsed = parseModelDimensionsFromInfoOutput(output);
if (parsed.isEmpty()) {
logger.warning("Could not parse size_x/size_y/size_z from OrcaSlicer --info output for "
+ inputModel.getName() + ": " + output);
}
return parsed;
} catch (Exception e) {
logger.warning("Failed to inspect model dimensions for " + inputModel.getName() + ": " + e.getMessage());
return Optional.empty();
} finally {
if (tempDir != null) {
deleteRecursively(tempDir);
}
}
}
static Optional<ModelDimensions> parseModelDimensionsFromInfoOutput(String output) {
if (output == null || output.isBlank()) {
return Optional.empty();
}
Double x = extractDouble(SIZE_X_PATTERN, output);
Double y = extractDouble(SIZE_Y_PATTERN, output);
Double z = extractDouble(SIZE_Z_PATTERN, output);
if (x == null || y == null || z == null) {
return Optional.empty();
}
if (x <= 0 || y <= 0 || z <= 0) {
return Optional.empty();
}
return Optional.of(new ModelDimensions(x, y, z));
}
private static Double extractDouble(Pattern pattern, String text) {
Matcher matcher = pattern.matcher(text);
if (!matcher.find()) {
return null;
}
try {
return Double.parseDouble(matcher.group(1));
} catch (NumberFormatException ignored) {
return null;
}
}
private void deleteRecursively(Path path) { private void deleteRecursively(Path path) {
if (path == null || !Files.exists(path)) { if (path == null || !Files.exists(path)) {
return; return;
@@ -177,4 +319,691 @@ public class SlicerService {
|| normalized.contains("no object is fully inside the print volume") || normalized.contains("no object is fully inside the print volume")
|| normalized.contains("calc_exclude_triangles"); || normalized.contains("calc_exclude_triangles");
} }
private List<String> resolveSlicerInputPaths(File inputModel, String inputModelPath, Path tempDir)
throws IOException, InterruptedException {
if (!inputModel.getName().toLowerCase().endsWith(".3mf")) {
return List.of(inputModelPath);
}
List<String> convertedStlPaths = convert3mfToStlInputPaths(inputModel, tempDir);
logger.info("Converted 3MF to " + convertedStlPaths.size() + " STL file(s) for slicing.");
return convertedStlPaths;
}
public Path convert3mfToPersistentStl(File input3mf, Path destinationStl) throws IOException {
Path tempDir = Files.createTempDirectory("slicer_convert_");
try {
List<String> convertedPaths = convert3mfToStlInputPaths(input3mf, tempDir);
if (convertedPaths.isEmpty()) {
throw new ModelProcessingException(
"MODEL_CONVERSION_FAILED",
"Unable to process this 3MF file. Try another format or contact us directly via Request Consultation."
);
}
Path source = Path.of(convertedPaths.get(0));
Path parent = destinationStl.toAbsolutePath().normalize().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.copy(source, destinationStl, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
return destinationStl;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted during 3MF conversion", e);
} finally {
deleteRecursively(tempDir);
}
}
private List<String> convert3mfToStlInputPaths(File input3mf, Path tempDir) throws IOException, InterruptedException {
Path conversionOutputDir = tempDir.resolve("converted-from-3mf");
Files.createDirectories(conversionOutputDir);
String conversionOutputStlPath = requireSafeArgument(
conversionOutputDir.resolve("converted.stl").toAbsolutePath().toString(),
"3MF conversion output STL path"
);
String conversionOutputObjPath = requireSafeArgument(
conversionOutputDir.resolve("converted.obj").toAbsolutePath().toString(),
"3MF conversion output OBJ path"
);
String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path");
String stlLog = "";
String objLog = "";
Path lwjglConvertedStl = conversionOutputDir.resolve("converted-lwjgl.stl");
try {
long lwjglTriangles = convert3mfToStlWithLwjglAssimp(input3mf.toPath(), lwjglConvertedStl);
if (lwjglTriangles > 0 && hasRenderableGeometry(lwjglConvertedStl)) {
logger.info("Converted 3MF to STL via LWJGL Assimp. Triangles: " + lwjglTriangles);
return List.of(lwjglConvertedStl.toString());
}
logger.warning("LWJGL Assimp conversion produced no renderable geometry.");
} catch (Exception | LinkageError e) {
logger.warning("LWJGL Assimp conversion failed, falling back to assimp CLI: " + e.getMessage());
}
Path convertedStl = Path.of(conversionOutputStlPath);
try {
stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log"));
if (hasRenderableGeometry(convertedStl)) {
return List.of(convertedStl.toString());
}
logger.warning("Assimp STL conversion produced empty geometry.");
} catch (IOException e) {
stlLog = e.getMessage() != null ? e.getMessage() : "";
logger.warning("Assimp STL conversion failed, trying alternate conversion paths: " + stlLog);
}
logger.warning("Retrying 3MF conversion to OBJ.");
Path convertedObj = Path.of(conversionOutputObjPath);
try {
objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
if (hasRenderableGeometry(convertedObj)) {
Path stlFromObj = conversionOutputDir.resolve("converted-from-obj.stl");
runAssimpExport(
convertedObj.toString(),
stlFromObj.toString(),
tempDir.resolve("assimp-convert-obj-to-stl.log")
);
if (hasRenderableGeometry(stlFromObj)) {
return List.of(stlFromObj.toString());
}
logger.warning("Assimp OBJ->STL conversion produced empty geometry.");
}
logger.warning("Assimp OBJ conversion produced empty geometry.");
} catch (IOException e) {
objLog = e.getMessage() != null ? e.getMessage() : "";
logger.warning("Assimp OBJ conversion failed: " + objLog);
}
Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl");
try {
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
+ fallbackTriangles + " triangles.");
return List.of(fallbackStl.toString());
}
logger.warning("3MF XML fallback completed but produced no renderable triangles.");
} catch (IOException e) {
logger.warning("3MF XML fallback conversion failed: " + e.getMessage());
}
throw new ModelProcessingException(
"MODEL_CONVERSION_FAILED",
"Unable to process this 3MF file. Try another format or contact us directly via Request Consultation."
);
}
private long convert3mfToStlWithLwjglAssimp(Path input3mf, Path outputStl) throws IOException {
int flags = aiProcess_Triangulate
| aiProcess_JoinIdenticalVertices
| aiProcess_PreTransformVertices
| aiProcess_SortByPType;
AIScene scene = aiImportFile(input3mf.toString(), flags);
if (scene == null) {
throw new IOException("LWJGL Assimp import failed: " + aiGetErrorString());
}
long triangleCount = 0L;
try (BufferedWriter writer = Files.newBufferedWriter(outputStl, StandardCharsets.UTF_8)) {
writer.write("solid converted\n");
int meshCount = scene.mNumMeshes();
PointerBuffer meshPointers = scene.mMeshes();
if (meshCount <= 0 || meshPointers == null) {
throw new IOException("LWJGL Assimp import contains no meshes");
}
for (int meshIndex = 0; meshIndex < meshCount; meshIndex++) {
long meshPtr = meshPointers.get(meshIndex);
if (meshPtr == 0L) {
continue;
}
AIMesh mesh = AIMesh.create(meshPtr);
AIVector3D.Buffer vertices = mesh.mVertices();
AIFace.Buffer faces = mesh.mFaces();
if (vertices == null || faces == null) {
continue;
}
int vertexCount = mesh.mNumVertices();
int faceCount = mesh.mNumFaces();
for (int faceIndex = 0; faceIndex < faceCount; faceIndex++) {
AIFace face = faces.get(faceIndex);
if (face.mNumIndices() != 3) {
continue;
}
IntBuffer indices = face.mIndices();
if (indices == null || indices.remaining() < 3) {
continue;
}
int i0 = indices.get(0);
int i1 = indices.get(1);
int i2 = indices.get(2);
if (i0 < 0 || i1 < 0 || i2 < 0
|| i0 >= vertexCount
|| i1 >= vertexCount
|| i2 >= vertexCount) {
continue;
}
Vec3 p1 = toVec3(vertices.get(i0));
Vec3 p2 = toVec3(vertices.get(i1));
Vec3 p3 = toVec3(vertices.get(i2));
writeAsciiFacet(writer, p1, p2, p3);
triangleCount++;
}
}
writer.write("endsolid converted\n");
} finally {
aiReleaseImport(scene);
}
if (triangleCount <= 0) {
throw new IOException("LWJGL Assimp conversion produced no triangles");
}
return triangleCount;
}
private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath)
throws IOException, InterruptedException {
ProcessBuilder conversionPb = new ProcessBuilder();
List<String> conversionCommand = conversionPb.command();
conversionCommand.add(trustedAssimpPath);
conversionCommand.add("export");
conversionCommand.add(input3mfPath);
conversionCommand.add(outputModelPath);
logger.info("Converting 3MF with Assimp: " + String.join(" ", conversionCommand));
Files.deleteIfExists(conversionLogPath);
conversionPb.redirectErrorStream(true);
conversionPb.redirectOutput(conversionLogPath.toFile());
Process conversionProcess = conversionPb.start();
boolean conversionFinished = conversionProcess.waitFor(3, TimeUnit.MINUTES);
if (!conversionFinished) {
conversionProcess.destroyForcibly();
throw new IOException("3MF conversion timed out");
}
String conversionLog = Files.exists(conversionLogPath)
? Files.readString(conversionLogPath, StandardCharsets.UTF_8)
: "";
if (conversionProcess.exitValue() != 0) {
throw new IOException("3MF conversion failed with exit code "
+ conversionProcess.exitValue() + ": " + conversionLog);
}
return conversionLog;
}
private boolean hasRenderableGeometry(Path modelPath) throws IOException {
if (!Files.isRegularFile(modelPath) || Files.size(modelPath) == 0) {
return false;
}
String fileName = modelPath.getFileName().toString().toLowerCase();
if (fileName.endsWith(".obj")) {
try (var lines = Files.lines(modelPath)) {
return lines.map(String::trim).anyMatch(line -> line.startsWith("f "));
}
}
if (fileName.endsWith(".stl")) {
long size = Files.size(modelPath);
if (size <= 84) {
return false;
}
byte[] header = new byte[84];
try (InputStream is = Files.newInputStream(modelPath)) {
int read = is.read(header);
if (read < 84) {
return false;
}
}
long triangleCount = ((long) (header[80] & 0xff))
| (((long) (header[81] & 0xff)) << 8)
| (((long) (header[82] & 0xff)) << 16)
| (((long) (header[83] & 0xff)) << 24);
if (triangleCount > 0) {
return true;
}
try (var lines = Files.lines(modelPath)) {
return lines.limit(2000).anyMatch(line -> line.contains("facet normal"));
}
}
return true;
}
private long convert3mfArchiveToAsciiStl(Path input3mf, Path outputStl) throws IOException {
Map<String, ThreeMfModelDocument> modelCache = new HashMap<>();
long[] triangleCount = new long[]{0L};
try (ZipFile zipFile = new ZipFile(input3mf.toFile());
BufferedWriter writer = Files.newBufferedWriter(outputStl, StandardCharsets.UTF_8)) {
writer.write("solid converted\n");
ThreeMfModelDocument rootModel = loadThreeMfModel(zipFile, modelCache, "3D/3dmodel.model");
Element build = findFirstChildByLocalName(rootModel.rootElement(), "build");
if (build == null) {
throw new IOException("3MF build section not found in root model");
}
for (Element item : findChildrenByLocalName(build, "item")) {
if ("0".equals(getAttributeByLocalName(item, "printable"))) {
continue;
}
String objectId = getAttributeByLocalName(item, "objectid");
if (objectId == null || objectId.isBlank()) {
continue;
}
Transform itemTransform = parseTransform(getAttributeByLocalName(item, "transform"));
writeObjectTriangles(
zipFile,
modelCache,
rootModel.modelPath(),
objectId,
itemTransform,
writer,
triangleCount,
new HashSet<>(),
0
);
}
writer.write("endsolid converted\n");
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("3MF fallback conversion failed: " + e.getMessage(), e);
}
return triangleCount[0];
}
private void writeObjectTriangles(
ZipFile zipFile,
Map<String, ThreeMfModelDocument> modelCache,
String modelPath,
String objectId,
Transform transform,
BufferedWriter writer,
long[] triangleCount,
Set<String> recursionGuard,
int depth
) throws Exception {
if (depth > 64) {
throw new IOException("3MF component nesting too deep");
}
String guardKey = modelPath + "#" + objectId;
if (!recursionGuard.add(guardKey)) {
return;
}
try {
ThreeMfModelDocument modelDocument = loadThreeMfModel(zipFile, modelCache, modelPath);
Element objectElement = modelDocument.objectsById().get(objectId);
if (objectElement == null) {
return;
}
Element mesh = findFirstChildByLocalName(objectElement, "mesh");
if (mesh != null) {
writeMeshTriangles(mesh, transform, writer, triangleCount);
}
Element components = findFirstChildByLocalName(objectElement, "components");
if (components != null) {
for (Element component : findChildrenByLocalName(components, "component")) {
String childObjectId = getAttributeByLocalName(component, "objectid");
if (childObjectId == null || childObjectId.isBlank()) {
continue;
}
String componentPath = getAttributeByLocalName(component, "path");
String resolvedModelPath = (componentPath == null || componentPath.isBlank())
? modelDocument.modelPath()
: normalizeZipPath(componentPath);
Transform componentTransform = parseTransform(getAttributeByLocalName(component, "transform"));
Transform combinedTransform = transform.multiply(componentTransform);
writeObjectTriangles(
zipFile,
modelCache,
resolvedModelPath,
childObjectId,
combinedTransform,
writer,
triangleCount,
recursionGuard,
depth + 1
);
}
}
} finally {
recursionGuard.remove(guardKey);
}
}
private void writeMeshTriangles(
Element meshElement,
Transform transform,
BufferedWriter writer,
long[] triangleCount
) throws IOException {
Element verticesElement = findFirstChildByLocalName(meshElement, "vertices");
Element trianglesElement = findFirstChildByLocalName(meshElement, "triangles");
if (verticesElement == null || trianglesElement == null) {
return;
}
List<Vec3> vertices = new java.util.ArrayList<>();
for (Element vertex : findChildrenByLocalName(verticesElement, "vertex")) {
Double x = parseDoubleAttribute(vertex, "x");
Double y = parseDoubleAttribute(vertex, "y");
Double z = parseDoubleAttribute(vertex, "z");
if (x == null || y == null || z == null) {
continue;
}
vertices.add(new Vec3(x, y, z));
}
if (vertices.isEmpty()) {
return;
}
for (Element triangle : findChildrenByLocalName(trianglesElement, "triangle")) {
Integer v1 = parseIntAttribute(triangle, "v1");
Integer v2 = parseIntAttribute(triangle, "v2");
Integer v3 = parseIntAttribute(triangle, "v3");
if (v1 == null || v2 == null || v3 == null) {
continue;
}
if (v1 < 0 || v2 < 0 || v3 < 0 || v1 >= vertices.size() || v2 >= vertices.size() || v3 >= vertices.size()) {
continue;
}
Vec3 p1 = transform.apply(vertices.get(v1));
Vec3 p2 = transform.apply(vertices.get(v2));
Vec3 p3 = transform.apply(vertices.get(v3));
writeAsciiFacet(writer, p1, p2, p3);
triangleCount[0]++;
}
}
private void writeAsciiFacet(BufferedWriter writer, Vec3 p1, Vec3 p2, Vec3 p3) throws IOException {
Vec3 normal = computeNormal(p1, p2, p3);
writer.write("facet normal " + normal.x() + " " + normal.y() + " " + normal.z() + "\n");
writer.write(" outer loop\n");
writer.write(" vertex " + p1.x() + " " + p1.y() + " " + p1.z() + "\n");
writer.write(" vertex " + p2.x() + " " + p2.y() + " " + p2.z() + "\n");
writer.write(" vertex " + p3.x() + " " + p3.y() + " " + p3.z() + "\n");
writer.write(" endloop\n");
writer.write("endfacet\n");
}
private Vec3 toVec3(AIVector3D v) {
return new Vec3(v.x(), v.y(), v.z());
}
private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) {
double ux = b.x() - a.x();
double uy = b.y() - a.y();
double uz = b.z() - a.z();
double vx = c.x() - a.x();
double vy = c.y() - a.y();
double vz = c.z() - a.z();
double nx = uy * vz - uz * vy;
double ny = uz * vx - ux * vz;
double nz = ux * vy - uy * vx;
double length = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (length <= 1e-12) {
return new Vec3(0.0, 0.0, 0.0);
}
return new Vec3(nx / length, ny / length, nz / length);
}
private ThreeMfModelDocument loadThreeMfModel(
ZipFile zipFile,
Map<String, ThreeMfModelDocument> modelCache,
String modelPath
) throws Exception {
String normalizedPath = normalizeZipPath(modelPath);
ThreeMfModelDocument cached = modelCache.get(normalizedPath);
if (cached != null) {
return cached;
}
ZipEntry entry = zipFile.getEntry(normalizedPath);
if (entry == null) {
throw new IOException("3MF model entry not found: " + normalizedPath);
}
Document document = parseXmlDocument(zipFile, entry);
Element root = document.getDocumentElement();
Map<String, Element> objectsById = new HashMap<>();
Element resources = findFirstChildByLocalName(root, "resources");
if (resources != null) {
for (Element objectElement : findChildrenByLocalName(resources, "object")) {
String id = getAttributeByLocalName(objectElement, "id");
if (id != null && !id.isBlank()) {
objectsById.put(id, objectElement);
}
}
}
ThreeMfModelDocument loaded = new ThreeMfModelDocument(normalizedPath, root, objectsById);
modelCache.put(normalizedPath, loaded);
return loaded;
}
private Document parseXmlDocument(ZipFile zipFile, ZipEntry entry) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
try {
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
} catch (Exception ignored) {
// Best-effort hardening.
}
try {
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
} catch (Exception ignored) {
// Best-effort hardening.
}
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
try (InputStream is = zipFile.getInputStream(entry)) {
return dbf.newDocumentBuilder().parse(is);
}
}
private String normalizeZipPath(String rawPath) throws IOException {
if (rawPath == null || rawPath.isBlank()) {
throw new IOException("Invalid empty 3MF model path");
}
String normalized = rawPath.trim().replace("\\", "/");
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
if (normalized.contains("..")) {
throw new IOException("Invalid 3MF model path: " + rawPath);
}
return normalized;
}
private List<Element> findChildrenByLocalName(Element parent, String localName) {
List<Element> result = new java.util.ArrayList<>();
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String nodeLocalName = element.getLocalName() != null ? element.getLocalName() : element.getTagName();
if (localName.equals(nodeLocalName)) {
result.add(element);
}
}
}
return result;
}
private Element findFirstChildByLocalName(Element parent, String localName) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String nodeLocalName = element.getLocalName() != null ? element.getLocalName() : element.getTagName();
if (localName.equals(nodeLocalName)) {
return element;
}
}
}
return null;
}
private String getAttributeByLocalName(Element element, String localName) {
if (element.hasAttribute(localName)) {
return element.getAttribute(localName);
}
NamedNodeMap attrs = element.getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
Node attr = attrs.item(i);
String attrLocal = attr.getLocalName() != null ? attr.getLocalName() : attr.getNodeName();
if (localName.equals(attrLocal)) {
return attr.getNodeValue();
}
}
return null;
}
private Double parseDoubleAttribute(Element element, String attributeName) {
String value = getAttributeByLocalName(element, attributeName);
if (value == null || value.isBlank()) {
return null;
}
try {
return Double.parseDouble(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private Integer parseIntAttribute(Element element, String attributeName) {
String value = getAttributeByLocalName(element, attributeName);
if (value == null || value.isBlank()) {
return null;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private Transform parseTransform(String rawTransform) throws IOException {
if (rawTransform == null || rawTransform.isBlank()) {
return Transform.identity();
}
String[] tokens = rawTransform.trim().split("\\s+");
if (tokens.length != 12) {
throw new IOException("Invalid 3MF transform format: " + rawTransform);
}
double[] v = new double[12];
for (int i = 0; i < 12; i++) {
try {
v[i] = Double.parseDouble(tokens[i]);
} catch (NumberFormatException e) {
throw new IOException("Invalid number in 3MF transform: " + rawTransform, e);
}
}
return new Transform(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]);
}
private record ThreeMfModelDocument(String modelPath, Element rootElement, Map<String, Element> objectsById) {
}
private record Vec3(double x, double y, double z) {
}
private record Transform(
double m00, double m01, double m02,
double m10, double m11, double m12,
double m20, double m21, double m22,
double tx, double ty, double tz
) {
static Transform identity() {
return new Transform(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0);
}
Transform multiply(Transform other) {
return new Transform(
m00 * other.m00 + m01 * other.m10 + m02 * other.m20,
m00 * other.m01 + m01 * other.m11 + m02 * other.m21,
m00 * other.m02 + m01 * other.m12 + m02 * other.m22,
m10 * other.m00 + m11 * other.m10 + m12 * other.m20,
m10 * other.m01 + m11 * other.m11 + m12 * other.m21,
m10 * other.m02 + m11 * other.m12 + m12 * other.m22,
m20 * other.m00 + m21 * other.m10 + m22 * other.m20,
m20 * other.m01 + m21 * other.m11 + m22 * other.m21,
m20 * other.m02 + m21 * other.m12 + m22 * other.m22,
m00 * other.tx + m01 * other.ty + m02 * other.tz + tx,
m10 * other.tx + m11 * other.ty + m12 * other.tz + ty,
m20 * other.tx + m21 * other.ty + m22 * other.tz + tz
);
}
Vec3 apply(Vec3 v) {
return new Vec3(
m00 * v.x() + m01 * v.y() + m02 * v.z() + tx,
m10 * v.x() + m11 * v.y() + m12 * v.z() + ty,
m20 * v.x() + m21 * v.y() + m22 * v.z() + tz
);
}
}
private String normalizeExecutablePath(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) {
throw new IllegalArgumentException("slicer.path is required");
}
if (containsControlChars(configuredPath)) {
throw new IllegalArgumentException("slicer.path contains invalid control characters");
}
try {
return Path.of(configuredPath.trim()).normalize().toString();
} catch (InvalidPathException e) {
throw new IllegalArgumentException("Invalid slicer.path: " + configuredPath, e);
}
}
private String requireSafeArgument(String value, String argName) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Missing required argument: " + argName);
}
if (containsControlChars(value)) {
throw new IOException("Invalid control characters in " + argName);
}
return value;
}
private boolean containsControlChars(String value) {
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\0' || ch == '\n' || ch == '\r') {
return true;
}
}
return false;
}
} }

View File

@@ -0,0 +1,327 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminFilamentControllerService {
private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999");
private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final Set<String> ALLOWED_FINISH_TYPES = Set.of(
"GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL"
);
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderItemRepository orderItemRepo;
public AdminFilamentControllerService(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
}
public List<AdminFilamentMaterialTypeDto> getMaterials() {
return materialRepo.findAll().stream()
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
}
public List<AdminFilamentVariantDto> getVariants() {
return variantRepo.findAll().stream()
.sorted(Comparator
.comparing((FilamentVariant variant) -> {
FilamentMaterialType type = variant.getFilamentMaterialType();
return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : "";
}, String.CASE_INSENSITIVE_ORDER)
.thenComparing(variant -> variant.getVariantDisplayName() != null ? variant.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER))
.map(this::toVariantDto)
.toList();
}
@Transactional
public AdminFilamentMaterialTypeDto createMaterial(AdminUpsertFilamentMaterialTypeRequest payload) {
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, null);
FilamentMaterialType material = new FilamentMaterialType();
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return toMaterialDto(saved);
}
@Transactional
public AdminFilamentMaterialTypeDto updateMaterial(Long materialTypeId, AdminUpsertFilamentMaterialTypeRequest payload) {
FilamentMaterialType material = materialRepo.findById(materialTypeId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found"));
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, materialTypeId);
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return toMaterialDto(saved);
}
@Transactional
public AdminFilamentVariantDto createVariant(AdminUpsertFilamentVariantRequest payload) {
FilamentMaterialType material = validateAndResolveMaterial(payload);
String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName());
String normalizedColorName = normalizeAndValidateColorName(payload.getColorName());
validateNumericPayload(payload);
ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null);
FilamentVariant variant = new FilamentVariant();
variant.setCreatedAt(OffsetDateTime.now());
applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName);
FilamentVariant saved = variantRepo.save(variant);
return toVariantDto(saved);
}
@Transactional
public AdminFilamentVariantDto updateVariant(Long variantId, AdminUpsertFilamentVariantRequest payload) {
FilamentVariant variant = variantRepo.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
FilamentMaterialType material = validateAndResolveMaterial(payload);
String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName());
String normalizedColorName = normalizeAndValidateColorName(payload.getColorName());
validateNumericPayload(payload);
ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId);
applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName);
FilamentVariant saved = variantRepo.save(variant);
return toVariantDto(saved);
}
@Transactional
public void deleteVariant(Long variantId) {
FilamentVariant variant = variantRepo.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) {
throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted");
}
variantRepo.delete(variant);
}
private void applyMaterialPayload(FilamentMaterialType material,
AdminUpsertFilamentMaterialTypeRequest payload,
String normalizedMaterialCode) {
boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible());
boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical());
String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null
? payload.getTechnicalTypeLabel().trim()
: null;
material.setMaterialCode(normalizedMaterialCode);
material.setIsFlexible(isFlexible);
material.setIsTechnical(isTechnical);
material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank()
? technicalTypeLabel
: null);
}
private void applyVariantPayload(FilamentVariant variant,
AdminUpsertFilamentVariantRequest payload,
FilamentMaterialType material,
String normalizedDisplayName,
String normalizedColorName) {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand());
variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName);
variant.setColorHex(normalizedColorHex);
variant.setFinishType(normalizedFinishType);
variant.setBrand(normalizedBrand);
variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType));
variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial()));
variant.setCostChfPerKg(payload.getCostChfPerKg());
variant.setStockSpools(payload.getStockSpools());
variant.setSpoolNetKg(payload.getSpoolNetKg());
variant.setIsActive(payload.getIsActive() == null || payload.getIsActive());
}
private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) {
if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Material code is required");
}
return payload.getMaterialCode().trim().toUpperCase(Locale.ROOT);
}
private String normalizeAndValidateVariantDisplayName(String value) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required");
}
return value.trim();
}
private String normalizeAndValidateColorName(String value) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Color name is required");
}
return value.trim();
}
private String normalizeAndValidateColorHex(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim();
if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) {
throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB");
}
return normalized.toUpperCase(Locale.ROOT);
}
private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) {
String normalized = finishType == null || finishType.isBlank()
? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY")
: finishType.trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_FINISH_TYPES.contains(normalized)) {
throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type");
}
return normalized;
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
}
return materialRepo.findById(payload.getMaterialTypeId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found"));
}
private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) {
if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0");
}
validateNumeric63(payload.getStockSpools(), "Stock spools", true);
validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false);
}
private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) {
if (value == null) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required");
}
if (allowZero) {
if (value.compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0");
}
} else if (value.compareTo(BigDecimal.ZERO) <= 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0");
}
if (value.scale() > 3) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places");
}
if (value.compareTo(MAX_NUMERIC_6_3) > 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999");
}
}
private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) {
materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> {
if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) {
throw new ResponseStatusException(BAD_REQUEST, "Material code already exists");
}
});
}
private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) {
variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> {
if (currentVariantId == null || !existing.getId().equals(currentVariantId)) {
throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material");
}
});
}
private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) {
AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto();
dto.setId(material.getId());
dto.setMaterialCode(material.getMaterialCode());
dto.setIsFlexible(material.getIsFlexible());
dto.setIsTechnical(material.getIsTechnical());
dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel());
return dto;
}
private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) {
AdminFilamentVariantDto dto = new AdminFilamentVariantDto();
dto.setId(variant.getId());
FilamentMaterialType material = variant.getFilamentMaterialType();
if (material != null) {
dto.setMaterialTypeId(material.getId());
dto.setMaterialCode(material.getMaterialCode());
dto.setMaterialIsFlexible(material.getIsFlexible());
dto.setMaterialIsTechnical(material.getIsTechnical());
dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel());
}
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand());
dto.setIsMatte(variant.getIsMatte());
dto.setIsSpecial(variant.getIsSpecial());
dto.setCostChfPerKg(variant.getCostChfPerKg());
dto.setStockSpools(variant.getStockSpools());
dto.setSpoolNetKg(variant.getSpoolNetKg());
BigDecimal stockKg = BigDecimal.ZERO;
if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) {
stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg());
}
dto.setStockKg(stockKg);
dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000)));
dto.setIsActive(variant.getIsActive());
dto.setCreatedAt(variant.getCreatedAt());
return dto;
}
}

View File

@@ -0,0 +1,469 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminCadInvoiceDto;
import com.printcalculator.dto.AdminContactRequestAttachmentDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantStockKg;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.FilamentVariantStockKgRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteSessionTotalsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.data.domain.Sort;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminOperationsControllerService {
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsControllerService.class);
private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Set<String> CONTACT_REQUEST_ALLOWED_STATUSES = Set.of(
"NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED"
);
private final FilamentVariantStockKgRepository filamentStockRepo;
private final FilamentVariantRepository filamentVariantRepo;
private final CustomQuoteRequestRepository customQuoteRequestRepo;
private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderRepository orderRepo;
private final PricingPolicyRepository pricingRepo;
private final QuoteSessionTotalsService quoteSessionTotalsService;
public AdminOperationsControllerService(FilamentVariantStockKgRepository filamentStockRepo,
FilamentVariantRepository filamentVariantRepo,
CustomQuoteRequestRepository customQuoteRequestRepo,
CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo,
QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderRepository orderRepo,
PricingPolicyRepository pricingRepo,
QuoteSessionTotalsService quoteSessionTotalsService) {
this.filamentStockRepo = filamentStockRepo;
this.filamentVariantRepo = filamentVariantRepo;
this.customQuoteRequestRepo = customQuoteRequestRepo;
this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderRepo = orderRepo;
this.pricingRepo = pricingRepo;
this.quoteSessionTotalsService = quoteSessionTotalsService;
}
public List<AdminFilamentStockDto> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg"));
Set<Long> variantIds = stocks.stream()
.map(FilamentVariantStockKg::getFilamentVariantId)
.collect(Collectors.toSet());
Map<Long, FilamentVariant> variantsById;
if (variantIds.isEmpty()) {
variantsById = Collections.emptyMap();
} else {
variantsById = filamentVariantRepo.findAllById(variantIds).stream()
.collect(Collectors.toMap(FilamentVariant::getId, variant -> variant));
}
return stocks.stream().map(stock -> {
FilamentVariant variant = variantsById.get(stock.getFilamentVariantId());
AdminFilamentStockDto dto = new AdminFilamentStockDto();
dto.setFilamentVariantId(stock.getFilamentVariantId());
dto.setStockSpools(stock.getStockSpools());
dto.setSpoolNetKg(stock.getSpoolNetKg());
dto.setStockKg(stock.getStockKg());
BigDecimal grams = stock.getStockKg() != null
? stock.getStockKg().multiply(BigDecimal.valueOf(1000))
: BigDecimal.ZERO;
dto.setStockFilamentGrams(grams);
if (variant != null) {
dto.setMaterialCode(
variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: "UNKNOWN"
);
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setActive(variant.getIsActive());
} else {
dto.setMaterialCode("UNKNOWN");
dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId());
dto.setColorName("-");
dto.setActive(false);
}
return dto;
}).toList();
}
public List<AdminContactRequestDto> getContactRequests() {
return customQuoteRequestRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt"))
.stream()
.map(this::toContactRequestDto)
.toList();
}
public AdminContactRequestDetailDto getContactRequestDetail(UUID requestId) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return toContactRequestDetailDto(request, attachments);
}
@Transactional
public AdminContactRequestDetailDto updateContactRequestStatus(UUID requestId,
AdminUpdateContactRequestStatusRequest payload) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
String requestedStatus = payload != null && payload.getStatus() != null
? payload.getStatus().trim().toUpperCase(Locale.ROOT)
: "";
if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES)
);
}
request.setStatus(requestedStatus);
request.setUpdatedAt(OffsetDateTime.now());
CustomQuoteRequest saved = customQuoteRequestRepo.save(request);
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return toContactRequestDetailDto(saved, attachments);
}
public ResponseEntity<Resource> downloadContactRequestAttachment(UUID requestId, UUID attachmentId) {
CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found"));
if (!attachment.getRequest().getId().equals(requestId)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request");
}
String relativePath = attachment.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/";
if (!relativePath.startsWith(expectedPrefix)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize();
if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
if (!Files.exists(filePath)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
try {
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
String mimeType = attachment.getMimeType();
if (mimeType != null && !mimeType.isBlank()) {
try {
mediaType = MediaType.parseMediaType(mimeType);
} catch (Exception ignored) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = attachment.getOriginalFilename();
if (filename == null || filename.isBlank()) {
filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank()
? attachment.getStoredFilename()
: "attachment-" + attachmentId;
}
return ResponseEntity.ok()
.contentType(mediaType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (MalformedURLException e) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
}
public List<AdminQuoteSessionDto> getQuoteSessions() {
return quoteSessionRepo.findAll(Sort.by(Sort.Direction.DESC, "createdAt"))
.stream()
.map(this::toQuoteSessionDto)
.toList();
}
public List<AdminCadInvoiceDto> getCadInvoices() {
return quoteSessionRepo.findByStatusInOrderByCreatedAtDesc(List.of("CAD_ACTIVE", "CONVERTED"))
.stream()
.filter(this::isCadSessionRecord)
.map(this::toCadInvoiceDto)
.toList();
}
@Transactional
public AdminCadInvoiceDto createOrUpdateCadInvoice(AdminCadInvoiceCreateRequest payload) {
if (payload == null || payload.getCadHours() == null) {
throw new ResponseStatusException(BAD_REQUEST, "cadHours is required");
}
BigDecimal cadHours = payload.getCadHours().setScale(2, RoundingMode.HALF_UP);
if (cadHours.compareTo(BigDecimal.ZERO) <= 0) {
throw new ResponseStatusException(BAD_REQUEST, "cadHours must be > 0");
}
BigDecimal cadRate = payload.getCadHourlyRateChf();
if (cadRate == null || cadRate.compareTo(BigDecimal.ZERO) <= 0) {
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
cadRate = policy != null && policy.getCadCostChfPerHour() != null
? policy.getCadCostChfPerHour()
: BigDecimal.ZERO;
}
cadRate = cadRate.setScale(2, RoundingMode.HALF_UP);
QuoteSession session;
if (payload.getSessionId() != null) {
session = quoteSessionRepo.findById(payload.getSessionId())
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found"));
} else {
session = new QuoteSession();
session.setStatus("CAD_ACTIVE");
session.setPricingVersion("v1");
session.setMaterialCode("PLA");
session.setNozzleDiameterMm(BigDecimal.valueOf(0.4));
session.setLayerHeightMm(BigDecimal.valueOf(0.2));
session.setInfillPattern("grid");
session.setInfillPercent(20);
session.setSupportsEnabled(false);
session.setSetupCostChf(BigDecimal.ZERO);
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
}
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(CONFLICT, "Session already converted to order");
}
if (payload.getSourceRequestId() != null) {
if (!customQuoteRequestRepo.existsById(payload.getSourceRequestId())) {
throw new ResponseStatusException(NOT_FOUND, "Source request not found");
}
session.setSourceRequestId(payload.getSourceRequestId());
} else {
session.setSourceRequestId(null);
}
session.setStatus("CAD_ACTIVE");
session.setCadHours(cadHours);
session.setCadHourlyRateChf(cadRate);
if (payload.getNotes() != null) {
String trimmedNotes = payload.getNotes().trim();
session.setNotes(trimmedNotes.isEmpty() ? null : trimmedNotes);
}
QuoteSession saved = quoteSessionRepo.save(session);
return toCadInvoiceDto(saved);
}
@Transactional
public void deleteQuoteSession(UUID sessionId) {
QuoteSession session = quoteSessionRepo.findById(sessionId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found"));
if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) {
throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order");
}
deleteSessionFiles(sessionId);
quoteSessionRepo.delete(session);
}
private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) {
AdminContactRequestDto dto = new AdminContactRequestDto();
dto.setId(request.getId());
dto.setRequestType(request.getRequestType());
dto.setCustomerType(request.getCustomerType());
dto.setEmail(request.getEmail());
dto.setPhone(request.getPhone());
dto.setName(request.getName());
dto.setCompanyName(request.getCompanyName());
dto.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
return dto;
}
private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) {
AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto();
dto.setId(attachment.getId());
dto.setOriginalFilename(attachment.getOriginalFilename());
dto.setMimeType(attachment.getMimeType());
dto.setFileSizeBytes(attachment.getFileSizeBytes());
dto.setCreatedAt(attachment.getCreatedAt());
return dto;
}
private AdminContactRequestDetailDto toContactRequestDetailDto(CustomQuoteRequest request,
List<AdminContactRequestAttachmentDto> attachments) {
AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto();
dto.setId(request.getId());
dto.setRequestType(request.getRequestType());
dto.setCustomerType(request.getCustomerType());
dto.setEmail(request.getEmail());
dto.setPhone(request.getPhone());
dto.setName(request.getName());
dto.setCompanyName(request.getCompanyName());
dto.setContactPerson(request.getContactPerson());
dto.setMessage(request.getMessage());
dto.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
dto.setUpdatedAt(request.getUpdatedAt());
dto.setAttachments(attachments);
return dto;
}
private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
dto.setId(session.getId());
dto.setStatus(session.getStatus());
dto.setMaterialCode(session.getMaterialCode());
dto.setCreatedAt(session.getCreatedAt());
dto.setExpiresAt(session.getExpiresAt());
dto.setConvertedOrderId(session.getConvertedOrderId());
dto.setSourceRequestId(session.getSourceRequestId());
dto.setCadHours(session.getCadHours());
dto.setCadHourlyRateChf(session.getCadHourlyRateChf());
dto.setCadTotalChf(quoteSessionTotalsService.calculateCadTotal(session));
return dto;
}
private boolean isCadSessionRecord(QuoteSession session) {
if ("CAD_ACTIVE".equals(session.getStatus())) {
return true;
}
if (!"CONVERTED".equals(session.getStatus())) {
return false;
}
BigDecimal cadHours = session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO;
return cadHours.compareTo(BigDecimal.ZERO) > 0 || session.getSourceRequestId() != null;
}
private AdminCadInvoiceDto toCadInvoiceDto(QuoteSession session) {
List<QuoteLineItem> items = quoteLineItemRepo.findByQuoteSessionId(session.getId());
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
AdminCadInvoiceDto dto = new AdminCadInvoiceDto();
dto.setSessionId(session.getId());
dto.setSessionStatus(session.getStatus());
dto.setSourceRequestId(session.getSourceRequestId());
dto.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO);
dto.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO);
dto.setCadTotalChf(totals.cadTotalChf());
dto.setPrintItemsTotalChf(totals.printItemsTotalChf());
dto.setSetupCostChf(totals.setupCostChf());
dto.setShippingCostChf(totals.shippingCostChf());
dto.setGrandTotalChf(totals.grandTotalChf());
dto.setConvertedOrderId(session.getConvertedOrderId());
dto.setCheckoutPath("/checkout/cad?session=" + session.getId());
dto.setNotes(session.getNotes());
dto.setCreatedAt(session.getCreatedAt());
if (session.getConvertedOrderId() != null) {
Order order = orderRepo.findById(session.getConvertedOrderId()).orElse(null);
dto.setConvertedOrderStatus(order != null ? order.getStatus() : null);
}
return dto;
}
private void deleteSessionFiles(UUID sessionId) {
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
if (!Files.exists(sessionDir)) {
return;
}
try (Stream<Path> walk = Files.walk(sessionDir)) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (IOException | UncheckedIOException e) {
logger.error("Failed to delete files for session {}", sessionId, e);
throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files");
}
}
}

View File

@@ -14,4 +14,16 @@ public interface EmailNotificationService {
*/ */
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData); void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
/**
* Sends an HTML email using a Thymeleaf template, with an optional attachment.
*
* @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.
* @param attachmentName The name for the attachment file.
* @param attachmentData The raw bytes of the attachment.
*/
void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData);
} }

View File

@@ -10,6 +10,7 @@ import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine; import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context; import org.thymeleaf.context.Context;
import org.springframework.core.io.ByteArrayResource;
import java.util.Map; import java.util.Map;
@@ -24,8 +25,21 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
@Value("${app.mail.from}") @Value("${app.mail.from}")
private String fromAddress; private String fromAddress;
@Value("${app.mail.enabled:true}")
private boolean mailEnabled;
@Override @Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) { public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
sendEmailWithAttachment(to, subject, templateName, contextData, null, null);
}
@Override
public void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData) {
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); log.info("Preparing to send email to {} with template {}", to, templateName);
try { try {
@@ -41,6 +55,10 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
helper.setSubject(subject); helper.setSubject(subject);
helper.setText(process, true); // true indicates HTML format helper.setText(process, true); // true indicates HTML format
if (attachmentName != null && attachmentData != null) {
helper.addAttachment(attachmentName, new ByteArrayResource(attachmentData));
}
emailSender.send(mimeMessage); emailSender.send(mimeMessage);
log.info("Email successfully sent to {}", to); log.info("Email successfully sent to {}", to);

View File

@@ -0,0 +1,435 @@
package com.printcalculator.service.order;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.event.OrderShippedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminOrderControllerService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public AdminOrderControllerService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
QuoteLineItemRepository quoteLineItemRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
}
public List<OrderDto> listOrders() {
return orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
}
public OrderDto getOrder(UUID orderId) {
return toOrderDto(getOrderOrThrow(orderId));
}
@Transactional
public OrderDto updatePaymentMethod(UUID orderId, Map<String, String> payload) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
if (method == null || method.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Payment method is required");
}
paymentService.updatePaymentMethod(orderId, method);
return toOrderDto(getOrderOrThrow(orderId));
}
@Transactional
public OrderDto updateOrderStatus(UUID orderId, AdminOrderStatusUpdateRequest payload) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Status is required");
}
Order order = getOrderOrThrow(orderId);
String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
);
}
String previousStatus = order.getStatus();
order.setStatus(normalizedStatus);
Order savedOrder = orderRepo.save(order);
if (!"SHIPPED".equals(previousStatus) && "SHIPPED".equals(normalizedStatus)) {
eventPublisher.publishEvent(new OrderShippedEvent(this, savedOrder));
}
return toOrderDto(savedOrder);
}
public ResponseEntity<Resource> downloadOrderItemFile(UUID orderId, UUID orderItemId) {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found"));
if (!item.getOrder().getId().equals(orderId)) {
throw new ResponseStatusException(NOT_FOUND, "Order item not found for order");
}
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
Resource resource = loadOrderItemResourceWithRecovery(item, safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(item.getMimeType());
} catch (Exception ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank()
? item.getOriginalFilename()
: "order-item-" + orderItemId;
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
public ResponseEntity<byte[]> downloadOrderConfirmation(UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true);
}
public ResponseEntity<byte[]> downloadOrderInvoice(UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false);
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
dto.setPaymentStatus(payment.getStatus());
dto.setPaymentMethod(payment.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
QuoteSession sourceSession = order.getSourceQuoteSession();
if (sourceSession != null) {
dto.setPrintMaterialCode(sourceSession.getMaterialCode());
dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm());
dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm());
dto.setPrintInfillPattern(sourceSession.getInfillPattern());
dto.setPrintInfillPercent(sourceSession.getInfillPercent());
dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled());
}
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId());
itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode());
if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
}
itemDto.setQuality(item.getQuality());
itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm());
itemDto.setLayerHeightMm(item.getLayerHeightMm());
itemDto.setInfillPercent(item.getInfillPercent());
itemDto.setInfillPattern(item.getInfillPattern());
itemDto.setSupportsEnabled(item.getSupportsEnabled());
itemDto.setQuantity(item.getQuantity());
itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds());
itemDto.setMaterialGrams(item.getMaterialGrams());
itemDto.setUnitPriceChf(item.getUnitPriceChf());
itemDto.setLineTotalChf(item.getLineTotalChf());
return itemDto;
}).toList();
dto.setItems(itemDtos);
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";
}
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// fallback to generated confirmation document
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String prefix = isConfirmation ? "confirmation-" : "invoice-";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Resource loadOrderItemResourceWithRecovery(OrderItem item, Path safeRelativePath) {
try {
return storageService.loadAsResource(safeRelativePath);
} catch (Exception primaryFailure) {
Path sourceQuotePath = resolveFallbackQuoteItemPath(item);
if (sourceQuotePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
storageService.store(sourceQuotePath, safeRelativePath);
return storageService.loadAsResource(safeRelativePath);
} catch (Exception copyFailure) {
try {
Resource quoteResource = new UrlResource(sourceQuotePath.toUri());
if (quoteResource.exists() || quoteResource.isReadable()) {
return quoteResource;
}
} catch (Exception ignored) {
// fall through to 404
}
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
}
private Path resolveFallbackQuoteItemPath(OrderItem orderItem) {
Order order = orderItem.getOrder();
QuoteSession sourceSession = order != null ? order.getSourceQuoteSession() : null;
UUID sourceSessionId = sourceSession != null ? sourceSession.getId() : null;
if (sourceSessionId == null) {
return null;
}
String targetFilename = normalizeFilename(orderItem.getOriginalFilename());
if (targetFilename == null) {
return null;
}
return quoteLineItemRepo.findByQuoteSessionId(sourceSessionId).stream()
.filter(quoteItem -> targetFilename.equals(normalizeFilename(quoteItem.getOriginalFilename())))
.sorted(Comparator.comparingInt((QuoteLineItem quoteItem) -> scoreQuoteMatch(orderItem, quoteItem)).reversed())
.map(quoteItem -> resolveStoredQuotePath(quoteItem.getStoredPath(), sourceSessionId))
.filter(path -> path != null && Files.exists(path))
.findFirst()
.orElse(null);
}
private int scoreQuoteMatch(OrderItem orderItem, QuoteLineItem quoteItem) {
int score = 0;
if (orderItem.getQuantity() != null && orderItem.getQuantity().equals(quoteItem.getQuantity())) {
score += 4;
}
if (orderItem.getPrintTimeSeconds() != null && orderItem.getPrintTimeSeconds().equals(quoteItem.getPrintTimeSeconds())) {
score += 3;
}
if (orderItem.getMaterialCode() != null
&& quoteItem.getMaterialCode() != null
&& orderItem.getMaterialCode().equalsIgnoreCase(quoteItem.getMaterialCode())) {
score += 3;
}
if (orderItem.getMaterialGrams() != null
&& quoteItem.getMaterialGrams() != null
&& orderItem.getMaterialGrams().compareTo(quoteItem.getMaterialGrams()) == 0) {
score += 2;
}
return score;
}
private String normalizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return null;
}
return filename.trim();
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
}

View File

@@ -0,0 +1,358 @@
package com.printcalculator.service.order;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.OrderService;
import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.payment.PaymentService;
import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.payment.TwintPaymentService;
import com.printcalculator.service.storage.StorageService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URI;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class OrderControllerService {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> PERSONAL_DATA_REDACTED_STATUSES = Set.of(
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED"
);
private final OrderService orderService;
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderControllerService(OrderService orderService,
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
}
@Transactional
public OrderDto createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
Order order = orderService.createOrderFromQuote(quoteSessionId, request);
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return convertToDto(order, items);
}
@Transactional
public boolean uploadOrderItemFile(UUID orderId, UUID orderItemId, MultipartFile file) throws IOException {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new RuntimeException("OrderItem not found"));
if (!item.getOrder().getId().equals(orderId)) {
return false;
}
String relativePath = item.getStoredRelativePath();
Path destinationRelativePath;
if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID() + "." + ext;
destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename);
item.setStoredRelativePath(destinationRelativePath.toString());
item.setStoredFilename(storedFilename);
} else {
destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (destinationRelativePath == null) {
return false;
}
}
storageService.store(file, destinationRelativePath);
item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType());
orderItemRepo.save(item);
return true;
}
public Optional<OrderDto> getOrder(UUID orderId) {
return orderRepo.findById(orderId)
.map(order -> {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
return convertToDto(order, items);
});
}
@Transactional
public Optional<OrderDto> reportPayment(UUID orderId, String method) {
paymentService.reportPayment(orderId, method);
return getOrder(orderId);
}
public ResponseEntity<byte[]> getConfirmation(UUID orderId) {
return generateDocument(orderId, true);
}
public ResponseEntity<Map<String, String>> getTwintPayment(UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(order, 360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order));
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
data.put("qrImageDataUri", qrDataUri);
return ResponseEntity.ok(data);
}
public ResponseEntity<Void> openTwintPayment(UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl(order)))
.build();
}
public ResponseEntity<byte[]> getTwintQr(UUID orderId, int size) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(order, normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(png);
}
private ResponseEntity<byte[]> generateDocument(UUID orderId, boolean isConfirmation) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// Fallback to on-the-fly generation if the stored file is missing or unreadable.
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String typePrefix = isConfirmation ? "confirmation-" : "invoice-";
String truncatedUuid = order.getId().toString().substring(0, 8);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path buildConfirmationPdfRelativePath(Order order) {
return Path.of(
"orders",
order.getId().toString(),
"documents",
"confirmation-" + getDisplayOrderNumber(order) + ".pdf"
);
}
private String getExtension(String filename) {
if (filename == null) {
return "stl";
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return "stl";
}
int i = cleaned.lastIndexOf('.');
if (i > 0 && i < cleaned.length() - 1) {
String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT);
if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) {
return ext;
}
}
return "stl";
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
dto.setPaymentStatus(payment.getStatus());
dto.setPaymentMethod(payment.getMethod());
});
boolean redactPersonalData = shouldRedactPersonalData(order.getStatus());
if (!redactPersonalData) {
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType());
}
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setIsCadOrder(order.getIsCadOrder());
dto.setSourceRequestId(order.getSourceRequestId());
dto.setCadHours(order.getCadHours());
dto.setCadHourlyRateChf(order.getCadHourlyRateChf());
dto.setCadTotalChf(order.getCadTotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
if (!redactPersonalData) {
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
}
List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId());
itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode());
if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
}
itemDto.setQuality(item.getQuality());
itemDto.setNozzleDiameterMm(item.getNozzleDiameterMm());
itemDto.setLayerHeightMm(item.getLayerHeightMm());
itemDto.setInfillPercent(item.getInfillPercent());
itemDto.setInfillPattern(item.getInfillPattern());
itemDto.setSupportsEnabled(item.getSupportsEnabled());
itemDto.setQuantity(item.getQuantity());
itemDto.setPrintTimeSeconds(item.getPrintTimeSeconds());
itemDto.setMaterialGrams(item.getMaterialGrams());
itemDto.setUnitPriceChf(item.getUnitPriceChf());
itemDto.setLineTotalChf(item.getLineTotalChf());
return itemDto;
}).collect(Collectors.toList());
dto.setItems(itemDtos);
return dto;
}
private boolean shouldRedactPersonalData(String status) {
if (status == null || status.isBlank()) {
return false;
}
return PERSONAL_DATA_REDACTED_STATUSES.contains(status.trim().toUpperCase(Locale.ROOT));
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
}

View File

@@ -0,0 +1,160 @@
package com.printcalculator.service.payment;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.util.stream.Collectors;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
@Service
public class InvoicePdfRenderingService {
private final TemplateEngine thymeleafTemplateEngine;
public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) {
this.thymeleafTemplateEngine = thymeleafTemplateEngine;
}
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables, String qrBillSvg) {
try {
Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY);
thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables);
thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg);
String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData);
String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm();
ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream();
PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder();
openHtmlToPdfRendererBuilder.useFastMode();
openHtmlToPdfRendererBuilder.useSVGDrawer(new BatikSVGDrawer());
openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources);
openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream);
openHtmlToPdfRendererBuilder.run();
return generatedPdfByteArrayOutputStream.toByteArray();
} catch (Exception pdfGenerationException) {
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
}
}
public byte[] generateDocumentPdf(Order order, List<OrderItem> items, boolean isConfirmation, QrBillService qrBillService, Payment payment) {
Map<String, Object> vars = new HashMap<>();
vars.put("isConfirmation", isConfirmation);
vars.put("sellerDisplayName", "3D Fab Küng Caletti");
vars.put("sellerAddressLine1", "Joe Küng e Matteo Caletti");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
String displayOrderNumber = order.getOrderNumber() != null && !order.getOrderNumber().isBlank()
? order.getOrderNumber()
: order.getId().toString();
vars.put("invoiceNumber", "INV-" + displayOrderNumber.toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
// Setup Shipping Info
if (order.getShippingAddressLine1() != null && !order.getShippingAddressLine1().isBlank()) {
String shippingName = order.getShippingCompanyName() != null && !order.getShippingCompanyName().isBlank()
? order.getShippingCompanyName()
: order.getShippingFirstName() + " " + order.getShippingLastName();
vars.put("shippingDisplayName", shippingName);
vars.put("shippingAddressLine1", order.getShippingAddressLine1());
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
}
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) {
BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO;
BigDecimal cadHourlyRate = order.getCadHourlyRateChf() != null ? order.getCadHourlyRateChf() : BigDecimal.ZERO;
Map<String, Object> cadLine = new HashMap<>();
cadLine.put("description", "Servizio CAD (" + formatCadHours(cadHours) + "h)");
cadLine.put("quantity", 1);
cadLine.put("unitPriceFormatted", String.format("CHF %.2f", cadHourlyRate));
cadLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getCadTotalChf()));
invoiceLineItems.add(cadLine);
}
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", isConfirmation ? "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie." : "Pagato. Grazie per l'acquisto.");
String paymentMethodText = "QR / Bonifico oppure TWINT";
if (payment != null && payment.getMethod() != null) {
paymentMethodText = switch (payment.getMethod().toUpperCase()) {
case "TWINT" -> "TWINT";
case "BANK_TRANSFER", "BONIFICO" -> "Bonifico Bancario";
case "QR_BILL", "QR" -> "QR Bill";
case "CASH" -> "Contanti";
default -> payment.getMethod();
};
}
vars.put("paymentMethodText", paymentMethodText);
String qrBillSvg = null;
if (isConfirmation) {
qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
}
return generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
}
private String formatCadHours(BigDecimal hours) {
return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
}

View File

@@ -0,0 +1,117 @@
package com.printcalculator.service.payment;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.event.PaymentConfirmedEvent;
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);
// Default to "OTHER" always, as payment method should only be set by the admin explicitly
payment.setMethod("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)
.orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER"));
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());
// We intentionally do not update the payment method here based on user input,
// because the system cannot reliably determine the actual method without an integration.
// It will be updated by the backoffice admin manually.
payment = paymentRepo.save(payment);
eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment));
return payment;
}
@Transactional
public Payment confirmPayment(UUID orderId, String method) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseGet(() -> getOrCreatePaymentForOrder(order, method != null ? method : "OTHER"));
payment.setStatus("COMPLETED");
if (method != null && !method.isBlank()) {
payment.setMethod(method.toUpperCase());
}
payment.setReceivedAt(OffsetDateTime.now());
payment = paymentRepo.save(payment);
order.setStatus("IN_PRODUCTION");
order.setPaidAt(OffsetDateTime.now());
orderRepo.save(order);
eventPublisher.publishEvent(new PaymentConfirmedEvent(this, order, payment));
return payment;
}
@Transactional
public Payment updatePaymentMethod(UUID orderId, String method) {
if (method == null || method.isBlank()) {
throw new IllegalArgumentException("Payment method is required");
}
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER"));
payment.setMethod(method.trim().toUpperCase());
return paymentRepo.save(payment);
}
}

View File

@@ -1,13 +1,10 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import com.printcalculator.entity.Order; import com.printcalculator.entity.Order;
import net.codecrete.qrbill.generator.Bill; import net.codecrete.qrbill.generator.Bill;
import net.codecrete.qrbill.generator.GraphicsFormat;
import net.codecrete.qrbill.generator.QRBill; import net.codecrete.qrbill.generator.QRBill;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service @Service
public class QrBillService { public class QrBillService {
@@ -22,7 +19,7 @@ public class QrBillService {
// Creditor (Merchant) // Creditor (Merchant)
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
bill.setCreditor(createAddress( bill.setCreditor(createAddress(
"Küng, Joe", "Joe Küng",
"Via G. Pioda 29a", "Via G. Pioda 29a",
"6710", "6710",
"Biasca", "Biasca",
@@ -49,10 +46,7 @@ public class QrBillService {
bill.setAmount(order.getTotalChf()); bill.setAmount(order.getTotalChf());
bill.setCurrency("CHF"); bill.setCurrency("CHF");
// Reference bill.setUnstructuredMessage(order.getId().toString());
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
bill.setUnstructuredMessage("Order " + orderRef);
return bill; return bill;
} }

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.payment;
import io.nayuki.qrcodegen.QrCode; import io.nayuki.qrcodegen.QrCode;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -15,20 +15,47 @@ public class TwintPaymentService {
private final String twintPaymentUrl; private final String twintPaymentUrl;
public TwintPaymentService( public TwintPaymentService(
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}") @Value("${payment.twint.url:}")
String twintPaymentUrl String twintPaymentUrl
) { ) {
this.twintPaymentUrl = twintPaymentUrl; this.twintPaymentUrl = twintPaymentUrl;
} }
public String getTwintPaymentUrl() { public String getTwintPaymentUrl(com.printcalculator.entity.Order order) {
return twintPaymentUrl; if (twintPaymentUrl == null || twintPaymentUrl.isBlank()) {
throw new IllegalStateException("TWINT_PAYMENT_URL is not configured");
} }
public byte[] generateQrPng(int sizePx) { StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl);
if (order != null) {
if (order.getTotalChf() != null) {
urlBuilder.append("&amount=").append(order.getTotalChf().toPlainString());
}
String orderNumber = order.getOrderNumber();
if (orderNumber == null && order.getId() != null) {
orderNumber = order.getId().toString();
}
if (orderNumber != null) {
try { try {
urlBuilder.append("&trxInfo=").append(order.getId());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
return urlBuilder.toString();
}
public byte[] generateQrPng(com.printcalculator.entity.Order order, int sizePx) {
try {
String url = getTwintPaymentUrl(order);
// Use High Error Correction for financial QR codes // Use High Error Correction for financial QR codes
QrCode qrCode = QrCode.encodeText(twintPaymentUrl, QrCode.Ecc.HIGH); QrCode qrCode = QrCode.encodeText(url, QrCode.Ecc.HIGH);
// Standard QR quiet zone is 4 modules // Standard QR quiet zone is 4 modules
int borderModules = 4; int borderModules = 4;

View File

@@ -0,0 +1,289 @@
package com.printcalculator.service.quote;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.storage.ClamAVService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@Service
public class QuoteSessionItemService {
private final QuoteLineItemRepository lineItemRepo;
private final QuoteSessionRepository sessionRepo;
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator;
private final OrcaProfileResolver orcaProfileResolver;
private final ClamAVService clamAVService;
private final QuoteStorageService quoteStorageService;
private final QuoteSessionSettingsService settingsService;
private final ProfileManager profileManager;
public QuoteSessionItemService(QuoteLineItemRepository lineItemRepo,
QuoteSessionRepository sessionRepo,
SlicerService slicerService,
QuoteCalculator quoteCalculator,
OrcaProfileResolver orcaProfileResolver,
ClamAVService clamAVService,
QuoteStorageService quoteStorageService,
QuoteSessionSettingsService settingsService,
ProfileManager profileManager) {
this.lineItemRepo = lineItemRepo;
this.sessionRepo = sessionRepo;
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator;
this.orcaProfileResolver = orcaProfileResolver;
this.clamAVService = clamAVService;
this.quoteStorageService = quoteStorageService;
this.settingsService = settingsService;
this.profileManager = profileManager;
}
public QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
}
if ("CONVERTED".equals(session.getStatus())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
}
clamAVService.scan(file.getInputStream());
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
}
Path convertedPersistentPath = null;
try {
boolean cadSession = "CAD_ACTIVE".equals(session.getStatus());
if (cadSession) {
settingsService.enforceCadPrintSettings(session, settings);
} else {
settingsService.applyPrintSettings(settings);
}
QuoteSessionSettingsService.NozzleLayerSettings nozzleAndLayer = settingsService.resolveNozzleAndLayer(settings);
BigDecimal nozzleDiameter = nozzleAndLayer.nozzleDiameter();
BigDecimal layerHeight = nozzleAndLayer.layerHeight();
PrinterMachine machine = settingsService.resolvePrinterMachine(settings.getPrinterMachineId());
FilamentVariant selectedVariant = settingsService.resolveFilamentVariant(settings);
validateCadMaterialLock(session, cadSession, selectedVariant);
if (!cadSession) {
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(layerHeight);
session.setInfillPattern(settings.getInfillPattern());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
sessionRepo.save(session);
}
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
String processProfile = resolveProcessProfile(
settings,
profiles.machineProfileName(),
nozzleDiameter,
layerHeight
);
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString());
if (settings.getInfillDensity() != null) {
processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
}
if (settings.getInfillPattern() != null) {
processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
}
Path slicerInputPath = persistentPath;
if ("3mf".equals(ext)) {
String convertedFilename = UUID.randomUUID() + "-converted.stl";
convertedPersistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, convertedFilename);
slicerService.convert3mfToPersistentStl(persistentPath.toFile(), convertedPersistentPath);
slicerInputPath = convertedPersistentPath;
}
PrintStats stats = slicerService.slice(
slicerInputPath.toFile(),
profiles.machineProfileName(),
profiles.filamentProfileName(),
processProfile,
null,
processOverrides
);
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(slicerInputPath.toFile());
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
QuoteLineItem item = buildLineItem(
session,
file.getOriginalFilename(),
settings,
selectedVariant,
nozzleDiameter,
layerHeight,
stats,
result,
modelDimensions,
persistentPath,
convertedPersistentPath
);
return lineItemRepo.save(item);
} catch (Exception e) {
Files.deleteIfExists(persistentPath);
if (convertedPersistentPath != null) {
Files.deleteIfExists(convertedPersistentPath);
}
throw e;
}
}
private void validateCadMaterialLock(QuoteSession session, boolean cadSession, FilamentVariant selectedVariant) {
if (!cadSession
|| session.getMaterialCode() == null
|| selectedVariant.getFilamentMaterialType() == null
|| selectedVariant.getFilamentMaterialType().getMaterialCode() == null) {
return;
}
String lockedMaterial = settingsService.normalizeRequestedMaterialCode(session.getMaterialCode());
String selectedMaterial = settingsService.normalizeRequestedMaterialCode(
selectedVariant.getFilamentMaterialType().getMaterialCode()
);
if (!lockedMaterial.equals(selectedMaterial)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Selected filament does not match locked CAD material");
}
}
private String resolveProcessProfile(PrintSettingsDto settings,
String machineProfileName,
BigDecimal nozzleDiameter,
BigDecimal layerHeight) {
if (machineProfileName == null || machineProfileName.isBlank() || layerHeight == null) {
return resolveLegacyProcessProfile(settings);
}
String qualityHint = settingsService.resolveQuality(settings, layerHeight);
return profileManager
.findCompatibleProcessProfileName(machineProfileName, layerHeight, qualityHint)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Layer height " + layerHeight.stripTrailingZeros().toPlainString()
+ " is not available for nozzle "
+ (nozzleDiameter != null
? nozzleDiameter.stripTrailingZeros().toPlainString()
: "-")
+ " on printer profile " + machineProfileName
));
}
private String resolveLegacyProcessProfile(PrintSettingsDto settings) {
if (settings.getLayerHeight() == null) {
return "standard";
}
if (settings.getLayerHeight() >= 0.28) {
return "draft";
}
if (settings.getLayerHeight() <= 0.12) {
return "extra_fine";
}
return "standard";
}
private QuoteLineItem buildLineItem(QuoteSession session,
String originalFilename,
PrintSettingsDto settings,
FilamentVariant selectedVariant,
BigDecimal nozzleDiameter,
BigDecimal layerHeight,
PrintStats stats,
QuoteResult result,
Optional<ModelDimensions> modelDimensions,
Path persistentPath,
Path convertedPersistentPath) {
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(originalFilename);
item.setStoredPath(quoteStorageService.toStoredPath(persistentPath));
item.setQuantity(normalizeQuantity(settings.getQuantity()));
item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant);
item.setMaterialCode(selectedVariant.getFilamentMaterialType() != null
? selectedVariant.getFilamentMaterialType().getMaterialCode()
: settingsService.normalizeRequestedMaterialCode(settings.getMaterial()));
item.setQuality(settingsService.resolveQuality(settings, layerHeight));
item.setNozzleDiameterMm(nozzleDiameter);
item.setLayerHeightMm(layerHeight);
item.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
item.setInfillPattern(settings.getInfillPattern());
item.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
item.setStatus("READY");
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice());
breakdown.put("setup_fee", 0);
if (convertedPersistentPath != null) {
breakdown.put("convertedStoredPath", quoteStorageService.toStoredPath(convertedPersistentPath));
}
item.setPricingBreakdown(breakdown);
item.setBoundingBoxXMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.xMm()))
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
item.setBoundingBoxYMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.yMm()))
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
item.setBoundingBoxZMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.zMm()))
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
return item;
}
private int normalizeQuantity(Integer quantity) {
if (quantity == null || quantity < 1) {
return 1;
}
return quantity;
}
}

View File

@@ -0,0 +1,80 @@
package com.printcalculator.service.quote;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.service.QuoteSessionTotalsService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class QuoteSessionResponseAssembler {
private final QuoteStorageService quoteStorageService;
public QuoteSessionResponseAssembler(QuoteStorageService quoteStorageService) {
this.quoteStorageService = quoteStorageService;
}
public Map<String, Object> assemble(QuoteSession session,
List<QuoteLineItem> items,
QuoteSessionTotalsService.QuoteSessionTotals totals) {
List<Map<String, Object>> itemsDto = new ArrayList<>();
for (QuoteLineItem item : items) {
itemsDto.add(toItemDto(item, totals));
}
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", itemsDto);
response.put("printItemsTotalChf", totals.printItemsTotalChf());
response.put("cadTotalChf", totals.cadTotalChf());
response.put("itemsTotalChf", totals.itemsTotalChf());
response.put("baseSetupCostChf", totals.baseSetupCostChf());
response.put("nozzleChangeCostChf", totals.nozzleChangeCostChf());
response.put("setupCostChf", totals.setupCostChf());
response.put("shippingCostChf", totals.shippingCostChf());
response.put("globalMachineCostChf", totals.globalMachineCostChf());
response.put("grandTotalChf", totals.grandTotalChf());
return response;
}
private Map<String, Object> toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
Map<String, Object> dto = new HashMap<>();
dto.put("id", item.getId());
dto.put("originalFilename", item.getOriginalFilename());
dto.put("quantity", item.getQuantity());
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("materialCode", item.getMaterialCode());
dto.put("quality", item.getQuality());
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
dto.put("layerHeightMm", item.getLayerHeightMm());
dto.put("infillPercent", item.getInfillPercent());
dto.put("infillPattern", item.getInfillPattern());
dto.put("supportsEnabled", item.getSupportsEnabled());
dto.put("status", item.getStatus());
dto.put("convertedStoredPath", quoteStorageService.extractConvertedStoredPath(item));
dto.put("unitPriceChf", resolveDistributedUnitPrice(item, totals));
return dto;
}
private BigDecimal resolveDistributedUnitPrice(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
BigDecimal unitPrice = item.getUnitPriceChf() != null ? item.getUnitPriceChf() : BigDecimal.ZERO;
int quantity = item.getQuantity() != null && item.getQuantity() > 0 ? item.getQuantity() : 1;
if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
unitPrice = unitPrice.add(unitMachineCost);
}
return unitPrice;
}
}

View File

@@ -0,0 +1,179 @@
package com.printcalculator.service.quote;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Optional;
@Service
public class QuoteSessionSettingsService {
private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService;
public QuoteSessionSettingsService(PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) {
this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
}
public void applyPrintSettings(PrintSettingsDto settings) {
if (settings.getNozzleDiameter() == null) {
settings.setNozzleDiameter(0.40);
}
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard";
switch (quality) {
case "draft" -> {
settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
}
case "extra_fine", "high_definition", "high" -> {
settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid");
}
case "standard" -> {
settings.setLayerHeight(0.20);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
}
default -> {
settings.setLayerHeight(0.20);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
}
}
} else {
if (settings.getInfillDensity() == null) {
settings.setInfillDensity(20.0);
}
if (settings.getInfillPattern() == null) {
settings.setInfillPattern("grid");
}
}
}
public void enforceCadPrintSettings(QuoteSession session, PrintSettingsDto settings) {
settings.setComplexityMode("ADVANCED");
settings.setMaterial(session.getMaterialCode() != null ? session.getMaterialCode() : "PLA");
settings.setNozzleDiameter(session.getNozzleDiameterMm() != null ? session.getNozzleDiameterMm().doubleValue() : 0.4);
settings.setLayerHeight(session.getLayerHeightMm() != null ? session.getLayerHeightMm().doubleValue() : 0.2);
settings.setInfillPattern(session.getInfillPattern() != null ? session.getInfillPattern() : "grid");
settings.setInfillDensity(session.getInfillPercent() != null ? session.getInfillPercent().doubleValue() : 20.0);
settings.setSupportsEnabled(Boolean.TRUE.equals(session.getSupportsEnabled()));
}
public NozzleLayerSettings resolveNozzleAndLayer(PrintSettingsDto settings) {
BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle(
settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null
);
BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer(
settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null,
nozzleDiameter
);
if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Layer height " + layerHeight.stripTrailingZeros().toPlainString()
+ " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString()
+ ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter)
);
}
settings.setNozzleDiameter(nozzleDiameter.doubleValue());
settings.setLayerHeight(layerHeight.doubleValue());
return new NozzleLayerSettings(nozzleDiameter, layerHeight);
}
public PrinterMachine resolvePrinterMachine(Long printerMachineId) {
if (printerMachineId != null) {
PrinterMachine selected = machineRepo.findById(printerMachineId)
.orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId));
if (!Boolean.TRUE.equals(selected.getIsActive())) {
throw new RuntimeException("Selected printer machine is not active");
}
return selected;
}
return machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
public FilamentVariant resolveFilamentVariant(PrintSettingsDto settings) {
if (settings.getFilamentVariantId() != null) {
FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId())
.orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId()));
if (!Boolean.TRUE.equals(variant.getIsActive())) {
throw new RuntimeException("Selected filament variant is not active");
}
return variant;
}
String requestedMaterialCode = normalizeRequestedMaterialCode(settings.getMaterial());
FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode)
.orElseGet(() -> materialRepo.findByMaterialCode("PLA")
.orElseThrow(() -> new RuntimeException("Fallback material PLA not configured")));
String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null;
if (requestedColor != null && !requestedColor.isBlank()) {
Optional<FilamentVariant> byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor);
if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) {
return byColor.get();
}
}
return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode));
}
public String normalizeRequestedMaterialCode(String value) {
if (value == null || value.isBlank()) {
return "PLA";
}
return value.trim()
.toUpperCase(Locale.ROOT)
.replace('_', ' ')
.replace('-', ' ')
.replaceAll("\\s+", " ");
}
public String resolveQuality(PrintSettingsDto settings, BigDecimal layerHeight) {
if (settings.getQuality() != null && !settings.getQuality().isBlank()) {
return settings.getQuality().trim().toLowerCase(Locale.ROOT);
}
if (layerHeight == null) {
return "standard";
}
if (layerHeight.compareTo(BigDecimal.valueOf(0.24)) >= 0) {
return "draft";
}
if (layerHeight.compareTo(BigDecimal.valueOf(0.12)) <= 0) {
return "extra_fine";
}
return "standard";
}
public record NozzleLayerSettings(BigDecimal nozzleDiameter, BigDecimal layerHeight) {
}
}

View File

@@ -0,0 +1,91 @@
package com.printcalculator.service.quote;
import com.printcalculator.entity.QuoteLineItem;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Service
public class QuoteStorageService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
public Path sessionStorageDir(UUID sessionId) throws IOException {
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(sessionId.toString()).normalize();
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
throw new IOException("Invalid quote session storage path");
}
Files.createDirectories(sessionStorageDir);
return sessionStorageDir;
}
public Path resolveSessionPath(Path sessionStorageDir, String filename) throws IOException {
Path resolved = sessionStorageDir.resolve(filename).normalize();
if (!resolved.startsWith(sessionStorageDir)) {
throw new IOException("Invalid quote line-item storage path");
}
return resolved;
}
public String toStoredPath(Path absolutePath) {
return QUOTE_STORAGE_ROOT.relativize(absolutePath).toString();
}
public String getSafeExtension(String filename, String fallback) {
if (filename == null) {
return fallback;
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return fallback;
}
int index = cleaned.lastIndexOf('.');
if (index <= 0 || index >= cleaned.length() - 1) {
return fallback;
}
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
return switch (ext) {
case "stl" -> "stl";
case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback;
};
}
public Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
public String extractConvertedStoredPath(QuoteLineItem item) {
Map<String, Object> breakdown = item.getPricingBreakdown();
if (breakdown == null) {
return null;
}
Object converted = breakdown.get("convertedStoredPath");
if (converted == null) {
return null;
}
String path = String.valueOf(converted).trim();
return path.isEmpty() ? null : path;
}
}

View File

@@ -0,0 +1,216 @@
package com.printcalculator.service.request;
import com.printcalculator.entity.CustomQuoteRequest;
import org.springframework.stereotype.Service;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
@Service
public class ContactRequestLocalizationService {
public String applyCustomerContactRequestTexts(Map<String, Object> templateData, String language, UUID requestId) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Contact request received");
templateData.put("headlineText", "We received your contact request");
templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ",");
templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible.");
templateData.put("requestIdHintText", "Please keep this request ID for future order references:");
templateData.put("detailsTitleText", "Request details");
templateData.put("labelRequestId", "Request ID");
templateData.put("labelDate", "Date");
templateData.put("labelRequestType", "Request type");
templateData.put("labelCustomerType", "Customer type");
templateData.put("labelName", "Name");
templateData.put("labelCompany", "Company");
templateData.put("labelContactPerson", "Contact person");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Phone");
templateData.put("labelMessage", "Message");
templateData.put("labelAttachments", "Attachments");
templateData.put("supportText", "If you need help, reply to this email.");
templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab.");
yield "We received your contact request #" + requestId + " - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Kontaktanfrage erhalten");
templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten");
templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ",");
templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich.");
templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:");
templateData.put("detailsTitleText", "Anfragedetails");
templateData.put("labelRequestId", "Anfrage-ID");
templateData.put("labelDate", "Datum");
templateData.put("labelRequestType", "Anfragetyp");
templateData.put("labelCustomerType", "Kundentyp");
templateData.put("labelName", "Name");
templateData.put("labelCompany", "Firma");
templateData.put("labelContactPerson", "Kontaktperson");
templateData.put("labelEmail", "E-Mail");
templateData.put("labelPhone", "Telefon");
templateData.put("labelMessage", "Nachricht");
templateData.put("labelAttachments", "Anhaenge");
templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail.");
templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab.");
yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Demande de contact recue");
templateData.put("headlineText", "Nous avons recu votre demande de contact");
templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ",");
templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible.");
templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :");
templateData.put("detailsTitleText", "Details de la demande");
templateData.put("labelRequestId", "ID de demande");
templateData.put("labelDate", "Date");
templateData.put("labelRequestType", "Type de demande");
templateData.put("labelCustomerType", "Type de client");
templateData.put("labelName", "Nom");
templateData.put("labelCompany", "Entreprise");
templateData.put("labelContactPerson", "Contact");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Telephone");
templateData.put("labelMessage", "Message");
templateData.put("labelAttachments", "Pieces jointes");
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab.");
yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Richiesta di contatto ricevuta");
templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto");
templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ",");
templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile.");
templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:");
templateData.put("detailsTitleText", "Dettagli richiesta");
templateData.put("labelRequestId", "ID richiesta");
templateData.put("labelDate", "Data");
templateData.put("labelRequestType", "Tipo richiesta");
templateData.put("labelCustomerType", "Tipo cliente");
templateData.put("labelName", "Nome");
templateData.put("labelCompany", "Azienda");
templateData.put("labelContactPerson", "Contatto");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Telefono");
templateData.put("labelMessage", "Messaggio");
templateData.put("labelAttachments", "Allegati");
templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email.");
templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab.");
yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab";
}
};
}
public String localizeRequestType(String requestType, String language) {
if (requestType == null || requestType.isBlank()) {
return "-";
}
String normalized = requestType.trim().toLowerCase(Locale.ROOT);
return switch (language) {
case "en" -> switch (normalized) {
case "custom", "print_service" -> "Custom part request";
case "series" -> "Series production request";
case "consult", "design_service" -> "Consultation request";
case "question" -> "General question";
default -> requestType;
};
case "de" -> switch (normalized) {
case "custom", "print_service" -> "Anfrage fuer Einzelteil";
case "series" -> "Anfrage fuer Serienproduktion";
case "consult", "design_service" -> "Beratungsanfrage";
case "question" -> "Allgemeine Frage";
default -> requestType;
};
case "fr" -> switch (normalized) {
case "custom", "print_service" -> "Demande de piece personnalisee";
case "series" -> "Demande de production en serie";
case "consult", "design_service" -> "Demande de conseil";
case "question" -> "Question generale";
default -> requestType;
};
default -> switch (normalized) {
case "custom", "print_service" -> "Richiesta pezzo personalizzato";
case "series" -> "Richiesta produzione in serie";
case "consult", "design_service" -> "Richiesta consulenza";
case "question" -> "Domanda generale";
default -> requestType;
};
};
}
public String localizeCustomerType(String customerType, String language) {
if (customerType == null || customerType.isBlank()) {
return "-";
}
String normalized = customerType.trim().toLowerCase(Locale.ROOT);
return switch (language) {
case "en" -> switch (normalized) {
case "private" -> "Private";
case "business" -> "Business";
default -> customerType;
};
case "de" -> switch (normalized) {
case "private" -> "Privat";
case "business" -> "Unternehmen";
default -> customerType;
};
case "fr" -> switch (normalized) {
case "private" -> "Prive";
case "business" -> "Entreprise";
default -> customerType;
};
default -> switch (normalized) {
case "private" -> "Privato";
case "business" -> "Azienda";
default -> customerType;
};
};
}
public Locale localeForLanguage(String language) {
return switch (language) {
case "en" -> Locale.ENGLISH;
case "de" -> Locale.GERMAN;
case "fr" -> Locale.FRENCH;
default -> Locale.ITALIAN;
};
}
public String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.toLowerCase(Locale.ROOT).trim();
if (normalized.startsWith("en")) {
return "en";
}
if (normalized.startsWith("de")) {
return "de";
}
if (normalized.startsWith("fr")) {
return "fr";
}
return "it";
}
public String resolveRecipientName(CustomQuoteRequest request, String language) {
if (request.getName() != null && !request.getName().isBlank()) {
return request.getName().trim();
}
if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
return request.getContactPerson().trim();
}
if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) {
return request.getCompanyName().trim();
}
return switch (language) {
case "en" -> "customer";
case "de" -> "Kunde";
case "fr" -> "client";
default -> "cliente";
};
}
}

View File

@@ -0,0 +1,155 @@
package com.printcalculator.service.request;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.service.storage.ClamAVService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
@Service
@Transactional(readOnly = true)
public class CustomQuoteRequestAttachmentService {
private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
);
private static final Set<String> FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of(
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-7z-compressed",
"application/gzip",
"application/x-gzip",
"application/x-tar",
"application/x-bzip2",
"application/x-xz",
"application/zstd",
"application/x-zstd"
);
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final ClamAVService clamAVService;
public CustomQuoteRequestAttachmentService(CustomQuoteRequestAttachmentRepository attachmentRepo,
ClamAVService clamAVService) {
this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService;
}
@Transactional
public int storeAttachments(CustomQuoteRequest request, List<MultipartFile> files) throws IOException {
if (files == null || files.isEmpty()) {
return 0;
}
if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed.");
}
int attachmentsCount = 0;
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
if (isCompressedFile(file)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Compressed files are not allowed.");
}
clamAVService.scan(file.getInputStream());
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
attachment.setRequest(request);
attachment.setOriginalFilename(file.getOriginalFilename());
attachment.setMimeType(file.getContentType());
attachment.setFileSizeBytes(file.getSize());
attachment.setCreatedAt(OffsetDateTime.now());
attachment.setStoredFilename(UUID.randomUUID() + ".upload");
attachment.setStoredRelativePath("PENDING");
attachment = attachmentRepo.save(attachment);
Path relativePath = Path.of(
"quote-requests",
request.getId().toString(),
"attachments",
attachment.getId().toString(),
attachment.getStoredFilename()
);
attachment.setStoredRelativePath(relativePath.toString());
attachmentRepo.save(attachment);
Path absolutePath = resolveWithinStorageRoot(relativePath);
Files.createDirectories(absolutePath.getParent());
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
}
attachmentsCount++;
}
return attachmentsCount;
}
private String getExtension(String filename) {
if (filename == null) {
return "dat";
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return "dat";
}
int i = cleaned.lastIndexOf('.');
if (i > 0 && i < cleaned.length() - 1) {
String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT);
if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) {
return ext;
}
}
return "dat";
}
private boolean isCompressedFile(MultipartFile file) {
String ext = getExtension(file.getOriginalFilename());
if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) {
return true;
}
String mime = file.getContentType();
return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase(Locale.ROOT));
}
private Path resolveWithinStorageRoot(Path relativePath) {
try {
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize();
if (!absolutePath.startsWith(STORAGE_ROOT)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
return absolutePath;
} catch (InvalidPathException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
}
}

View File

@@ -0,0 +1,68 @@
package com.printcalculator.service.request;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@Transactional(readOnly = true)
public class CustomQuoteRequestControllerService {
private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentService attachmentService;
private final CustomQuoteRequestNotificationService notificationService;
public CustomQuoteRequestControllerService(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentService attachmentService,
CustomQuoteRequestNotificationService notificationService) {
this.requestRepo = requestRepo;
this.attachmentService = attachmentService;
this.notificationService = notificationService;
}
@Transactional
public CustomQuoteRequest createCustomQuoteRequest(QuoteRequestDto requestDto, List<MultipartFile> files) throws IOException {
validateConsents(requestDto);
CustomQuoteRequest request = new CustomQuoteRequest();
request.setRequestType(requestDto.getRequestType());
request.setCustomerType(requestDto.getCustomerType());
request.setEmail(requestDto.getEmail());
request.setPhone(requestDto.getPhone());
request.setName(requestDto.getName());
request.setCompanyName(requestDto.getCompanyName());
request.setContactPerson(requestDto.getContactPerson());
request.setMessage(requestDto.getMessage());
request.setStatus("PENDING");
request.setCreatedAt(OffsetDateTime.now());
request.setUpdatedAt(OffsetDateTime.now());
request = requestRepo.save(request);
int attachmentsCount = attachmentService.storeAttachments(request, files);
notificationService.sendNotifications(request, attachmentsCount, requestDto.getLanguage());
return request;
}
public Optional<CustomQuoteRequest> getCustomQuoteRequest(UUID id) {
return requestRepo.findById(id);
}
private void validateConsents(QuoteRequestDto requestDto) {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Accettazione Termini e Privacy obbligatoria.");
}
}
}

View File

@@ -0,0 +1,122 @@
package com.printcalculator.service.request;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.service.email.EmailNotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.HashMap;
import java.util.Map;
@Service
public class CustomQuoteRequestNotificationService {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestNotificationService.class);
private final EmailNotificationService emailNotificationService;
private final ContactRequestLocalizationService localizationService;
@Value("${app.mail.contact-request.admin.enabled:true}")
private boolean contactRequestAdminMailEnabled;
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress;
@Value("${app.mail.contact-request.customer.enabled:true}")
private boolean contactRequestCustomerMailEnabled;
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
ContactRequestLocalizationService localizationService) {
this.emailNotificationService = emailNotificationService;
this.localizationService = localizationService;
}
public void sendNotifications(CustomQuoteRequest request, int attachmentsCount, String rawLanguage) {
String language = localizationService.normalizeLanguage(rawLanguage);
sendAdminContactRequestNotification(request, attachmentsCount);
sendCustomerContactRequestConfirmation(request, attachmentsCount, language);
}
private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) {
if (!contactRequestAdminMailEnabled) {
return;
}
if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) {
logger.warn("Contact request admin notification enabled but no admin address configured.");
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put("createdAt", request.getCreatedAt());
templateData.put("requestType", safeValue(request.getRequestType()));
templateData.put("customerType", safeValue(request.getCustomerType()));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail(
contactRequestAdminMailAddress,
"Nuova richiesta di contatto #" + request.getId(),
"contact-request-admin",
templateData
);
}
private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) {
if (!contactRequestCustomerMailEnabled) {
return;
}
if (request.getEmail() == null || request.getEmail().isBlank()) {
logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId());
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put(
"createdAt",
request.getCreatedAt().format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withLocale(localizationService.localeForLanguage(language))
)
);
templateData.put("recipientName", localizationService.resolveRecipientName(request, language));
templateData.put("requestType", localizationService.localizeRequestType(request.getRequestType(), language));
templateData.put("customerType", localizationService.localizeCustomerType(request.getCustomerType(), language));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
emailNotificationService.sendEmail(
request.getEmail(),
subject,
"contact-request-customer",
templateData
);
}
private String safeValue(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return value;
}
}

View File

@@ -1,4 +1,4 @@
package com.printcalculator.service; package com.printcalculator.service.storage;
import com.printcalculator.exception.VirusDetectedException; import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger; import org.slf4j.Logger;

Some files were not shown because too many files have changed in this diff Show More