225 Commits

Author SHA1 Message Date
fa2e249e94 Merge pull request 'feat(front-end): linkedin logo' (#56) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #56
2026-03-25 19:26:41 +01:00
132f0f3646 feat(front-end): linkedin logo
All checks were successful
Build and Deploy / test-backend (push) Successful in 37s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 36s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-25 19:18:38 +01:00
3cbcec5f53 Merge pull request 'dev' (#55) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m46s
Build and Deploy / deploy (push) Successful in 35s
Build and Deploy / build-and-push (push) Successful in 1m5s
Reviewed-on: #55
2026-03-25 11:57:49 +01:00
printcalc-ci
8835175fb3 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 52s
PR Checks / test-frontend (pull_request) Successful in 1m17s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-25 10:46:11 +00:00
28c3abdb4a Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m41s
PR Checks / security-sast (pull_request) Successful in 52s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 20s
PR Checks / test-frontend (pull_request) Successful in 1m42s
2026-03-25 11:44:01 +01:00
b30bfc9293 fix(front-end): improvements in load products by uuid truncated
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m37s
Build and Deploy / build-and-push (push) Successful in 1m57s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / security-sast (pull_request) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m15s
2026-03-25 11:27:43 +01:00
d70423fcc0 fix(front-end): improvements in ssr
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
2026-03-24 16:20:40 +01:00
1b7c0c48e7 Merge pull request 'dev' (#54) 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 59s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #54
2026-03-24 13:29:50 +01:00
printcalc-ci
cb86137730 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-24 12:19:19 +00:00
c8913af660 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / build-and-push (push) Successful in 31s
2026-03-24 13:17:30 +01:00
9611049e01 fix(front-end): new test 2026-03-24 13:17:25 +01:00
bad5947fb5 Merge branch 'main' into dev
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 31s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / test-backend (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 10s
2026-03-24 13:17:10 +01:00
d27558a3ee fix(front-end): fix no index product 3 hope the last one
Some checks failed
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-24 12:59:09 +01:00
81f6f78c49 fix(front-end): fix no index product 2
All checks were successful
Build and Deploy / test-backend (push) Successful in 32s
Build and Deploy / test-frontend (push) Successful in 1m9s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
2026-03-23 19:11:26 +01:00
bf593445bd fix(front-end): fix no index product
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 1m16s
Build and Deploy / deploy (push) Successful in 20s
2026-03-23 18:07:07 +01:00
aa032c0140 Merge pull request 'fix(front-end): fix no index in products' (#53) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #53
2026-03-23 17:36:11 +01:00
printcalc-ci
95e60692c0 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / security-sast (pull_request) Successful in 37s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-23 16:31:33 +00:00
fda2cdbecb Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 15s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 23s
PR Checks / test-frontend (pull_request) Successful in 1m5s
2026-03-23 17:29:38 +01:00
a1cc9f18c4 fix(front-end): fix no index in products
Some checks failed
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-23 17:27:18 +01:00
084d35d605 Merge pull request 'fix(front-end): seo improvemnts' (#52) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #52
2026-03-23 16:21:00 +01:00
printcalc-ci
02aac24a09 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-23 15:15:15 +00:00
51c2bf6985 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 29s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 21s
2026-03-23 16:14:18 +01:00
4e99d12be1 fix(front-end): seo improvemnts
Some checks failed
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
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / prettier-autofix (pull_request) Failing after 12s
Build and Deploy / test-frontend (push) Has been cancelled
2026-03-23 16:14:04 +01:00
8b5d8f92e0 Merge pull request 'dev' (#51) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m7s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #51
2026-03-22 23:06:02 +01:00
d3c9dd6eb9 Merge branch 'main' into dev
All checks were successful
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 22s
2026-03-22 23:03:08 +01:00
254ff36c50 fix(front-end): seo improvemnts
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-22 23:02:59 +01:00
b317196217 fix(front-end): redirect
All checks were successful
Build and Deploy / test-backend (push) Successful in 40s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 1m19s
Build and Deploy / deploy (push) Successful in 22s
2026-03-22 22:41:12 +01:00
cc343ee27c fix(back-end): fix csrm and cors 2026-03-22 21:11:48 +01:00
74d1b16b7c fix(back-end): fix load product 2026-03-22 21:11:33 +01:00
adf6889712 Merge pull request 'dev' (#49) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #49
2026-03-21 18:57:57 +01:00
653082868a Merge pull request 'feat/brand-logo' (#50) from feat/brand-logo into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 1m21s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #50
2026-03-20 13:17:03 +01:00
997e770256 Merge remote-tracking branch 'origin/feat/brand-logo' into feat/brand-logo
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-20 11:45:15 +01:00
fb1a6456e6 fix(back-end) base url fix 2026-03-20 11:45:10 +01:00
43cd80600e Merge branch 'dev' into feat/brand-logo
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Failing after 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-20 11:28:10 +01:00
printcalc-ci
23e1abdbbb style: apply prettier formatting 2026-03-20 09:37:56 +00:00
e575021f53 feat(front-end): new logo edited
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 22s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Failing after 34s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-20 10:36:50 +01:00
7e8c89ce45 feat(front-end): new logo edited 2026-03-19 14:33:29 +01:00
a40a8df894 feat(animation logo) 2026-03-18 17:30:53 +01:00
printcalc-ci
41f36ed18a style: apply prettier formatting 2026-03-17 08:03:30 +00:00
e04189bbfe Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m46s
Build and Deploy / deploy (push) Successful in 22s
2026-03-17 09:01:34 +01:00
20988e425a fix(front-end): set fallback lang
Some checks failed
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / test-backend (push) Successful in 36s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / prettier-autofix (pull_request) Failing after 18s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-14 20:53:50 +01:00
df63937406 feat(front-end): faster load
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 19s
2026-03-14 19:28:30 +01:00
4ba408859d Merge pull request 'dev' (#48) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #48
2026-03-14 19:18:15 +01:00
996e95f93c fix(back-end): quote line items
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 32s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m25s
Build and Deploy / deploy (push) Successful in 21s
2026-03-14 19:15:44 +01:00
printcalc-ci
c4bd0b5a67 style: apply prettier formatting 2026-03-14 17:58:02 +00:00
5c43873ede Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 15s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 18s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 27s
2026-03-14 18:56:07 +01:00
249645619e fix(deploy): common..env
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 22s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-14 18:52:18 +01:00
be9f303b37 fix(deploy): common..env
Some checks failed
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Failing after 6s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-14 18:42:53 +01:00
6da8b3b6e4 feat(back-end): new translation api with openai
Some checks failed
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 1m20s
Build and Deploy / deploy (push) Failing after 6s
2026-03-14 18:33:51 +01:00
a3cd451575 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #47
2026-03-14 16:13:37 +01:00
printcalc-ci
6f700c923a style: apply prettier formatting
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 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-14 14:15:10 +00:00
46fd59ed71 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 22s
2026-03-14 15:14:12 +01:00
ba49463ee7 fix(front-end): seo improvements with SSR
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-14 15:13:54 +01:00
576380e9a0 fix(front-end): seo translated 2026-03-14 15:02:00 +01:00
cac534ccbb Merge pull request 'dev' (#46) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
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 18s
Reviewed-on: #46
2026-03-13 17:44:20 +01:00
2e68105da4 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 24s
2026-03-13 17:41:55 +01:00
ed7ed6636d fix(front-end): al categories translated 2026-03-13 17:41:25 +01:00
e190359041 Merge pull request 'dev' (#45) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #45
2026-03-13 16:36:42 +01:00
printcalc-ci
bed94790d4 style: apply prettier formatting
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
2026-03-13 15:30:28 +00:00
d8ad61ec54 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m4s
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 21s
2026-03-13 16:28:40 +01:00
7e9a1482d6 fix(front-end): maximum budget
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-13 16:24:39 +01:00
aa0adbf993 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 41s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:21:57 +01:00
00af9a9701 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Failing after 1m15s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:16:49 +01:00
fcdede2dd6 chore(front-end):map color
Some checks failed
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) Failing after 26s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:43:00 +01:00
5d17b23c3a chore(front-end): new seo, and improvements in shop component
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Failing after 1m16s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:26:36 +01:00
1ec8a43a50 Merge pull request 'dev' (#43) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #43
2026-03-12 12:20:34 +01:00
96cfa91c67 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Failing after 1m25s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 12:17:42 +01:00
669ace82c0 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-12 12:17:28 +01:00
93163ae6e8 fix(front-end): sitemap static 2026-03-12 12:17:13 +01:00
af2d506da1 Merge pull request 'dev' (#42) 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 59s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #42
2026-03-11 17:35:12 +01:00
637541994a 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
Build and Deploy / test-frontend (push) Successful in 1m1s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 22s
2026-03-11 17:32:53 +01:00
printcalc-ci
63cd4c4f5e style: apply prettier formatting 2026-03-11 16:31:15 +00:00
fd4104da39 fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-11 17:27:28 +01:00
5bb23fbcfa fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 26s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:23:32 +01:00
6a22c54e9f feat(front-end): ssr i18n fix
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 30s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:19:26 +01:00
3ac3262e77 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 27s
Build and Deploy / deploy (push) Successful in 20s
# Conflicts:
#	frontend/src/app/app.config.ts
2026-03-11 17:10:06 +01:00
18ecc07240 feat(front-end): ssr i18n fix 2026-03-11 17:09:51 +01:00
cb468492b3 Merge pull request 'feat(front-end): ssr implementation' (#41) from feat/ssr into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 52s
Build and Deploy / deploy (push) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m0s
Reviewed-on: #41
2026-03-11 16:59:21 +01:00
379a2161ca Merge remote-tracking branch 'origin/feat/ssr' into feat/ssr
All checks were successful
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
2026-03-11 16:57:07 +01:00
c47a7e28c7 feat(front-end): ssr implementation 2026-03-11 16:57:03 +01:00
printcalc-ci
502126c915 style: apply prettier formatting 2026-03-11 15:37:29 +00:00
2ace632022 feat(front-end): ssr implementation
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Failing after 57s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-11 16:37:08 +01:00
feee2b0bff Merge pull request 'dev' (#40) 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 1m3s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #40
2026-03-11 15:32:14 +01:00
b7dfc53bc0 feat(front-end): update header layout
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 28s
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 48s
Build and Deploy / deploy (push) Successful in 11s
2026-03-11 15:30:21 +01:00
d77c3c7a5c feat(front-end): update header layout
Some checks failed
Build and Deploy / test-frontend (push) Successful in 1m6s
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 36s
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-11 15:28:40 +01:00
printcalc-ci
faaa59ae01 style: apply prettier formatting 2026-03-11 14:18:47 +00:00
1bde2690b6 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 1m41s
Build and Deploy / deploy (push) Has been skipped
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 28s
2026-03-11 15:17:52 +01:00
92fddd9e4a Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
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
Build and Deploy / test-frontend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m4s
2026-03-11 15:17:32 +01:00
f9414b2db7 feat(front-end): sitemap update 2026-03-11 15:17:19 +01:00
d1e7e7eaca feat(front-end): sitemap 2026-03-11 15:07:12 +01:00
75929a15f8 Merge pull request 'fix(front-end): fix security' (#39) 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 1m5s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #39
2026-03-11 11:42:50 +01:00
c0585d7107 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 31s
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 57s
Build and Deploy / deploy (push) Successful in 12s
2026-03-11 11:39:59 +01:00
aeeed1c138 fix(front-end): fix security
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-11 11:39:45 +01:00
10f05fabc9 Merge pull request 'dev' (#38) from dev into main
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 7s
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 16s
Reviewed-on: #38
2026-03-11 11:05:12 +01:00
78b719d3c2 fix(front-end): fix security
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 36s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 23s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 9s
2026-03-11 11:02:47 +01:00
printcalc-ci
052ade3e91 style: apply prettier formatting 2026-03-11 09:59:12 +00:00
035851133e Merge branch 'main' into dev
Some checks failed
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 13s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / security-sast (pull_request) Failing after 27s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-11 10:57:23 +01:00
1ff8883d25 fix(back-end): fix hex color
Some checks failed
Build and Deploy / test-backend (push) Successful in 26s
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 8s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Failing after 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-11 10:29:29 +01:00
30ada043ef fix(back-end): fix hex color
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 34s
Build and Deploy / deploy (push) Successful in 9s
2026-03-11 10:21:15 +01:00
e0aaa7c92e fix(back-end): fix hex color
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 1m43s
Build and Deploy / deploy (push) Successful in 10s
2026-03-11 10:14:30 +01:00
edae13541f feat(back-end): rich text improvements
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 27s
Build and Deploy / deploy (push) Successful in 8s
2026-03-10 19:01:17 +01:00
d150c19f9f feat(back-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 53s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 57s
Build and Deploy / deploy (push) Successful in 8s
2026-03-10 18:51:15 +01:00
71890e4cc2 feat(front-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 25s
Build and Deploy / deploy (push) Successful in 9s
2026-03-10 18:48:27 +01:00
95494efcae Merge pull request 'dev' (#37) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #37
2026-03-10 17:43:45 +01:00
8893a80c12 feat(front-end): update media ffmpeg
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / test-frontend (pull_request) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 1m40s
Build and Deploy / deploy (push) Successful in 11s
2026-03-10 17:29:38 +01:00
a4facf74d7 feat(front-end): update media ffmpeg
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 13s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Failing after 1m0s
Build and Deploy / deploy (push) Has been skipped
2026-03-10 17:20:45 +01:00
ed8fd89217 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 9s
2026-03-10 15:32:59 +01:00
58869be9f7 feat(front-end): css allert fix 2026-03-10 15:32:53 +01:00
fda0174a77 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 8s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 36s
PR Checks / test-backend (pull_request) Successful in 29s
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-10 15:25:51 +01:00
printcalc-ci
52c6239f6d style: apply prettier formatting 2026-03-10 14:23:52 +00:00
c007cee093 feat(front-end): shop category ui
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / build-and-push (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 13s
2026-03-10 15:23:08 +01:00
d011268d20 feat(front-end): shop category ui
Some checks failed
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Failing after 44s
Build and Deploy / deploy (push) Has been skipped
2026-03-10 15:14:10 +01:00
126d3ef4c4 feat(back-end front-end): shop route 2026-03-10 15:04:56 +01:00
3f228ef6e2 feat(back-end front-end): shop feature 2026-03-10 15:04:49 +01:00
8733184dc5 Merge branch 'feat/shop' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 38s
Build and Deploy / deploy (push) Successful in 10s
2026-03-10 12:42:42 +01:00
5f815d8a54 fix(back-end) url construction for media 2026-03-10 12:42:26 +01:00
6d491eb694 Merge pull request 'feat/shop' (#36) from feat/shop into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 50s
Build and Deploy / deploy (push) Successful in 11s
Reviewed-on: #36
2026-03-10 11:16:22 +01:00
printcalc-ci
42e0e75d70 style: apply prettier formatting 2026-03-10 10:15:56 +00:00
c24e27a9db Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 26s
# Conflicts:
#	frontend/src/app/features/shop/shop-page.component.scss
2026-03-10 11:15:32 +01:00
3d12ae4da4 feat(front-end): ui improvements and alligment 2026-03-10 11:15:25 +01:00
printcalc-ci
b9e6916dfe style: apply prettier formatting 2026-03-10 09:54:16 +00:00
7cd9ef53b5 Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
# Conflicts:
#	frontend/src/app/features/shop/components/product-card/product-card.component.scss
#	frontend/src/app/features/shop/product-detail.component.scss
#	frontend/src/app/features/shop/shop-page.component.html
#	frontend/src/app/features/shop/shop-page.component.scss
#	frontend/src/app/features/shop/shop-page.component.ts
2026-03-10 10:53:42 +01:00
e747a9820e feat(front-end): back-office desing improvements 2026-03-10 10:53:30 +01:00
ba6940e64b Merge branch 'dev' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-10 09:21:28 +01:00
printcalc-ci
4342e9b1b1 style: apply prettier formatting 2026-03-10 07:32:17 +00:00
a212a1d8cc feat(back-end): shop ui implementation
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 34s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-10 08:31:29 +01:00
cd0c13203f feat(back-end): category and shop implementation 2026-03-09 20:32:13 +01:00
77d7bdb265 fix(back-end): img convert
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 39s
Build and Deploy / deploy (push) Successful in 8s
2026-03-09 20:04:21 +01:00
fb5e753769 feat(back-end) shop logic 2026-03-09 19:58:53 +01:00
c953232f3c feat(back-end) entities for shop 2026-03-09 19:50:00 +01:00
cd2666d8e2 Merge pull request 'feat/shop' (#35) from feat/shop into dev
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 28s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #35
2026-03-09 19:31:12 +01:00
2f34c52b6f Merge branch 'dev' into feat/shop
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 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-09 19:31:06 +01:00
printcalc-ci
2606b22185 style: apply prettier formatting 2026-03-09 18:22:55 +00:00
038e1634eb Merge pull request 'fix(front-end): css file reduced' (#34) from fix/css-files into feat/shop
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 25s
PR Checks / test-frontend (pull_request) Successful in 59s
Reviewed-on: #34
2026-03-09 19:22:33 +01:00
f03f111d5e Merge pull request 'feat/shop' (#33) from feat/shop into dev
Some checks failed
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) Failing after 25s
Build and Deploy / deploy (push) Has been skipped
Reviewed-on: #33
2026-03-09 19:21:27 +01:00
aaa58346d3 fix(front-end): css file reduced 2026-03-09 19:21:18 +01:00
506762c538 Merge branch 'dev' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
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 57s
2026-03-09 19:19:38 +01:00
printcalc-ci
492d474c82 style: apply prettier formatting 2026-03-09 18:09:45 +00:00
225995c892 Merge pull request 'fix(front-end): css file duplicte' (#32) from fix/css-files into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Reviewed-on: #32
2026-03-09 19:09:19 +01:00
dfe109ac8d fix(front-end): css file duplicte 2026-03-09 19:08:51 +01:00
ca22c0c461 Merge pull request 'feat/shop' (#31) from feat/shop into dev
Some checks failed
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) Failing after 1m32s
Build and Deploy / deploy (push) Has been skipped
Reviewed-on: #31
2026-03-09 18:52:25 +01:00
8afab3e58e Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 32s
2026-03-09 18:49:34 +01:00
b4462dcd9d feat(back-end front-end):ffmpeg local 2026-03-09 18:49:25 +01:00
printcalc-ci
f598e376a6 style: apply prettier formatting 2026-03-09 17:49:19 +00:00
e8ebef926e feat(back-end front-end): traslate alt and description images
All checks were successful
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 25s
PR Checks / test-frontend (pull_request) Successful in 1m4s
2026-03-09 18:49:03 +01:00
85598dee3b Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-09 18:14:25 +01:00
2dbf7e9c09 fix(back-end): security problem 2026-03-09 18:14:19 +01:00
printcalc-ci
210820185b style: apply prettier formatting 2026-03-09 17:08:05 +00:00
7615b8b601 feat(front-end): admin desing improvements
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 20s
PR Checks / security-sast (pull_request) Failing after 34s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-09 18:07:22 +01:00
17df0c6b9b feat(back-end): admin home edit image page 2026-03-09 17:44:17 +01:00
9e306ea1d1 feat(back-end): upload media 2026-03-09 16:30:00 +01:00
3ab518a9b6 Merge pull request 'dev' (#29) 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 1m2s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #29
2026-03-09 09:58:45 +01:00
63804e7561 fix(back-end): nozle layer height
All checks were successful
Build and Deploy / test-backend (push) Successful in 41s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m14s
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
Build and Deploy / build-and-push (push) Successful in 1m10s
Build and Deploy / deploy (push) Successful in 11s
2026-03-08 18:47:50 +01:00
0c4800443f Merge pull request 'feat/calculator-options' (#30) from feat/calculator-options into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 50s
PR Checks / prettier-autofix (pull_request) Successful in 9s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / deploy (push) Successful in 13s
Reviewed-on: #30
2026-03-06 15:14:29 +01:00
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
1bd6a43614 Merge pull request 'feat/calculator-options' (#28) from feat/calculator-options into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 36s
PR Checks / prettier-autofix (pull_request) Successful in 12s
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 25s
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 13s
Reviewed-on: #28
2026-03-06 12:55:05 +01:00
printcalc-ci
575a540a70 style: apply prettier formatting 2026-03-05 20:56:22 +00:00
47c442aba9 Merge pull request 'Merge pull request 'fix(back-end): twint url' (#24) from fix/twint into main' (#27) from main into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m7s
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 26s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 13s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Reviewed-on: #27
2026-03-05 21:54:27 +01: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
00825b1002 Merge pull request 'feat/calculator-options' (#26) from feat/calculator-options into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 50s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #26
2026-03-05 20:50:33 +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
40da5ff1b7 Merge pull request 'fix(back-end): twint url' (#24) from fix/twint into main
All checks were successful
Build and Deploy / build-and-push (push) Successful in 36s
Build and Deploy / deploy (push) Successful in 8s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / prettier-autofix (pull_request) Successful in 7s
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: #24
2026-03-05 15:46:48 +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
359 changed files with 61080 additions and 5369 deletions

View File

@@ -41,25 +41,38 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: "frontend/package-lock.json" cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium - name: Resolve Chrome binary
shell: bash shell: bash
run: | run: |
apt-get update set -euo pipefail
apt-get install -y --no-install-recommends chromium 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 - name: Install frontend dependencies
shell: bash shell: bash
run: | run: |
cd frontend cd frontend
npm ci --no-audit --no-fund npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless) - name: Run frontend tests (headless)
shell: bash shell: bash
env: env:
CHROME_BIN: /usr/bin/chromium
CI: "true" CI: "true"
run: | run: |
cd frontend cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox
build-and-push: build-and-push:
@@ -112,6 +125,18 @@ jobs:
docker build -t "$FRONTEND_IMAGE" ./frontend docker build -t "$FRONTEND_IMAGE" ./frontend
docker push "$FRONTEND_IMAGE" docker push "$FRONTEND_IMAGE"
- name: Cleanup Docker on runner (prevent vdisk growth)
if: always()
shell: bash
run: |
set +e
# Keep recent artifacts, drop old local residue from CI builds.
docker container prune -f --filter "until=168h" || true
docker image prune -a -f --filter "until=168h" || true
docker builder prune -a -f --filter "until=168h" || true
docker network prune -f --filter "until=168h" || true
deploy: deploy:
needs: build-and-push needs: build-and-push
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -192,9 +217,12 @@ jobs:
ADMIN_TTL="${ADMIN_TTL:-480}" ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ 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 "${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env
fi
echo "Preparing to send env file with variables:" echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
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

View File

@@ -150,23 +150,36 @@ jobs:
cache: "npm" cache: "npm"
cache-dependency-path: "frontend/package-lock.json" cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium - name: Resolve Chrome binary
shell: bash shell: bash
run: | run: |
apt-get update set -euo pipefail
apt-get install -y --no-install-recommends chromium 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 - name: Install frontend dependencies
shell: bash shell: bash
run: | run: |
cd frontend cd frontend
npm ci --no-audit --no-fund npm ci --no-audit --no-fund --prefer-offline
- name: Run frontend tests (headless) - name: Run frontend tests (headless)
shell: bash shell: bash
env: env:
CHROME_BIN: /usr/bin/chromium
CI: "true" CI: "true"
run: | run: |
cd frontend cd frontend
echo "Karma CHROME_BIN=$CHROME_BIN"
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox

6
.gitignore vendored
View File

@@ -44,8 +44,14 @@ build/
./storage_orders ./storage_orders
./storage_quotes ./storage_quotes
./storage_requests
./storage_media
./storage_shop
storage_orders storage_orders
storage_quotes storage_quotes
storage_requests
storage_media
storage_shop
# Qodana local reports/artifacts # Qodana local reports/artifacts
backend/.qodana/ backend/.qodana/

View File

@@ -11,7 +11,7 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s
## Stack Tecnologico ## Stack Tecnologico
- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway. - **Backend**: Java 21, Spring Boot 3.4, PostgreSQL.
- **Frontend**: Angular 19, Angular Material, Three.js. - **Frontend**: Angular 19, Angular Material, Three.js.
- **Slicer**: OrcaSlicer (invocato via CLI). - **Slicer**: OrcaSlicer (invocato via CLI).
@@ -21,14 +21,20 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s
* **Node.js 22** e **npm** installati. * **Node.js 22** e **npm** installati.
* **PostgreSQL** attivo. * **PostgreSQL** attivo.
* **OrcaSlicer** installato sul sistema. * **OrcaSlicer** installato sul sistema.
* **FFmpeg** installato sul sistema o presente nell'immagine Docker del backend.
## Avvio Rapido ## Avvio Rapido
### 1. Database ### 1. Database
Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. Crea un database PostgreSQL chiamato `printcalc`. Lo schema viene gestito dal progetto tramite configurazione JPA/SQL del repository.
### 2. Backend ### 2. Backend
Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche:
- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`)
- `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop
- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` (nel deploy Docker default: `/usr/local/bin/ffmpeg-media`)
- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine
```bash ```bash
cd backend cd backend
@@ -57,11 +63,54 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab
* `/backend`: API Spring Boot. * `/backend`: API Spring Boot.
* `/frontend`: Applicazione Angular. * `/frontend`: Applicazione Angular.
* `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. * `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer.
* `/storage_media`: Originali e varianti media pubbliche/private su filesystem.
* `/storage_shop`: Modelli e file prodotti dello shop.
## Media pubblici
Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici.
Nel deploy Docker i volumi attesi sono `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media` e `/mnt/cache/appdata/print-calculator/${ENV}/storage_shop:/app/storage_shop`.
Nginx non deve passare dal backend per i file pubblici. Configurazione attesa:
```nginx
location /media/ {
alias /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/;
}
```
Usage key iniziali previste per frontend:
- `HOME_SECTION / shop-gallery`
- `HOME_SECTION / founders-gallery`
- `HOME_SECTION / capability-prototyping`
- `HOME_SECTION / capability-custom-parts`
- `HOME_SECTION / capability-small-series`
- `HOME_SECTION / capability-cad`
- `ABOUT_MEMBER / joe`
- `ABOUT_MEMBER / matteo`
- riservati per estensioni future: `SHOP_PRODUCT`, `SHOP_CATEGORY`, `SHOP_GALLERY`
Operativamente:
- carica i file dal media admin endpoint del backend
- associa ogni asset con `POST /api/admin/media/usages`
- per `ABOUT_MEMBER` imposta `isPrimary=true` sulla foto principale del membro
- home e about leggono da `GET /api/public/media/usages?usageType=...&usageKey=...`
- il frontend usa `<picture>` e preferisce AVIF/WEBP con fallback JPEG, senza usare l'originale
- nel back-office frontend la gestione operativa della home passa dalla pagina `admin/home-media`
## Troubleshooting ## Troubleshooting
### Percorso OrcaSlicer ### Percorso OrcaSlicer
Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno).
### FFmpeg e media pubblici
Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF (encoder + muxer AVIF). Nel container backend il default è `/usr/local/bin/ffmpeg-media`: usa `/usr/bin/ffmpeg` se già compatibile, altrimenti installa un fallback statico con supporto AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente.
### Database connection ### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
### Deploy e traduzioni OpenAI
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.

View File

@@ -10,28 +10,92 @@ 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
ARG FFMPEG_STATIC_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
# Install system dependencies for OrcaSlicer (same as before) # Install system dependencies for OrcaSlicer and media processing.
RUN apt-get update && apt-get install -y \ # Prefer system ffmpeg; if AVIF support is incomplete, install a static ffmpeg fallback.
RUN set -eux; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ffmpeg \
wget \ wget \
p7zip-full \ xz-utils \
ca-certificates \
assimp-utils \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
libdbus-1-3 \ libdbus-1-3 \
libwebkit2gtk-4.0-37 \ libwebkit2gtk-4.0-37; \
&& rm -rf /var/lib/apt/lists/* check_ffmpeg_support() { \
ffmpeg_bin="$1"; \
"$ffmpeg_bin" -hide_banner -encoders > /tmp/ffmpeg-encoders.txt 2>&1 || return 1; \
"$ffmpeg_bin" -hide_banner -muxers > /tmp/ffmpeg-muxers.txt 2>&1 || return 1; \
grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]]avif([[:space:]]|,|$)' /tmp/ffmpeg-muxers.txt || return 1; \
return 0; \
}; \
if check_ffmpeg_support /usr/bin/ffmpeg; then \
ln -sf /usr/bin/ffmpeg /usr/local/bin/ffmpeg-media; \
else \
echo "System ffmpeg lacks AVIF support, installing static fallback from ${FFMPEG_STATIC_URL}"; \
wget -q "${FFMPEG_STATIC_URL}" -O /tmp/ffmpeg-static.tar.xz; \
tar -xJf /tmp/ffmpeg-static.tar.xz -C /tmp; \
FFMPEG_STATIC_BIN="$(find /tmp -maxdepth 2 -type f -name ffmpeg | head -n 1)"; \
test -n "${FFMPEG_STATIC_BIN}"; \
install -m 0755 "${FFMPEG_STATIC_BIN}" /usr/local/bin/ffmpeg-media; \
check_ffmpeg_support /usr/local/bin/ffmpeg-media; \
fi; \
rm -f /tmp/ffmpeg-muxers.txt; \
rm -f /tmp/ffmpeg-encoders.txt; \
rm -f /tmp/ffmpeg-static.tar.xz; \
rm -rf /tmp/ffmpeg-*-amd64-static; \
rm -rf /var/lib/apt/lists/*
# 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"
# Use ffmpeg selected at image build time (system or static fallback) for media generation.
ENV MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media"
WORKDIR /app WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -42,6 +42,16 @@ 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 'org.jsoup:jsoup:1.18.3'
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

@@ -1,10 +1,61 @@
#!/bin/sh #!/bin/sh
set -e
# In container default to the ffmpeg selected during image build.
if [ -z "${MEDIA_FFMPEG_PATH:-}" ]; then
MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media"
fi
export MEDIA_FFMPEG_PATH
validate_ffmpeg_support() {
ffmpeg_bin="$1"
if ! command -v "$ffmpeg_bin" >/dev/null 2>&1; then
echo "ERROR: FFmpeg executable not found: ${ffmpeg_bin}" >&2
exit 11
fi
encoders="$(mktemp)"
muxers="$(mktemp)"
trap 'rm -f "$encoders" "$muxers"' EXIT
"$ffmpeg_bin" -hide_banner -encoders > "$encoders" 2>&1 || {
echo "ERROR: Unable to inspect FFmpeg encoders from ${ffmpeg_bin}" >&2
cat "$encoders" >&2
exit 12
}
"$ffmpeg_bin" -hide_banner -muxers > "$muxers" 2>&1 || {
echo "ERROR: Unable to inspect FFmpeg muxers from ${ffmpeg_bin}" >&2
cat "$muxers" >&2
exit 13
}
grep -Eq '[[:space:]]mjpeg[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing JPEG encoder (mjpeg)." >&2
exit 14
}
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing WebP encoder." >&2
exit 15
}
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF-capable encoder." >&2
exit 16
}
grep -Eq '[[:space:]]avif([[:space:]]|,|$)' "$muxers" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF muxer." >&2
exit 17
}
}
validate_ffmpeg_support "$MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
echo "Starting Backend Application" echo "Starting Backend Application"
echo "DB_URL: $DB_URL" echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "MEDIA_FFMPEG_PATH: $MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Determine which environment variables to use for database connection

View File

@@ -0,0 +1,88 @@
package com.printcalculator.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
public class AllowedOriginService {
private final List<String> allowedOrigins;
public AllowedOriginService(
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
) {
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
}
if (configuredOrigins.isEmpty()) {
throw new IllegalStateException("At least one allowed origin must be configured.");
}
this.allowedOrigins = List.copyOf(configuredOrigins);
}
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public boolean isAllowed(String rawOriginOrUrl) {
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
}
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
return;
}
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
if (normalizedOrigin == null) {
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
}
configuredOrigins.add(normalizedOrigin);
}
private String normalizeRequestOrigin(String rawOriginOrUrl) {
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
return null;
}
try {
URI uri = URI.create(rawOriginOrUrl.trim());
String scheme = uri.getScheme();
String host = uri.getHost();
if (scheme == null || host == null) {
return null;
}
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
return null;
}
String normalizedHost = host.toLowerCase(Locale.ROOT);
int port = uri.getPort();
if (isDefaultPort(normalizedScheme, port) || port < 0) {
return normalizedScheme + "://" + normalizedHost;
}
return normalizedScheme + "://" + normalizedHost + ":" + port;
} catch (IllegalArgumentException ignored) {
return null;
}
}
private boolean isDefaultPort(String scheme, int port) {
return ("http".equals(scheme) && port == 80)
|| ("https".equals(scheme) && port == 443);
}
}

View File

@@ -1,27 +1,27 @@
package com.printcalculator.config; package com.printcalculator.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration @Configuration
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig {
@Override @Bean
public void addCorsMappings(CorsRegistry registry) { public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
registry.addMapping("/**") CorsConfiguration configuration = new CorsConfiguration();
.allowedOrigins( configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
"http://localhost", configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
"http://localhost:4200", configuration.setAllowedHeaders(List.of("*"));
"http://localhost:80", configuration.setAllowCredentials(true);
"http://127.0.0.1", configuration.setMaxAge(3600L);
"https://dev.3d-fab.ch",
"https://int.3d-fab.ch", UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
"https://3d-fab.ch" source.registerCorsConfiguration("/**", configuration);
) return source;
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
} }
} }

View File

@@ -1,5 +1,6 @@
package com.printcalculator.config; package com.printcalculator.config;
import com.printcalculator.security.AdminCsrfProtectionFilter;
import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -18,6 +19,7 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain( public SecurityFilterChain securityFilterChain(
HttpSecurity http, HttpSecurity http,
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception { ) throws Exception {
http http
@@ -40,7 +42,8 @@ public class SecurityConfig {
response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
})) }))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
return http.build(); return http.build();
} }

View File

@@ -2,266 +2,46 @@ package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto; 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 com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
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.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException; 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.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.time.Year;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class); private final CustomQuoteRequestControllerService customQuoteRequestControllerService;
private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final ClamAVService clamAVService;
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.contact-request.admin.enabled:true}") public CustomQuoteRequestController(CustomQuoteRequestControllerService customQuoteRequestControllerService) {
private boolean contactRequestAdminMailEnabled; this.customQuoteRequestControllerService = customQuoteRequestControllerService;
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress;
// TODO: Inject Storage Service
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"
);
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo,
ClamAVService clamAVService,
EmailNotificationService emailNotificationService) {
this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService;
this.emailNotificationService = emailNotificationService;
} }
// 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(
@Valid @RequestPart("request") 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 {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { return ResponseEntity.ok(customQuoteRequestControllerService.createCustomQuoteRequest(requestDto, files));
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Accettazione Termini e Privacy obbligatoria."
);
}
// 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
int attachmentsCount = 0;
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;
if (isCompressedFile(file)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Compressed files are not allowed."
);
}
// 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 storedFilename = fileUuid + ".upload";
// 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);
Path relativePath = Path.of(
"quote-requests",
request.getId().toString(),
"attachments",
attachment.getId().toString(),
storedFilename
);
attachment.setStoredRelativePath(relativePath.toString());
attachmentRepo.save(attachment);
// Save file to disk
Path absolutePath = resolveWithinStorageRoot(relativePath);
Files.createDirectories(absolutePath.getParent());
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
}
attachmentsCount++;
}
}
sendAdminContactRequestNotification(request, attachmentsCount);
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";
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());
}
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");
}
}
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 String safeValue(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return value;
}
} }

View File

@@ -3,18 +3,19 @@ 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.LayerHeightOption;
import com.printcalculator.entity.MaterialOrcaProfileMap; import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile; 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.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import com.printcalculator.service.NozzleLayerHeightPolicyService;
import com.printcalculator.service.OrcaProfileResolver; 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.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -23,7 +24,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal; 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.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -32,26 +37,32 @@ 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 PrinterMachineRepository printerMachineRepo;
private final PrinterMachineProfileRepository printerMachineProfileRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver; 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, PrinterMachineRepository printerMachineRepo,
PrinterMachineProfileRepository printerMachineProfileRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver) { 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.printerMachineRepo = printerMachineRepo;
this.printerMachineProfileRepo = printerMachineProfileRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo; this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver; this.orcaProfileResolver = orcaProfileResolver;
this.profileManager = profileManager;
this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService;
} }
@GetMapping("/api/calculator/options") @GetMapping("/api/calculator/options")
@@ -83,6 +94,10 @@ public class OptionsController {
v.getId(), v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
v.getColorLabelIt(),
v.getColorLabelEn(),
v.getColorLabelDe(),
v.getColorLabelFr(),
resolveHexColor(v), resolveHexColor(v),
v.getFinishType() != null ? v.getFinishType() : "GLOSSY", v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
@@ -116,17 +131,27 @@ public class OptionsController {
new OptionsResponse.InfillPatternOption("cubic", "Cubic") new OptionsResponse.InfillPatternOption("cubic", "Cubic")
); );
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream() PrinterMachine targetMachine = resolveMachine(printerMachineId);
.filter(l -> Boolean.TRUE.equals(l.getIsActive()))
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) Set<BigDecimal> supportedMachineNozzles = targetMachine != null
.map(l -> new OptionsResponse.LayerHeightOptionDTO( ? printerMachineProfileRepo.findByPrinterMachineAndIsActiveTrue(targetMachine).stream()
l.getLayerHeightMm().doubleValue(), .map(PrinterMachineProfile::getNozzleDiameterMm)
String.format("%.2f mm", l.getLayerHeightMm()) .filter(v -> v != null)
)) .map(nozzleLayerHeightPolicyService::normalizeNozzle)
.toList(); .collect(Collectors.toCollection(LinkedHashSet::new))
: Set.of();
boolean restrictNozzlesByMachineProfile = !supportedMachineNozzles.isEmpty();
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream() List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> Boolean.TRUE.equals(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(),
@@ -137,24 +162,88 @@ public class OptionsController {
)) ))
.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);
}
}
}
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) { private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
PrinterMachine machine = null; PrinterMachine machine = resolveMachine(printerMachineId);
if (printerMachineId != null) {
machine = printerMachineRepo.findById(printerMachineId).orElse(null);
}
if (machine == null) {
machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
}
if (machine == null) { if (machine == null) {
return Set.of(); return Set.of();
} }
BigDecimal nozzle = nozzleDiameter != null BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle(
? BigDecimal.valueOf(nozzleDiameter) nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null
: BigDecimal.valueOf(0.40); );
PrinterMachineProfile machineProfile = orcaProfileResolver PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle) .resolveMachineProfile(machine, nozzle)
@@ -172,6 +261,73 @@ public class OptionsController {
.collect(Collectors.toSet()); .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) { private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex(); return variant.getColorHex();

View File

@@ -1,140 +1,62 @@
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.PaymentService;
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.util.StringUtils;
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 jakarta.validation.Valid;
import java.io.IOException; import java.io.IOException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
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;
import java.util.Locale;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
public class OrderController { public class OrderController {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
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;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
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,
PaymentService paymentService,
PaymentRepository paymentRepo) {
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;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
} }
// 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,
@Valid @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)
@Transactional @Transactional
public ResponseEntity<Void> uploadOrderItemFile( public ResponseEntity<Void> uploadOrderItemFile(
@PathVariable UUID orderId, @PathVariable UUID orderId,
@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();
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 ResponseEntity.badRequest().build();
}
}
storageService.store(file, destinationRelativePath);
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());
} }
@@ -144,89 +66,29 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody Map<String, String> payload @RequestBody Map<String, String> payload
) { ) {
String method = payload.get("method"); return orderControllerService.reportPayment(orderId, payload.get("method"))
paymentService.reportPayment(orderId, method); .map(ResponseEntity::ok)
return getOrder(orderId); .orElse(ResponseEntity.notFound().build());
} }
@GetMapping("/{orderId}/confirmation") @GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return generateDocument(orderId, true); return orderControllerService.getConfirmation(orderId);
} }
@GetMapping("/{orderId}/invoice") @GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
// Paid invoices are sent by email after back-office payment confirmation.
// The public endpoint must not expose a "paid" invoice download.
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
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"
);
}
@GetMapping("/{orderId}/twint") @GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null); return orderControllerService.getTwintPayment(orderId);
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);
} }
@GetMapping("/{orderId}/twint/open") @GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) { public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId).orElse(null); return orderControllerService.openTwintPayment(orderId);
if (order == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl(order)))
.build();
} }
@GetMapping("/{orderId}/twint/qr") @GetMapping("/{orderId}/twint/qr")
@@ -234,127 +96,6 @@ public class OrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size @RequestParam(defaultValue = "320") int size
) { ) {
Order order = orderRepo.findById(orderId).orElse(null); return orderControllerService.getTwintQr(orderId, size);
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 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(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.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.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

@@ -0,0 +1,31 @@
package com.printcalculator.controller;
import com.printcalculator.dto.PublicMediaUsageDto;
import com.printcalculator.service.media.PublicMediaQueryService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/public/media")
@Transactional(readOnly = true)
public class PublicMediaController {
private final PublicMediaQueryService publicMediaQueryService;
public PublicMediaController(PublicMediaQueryService publicMediaQueryService) {
this.publicMediaQueryService = publicMediaQueryService;
}
@GetMapping("/usages")
public ResponseEntity<List<PublicMediaUsageDto>> getUsageMedia(@RequestParam String usageType,
@RequestParam String usageKey,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey, lang));
}
}

View File

@@ -0,0 +1,89 @@
package com.printcalculator.controller;
import com.printcalculator.dto.ShopCategoryDetailDto;
import com.printcalculator.dto.ShopCategoryTreeDto;
import com.printcalculator.dto.ShopProductCatalogResponseDto;
import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.service.shop.PublicShopCatalogService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/api/shop")
@Transactional(readOnly = true)
public class PublicShopController {
private final PublicShopCatalogService publicShopCatalogService;
public PublicShopController(PublicShopCatalogService publicShopCatalogService) {
this.publicShopCatalogService = publicShopCatalogService;
}
@GetMapping("/categories")
public ResponseEntity<List<ShopCategoryTreeDto>> getCategories(@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getCategories(lang));
}
@GetMapping("/categories/{slug}")
public ResponseEntity<ShopCategoryDetailDto> getCategory(@PathVariable String slug,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getCategory(slug, lang));
}
@GetMapping("/products")
public ResponseEntity<ShopProductCatalogResponseDto> getProducts(
@RequestParam(required = false) String categorySlug,
@RequestParam(required = false) Boolean featured,
@RequestParam(required = false) String lang
) {
return ResponseEntity.ok(publicShopCatalogService.getProductCatalog(categorySlug, featured, lang));
}
@GetMapping("/products/{slug}")
public ResponseEntity<ShopProductDetailDto> getProduct(@PathVariable String slug,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
}
@GetMapping("/products/by-path/{publicPath}")
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
}
@GetMapping("/products/by-id-prefix/{idPrefix}")
public ResponseEntity<ShopProductDetailDto> getProductByIdPrefix(@PathVariable String idPrefix,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang));
}
@GetMapping("/products/{slug}/model")
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
Resource resource = new UrlResource(model.path().toUri());
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (model.mimeType() != null && !model.mimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(model.mimeType());
} catch (IllegalArgumentException ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"")
.body(resource);
}
}

View File

@@ -4,17 +4,24 @@ 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.Locale;
import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
public class QuoteController { public class QuoteController {
@@ -22,17 +29,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 +82,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.
@@ -100,6 +125,9 @@ public class QuoteController {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (!isSupportedInputFile(file)) {
throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
}
// Scan for virus // Scan for virus
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
@@ -129,4 +157,14 @@ public class QuoteController {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
} }
} }
private boolean isSupportedInputFile(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
return false;
}
String normalized = originalFilename.toLowerCase(Locale.ROOT);
return normalized.endsWith(".stl") || normalized.endsWith(".3mf");
}
} }

View File

@@ -1,96 +1,69 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
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.OrcaProfileResolver;
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.util.StringUtils;
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.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
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.time.OffsetDateTime;
import java.util.ArrayList;
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 java.util.Optional;
import java.util.Locale; import static org.springframework.http.HttpStatus.BAD_REQUEST;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
@RestController @RestController
@RequestMapping("/api/quote-sessions") @RequestMapping("/api/quote-sessions")
public class QuoteSessionController { public class QuoteSessionController {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
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 FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final OrcaProfileResolver orcaProfileResolver;
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;
private final QuoteStorageService quoteStorageService;
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
OrcaProfileResolver orcaProfileResolver,
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.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.orcaProfileResolver = orcaProfileResolver;
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.setSessionType("PRINT_QUOTE");
session.setPricingVersion("v1"); session.setPricingVersion("v1");
// Default material/settings will be set when items are added or updated?
// For now set safe defaults
session.setMaterialCode("PLA"); session.setMaterialCode("PLA");
session.setSupportsEnabled(false); session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now()); session.setCreatedAt(OffsetDateTime.now());
@@ -103,261 +76,48 @@ public class QuoteSessionController {
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}
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize();
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
throw new IOException("Invalid quote session storage path");
}
Files.createDirectories(sessionStorageDir);
String originalFilename = file.getOriginalFilename();
String ext = getSafeExtension(originalFilename, "stl");
String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize();
if (!persistentPath.startsWith(sessionStorageDir)) {
throw new IOException("Invalid quote line-item storage path");
}
// Save file
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
}
try {
// Apply Basic/Advanced Logic
applyPrintSettings(settings);
BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4);
// Pick machine (selected machine if provided, otherwise first active)
PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId());
// Resolve selected filament variant
FilamentVariant selectedVariant = resolveFilamentVariant(settings);
// Update session global settings from the most recent item added
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2));
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 machineProfile = profiles.machineProfileName();
String filamentProfile = profiles.filamentProfileName();
String processProfile = "standard";
if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "draft";
else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine";
}
// 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
);
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile());
// 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
// 5. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root)
item.setQuantity(1);
item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant);
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()); // Excludes setup fee which is at session level
breakdown.put("setup_fee", 0);
item.setPricingBreakdown(breakdown);
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
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 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(15.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");
}
}
private 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"));
}
private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.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));
}
private String normalizeRequestedMaterialCode(String value) {
if (value == null || value.isBlank()) {
return "PLA";
}
return value.trim()
.toUpperCase(Locale.ROOT)
.replace('_', ' ')
.replace('-', ' ')
.replaceAll("\\s+", " ");
}
// 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"));
@@ -369,107 +129,22 @@ public class QuoteSessionController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// 5. Get Session (Session + Items + Total)
@GetMapping("/{id}") @GetMapping("/{id}")
@Transactional(readOnly = true)
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 and global session hours return ResponseEntity.ok(quoteSessionResponseAssembler.assemble(session, items, totals));
BigDecimal itemsTotal = BigDecimal.ZERO;
BigDecimal totalSeconds = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
itemsTotal = itemsTotal.add(lineTotal);
if (item.getPrintTimeSeconds() != null) {
totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity())));
}
}
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
com.printcalculator.entity.PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
itemsTotal = itemsTotal.add(globalMachineCost);
// Map items to DTO to embed distributed machine cost
List<Map<String, Object>> itemsDto = new ArrayList<>();
for (QuoteLineItem item : items) {
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("status", item.getStatus());
BigDecimal unitPrice = item.getUnitPriceChf();
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity()));
BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = globalMachineCost.multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(item.getQuantity()), 2, RoundingMode.HALF_UP);
unitPrice = unitPrice.add(unitMachineCost);
}
dto.put("unitPriceChf", unitPrice);
itemsDto.add(dto);
}
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
// Calculate shipping cost based on dimensions
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};
java.util.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 -> i.getQuantity() != null ? i.getQuantity() : 1)
.sum();
BigDecimal shippingCostChf;
if (exceedsBaseSize) {
shippingCostChf = totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00);
} else {
shippingCostChf = BigDecimal.valueOf(2.00);
}
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf);
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", itemsDto);
response.put("itemsTotalChf", itemsTotal); // Includes the base cost of all items + the global tiered machine cost
response.put("shippingCostChf", shippingCostChf);
response.put("globalMachineCostChf", globalMachineCost); // Provide it so frontend knows how much it was (optional now)
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"));
@@ -477,58 +152,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 = resolveStoredQuotePath(item.getStoredPath(), sessionId); java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
if (path == null || !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 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);
} }
private String getSafeExtension(String filename, String fallback) { @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/stl-preview")
if (filename == null) { public ResponseEntity<Resource> downloadLineItemStlPreview(@PathVariable UUID sessionId,
return fallback; @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();
} }
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) { if (!"stl".equals(quoteStorageService.getSafeExtension(item.getOriginalFilename(), ""))) {
return fallback; return ResponseEntity.notFound().build();
} }
int index = cleaned.lastIndexOf('.');
if (index <= 0 || index >= cleaned.length() - 1) { String targetStoredPath = item.getStoredPath();
return fallback; if (targetStoredPath == null || targetStoredPath.isBlank()) {
return ResponseEntity.notFound().build();
} }
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
return switch (ext) { java.nio.file.Path path = quoteStorageService.resolveStoredQuotePath(targetStoredPath, sessionId);
case "stl" -> "stl"; if (path == null || !java.nio.file.Files.exists(path)) {
case "3mf" -> "3mf"; return ResponseEntity.notFound().build();
case "step", "stp" -> "step"; }
default -> fallback;
}; 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 Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { private int parsePositiveQuantity(Object raw) {
if (storedPath == null || storedPath.isBlank()) { if (raw == null) {
return null; throw new ResponseStatusException(BAD_REQUEST, "Quantity is required");
} }
try {
Path raw = Path.of(storedPath).normalize(); int quantity;
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize(); if (raw instanceof Number number) {
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize(); double numericValue = number.doubleValue();
if (!resolved.startsWith(expectedSessionRoot)) { if (!Double.isFinite(numericValue)) {
return null; 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");
} }
return resolved;
} catch (InvalidPathException e) {
return null;
} }
if (quantity < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Quantity must be >= 1");
}
return quantity;
} }
} }

View File

@@ -0,0 +1,85 @@
package com.printcalculator.controller;
import com.printcalculator.dto.ShopCartAddItemRequest;
import com.printcalculator.dto.ShopCartUpdateItemRequest;
import com.printcalculator.service.shop.ShopCartCookieService;
import com.printcalculator.service.shop.ShopCartService;
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.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.UUID;
@RestController
@RequestMapping("/api/shop/cart")
public class ShopCartController {
private final ShopCartService shopCartService;
private final ShopCartCookieService shopCartCookieService;
public ShopCartController(ShopCartService shopCartService, ShopCartCookieService shopCartCookieService) {
this.shopCartService = shopCartService;
this.shopCartCookieService = shopCartCookieService;
}
@GetMapping
public ResponseEntity<?> getCart(HttpServletRequest request, HttpServletResponse response) {
ShopCartService.CartResult result = shopCartService.loadCart(request);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@PostMapping("/items")
public ResponseEntity<?> addItem(HttpServletRequest request,
HttpServletResponse response,
@Valid @RequestBody ShopCartAddItemRequest payload) {
ShopCartService.CartResult result = shopCartService.addItem(request, payload);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@PatchMapping("/items/{lineItemId}")
public ResponseEntity<?> updateItem(HttpServletRequest request,
HttpServletResponse response,
@PathVariable UUID lineItemId,
@Valid @RequestBody ShopCartUpdateItemRequest payload) {
ShopCartService.CartResult result = shopCartService.updateItem(request, lineItemId, payload);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@DeleteMapping("/items/{lineItemId}")
public ResponseEntity<?> removeItem(HttpServletRequest request,
HttpServletResponse response,
@PathVariable UUID lineItemId) {
ShopCartService.CartResult result = shopCartService.removeItem(request, lineItemId);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@DeleteMapping
public ResponseEntity<?> clearCart(HttpServletRequest request, HttpServletResponse response) {
ShopCartService.CartResult result = shopCartService.clearCart(request);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
private void applyCookie(HttpServletResponse response, ShopCartService.CartResult result) {
if (result.clearCookie()) {
response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildClearCookie().toString());
return;
}
if (result.sessionId() != null) {
response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildSessionCookie(result.sessionId()).toString());
}
}
}

View File

@@ -0,0 +1,37 @@
package com.printcalculator.controller;
import com.printcalculator.service.shop.ShopSitemapService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
@RestController
public class SitemapController {
private final ShopSitemapService shopSitemapService;
private final long cacheSeconds;
public SitemapController(
ShopSitemapService shopSitemapService,
@Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds
) {
this.shopSitemapService = shopSitemapService;
this.cacheSeconds = Math.max(cacheSeconds, 0L);
}
@GetMapping(value = "/api/sitemap-shop.xml", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> getShopSitemap() {
CacheControl cacheControl = cacheSeconds > 0
? CacheControl.maxAge(Duration.ofSeconds(cacheSeconds)).cachePublic()
: CacheControl.noCache();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/xml;charset=UTF-8"))
.cacheControl(cacheControl)
.body(shopSitemapService.getShopSitemapXml());
}
}

View File

@@ -4,77 +4,39 @@ import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto; import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.service.admin.AdminFilamentControllerService;
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.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.DeleteMapping;
import org.springframework.web.server.ResponseStatusException; 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.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List; 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;
@RestController @RestController
@RequestMapping("/api/admin/filaments") @RequestMapping("/api/admin/filaments")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminFilamentController { public class AdminFilamentController {
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 AdminFilamentControllerService adminFilamentControllerService;
private final FilamentVariantRepository variantRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderItemRepository orderItemRepo;
public AdminFilamentController( public AdminFilamentController(AdminFilamentControllerService adminFilamentControllerService) {
FilamentMaterialTypeRepository materialRepo, this.adminFilamentControllerService = adminFilamentControllerService;
FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo
) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
} }
@GetMapping("/materials") @GetMapping("/materials")
public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() { public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() {
List<AdminFilamentMaterialTypeDto> response = materialRepo.findAll().stream() return ResponseEntity.ok(adminFilamentControllerService.getMaterials());
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/variants") @GetMapping("/variants")
public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() { public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() {
List<AdminFilamentVariantDto> response = variantRepo.findAll().stream() return ResponseEntity.ok(adminFilamentControllerService.getVariants());
.sorted(Comparator
.comparing((FilamentVariant v) -> {
FilamentMaterialType type = v.getFilamentMaterialType();
return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : "";
}, String.CASE_INSENSITIVE_ORDER)
.thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER))
.map(this::toVariantDto)
.toList();
return ResponseEntity.ok(response);
} }
@PostMapping("/materials") @PostMapping("/materials")
@@ -82,13 +44,7 @@ public class AdminFilamentController {
public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial( public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial(
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload @RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) { ) {
String materialCode = normalizeAndValidateMaterialCode(payload); return ResponseEntity.ok(adminFilamentControllerService.createMaterial(payload));
ensureMaterialCodeAvailable(materialCode, null);
FilamentMaterialType material = new FilamentMaterialType();
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return ResponseEntity.ok(toMaterialDto(saved));
} }
@PutMapping("/materials/{materialTypeId}") @PutMapping("/materials/{materialTypeId}")
@@ -97,15 +53,7 @@ public class AdminFilamentController {
@PathVariable Long materialTypeId, @PathVariable Long materialTypeId,
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload @RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) { ) {
FilamentMaterialType material = materialRepo.findById(materialTypeId) return ResponseEntity.ok(adminFilamentControllerService.updateMaterial(materialTypeId, payload));
.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 ResponseEntity.ok(toMaterialDto(saved));
} }
@PostMapping("/variants") @PostMapping("/variants")
@@ -113,17 +61,7 @@ public class AdminFilamentController {
public ResponseEntity<AdminFilamentVariantDto> createVariant( public ResponseEntity<AdminFilamentVariantDto> createVariant(
@RequestBody AdminUpsertFilamentVariantRequest payload @RequestBody AdminUpsertFilamentVariantRequest payload
) { ) {
FilamentMaterialType material = validateAndResolveMaterial(payload); return ResponseEntity.ok(adminFilamentControllerService.createVariant(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 ResponseEntity.ok(toVariantDto(saved));
} }
@PutMapping("/variants/{variantId}") @PutMapping("/variants/{variantId}")
@@ -132,224 +70,13 @@ public class AdminFilamentController {
@PathVariable Long variantId, @PathVariable Long variantId,
@RequestBody AdminUpsertFilamentVariantRequest payload @RequestBody AdminUpsertFilamentVariantRequest payload
) { ) {
FilamentVariant variant = variantRepo.findById(variantId) return ResponseEntity.ok(adminFilamentControllerService.updateVariant(variantId, payload));
.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 ResponseEntity.ok(toVariantDto(saved));
} }
@DeleteMapping("/variants/{variantId}") @DeleteMapping("/variants/{variantId}")
@Transactional @Transactional
public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) { public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) {
FilamentVariant variant = variantRepo.findById(variantId) adminFilamentControllerService.deleteVariant(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);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
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();
}
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,89 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminCreateMediaUsageRequest;
import com.printcalculator.dto.AdminMediaAssetDto;
import com.printcalculator.dto.AdminMediaUsageDto;
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
import com.printcalculator.dto.AdminUpdateMediaUsageRequest;
import com.printcalculator.service.admin.AdminMediaControllerService;
import org.springframework.http.MediaType;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/media")
@Transactional(readOnly = true)
public class AdminMediaController {
private final AdminMediaControllerService adminMediaControllerService;
public AdminMediaController(AdminMediaControllerService adminMediaControllerService) {
this.adminMediaControllerService = adminMediaControllerService;
}
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity<AdminMediaAssetDto> uploadAsset(@RequestParam("file") MultipartFile file,
@RequestParam(value = "title", required = false) String title,
@RequestParam(value = "altText", required = false) String altText,
@RequestParam(value = "visibility", required = false) String visibility) {
return ResponseEntity.ok(adminMediaControllerService.uploadAsset(file, title, altText, visibility));
}
@GetMapping("/assets")
public ResponseEntity<List<AdminMediaAssetDto>> listAssets() {
return ResponseEntity.ok(adminMediaControllerService.listAssets());
}
@GetMapping("/assets/{mediaAssetId}")
public ResponseEntity<AdminMediaAssetDto> getAsset(@PathVariable UUID mediaAssetId) {
return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId));
}
@GetMapping("/usages")
public ResponseEntity<List<AdminMediaUsageDto>> getUsages(@RequestParam String usageType,
@RequestParam String usageKey,
@RequestParam(required = false) UUID ownerId) {
return ResponseEntity.ok(adminMediaControllerService.getUsages(usageType, usageKey, ownerId));
}
@PatchMapping("/assets/{mediaAssetId}")
@Transactional
public ResponseEntity<AdminMediaAssetDto> updateAsset(@PathVariable UUID mediaAssetId,
@RequestBody AdminUpdateMediaAssetRequest payload) {
return ResponseEntity.ok(adminMediaControllerService.updateAsset(mediaAssetId, payload));
}
@PostMapping("/usages")
@Transactional
public ResponseEntity<AdminMediaUsageDto> createUsage(@RequestBody AdminCreateMediaUsageRequest payload) {
return ResponseEntity.ok(adminMediaControllerService.createUsage(payload));
}
@PatchMapping("/usages/{mediaUsageId}")
@Transactional
public ResponseEntity<AdminMediaUsageDto> updateUsage(@PathVariable UUID mediaUsageId,
@RequestBody AdminUpdateMediaUsageRequest payload) {
return ResponseEntity.ok(adminMediaControllerService.updateUsage(mediaUsageId, payload));
}
@DeleteMapping("/usages/{mediaUsageId}")
@Transactional
public ResponseEntity<Void> deleteUsage(@PathVariable UUID mediaUsageId) {
adminMediaControllerService.deleteUsage(mediaUsageId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -1,171 +1,52 @@
package com.printcalculator.controller.admin; package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminContactRequestDto; import com.printcalculator.dto.AdminCadInvoiceCreateRequest;
import com.printcalculator.dto.AdminContactRequestAttachmentDto; import com.printcalculator.dto.AdminCadInvoiceDto;
import com.printcalculator.dto.AdminContactRequestDetailDto; import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminFilamentStockDto; import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto; import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.service.admin.AdminOperationsControllerService;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantStockKg;
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.QuoteSessionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource; 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.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
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.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID; 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;
@RestController @RestController
@RequestMapping("/api/admin") @RequestMapping("/api/admin")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminOperationsController { public class AdminOperationsController {
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.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 AdminOperationsControllerService adminOperationsControllerService;
private final FilamentVariantRepository filamentVariantRepo;
private final CustomQuoteRequestRepository customQuoteRequestRepo;
private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final OrderRepository orderRepo;
public AdminOperationsController( public AdminOperationsController(AdminOperationsControllerService adminOperationsControllerService) {
FilamentVariantStockKgRepository filamentStockRepo, this.adminOperationsControllerService = adminOperationsControllerService;
FilamentVariantRepository filamentVariantRepo,
CustomQuoteRequestRepository customQuoteRequestRepo,
CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo,
QuoteSessionRepository quoteSessionRepo,
OrderRepository orderRepo
) {
this.filamentStockRepo = filamentStockRepo;
this.filamentVariantRepo = filamentVariantRepo;
this.customQuoteRequestRepo = customQuoteRequestRepo;
this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.orderRepo = orderRepo;
} }
@GetMapping("/filament-stock") @GetMapping("/filament-stock")
public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() { public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); return ResponseEntity.ok(adminOperationsControllerService.getFilamentStock());
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));
}
List<AdminFilamentStockDto> response = 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();
return ResponseEntity.ok(response);
} }
@GetMapping("/contact-requests") @GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() { public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
List<AdminContactRequestDto> response = customQuoteRequestRepo.findAll( return ResponseEntity.ok(adminOperationsControllerService.getContactRequests());
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toContactRequestDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/contact-requests/{requestId}") @GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) { public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) return ResponseEntity.ok(adminOperationsControllerService.getContactRequestDetail(requestId));
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return ResponseEntity.ok(toContactRequestDetailDto(request, attachments));
} }
@PatchMapping("/contact-requests/{requestId}/status") @PatchMapping("/contact-requests/{requestId}/status")
@@ -174,31 +55,7 @@ public class AdminOperationsController {
@PathVariable UUID requestId, @PathVariable UUID requestId,
@RequestBody AdminUpdateContactRequestStatusRequest payload @RequestBody AdminUpdateContactRequestStatusRequest payload
) { ) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) return ResponseEntity.ok(adminOperationsControllerService.updateContactRequestStatus(requestId, payload));
.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 ResponseEntity.ok(toContactRequestDetailDto(saved, attachments));
} }
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file") @GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
@@ -206,167 +63,31 @@ public class AdminOperationsController {
@PathVariable UUID requestId, @PathVariable UUID requestId,
@PathVariable UUID attachmentId @PathVariable UUID attachmentId
) { ) {
CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) return adminOperationsControllerService.downloadContactRequestAttachment(requestId, 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");
}
} }
@GetMapping("/sessions") @GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() { public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll( return ResponseEntity.ok(adminOperationsControllerService.getQuoteSessions());
Sort.by(Sort.Direction.DESC, "createdAt") }
)
.stream()
.map(this::toQuoteSessionDto)
.toList();
return ResponseEntity.ok(response); @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}") @DeleteMapping("/sessions/{sessionId}")
@Transactional @Transactional
public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) { public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) {
QuoteSession session = quoteSessionRepo.findById(sessionId) adminOperationsControllerService.deleteQuoteSession(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);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
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());
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

@@ -1,24 +1,9 @@
package com.printcalculator.controller.admin; package com.printcalculator.controller.admin;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest; import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto; import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto; import com.printcalculator.service.order.AdminOrderControllerService;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
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.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -27,83 +12,39 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController @RestController
@RequestMapping("/api/admin/orders") @RequestMapping("/api/admin/orders")
@Transactional(readOnly = true) @Transactional(readOnly = true)
public class AdminOrderController { public class AdminOrderController {
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo; private final AdminOrderControllerService adminOrderControllerService;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
public AdminOrderController( public AdminOrderController(AdminOrderControllerService adminOrderControllerService) {
OrderRepository orderRepo, this.adminOrderControllerService = adminOrderControllerService;
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
} }
@GetMapping @GetMapping
public ResponseEntity<List<OrderDto>> listOrders() { public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc() return ResponseEntity.ok(adminOrderControllerService.listOrders());
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
} }
@GetMapping("/{orderId}") @GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) { public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); return ResponseEntity.ok(adminOrderControllerService.getOrder(orderId));
} }
@PostMapping("/{orderId}/payments/confirm") @PostMapping("/{orderId}/payments/confirm")
@Transactional @Transactional
public ResponseEntity<OrderDto> confirmPayment( public ResponseEntity<OrderDto> updatePaymentMethod(
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload @RequestBody(required = false) Map<String, String> payload
) { ) {
getOrderOrThrow(orderId); return ResponseEntity.ok(adminOrderControllerService.updatePaymentMethod(orderId, payload));
String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
} }
@PostMapping("/{orderId}/status") @PostMapping("/{orderId}/status")
@@ -112,22 +53,7 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload @RequestBody AdminOrderStatusUpdateRequest payload
) { ) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { return ResponseEntity.ok(adminOrderControllerService.updateOrderStatus(orderId, payload));
throw new ResponseStatusException(org.springframework.http.HttpStatus.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)
);
}
order.setStatus(normalizedStatus);
orderRepo.save(order);
return ResponseEntity.ok(toOrderDto(order));
} }
@GetMapping("/{orderId}/items/{orderItemId}/file") @GetMapping("/{orderId}/items/{orderItemId}/file")
@@ -135,193 +61,16 @@ public class AdminOrderController {
@PathVariable UUID orderId, @PathVariable UUID orderId,
@PathVariable UUID orderItemId @PathVariable UUID orderItemId
) { ) {
OrderItem item = orderItemRepo.findById(orderItemId) return adminOrderControllerService.downloadOrderItemFile(orderId, 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 = storageService.loadAsResource(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 (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
} }
@GetMapping("/{orderId}/documents/confirmation") @GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) { public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true); return adminOrderControllerService.downloadOrderConfirmation(orderId);
} }
@GetMapping("/{orderId}/documents/invoice") @GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false); return adminOrderControllerService.downloadOrderInvoice(orderId);
}
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(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.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.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(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;
}).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 Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
} }
} }

View File

@@ -0,0 +1,64 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminShopCategoryDto;
import com.printcalculator.dto.AdminUpsertShopCategoryRequest;
import com.printcalculator.service.admin.AdminShopCategoryControllerService;
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;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/shop/categories")
@Transactional(readOnly = true)
public class AdminShopCategoryController {
private final AdminShopCategoryControllerService adminShopCategoryControllerService;
public AdminShopCategoryController(AdminShopCategoryControllerService adminShopCategoryControllerService) {
this.adminShopCategoryControllerService = adminShopCategoryControllerService;
}
@GetMapping
public ResponseEntity<List<AdminShopCategoryDto>> getCategories() {
return ResponseEntity.ok(adminShopCategoryControllerService.getCategories());
}
@GetMapping("/tree")
public ResponseEntity<List<AdminShopCategoryDto>> getCategoryTree() {
return ResponseEntity.ok(adminShopCategoryControllerService.getCategoryTree());
}
@GetMapping("/{categoryId}")
public ResponseEntity<AdminShopCategoryDto> getCategory(@PathVariable UUID categoryId) {
return ResponseEntity.ok(adminShopCategoryControllerService.getCategory(categoryId));
}
@PostMapping
@Transactional
public ResponseEntity<AdminShopCategoryDto> createCategory(@RequestBody AdminUpsertShopCategoryRequest payload) {
return ResponseEntity.ok(adminShopCategoryControllerService.createCategory(payload));
}
@PutMapping("/{categoryId}")
@Transactional
public ResponseEntity<AdminShopCategoryDto> updateCategory(@PathVariable UUID categoryId,
@RequestBody AdminUpsertShopCategoryRequest payload) {
return ResponseEntity.ok(adminShopCategoryControllerService.updateCategory(categoryId, payload));
}
@DeleteMapping("/{categoryId}")
@Transactional
public ResponseEntity<Void> deleteCategory(@PathVariable UUID categoryId) {
adminShopCategoryControllerService.deleteCategory(categoryId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,110 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminShopProductDto;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.dto.AdminUpsertShopProductRequest;
import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/shop/products")
@Transactional(readOnly = true)
public class AdminShopProductController {
private final AdminShopProductControllerService adminShopProductControllerService;
private final AdminShopProductTranslationService adminShopProductTranslationService;
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
AdminShopProductTranslationService adminShopProductTranslationService) {
this.adminShopProductControllerService = adminShopProductControllerService;
this.adminShopProductTranslationService = adminShopProductTranslationService;
}
@GetMapping
public ResponseEntity<List<AdminShopProductDto>> getProducts() {
return ResponseEntity.ok(adminShopProductControllerService.getProducts());
}
@GetMapping("/{productId}")
public ResponseEntity<AdminShopProductDto> getProduct(@PathVariable UUID productId) {
return ResponseEntity.ok(adminShopProductControllerService.getProduct(productId));
}
@PostMapping
@Transactional
public ResponseEntity<AdminShopProductDto> createProduct(@RequestBody AdminUpsertShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
}
@PostMapping("/translate")
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
}
@PutMapping("/{productId}")
@Transactional
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
@RequestBody AdminUpsertShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductControllerService.updateProduct(productId, payload));
}
@DeleteMapping("/{productId}")
@Transactional
public ResponseEntity<Void> deleteProduct(@PathVariable UUID productId) {
adminShopProductControllerService.deleteProduct(productId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{productId}/model")
@Transactional
public ResponseEntity<AdminShopProductDto> uploadProductModel(@PathVariable UUID productId,
@RequestParam("file") MultipartFile file) throws IOException {
return ResponseEntity.ok(adminShopProductControllerService.uploadProductModel(productId, file));
}
@DeleteMapping("/{productId}/model")
@Transactional
public ResponseEntity<Void> deleteProductModel(@PathVariable UUID productId) {
adminShopProductControllerService.deleteProductModel(productId);
return ResponseEntity.noContent().build();
}
@GetMapping("/{productId}/model")
public ResponseEntity<Resource> getProductModel(@PathVariable UUID productId) throws IOException {
AdminShopProductControllerService.ProductModelDownload model = adminShopProductControllerService.getProductModel(productId);
Resource resource = new UrlResource(model.path().toUri());
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (model.mimeType() != null && !model.mimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(model.mimeType());
} catch (IllegalArgumentException ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"")
.body(resource);
}
}

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,79 @@
package com.printcalculator.dto;
import java.util.UUID;
import java.util.Map;
public class AdminCreateMediaUsageRequest {
private String usageType;
private String usageKey;
private UUID ownerId;
private UUID mediaAssetId;
private Integer sortOrder;
private Boolean isPrimary;
private Boolean isActive;
private Map<String, MediaTextTranslationDto> translations;
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Map<String, MediaTextTranslationDto> getTranslations() {
return translations;
}
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
this.translations = translations;
}
}

View File

@@ -12,6 +12,10 @@ public class AdminFilamentVariantDto {
private String materialTechnicalTypeLabel; private String materialTechnicalTypeLabel;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -89,6 +93,38 @@ public class AdminFilamentVariantDto {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -0,0 +1,153 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class AdminMediaAssetDto {
private UUID id;
private String originalFilename;
private String storageKey;
private String mimeType;
private Long fileSizeBytes;
private String sha256Hex;
private Integer widthPx;
private Integer heightPx;
private String status;
private String visibility;
private String title;
private String altText;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private List<AdminMediaVariantDto> variants = new ArrayList<>();
private List<AdminMediaUsageDto> usages = new ArrayList<>();
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 getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
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 String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getVisibility() {
return visibility;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
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<AdminMediaVariantDto> getVariants() {
return variants;
}
public void setVariants(List<AdminMediaVariantDto> variants) {
this.variants = variants;
}
public List<AdminMediaUsageDto> getUsages() {
return usages;
}
public void setUsages(List<AdminMediaUsageDto> usages) {
this.usages = usages;
}
}

View File

@@ -0,0 +1,98 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
public class AdminMediaUsageDto {
private UUID id;
private String usageType;
private String usageKey;
private UUID ownerId;
private UUID mediaAssetId;
private Integer sortOrder;
private Boolean isPrimary;
private Boolean isActive;
private Map<String, MediaTextTranslationDto> translations;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Map<String, MediaTextTranslationDto> getTranslations() {
return translations;
}
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
this.translations = translations;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,106 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminMediaVariantDto {
private UUID id;
private String variantName;
private String format;
private String storageKey;
private String mimeType;
private Integer widthPx;
private Integer heightPx;
private Long fileSizeBytes;
private Boolean isGenerated;
private String publicUrl;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getVariantName() {
return variantName;
}
public void setVariantName(String variantName) {
this.variantName = variantName;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public String getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public Boolean getIsGenerated() {
return isGenerated;
}
public void setIsGenerated(Boolean generated) {
isGenerated = generated;
}
public String getPublicUrl() {
return publicUrl;
}
public void setPublicUrl(String publicUrl) {
this.publicUrl = publicUrl;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,15 +1,21 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
public class AdminQuoteSessionDto { public class AdminQuoteSessionDto {
private UUID id; private UUID id;
private String status; private String status;
private String sessionType;
private String materialCode; private String materialCode;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
private OffsetDateTime expiresAt; private OffsetDateTime expiresAt;
private UUID convertedOrderId; private UUID convertedOrderId;
private UUID sourceRequestId;
private BigDecimal cadHours;
private BigDecimal cadHourlyRateChf;
private BigDecimal cadTotalChf;
public UUID getId() { public UUID getId() {
return id; return id;
@@ -27,6 +33,14 @@ public class AdminQuoteSessionDto {
this.status = status; this.status = status;
} }
public String getSessionType() {
return sessionType;
}
public void setSessionType(String sessionType) {
this.sessionType = sessionType;
}
public String getMaterialCode() { public String getMaterialCode() {
return materialCode; return materialCode;
} }
@@ -58,4 +72,36 @@ public class AdminQuoteSessionDto {
public void setConvertedOrderId(UUID convertedOrderId) { public void setConvertedOrderId(UUID convertedOrderId) {
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;
}
public BigDecimal getCadTotalChf() {
return cadTotalChf;
}
public void setCadTotalChf(BigDecimal cadTotalChf) {
this.cadTotalChf = cadTotalChf;
}
} }

View File

@@ -0,0 +1,359 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminShopCategoryDto {
private UUID id;
private UUID parentCategoryId;
private String parentCategoryName;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isActive;
private Integer sortOrder;
private Integer depth;
private Integer childCount;
private Integer directProductCount;
private Integer descendantProductCount;
private String mediaUsageType;
private String mediaUsageKey;
private List<AdminShopCategoryRefDto> breadcrumbs;
private List<AdminShopCategoryDto> children;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getParentCategoryId() {
return parentCategoryId;
}
public void setParentCategoryId(UUID parentCategoryId) {
this.parentCategoryId = parentCategoryId;
}
public String getParentCategoryName() {
return parentCategoryName;
}
public void setParentCategoryName(String parentCategoryName) {
this.parentCategoryName = parentCategoryName;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getDepth() {
return depth;
}
public void setDepth(Integer depth) {
this.depth = depth;
}
public Integer getChildCount() {
return childCount;
}
public void setChildCount(Integer childCount) {
this.childCount = childCount;
}
public Integer getDirectProductCount() {
return directProductCount;
}
public void setDirectProductCount(Integer directProductCount) {
this.directProductCount = directProductCount;
}
public Integer getDescendantProductCount() {
return descendantProductCount;
}
public void setDescendantProductCount(Integer descendantProductCount) {
this.descendantProductCount = descendantProductCount;
}
public String getMediaUsageType() {
return mediaUsageType;
}
public void setMediaUsageType(String mediaUsageType) {
this.mediaUsageType = mediaUsageType;
}
public String getMediaUsageKey() {
return mediaUsageKey;
}
public void setMediaUsageKey(String mediaUsageKey) {
this.mediaUsageKey = mediaUsageKey;
}
public List<AdminShopCategoryRefDto> getBreadcrumbs() {
return breadcrumbs;
}
public void setBreadcrumbs(List<AdminShopCategoryRefDto> breadcrumbs) {
this.breadcrumbs = breadcrumbs;
}
public List<AdminShopCategoryDto> getChildren() {
return children;
}
public void setChildren(List<AdminShopCategoryDto> children) {
this.children = children;
}
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;
}
}

View File

@@ -0,0 +1,33 @@
package com.printcalculator.dto;
import java.util.UUID;
public class AdminShopCategoryRefDto {
private UUID id;
private String slug;
private String name;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,441 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminShopProductDto {
private UUID id;
private UUID categoryId;
private String categoryName;
private String categorySlug;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String excerpt;
private String excerptIt;
private String excerptEn;
private String excerptDe;
private String excerptFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isFeatured;
private Boolean isActive;
private Integer sortOrder;
private Integer variantCount;
private Integer activeVariantCount;
private BigDecimal priceFromChf;
private BigDecimal priceToChf;
private String mediaUsageType;
private String mediaUsageKey;
private List<AdminMediaUsageDto> mediaUsages;
private List<PublicMediaUsageDto> images;
private ShopProductModelDto model3d;
private List<AdminShopProductVariantDto> variants;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public String getCategorySlug() {
return categorySlug;
}
public void setCategorySlug(String categorySlug) {
this.categorySlug = categorySlug;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getExcerptIt() {
return excerptIt;
}
public void setExcerptIt(String excerptIt) {
this.excerptIt = excerptIt;
}
public String getExcerptEn() {
return excerptEn;
}
public void setExcerptEn(String excerptEn) {
this.excerptEn = excerptEn;
}
public String getExcerptDe() {
return excerptDe;
}
public void setExcerptDe(String excerptDe) {
this.excerptDe = excerptDe;
}
public String getExcerptFr() {
return excerptFr;
}
public void setExcerptFr(String excerptFr) {
this.excerptFr = excerptFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsFeatured() {
return isFeatured;
}
public void setIsFeatured(Boolean featured) {
isFeatured = featured;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getVariantCount() {
return variantCount;
}
public void setVariantCount(Integer variantCount) {
this.variantCount = variantCount;
}
public Integer getActiveVariantCount() {
return activeVariantCount;
}
public void setActiveVariantCount(Integer activeVariantCount) {
this.activeVariantCount = activeVariantCount;
}
public BigDecimal getPriceFromChf() {
return priceFromChf;
}
public void setPriceFromChf(BigDecimal priceFromChf) {
this.priceFromChf = priceFromChf;
}
public BigDecimal getPriceToChf() {
return priceToChf;
}
public void setPriceToChf(BigDecimal priceToChf) {
this.priceToChf = priceToChf;
}
public String getMediaUsageType() {
return mediaUsageType;
}
public void setMediaUsageType(String mediaUsageType) {
this.mediaUsageType = mediaUsageType;
}
public String getMediaUsageKey() {
return mediaUsageKey;
}
public void setMediaUsageKey(String mediaUsageKey) {
this.mediaUsageKey = mediaUsageKey;
}
public List<AdminMediaUsageDto> getMediaUsages() {
return mediaUsages;
}
public void setMediaUsages(List<AdminMediaUsageDto> mediaUsages) {
this.mediaUsages = mediaUsages;
}
public List<PublicMediaUsageDto> getImages() {
return images;
}
public void setImages(List<PublicMediaUsageDto> images) {
this.images = images;
}
public ShopProductModelDto getModel3d() {
return model3d;
}
public void setModel3d(ShopProductModelDto model3d) {
this.model3d = model3d;
}
public List<AdminShopProductVariantDto> getVariants() {
return variants;
}
public void setVariants(List<AdminShopProductVariantDto> variants) {
this.variants = variants;
}
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;
}
}

View File

@@ -0,0 +1,152 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminShopProductVariantDto {
private UUID id;
private String sku;
private String variantLabel;
private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex;
private String internalMaterialCode;
private BigDecimal priceChf;
private Boolean isDefault;
private Boolean isActive;
private Integer sortOrder;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getVariantLabel() {
return variantLabel;
}
public void setVariantLabel(String variantLabel) {
this.variantLabel = variantLabel;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getInternalMaterialCode() {
return internalMaterialCode;
}
public void setInternalMaterialCode(String internalMaterialCode) {
this.internalMaterialCode = internalMaterialCode;
}
public BigDecimal getPriceChf() {
return priceChf;
}
public void setPriceChf(BigDecimal priceChf) {
this.priceChf = priceChf;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean aDefault) {
isDefault = aDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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;
}
}

View File

@@ -0,0 +1,89 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class AdminTranslateShopProductRequest {
private UUID categoryId;
private String sourceLanguage;
private Boolean overwriteExisting;
private List<String> materialCodes;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public Boolean getOverwriteExisting() {
return overwriteExisting;
}
public void setOverwriteExisting(Boolean overwriteExisting) {
this.overwriteExisting = overwriteExisting;
}
public List<String> getMaterialCodes() {
return materialCodes;
}
public void setMaterialCodes(List<String> materialCodes) {
this.materialCodes = materialCodes;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -0,0 +1,70 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
public class AdminTranslateShopProductResponse {
private String sourceLanguage;
private List<String> targetLanguages;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public List<String> getTargetLanguages() {
return targetLanguages;
}
public void setTargetLanguages(List<String> targetLanguages) {
this.targetLanguages = targetLanguages;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -0,0 +1,40 @@
package com.printcalculator.dto;
public class AdminUpdateMediaAssetRequest {
private String title;
private String altText;
private String visibility;
private String status;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
public String getVisibility() {
return visibility;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,79 @@
package com.printcalculator.dto;
import java.util.UUID;
import java.util.Map;
public class AdminUpdateMediaUsageRequest {
private String usageType;
private String usageKey;
private UUID ownerId;
private UUID mediaAssetId;
private Integer sortOrder;
private Boolean isPrimary;
private Boolean isActive;
private Map<String, MediaTextTranslationDto> translations;
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Map<String, MediaTextTranslationDto> getTranslations() {
return translations;
}
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
this.translations = translations;
}
}

View File

@@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId; private Long materialTypeId;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -0,0 +1,249 @@
package com.printcalculator.dto;
import java.util.UUID;
public class AdminUpsertShopCategoryRequest {
private UUID parentCategoryId;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isActive;
private Integer sortOrder;
public UUID getParentCategoryId() {
return parentCategoryId;
}
public void setParentCategoryId(UUID parentCategoryId) {
this.parentCategoryId = parentCategoryId;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
}

View File

@@ -0,0 +1,313 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.UUID;
public class AdminUpsertShopProductRequest {
private UUID categoryId;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String excerpt;
private String excerptIt;
private String excerptEn;
private String excerptDe;
private String excerptFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isFeatured;
private Boolean isActive;
private Integer sortOrder;
private List<AdminUpsertShopProductVariantRequest> variants;
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getExcerptIt() {
return excerptIt;
}
public void setExcerptIt(String excerptIt) {
this.excerptIt = excerptIt;
}
public String getExcerptEn() {
return excerptEn;
}
public void setExcerptEn(String excerptEn) {
this.excerptEn = excerptEn;
}
public String getExcerptDe() {
return excerptDe;
}
public void setExcerptDe(String excerptDe) {
this.excerptDe = excerptDe;
}
public String getExcerptFr() {
return excerptFr;
}
public void setExcerptFr(String excerptFr) {
this.excerptFr = excerptFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsFeatured() {
return isFeatured;
}
public void setIsFeatured(Boolean featured) {
isFeatured = featured;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public List<AdminUpsertShopProductVariantRequest> getVariants() {
return variants;
}
public void setVariants(List<AdminUpsertShopProductVariantRequest> variants) {
this.variants = variants;
}
}

View File

@@ -0,0 +1,133 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public class AdminUpsertShopProductVariantRequest {
private UUID id;
private String sku;
private String variantLabel;
private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex;
private String internalMaterialCode;
private BigDecimal priceChf;
private Boolean isDefault;
private Boolean isActive;
private Integer sortOrder;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getVariantLabel() {
return variantLabel;
}
public void setVariantLabel(String variantLabel) {
this.variantLabel = variantLabel;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getInternalMaterialCode() {
return internalMaterialCode;
}
public void setInternalMaterialCode(String internalMaterialCode) {
this.internalMaterialCode = internalMaterialCode;
}
public BigDecimal getPriceChf() {
return priceChf;
}
public void setPriceChf(BigDecimal priceChf) {
this.priceChf = priceChf;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean aDefault) {
isDefault = aDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
}

View File

@@ -0,0 +1,22 @@
package com.printcalculator.dto;
public class MediaTextTranslationDto {
private String title;
private String altText;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
}

View File

@@ -7,13 +7,18 @@ 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( public record VariantOption(
Long id, Long id,
String name, String name,
String colorName, String colorName,
String colorLabelIt,
String colorLabelEn,
String colorLabelDe,
String colorLabelFr,
String hexColor, String hexColor,
String finishType, String finishType,
Double stockSpools, Double stockSpools,
@@ -24,4 +29,5 @@ public record OptionsResponse(
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

@@ -8,6 +8,7 @@ import java.util.UUID;
public class OrderDto { public class OrderDto {
private UUID id; private UUID id;
private String orderNumber; private String orderNumber;
private String sourceType;
private String status; private String status;
private String paymentStatus; private String paymentStatus;
private String paymentMethod; private String paymentMethod;
@@ -23,6 +24,11 @@ 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 String printMaterialCode;
@@ -40,6 +46,9 @@ public class OrderDto {
public String getOrderNumber() { return orderNumber; } public String getOrderNumber() { return orderNumber; }
public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; } public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
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; }
@@ -85,6 +94,21 @@ 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; }

View File

@@ -5,9 +5,36 @@ import java.util.UUID;
public class OrderItemDto { public class OrderItemDto {
private UUID id; private UUID id;
private String itemType;
private String originalFilename; private String originalFilename;
private String displayName;
private String materialCode; private String materialCode;
private String colorCode; private String colorCode;
private Long filamentVariantId;
private UUID shopProductId;
private UUID shopProductVariantId;
private String shopProductSlug;
private String shopProductName;
private String shopVariantLabel;
private String shopVariantColorName;
private String shopVariantColorLabelIt;
private String shopVariantColorLabelEn;
private String shopVariantColorLabelDe;
private String shopVariantColorLabelFr;
private String shopVariantColorHex;
private String filamentVariantDisplayName;
private String filamentColorName;
private String filamentColorLabelIt;
private String filamentColorLabelEn;
private String filamentColorLabelDe;
private String filamentColorLabelFr;
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;
@@ -18,15 +45,96 @@ public class OrderItemDto {
public UUID getId() { return id; } public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; } public void setId(UUID id) { this.id = id; }
public String getItemType() { return itemType; }
public void setItemType(String itemType) { this.itemType = itemType; }
public String getOriginalFilename() { return originalFilename; } public String getOriginalFilename() { return originalFilename; }
public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; } public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getMaterialCode() { return materialCode; } public String getMaterialCode() { return materialCode; }
public void setMaterialCode(String materialCode) { this.materialCode = materialCode; } public void setMaterialCode(String materialCode) { this.materialCode = materialCode; }
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 UUID getShopProductId() { return shopProductId; }
public void setShopProductId(UUID shopProductId) { this.shopProductId = shopProductId; }
public UUID getShopProductVariantId() { return shopProductVariantId; }
public void setShopProductVariantId(UUID shopProductVariantId) { this.shopProductVariantId = shopProductVariantId; }
public String getShopProductSlug() { return shopProductSlug; }
public void setShopProductSlug(String shopProductSlug) { this.shopProductSlug = shopProductSlug; }
public String getShopProductName() { return shopProductName; }
public void setShopProductName(String shopProductName) { this.shopProductName = shopProductName; }
public String getShopVariantLabel() { return shopVariantLabel; }
public void setShopVariantLabel(String shopVariantLabel) { this.shopVariantLabel = shopVariantLabel; }
public String getShopVariantColorName() { return shopVariantColorName; }
public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; }
public String getShopVariantColorLabelIt() { return shopVariantColorLabelIt; }
public void setShopVariantColorLabelIt(String shopVariantColorLabelIt) { this.shopVariantColorLabelIt = shopVariantColorLabelIt; }
public String getShopVariantColorLabelEn() { return shopVariantColorLabelEn; }
public void setShopVariantColorLabelEn(String shopVariantColorLabelEn) { this.shopVariantColorLabelEn = shopVariantColorLabelEn; }
public String getShopVariantColorLabelDe() { return shopVariantColorLabelDe; }
public void setShopVariantColorLabelDe(String shopVariantColorLabelDe) { this.shopVariantColorLabelDe = shopVariantColorLabelDe; }
public String getShopVariantColorLabelFr() { return shopVariantColorLabelFr; }
public void setShopVariantColorLabelFr(String shopVariantColorLabelFr) { this.shopVariantColorLabelFr = shopVariantColorLabelFr; }
public String getShopVariantColorHex() { return shopVariantColorHex; }
public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
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 getFilamentColorLabelIt() { return filamentColorLabelIt; }
public void setFilamentColorLabelIt(String filamentColorLabelIt) { this.filamentColorLabelIt = filamentColorLabelIt; }
public String getFilamentColorLabelEn() { return filamentColorLabelEn; }
public void setFilamentColorLabelEn(String filamentColorLabelEn) { this.filamentColorLabelEn = filamentColorLabelEn; }
public String getFilamentColorLabelDe() { return filamentColorLabelDe; }
public void setFilamentColorLabelDe(String filamentColorLabelDe) { this.filamentColorLabelDe = filamentColorLabelDe; }
public String getFilamentColorLabelFr() { return filamentColorLabelFr; }
public void setFilamentColorLabelFr(String filamentColorLabelFr) { this.filamentColorLabelFr = filamentColorLabelFr; }
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,8 +1,5 @@
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;
@@ -10,6 +7,7 @@ public class PrintSettingsDto {
// Common // Common
private String material; // e.g. "PLA", "PLA TOUGH", "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 filamentVariantId;
private Long printerMachineId; private Long printerMachineId;
@@ -28,4 +26,132 @@ public class PrintSettingsDto {
private Double boundingBoxX; private Double boundingBoxX;
private Double boundingBoxY; private Double boundingBoxY;
private Double boundingBoxZ; 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

@@ -0,0 +1,96 @@
package com.printcalculator.dto;
import java.util.UUID;
public class PublicMediaUsageDto {
private UUID mediaAssetId;
private String title;
private String altText;
private String usageType;
private String usageKey;
private Integer sortOrder;
private Boolean isPrimary;
private PublicMediaVariantDto thumb;
private PublicMediaVariantDto card;
private PublicMediaVariantDto hero;
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public PublicMediaVariantDto getThumb() {
return thumb;
}
public void setThumb(PublicMediaVariantDto thumb) {
this.thumb = thumb;
}
public PublicMediaVariantDto getCard() {
return card;
}
public void setCard(PublicMediaVariantDto card) {
this.card = card;
}
public PublicMediaVariantDto getHero() {
return hero;
}
public void setHero(PublicMediaVariantDto hero) {
this.hero = hero;
}
}

View File

@@ -0,0 +1,31 @@
package com.printcalculator.dto;
public class PublicMediaVariantDto {
private String avifUrl;
private String webpUrl;
private String jpegUrl;
public String getAvifUrl() {
return avifUrl;
}
public void setAvifUrl(String avifUrl) {
this.avifUrl = avifUrl;
}
public String getWebpUrl() {
return webpUrl;
}
public void setWebpUrl(String webpUrl) {
this.webpUrl = webpUrl;
}
public String getJpegUrl() {
return jpegUrl;
}
public void setJpegUrl(String jpegUrl) {
this.jpegUrl = jpegUrl;
}
}

View File

@@ -7,6 +7,7 @@ import jakarta.validation.constraints.AssertTrue;
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;

View File

@@ -0,0 +1,30 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
public class ShopCartAddItemRequest {
@NotNull
private UUID shopProductVariantId;
@Min(1)
private Integer quantity = 1;
public UUID getShopProductVariantId() {
return shopProductVariantId;
}
public void setShopProductVariantId(UUID shopProductVariantId) {
this.shopProductVariantId = shopProductVariantId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}

View File

@@ -0,0 +1,18 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class ShopCartUpdateItemRequest {
@NotNull
@Min(1)
private Integer quantity;
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}

View File

@@ -0,0 +1,23 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.UUID;
public record ShopCategoryDetailDto(
UUID id,
String slug,
String name,
String description,
String seoTitle,
String seoDescription,
String ogTitle,
String ogDescription,
Boolean indexable,
Integer sortOrder,
Integer productCount,
List<ShopCategoryRefDto> breadcrumbs,
PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images,
List<ShopCategoryTreeDto> children
) {
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.dto;
import java.util.UUID;
public record ShopCategoryRefDto(
UUID id,
String slug,
String name
) {
}

View File

@@ -0,0 +1,22 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.UUID;
public record ShopCategoryTreeDto(
UUID id,
UUID parentCategoryId,
String slug,
String name,
String description,
String seoTitle,
String seoDescription,
String ogTitle,
String ogDescription,
Boolean indexable,
Integer sortOrder,
Integer productCount,
PublicMediaUsageDto primaryImage,
List<ShopCategoryTreeDto> children
) {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.dto;
import java.util.List;
public record ShopProductCatalogResponseDto(
String categorySlug,
Boolean featuredOnly,
ShopCategoryDetailDto category,
List<ShopProductSummaryDto> products
) {
}

View File

@@ -0,0 +1,33 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record ShopProductDetailDto(
UUID id,
String slug,
String name,
String excerpt,
String description,
String seoTitle,
String seoDescription,
String ogTitle,
String ogDescription,
Boolean indexable,
Boolean isFeatured,
Integer sortOrder,
ShopCategoryRefDto category,
List<ShopCategoryRefDto> breadcrumbs,
BigDecimal priceFromChf,
BigDecimal priceToChf,
ShopProductVariantOptionDto defaultVariant,
List<ShopProductVariantOptionDto> variants,
PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images,
ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) {
}

View File

@@ -0,0 +1,14 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public record ShopProductModelDto(
String url,
String originalFilename,
String mimeType,
Long fileSizeBytes,
BigDecimal boundingBoxXMm,
BigDecimal boundingBoxYMm,
BigDecimal boundingBoxZMm
) {
}

View File

@@ -0,0 +1,23 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.Map;
import java.util.UUID;
public record ShopProductSummaryDto(
UUID id,
String slug,
String name,
String excerpt,
Boolean isFeatured,
Integer sortOrder,
ShopCategoryRefDto category,
BigDecimal priceFromChf,
BigDecimal priceToChf,
ShopProductVariantOptionDto defaultVariant,
PublicMediaUsageDto primaryImage,
ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) {
}

View File

@@ -0,0 +1,16 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public record ShopProductVariantOptionDto(
UUID id,
String sku,
String variantLabel,
String colorName,
String colorLabel,
String colorHex,
BigDecimal priceChf,
Boolean isDefault
) {
}

View File

@@ -24,6 +24,18 @@ 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_label_it", length = Integer.MAX_VALUE)
private String colorLabelIt;
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
private String colorLabelEn;
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
private String colorLabelDe;
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
private String colorLabelFr;
@Column(name = "color_hex", length = Integer.MAX_VALUE) @Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex; private String colorHex;
@@ -93,6 +105,38 @@ public class FilamentVariant {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }
@@ -173,4 +217,60 @@ public class FilamentVariant {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
public String getColorLabelForLanguage(String language) {
return resolveLocalizedValue(
language,
colorName,
colorLabelIt,
colorLabelEn,
colorLabelDe,
colorLabelFr
);
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
} }

View File

@@ -0,0 +1,177 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "media_asset", indexes = {
@Index(name = "ix_media_asset_status_visibility_created_at", columnList = "status, visibility, created_at")
})
public class MediaAsset {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "media_asset_id", nullable = false)
private UUID id;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true)
private String storageKey;
@Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "file_size_bytes", nullable = false)
private Long fileSizeBytes;
@Column(name = "sha256_hex", nullable = false, length = Integer.MAX_VALUE)
private String sha256Hex;
@Column(name = "width_px")
private Integer widthPx;
@Column(name = "height_px")
private Integer heightPx;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@Column(name = "visibility", nullable = false, length = Integer.MAX_VALUE)
private String visibility;
@Column(name = "title", length = Integer.MAX_VALUE)
private String title;
@Column(name = "alt_text", length = Integer.MAX_VALUE)
private String altText;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
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 getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
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 String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getVisibility() {
return visibility;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
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;
}
}

View File

@@ -0,0 +1,273 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "media_usage", indexes = {
@Index(name = "ix_media_usage_scope", columnList = "usage_type, usage_key, is_active, sort_order")
})
public class MediaUsage {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "media_usage_id", nullable = false)
private UUID id;
@Column(name = "usage_type", nullable = false, length = Integer.MAX_VALUE)
private String usageType;
@Column(name = "usage_key", nullable = false, length = Integer.MAX_VALUE)
private String usageKey;
@Column(name = "owner_id")
private UUID ownerId;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "media_asset_id", nullable = false)
private MediaAsset mediaAsset;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("false")
@Column(name = "is_primary", nullable = false)
private Boolean isPrimary;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "title_it", length = Integer.MAX_VALUE)
private String titleIt;
@Column(name = "title_en", length = Integer.MAX_VALUE)
private String titleEn;
@Column(name = "title_de", length = Integer.MAX_VALUE)
private String titleDe;
@Column(name = "title_fr", length = Integer.MAX_VALUE)
private String titleFr;
@Column(name = "alt_text_it", length = Integer.MAX_VALUE)
private String altTextIt;
@Column(name = "alt_text_en", length = Integer.MAX_VALUE)
private String altTextEn;
@Column(name = "alt_text_de", length = Integer.MAX_VALUE)
private String altTextDe;
@Column(name = "alt_text_fr", length = Integer.MAX_VALUE)
private String altTextFr;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public MediaAsset getMediaAsset() {
return mediaAsset;
}
public void setMediaAsset(MediaAsset mediaAsset) {
this.mediaAsset = mediaAsset;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public String getTitleIt() {
return titleIt;
}
public void setTitleIt(String titleIt) {
this.titleIt = titleIt;
}
public String getTitleEn() {
return titleEn;
}
public void setTitleEn(String titleEn) {
this.titleEn = titleEn;
}
public String getTitleDe() {
return titleDe;
}
public void setTitleDe(String titleDe) {
this.titleDe = titleDe;
}
public String getTitleFr() {
return titleFr;
}
public void setTitleFr(String titleFr) {
this.titleFr = titleFr;
}
public String getAltTextIt() {
return altTextIt;
}
public void setAltTextIt(String altTextIt) {
this.altTextIt = altTextIt;
}
public String getAltTextEn() {
return altTextEn;
}
public void setAltTextEn(String altTextEn) {
this.altTextEn = altTextEn;
}
public String getAltTextDe() {
return altTextDe;
}
public void setAltTextDe(String altTextDe) {
this.altTextDe = altTextDe;
}
public String getAltTextFr() {
return altTextFr;
}
public void setAltTextFr(String altTextFr) {
this.altTextFr = altTextFr;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public String getTitleForLanguage(String language) {
if (language == null) {
return null;
}
return switch (language.trim().toLowerCase()) {
case "it" -> titleIt;
case "en" -> titleEn;
case "de" -> titleDe;
case "fr" -> titleFr;
default -> null;
};
}
public void setTitleForLanguage(String language, String value) {
if (language == null) {
return;
}
switch (language.trim().toLowerCase()) {
case "it" -> titleIt = value;
case "en" -> titleEn = value;
case "de" -> titleDe = value;
case "fr" -> titleFr = value;
default -> {
}
}
}
public String getAltTextForLanguage(String language) {
if (language == null) {
return null;
}
return switch (language.trim().toLowerCase()) {
case "it" -> altTextIt;
case "en" -> altTextEn;
case "de" -> altTextDe;
case "fr" -> altTextFr;
default -> null;
};
}
public void setAltTextForLanguage(String language, String value) {
if (language == null) {
return;
}
switch (language.trim().toLowerCase()) {
case "it" -> altTextIt = value;
case "en" -> altTextEn = value;
case "de" -> altTextDe = value;
case "fr" -> altTextFr = value;
default -> {
}
}
}
}

View File

@@ -0,0 +1,154 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "media_variant", indexes = {
@Index(name = "ix_media_variant_asset", columnList = "media_asset_id")
}, uniqueConstraints = {
@UniqueConstraint(name = "uq_media_variant_asset_name_format", columnNames = {"media_asset_id", "variant_name", "format"})
})
public class MediaVariant {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "media_variant_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "media_asset_id", nullable = false)
private MediaAsset mediaAsset;
@Column(name = "variant_name", nullable = false, length = Integer.MAX_VALUE)
private String variantName;
@Column(name = "format", nullable = false, length = Integer.MAX_VALUE)
private String format;
@Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true)
private String storageKey;
@Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "width_px", nullable = false)
private Integer widthPx;
@Column(name = "height_px", nullable = false)
private Integer heightPx;
@Column(name = "file_size_bytes", nullable = false)
private Long fileSizeBytes;
@ColumnDefault("true")
@Column(name = "is_generated", nullable = false)
private Boolean isGenerated;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public MediaAsset getMediaAsset() {
return mediaAsset;
}
public void setMediaAsset(MediaAsset mediaAsset) {
this.mediaAsset = mediaAsset;
}
public String getVariantName() {
return variantName;
}
public void setVariantName(String variantName) {
this.variantName = variantName;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public String getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public Boolean getIsGenerated() {
return isGenerated;
}
public void setIsGenerated(Boolean generated) {
isGenerated = generated;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

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

@@ -20,6 +20,10 @@ public class Order {
@JoinColumn(name = "source_quote_session_id") @JoinColumn(name = "source_quote_session_id")
private QuoteSession sourceQuoteSession; private QuoteSession sourceQuoteSession;
@ColumnDefault("'CALCULATOR'")
@Column(name = "source_type", nullable = false, length = Integer.MAX_VALUE)
private String sourceType;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status; private String status;
@@ -119,6 +123,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;
@@ -134,6 +155,34 @@ public class Order {
@Column(name = "paid_at") @Column(name = "paid_at")
private OffsetDateTime paidAt; private OffsetDateTime paidAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (shippingSameAsBilling == null) {
shippingSameAsBilling = true;
}
if (sourceType == null || sourceType.isBlank()) {
sourceType = "CALCULATOR";
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (shippingSameAsBilling == null) {
shippingSameAsBilling = true;
}
if (sourceType == null || sourceType.isBlank()) {
sourceType = "CALCULATOR";
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -160,6 +209,14 @@ public class Order {
this.sourceQuoteSession = sourceQuoteSession; this.sourceQuoteSession = sourceQuoteSession;
} }
public String getSourceType() {
return sourceType;
}
public void setSourceType(String sourceType) {
this.sourceType = sourceType;
}
public String getStatus() { public String getStatus() {
return status; return status;
} }
@@ -400,6 +457,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

@@ -23,9 +23,16 @@ public class OrderItem {
@JoinColumn(name = "order_id", nullable = false) @JoinColumn(name = "order_id", nullable = false)
private Order order; private Order order;
@ColumnDefault("'PRINT_FILE'")
@Column(name = "item_type", nullable = false, length = Integer.MAX_VALUE)
private String itemType;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename; private String originalFilename;
@Column(name = "display_name", length = Integer.MAX_VALUE)
private String displayName;
@Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE)
private String storedRelativePath; private String storedRelativePath;
@@ -44,10 +51,51 @@ 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) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id") @JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant; private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_id")
private ShopProduct shopProduct;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_variant_id")
private ShopProductVariant shopProductVariant;
@Column(name = "shop_product_slug", length = Integer.MAX_VALUE)
private String shopProductSlug;
@Column(name = "shop_product_name", length = Integer.MAX_VALUE)
private String shopProductName;
@Column(name = "shop_variant_label", length = Integer.MAX_VALUE)
private String shopVariantLabel;
@Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE)
private String shopVariantColorName;
@Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE)
private String shopVariantColorHex;
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@@ -88,6 +136,14 @@ public class OrderItem {
if (quantity == null) { if (quantity == null) {
quantity = 1; quantity = 1;
} }
if (itemType == null || itemType.isBlank()) {
itemType = "PRINT_FILE";
}
if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) {
displayName = originalFilename;
} else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) {
displayName = shopProductName;
}
} }
public UUID getId() { public UUID getId() {
@@ -106,6 +162,14 @@ public class OrderItem {
this.order = order; this.order = order;
} }
public String getItemType() {
return itemType;
}
public void setItemType(String itemType) {
this.itemType = itemType;
}
public String getOriginalFilename() { public String getOriginalFilename() {
return originalFilename; return originalFilename;
} }
@@ -114,6 +178,14 @@ public class OrderItem {
this.originalFilename = originalFilename; this.originalFilename = originalFilename;
} }
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getStoredRelativePath() { public String getStoredRelativePath() {
return storedRelativePath; return storedRelativePath;
} }
@@ -162,6 +234,54 @@ 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() { public FilamentVariant getFilamentVariant() {
return filamentVariant; return filamentVariant;
} }
@@ -170,6 +290,62 @@ public class OrderItem {
this.filamentVariant = filamentVariant; this.filamentVariant = filamentVariant;
} }
public ShopProduct getShopProduct() {
return shopProduct;
}
public void setShopProduct(ShopProduct shopProduct) {
this.shopProduct = shopProduct;
}
public ShopProductVariant getShopProductVariant() {
return shopProductVariant;
}
public void setShopProductVariant(ShopProductVariant shopProductVariant) {
this.shopProductVariant = shopProductVariant;
}
public String getShopProductSlug() {
return shopProductSlug;
}
public void setShopProductSlug(String shopProductSlug) {
this.shopProductSlug = shopProductSlug;
}
public String getShopProductName() {
return shopProductName;
}
public void setShopProductName(String shopProductName) {
this.shopProductName = shopProductName;
}
public String getShopVariantLabel() {
return shopVariantLabel;
}
public void setShopVariantLabel(String shopVariantLabel) {
this.shopVariantLabel = shopVariantLabel;
}
public String getShopVariantColorName() {
return shopVariantColorName;
}
public void setShopVariantColorName(String shopVariantColorName) {
this.shopVariantColorName = shopVariantColorName;
}
public String getShopVariantColorHex() {
return shopVariantColorHex;
}
public void setShopVariantColorHex(String shopVariantColorHex) {
this.shopVariantColorHex = shopVariantColorHex;
}
public String getColorCode() { public String getColorCode() {
return colorCode; return colorCode;
} }

View File

@@ -30,9 +30,16 @@ public class QuoteLineItem {
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status; private String status;
@ColumnDefault("'PRINT_FILE'")
@Column(name = "line_item_type", nullable = false, length = Integer.MAX_VALUE)
private String lineItemType;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename; private String originalFilename;
@Column(name = "display_name", length = Integer.MAX_VALUE)
private String displayName;
@ColumnDefault("1") @ColumnDefault("1")
@Column(name = "quantity", nullable = false) @Column(name = "quantity", nullable = false)
private Integer quantity; private Integer quantity;
@@ -45,6 +52,52 @@ public class QuoteLineItem {
@com.fasterxml.jackson.annotation.JsonIgnore @com.fasterxml.jackson.annotation.JsonIgnore
private FilamentVariant filamentVariant; private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private ShopProduct shopProduct;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_variant_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private ShopProductVariant shopProductVariant;
@Column(name = "shop_product_slug", length = Integer.MAX_VALUE)
private String shopProductSlug;
@Column(name = "shop_product_name", length = Integer.MAX_VALUE)
private String shopProductName;
@Column(name = "shop_variant_label", length = Integer.MAX_VALUE)
private String shopVariantLabel;
@Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE)
private String shopVariantColorName;
@Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE)
private String shopVariantColorHex;
@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;
@@ -81,6 +134,41 @@ public class QuoteLineItem {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (quantity == null) {
quantity = 1;
}
if (lineItemType == null || lineItemType.isBlank()) {
lineItemType = "PRINT_FILE";
}
if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) {
displayName = originalFilename;
} else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) {
displayName = shopProductName;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (lineItemType == null || lineItemType.isBlank()) {
lineItemType = "PRINT_FILE";
}
if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) {
displayName = originalFilename;
} else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) {
displayName = shopProductName;
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -105,6 +193,14 @@ public class QuoteLineItem {
this.status = status; this.status = status;
} }
public String getLineItemType() {
return lineItemType;
}
public void setLineItemType(String lineItemType) {
this.lineItemType = lineItemType;
}
public String getOriginalFilename() { public String getOriginalFilename() {
return originalFilename; return originalFilename;
} }
@@ -113,6 +209,14 @@ public class QuoteLineItem {
this.originalFilename = originalFilename; this.originalFilename = originalFilename;
} }
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Integer getQuantity() { public Integer getQuantity() {
return quantity; return quantity;
} }
@@ -137,6 +241,118 @@ public class QuoteLineItem {
this.filamentVariant = filamentVariant; this.filamentVariant = filamentVariant;
} }
public ShopProduct getShopProduct() {
return shopProduct;
}
public void setShopProduct(ShopProduct shopProduct) {
this.shopProduct = shopProduct;
}
public ShopProductVariant getShopProductVariant() {
return shopProductVariant;
}
public void setShopProductVariant(ShopProductVariant shopProductVariant) {
this.shopProductVariant = shopProductVariant;
}
public String getShopProductSlug() {
return shopProductSlug;
}
public void setShopProductSlug(String shopProductSlug) {
this.shopProductSlug = shopProductSlug;
}
public String getShopProductName() {
return shopProductName;
}
public void setShopProductName(String shopProductName) {
this.shopProductName = shopProductName;
}
public String getShopVariantLabel() {
return shopVariantLabel;
}
public void setShopVariantLabel(String shopVariantLabel) {
this.shopVariantLabel = shopVariantLabel;
}
public String getShopVariantColorName() {
return shopVariantColorName;
}
public void setShopVariantColorName(String shopVariantColorName) {
this.shopVariantColorName = shopVariantColorName;
}
public String getShopVariantColorHex() {
return shopVariantColorHex;
}
public void setShopVariantColorHex(String shopVariantColorHex) {
this.shopVariantColorHex = shopVariantColorHex;
}
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

@@ -22,6 +22,10 @@ public class QuoteSession {
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status; private String status;
@ColumnDefault("'PRINT_QUOTE'")
@Column(name = "session_type", nullable = false, length = Integer.MAX_VALUE)
private String sessionType;
@Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE) @Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE)
private String pricingVersion; private String pricingVersion;
@@ -61,6 +65,28 @@ 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;
@PrePersist
private void onCreate() {
if (sessionType == null || sessionType.isBlank()) {
sessionType = "PRINT_QUOTE";
}
if (supportsEnabled == null) {
supportsEnabled = false;
}
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -77,6 +103,14 @@ public class QuoteSession {
this.status = status; this.status = status;
} }
public String getSessionType() {
return sessionType;
}
public void setSessionType(String sessionType) {
this.sessionType = sessionType;
}
public String getPricingVersion() { public String getPricingVersion() {
return pricingVersion; return pricingVersion;
} }
@@ -173,4 +207,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,505 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "shop_category", indexes = {
@Index(name = "ix_shop_category_parent_sort", columnList = "parent_category_id, sort_order"),
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
})
public class ShopCategory {
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_category_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_category_id")
private ShopCategory parentCategory;
@Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE)
private String slug;
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
private String name;
@Column(name = "name_it", length = Integer.MAX_VALUE)
private String nameIt;
@Column(name = "name_en", length = Integer.MAX_VALUE)
private String nameEn;
@Column(name = "name_de", length = Integer.MAX_VALUE)
private String nameDe;
@Column(name = "name_fr", length = Integer.MAX_VALUE)
private String nameFr;
@Column(name = "description", length = Integer.MAX_VALUE)
private String description;
@Column(name = "description_it", length = Integer.MAX_VALUE)
private String descriptionIt;
@Column(name = "description_en", length = Integer.MAX_VALUE)
private String descriptionEn;
@Column(name = "description_de", length = Integer.MAX_VALUE)
private String descriptionDe;
@Column(name = "description_fr", length = Integer.MAX_VALUE)
private String descriptionFr;
@Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle;
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
private String seoTitleIt;
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
private String seoTitleEn;
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
private String seoTitleDe;
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
private String seoTitleFr;
@Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription;
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
private String seoDescriptionIt;
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
private String seoDescriptionEn;
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
private String seoDescriptionDe;
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
private String seoDescriptionFr;
@Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle;
@Column(name = "og_description", length = Integer.MAX_VALUE)
private String ogDescription;
@ColumnDefault("true")
@Column(name = "indexable", nullable = false)
private Boolean indexable;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (indexable == null) {
indexable = true;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (indexable == null) {
indexable = true;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopCategory getParentCategory() {
return parentCategory;
}
public void setParentCategory(ShopCategory parentCategory) {
this.parentCategory = parentCategory;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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 String getNameForLanguage(String language) {
return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr);
}
public void setNameForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> nameIt = value;
case "en" -> nameEn = value;
case "de" -> nameDe = value;
case "fr" -> nameFr = value;
default -> {
}
}
}
public String getDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr);
}
public void setDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> descriptionIt = value;
case "en" -> descriptionEn = value;
case "de" -> descriptionDe = value;
case "fr" -> descriptionFr = value;
default -> {
}
}
}
public String getSeoTitleForLanguage(String language) {
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
}
public void setSeoTitleForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoTitleIt = value;
case "en" -> seoTitleEn = value;
case "de" -> seoTitleDe = value;
case "fr" -> seoTitleFr = value;
default -> {
}
}
}
public String getSeoDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
}
public void setSeoDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoDescriptionIt = value;
case "en" -> seoDescriptionEn = value;
case "de" -> seoDescriptionDe = value;
case "fr" -> seoDescriptionFr = value;
default -> {
}
}
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,593 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "shop_product", indexes = {
@Index(name = "ix_shop_product_category_active_sort", columnList = "shop_category_id, is_active, sort_order"),
@Index(name = "ix_shop_product_featured_sort", columnList = "is_featured, is_active, sort_order")
})
public class ShopProduct {
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_product_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "shop_category_id", nullable = false)
private ShopCategory category;
@Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE)
private String slug;
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
private String name;
@Column(name = "name_it", length = Integer.MAX_VALUE)
private String nameIt;
@Column(name = "name_en", length = Integer.MAX_VALUE)
private String nameEn;
@Column(name = "name_de", length = Integer.MAX_VALUE)
private String nameDe;
@Column(name = "name_fr", length = Integer.MAX_VALUE)
private String nameFr;
@Column(name = "excerpt", length = Integer.MAX_VALUE)
private String excerpt;
@Column(name = "excerpt_it", length = Integer.MAX_VALUE)
private String excerptIt;
@Column(name = "excerpt_en", length = Integer.MAX_VALUE)
private String excerptEn;
@Column(name = "excerpt_de", length = Integer.MAX_VALUE)
private String excerptDe;
@Column(name = "excerpt_fr", length = Integer.MAX_VALUE)
private String excerptFr;
@Column(name = "description", length = Integer.MAX_VALUE)
private String description;
@Column(name = "description_it", length = Integer.MAX_VALUE)
private String descriptionIt;
@Column(name = "description_en", length = Integer.MAX_VALUE)
private String descriptionEn;
@Column(name = "description_de", length = Integer.MAX_VALUE)
private String descriptionDe;
@Column(name = "description_fr", length = Integer.MAX_VALUE)
private String descriptionFr;
@Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle;
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
private String seoTitleIt;
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
private String seoTitleEn;
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
private String seoTitleDe;
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
private String seoTitleFr;
@Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription;
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
private String seoDescriptionIt;
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
private String seoDescriptionEn;
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
private String seoDescriptionDe;
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
private String seoDescriptionFr;
@Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle;
@Column(name = "og_description", length = Integer.MAX_VALUE)
private String ogDescription;
@ColumnDefault("true")
@Column(name = "indexable", nullable = false)
private Boolean indexable;
@ColumnDefault("false")
@Column(name = "is_featured", nullable = false)
private Boolean isFeatured;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (indexable == null) {
indexable = true;
}
if (isFeatured == null) {
isFeatured = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (indexable == null) {
indexable = true;
}
if (isFeatured == null) {
isFeatured = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopCategory getCategory() {
return category;
}
public void setCategory(ShopCategory category) {
this.category = category;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getExcerptIt() {
return excerptIt;
}
public void setExcerptIt(String excerptIt) {
this.excerptIt = excerptIt;
}
public String getExcerptEn() {
return excerptEn;
}
public void setExcerptEn(String excerptEn) {
this.excerptEn = excerptEn;
}
public String getExcerptDe() {
return excerptDe;
}
public void setExcerptDe(String excerptDe) {
this.excerptDe = excerptDe;
}
public String getExcerptFr() {
return excerptFr;
}
public void setExcerptFr(String excerptFr) {
this.excerptFr = excerptFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsFeatured() {
return isFeatured;
}
public void setIsFeatured(Boolean featured) {
isFeatured = featured;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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 String getNameForLanguage(String language) {
return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr);
}
public void setNameForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> nameIt = value;
case "en" -> nameEn = value;
case "de" -> nameDe = value;
case "fr" -> nameFr = value;
default -> {
}
}
}
public String getExcerptForLanguage(String language) {
return resolveLocalizedValue(language, excerpt, excerptIt, excerptEn, excerptDe, excerptFr);
}
public void setExcerptForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> excerptIt = value;
case "en" -> excerptEn = value;
case "de" -> excerptDe = value;
case "fr" -> excerptFr = value;
default -> {
}
}
}
public String getDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr);
}
public void setDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> descriptionIt = value;
case "en" -> descriptionEn = value;
case "de" -> descriptionDe = value;
case "fr" -> descriptionFr = value;
default -> {
}
}
}
public String getSeoTitleForLanguage(String language) {
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
}
public void setSeoTitleForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoTitleIt = value;
case "en" -> seoTitleEn = value;
case "de" -> seoTitleDe = value;
case "fr" -> seoTitleFr = value;
default -> {
}
}
}
public String getSeoDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
}
public void setSeoDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoDescriptionIt = value;
case "en" -> seoDescriptionEn = value;
case "de" -> seoDescriptionDe = value;
case "fr" -> seoDescriptionFr = value;
default -> {
}
}
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,189 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "shop_product_model_asset", indexes = {
@Index(name = "ix_shop_product_model_asset_product", columnList = "shop_product_id")
})
public class ShopProductModelAsset {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_product_model_asset_id", nullable = false)
private UUID id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "shop_product_id", nullable = false, unique = true)
private ShopProduct product;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE)
private String storedRelativePath;
@Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE)
private String storedFilename;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "mime_type", length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "sha256_hex", length = Integer.MAX_VALUE)
private String sha256Hex;
@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;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopProduct getProduct() {
return product;
}
public void setProduct(ShopProduct product) {
this.product = product;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getStoredRelativePath() {
return storedRelativePath;
}
public void setStoredRelativePath(String storedRelativePath) {
this.storedRelativePath = storedRelativePath;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
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 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;
}
}

View File

@@ -0,0 +1,318 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "shop_product_variant", indexes = {
@Index(name = "ix_shop_product_variant_product_active_sort", columnList = "shop_product_id, is_active, sort_order"),
@Index(name = "ix_shop_product_variant_sku", columnList = "sku")
})
public class ShopProductVariant {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_product_variant_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "shop_product_id", nullable = false)
private ShopProduct product;
@Column(name = "sku", unique = true, length = Integer.MAX_VALUE)
private String sku;
@Column(name = "variant_label", nullable = false, length = Integer.MAX_VALUE)
private String variantLabel;
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName;
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
private String colorLabelIt;
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
private String colorLabelEn;
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
private String colorLabelDe;
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
private String colorLabelFr;
@Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex;
@Column(name = "internal_material_code", nullable = false, length = Integer.MAX_VALUE)
private String internalMaterialCode;
@ColumnDefault("0.00")
@Column(name = "price_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal priceChf;
@ColumnDefault("false")
@Column(name = "is_default", nullable = false)
private Boolean isDefault;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (priceChf == null) {
priceChf = BigDecimal.ZERO;
}
if (isDefault == null) {
isDefault = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (priceChf == null) {
priceChf = BigDecimal.ZERO;
}
if (isDefault == null) {
isDefault = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopProduct getProduct() {
return product;
}
public void setProduct(ShopProduct product) {
this.product = product;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getVariantLabel() {
return variantLabel;
}
public void setVariantLabel(String variantLabel) {
this.variantLabel = variantLabel;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getInternalMaterialCode() {
return internalMaterialCode;
}
public void setInternalMaterialCode(String internalMaterialCode) {
this.internalMaterialCode = internalMaterialCode;
}
public BigDecimal getPriceChf() {
return priceChf;
}
public void setPriceChf(BigDecimal priceChf) {
this.priceChf = priceChf;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean aDefault) {
isDefault = aDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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 String getColorLabelForLanguage(String language) {
return resolveLocalizedValue(
language,
colorName,
colorLabelIt,
colorLabelEn,
colorLabelDe,
colorLabelFr
);
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

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

@@ -4,12 +4,13 @@ import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem; import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment; 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.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent; import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.payment.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.payment.QrBillService;
import com.printcalculator.service.StorageService; 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;
@@ -95,6 +96,19 @@ public class OrderEmailListener {
} }
} }
@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) {
String language = resolveLanguage(order.getPreferredLanguage()); String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order); String orderNumber = getDisplayOrderNumber(order);
@@ -153,6 +167,21 @@ public class OrderEmailListener {
); );
} }
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
);
}
private void sendAdminNotificationEmail(Order order) { private void sendAdminNotificationEmail(Order order) {
String orderNumber = getDisplayOrderNumber(order); String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE); Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
@@ -194,10 +223,15 @@ public class OrderEmailListener {
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
); );
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
return templateData; return templateData;
} }
private String buildLogoUrl() {
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
}
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) { private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) { return switch (language) {
case "en" -> { case "en" -> {
@@ -381,6 +415,63 @@ public class OrderEmailListener {
}; };
} }
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()) {

View File

@@ -17,6 +17,20 @@ 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) {
@@ -58,4 +72,12 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(body, HttpStatus.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,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.MediaAsset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface MediaAssetRepository extends JpaRepository<MediaAsset, UUID> {
List<MediaAsset> findAllByOrderByCreatedAtDesc();
}

View File

@@ -0,0 +1,43 @@
package com.printcalculator.repository;
import com.printcalculator.entity.MediaUsage;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public interface MediaUsageRepository extends JpaRepository<MediaUsage, UUID> {
List<MediaUsage> findByMediaAsset_IdOrderBySortOrderAscCreatedAtAsc(UUID mediaAssetId);
List<MediaUsage> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
List<MediaUsage> findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType,
String usageKey);
List<MediaUsage> findByUsageTypeAndUsageKeyOrderBySortOrderAscCreatedAtAsc(String usageType,
String usageKey);
@Query("""
select usage from MediaUsage usage
where usage.usageType = :usageType
and usage.usageKey in :usageKeys
and usage.isActive = true
order by usage.usageKey asc, usage.sortOrder asc, usage.createdAt asc
""")
List<MediaUsage> findActiveByUsageTypeAndUsageKeys(@Param("usageType") String usageType,
@Param("usageKeys") Collection<String> usageKeys);
@Query("""
select usage from MediaUsage usage
where usage.usageType = :usageType
and usage.usageKey = :usageKey
and ((:ownerId is null and usage.ownerId is null) or usage.ownerId = :ownerId)
order by usage.sortOrder asc, usage.createdAt asc
""")
List<MediaUsage> findByUsageScope(@Param("usageType") String usageType,
@Param("usageKey") String usageKey,
@Param("ownerId") UUID ownerId);
}

View File

@@ -0,0 +1,14 @@
package com.printcalculator.repository;
import com.printcalculator.entity.MediaVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public interface MediaVariantRepository extends JpaRepository<MediaVariant, UUID> {
List<MediaVariant> findByMediaAsset_IdOrderByCreatedAtAsc(UUID mediaAssetId);
List<MediaVariant> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
}

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

@@ -9,4 +9,6 @@ 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); boolean existsByFilamentVariant_Id(Long filamentVariantId);
boolean existsByShopProduct_Id(UUID shopProductId);
boolean existsByShopProductVariant_Id(UUID shopProductVariantId);
} }

View File

@@ -1,12 +1,30 @@
package com.printcalculator.repository; package com.printcalculator.repository;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> { public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
@EntityGraph(attributePaths = {"shopProductVariant"})
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
UUID quoteSessionId,
String lineItemType,
UUID shopProductVariantId
);
boolean existsByFilamentVariant_Id(Long filamentVariantId); boolean existsByFilamentVariant_Id(Long filamentVariantId);
boolean existsByShopProduct_Id(UUID shopProductId);
boolean existsByShopProductVariant_Id(UUID shopProductVariantId);
} }

View File

@@ -4,8 +4,13 @@ import com.printcalculator.entity.QuoteSession;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; 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);
Optional<QuoteSession> findByIdAndSessionType(UUID id, String sessionType);
} }

View File

@@ -0,0 +1,24 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopCategoryRepository extends JpaRepository<ShopCategory, UUID> {
Optional<ShopCategory> findBySlug(String slug);
Optional<ShopCategory> findBySlugIgnoreCase(String slug);
Optional<ShopCategory> findBySlugAndIsActiveTrue(String slug);
boolean existsBySlugIgnoreCase(String slug);
boolean existsByParentCategory_Id(UUID parentCategoryId);
List<ShopCategory> findAllByOrderBySortOrderAscNameAsc();
List<ShopCategory> findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
}

View File

@@ -0,0 +1,17 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopProductModelAsset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopProductModelAssetRepository extends JpaRepository<ShopProductModelAsset, UUID> {
Optional<ShopProductModelAsset> findByProduct_Id(UUID productId);
List<ShopProductModelAsset> findByProduct_IdIn(Collection<UUID> productIds);
void deleteByProduct_Id(UUID productId);
}

View File

@@ -0,0 +1,28 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopProduct;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopProductRepository extends JpaRepository<ShopProduct, UUID> {
Optional<ShopProduct> findBySlug(String slug);
Optional<ShopProduct> findBySlugIgnoreCase(String slug);
Optional<ShopProduct> findBySlugAndIsActiveTrue(String slug);
boolean existsBySlugIgnoreCase(String slug);
List<ShopProduct> findAllByOrderBySortOrderAscNameAsc();
List<ShopProduct> findAllByOrderByIsFeaturedDescSortOrderAscNameAsc();
List<ShopProduct> findByCategory_IdOrderBySortOrderAscNameAsc(UUID categoryId);
List<ShopProduct> findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
boolean existsByCategory_Id(UUID categoryId);
}

View File

@@ -0,0 +1,25 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopProductVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopProductVariantRepository extends JpaRepository<ShopProductVariant, UUID> {
List<ShopProductVariant> findByProduct_IdOrderBySortOrderAscColorNameAsc(UUID productId);
List<ShopProductVariant> findByProduct_IdInOrderBySortOrderAscColorNameAsc(Collection<UUID> productIds);
List<ShopProductVariant> findByProduct_IdAndIsActiveTrueOrderBySortOrderAscColorNameAsc(UUID productId);
List<ShopProductVariant> findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(Collection<UUID> productIds);
Optional<ShopProductVariant> findFirstByProduct_IdAndIsDefaultTrue(UUID productId);
boolean existsBySkuIgnoreCase(String sku);
boolean existsBySkuIgnoreCaseAndIdNot(String sku, UUID variantId);
}

View File

@@ -0,0 +1,60 @@
package com.printcalculator.security;
import com.printcalculator.config.AllowedOriginService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Locale;
import java.util.Set;
@Component
public class AdminCsrfProtectionFilter extends OncePerRequestFilter {
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
private final AllowedOriginService allowedOriginService;
public AdminCsrfProtectionFilter(AllowedOriginService allowedOriginService) {
this.allowedOriginService = allowedOriginService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = resolvePath(request);
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase(Locale.ROOT);
return !path.startsWith("/api/admin/") || SAFE_METHODS.contains(method);
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String origin = request.getHeader(HttpHeaders.ORIGIN);
String referer = request.getHeader(HttpHeaders.REFERER);
if (allowedOriginService.isAllowed(origin) || allowedOriginService.isAllowed(referer)) {
filterChain.doFilter(request, response);
return;
}
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"CSRF_INVALID\"}");
}
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,126 @@
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;
import java.util.logging.Logger;
@Service
public class NozzleLayerHeightPolicyService {
private static final Logger logger = Logger.getLogger(NozzleLayerHeightPolicyService.class.getName());
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()) {
logger.warning("No active nozzle->layer rules found in DB (table nozzle_layer_height_option is empty)");
return Map.of();
}
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);
}
}
}
}

View File

@@ -7,8 +7,11 @@ import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.repository.PricingPolicyRepository;
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;
@@ -17,6 +20,7 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; 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;
@@ -24,6 +28,8 @@ import java.util.*;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -35,8 +41,7 @@ public class OrderService {
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final PaymentService paymentService; private final PaymentService paymentService;
private final QuoteCalculator quoteCalculator; private final QuoteSessionTotalsService quoteSessionTotalsService;
private final PricingPolicyRepository pricingRepo;
public OrderService(OrderRepository orderRepo, public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -48,8 +53,7 @@ public class OrderService {
QrBillService qrBillService, QrBillService qrBillService,
ApplicationEventPublisher eventPublisher, ApplicationEventPublisher eventPublisher,
PaymentService paymentService, PaymentService paymentService,
QuoteCalculator quoteCalculator, QuoteSessionTotalsService quoteSessionTotalsService) {
PricingPolicyRepository pricingRepo) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
@@ -60,8 +64,7 @@ public class OrderService {
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher; this.eventPublisher = eventPublisher;
this.paymentService = paymentService; this.paymentService = paymentService;
this.quoteCalculator = quoteCalculator; this.quoteSessionTotalsService = quoteSessionTotalsService;
this.pricingRepo = pricingRepo;
} }
@Transactional @Transactional
@@ -102,6 +105,7 @@ public class OrderService {
Order order = new Order(); Order order = new Order();
order.setSourceQuoteSession(session); order.setSourceQuoteSession(session);
order.setSourceType(resolveOrderSourceType(session));
order.setCustomer(customer); order.setCustomer(customer);
order.setCustomerEmail(request.getCustomer().getEmail()); order.setCustomerEmail(request.getCustomer().getEmail());
order.setCustomerPhone(request.getCustomer().getPhone()); order.setCustomerPhone(request.getCustomer().getPhone());
@@ -148,81 +152,73 @@ 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(totals.shippingCostChf());
// Calculate shipping cost based on dimensions before initial save order.setIsCadOrder(cadTotal.compareTo(BigDecimal.ZERO) > 0 || "CAD_ACTIVE".equals(session.getStatus()));
boolean exceedsBaseSize = false; order.setSourceRequestId(session.getSourceRequestId());
for (QuoteLineItem item : quoteItems) { order.setCadHours(session.getCadHours() != null ? session.getCadHours() : BigDecimal.ZERO);
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; order.setCadHourlyRateChf(session.getCadHourlyRateChf() != null ? session.getCadHourlyRateChf() : BigDecimal.ZERO);
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; order.setCadTotalChf(cadTotal);
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
BigDecimal[] dims = {x, y, z};
java.util.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 = quoteItems.stream()
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
.sum();
if (exceedsBaseSize) {
order.setShippingCostChf(totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00));
} else {
order.setShippingCostChf(BigDecimal.valueOf(2.00));
}
order = orderRepo.save(order); order = orderRepo.save(order);
List<OrderItem> savedItems = new ArrayList<>(); List<OrderItem> savedItems = new ArrayList<>();
// Calculate global machine cost upfront
BigDecimal totalSeconds = BigDecimal.ZERO;
for (QuoteLineItem qItem : quoteItems) {
if (qItem.getPrintTimeSeconds() != null) {
totalSeconds = totalSeconds.add(BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())));
}
}
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
for (QuoteLineItem qItem : quoteItems) { for (QuoteLineItem qItem : quoteItems) {
OrderItem oItem = new OrderItem(); OrderItem oItem = new OrderItem();
oItem.setOrder(order); oItem.setOrder(order);
oItem.setItemType(qItem.getLineItemType() != null ? qItem.getLineItemType() : "PRINT_FILE");
oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setQuantity(qItem.getQuantity()); oItem.setDisplayName(
qItem.getDisplayName() != null && !qItem.getDisplayName().isBlank()
? qItem.getDisplayName()
: qItem.getOriginalFilename()
);
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()); oItem.setFilamentVariant(qItem.getFilamentVariant());
if (qItem.getFilamentVariant() != null oItem.setShopProduct(qItem.getShopProduct());
oItem.setShopProductVariant(qItem.getShopProductVariant());
oItem.setShopProductSlug(qItem.getShopProductSlug());
oItem.setShopProductName(qItem.getShopProductName());
oItem.setShopVariantLabel(qItem.getShopVariantLabel());
oItem.setShopVariantColorName(qItem.getShopVariantColorName());
oItem.setShopVariantColorHex(qItem.getShopVariantColorHex());
if (qItem.getMaterialCode() != null && !qItem.getMaterialCode().isBlank()) {
oItem.setMaterialCode(qItem.getMaterialCode());
} else if (qItem.getFilamentVariant() != null
&& qItem.getFilamentVariant().getFilamentMaterialType() != null && qItem.getFilamentVariant().getFilamentMaterialType() != null
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
} else { } 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());
BigDecimal distributedUnitPrice = qItem.getUnitPriceChf(); BigDecimal distributedUnitPrice = qItem.getUnitPriceChf() != null ? qItem.getUnitPriceChf() : BigDecimal.ZERO;
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { if (totals.totalPrintSeconds().compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())); BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(quantity));
BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); BigDecimal share = itemSeconds.divide(totals.totalPrintSeconds(), 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = globalMachineCost.multiply(share); BigDecimal itemMachineCost = totals.globalMachineCostChf().multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(qItem.getQuantity()), 2, RoundingMode.HALF_UP); BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(quantity), 2, RoundingMode.HALF_UP);
distributedUnitPrice = distributedUnitPrice.add(unitMachineCost); distributedUnitPrice = distributedUnitPrice.add(unitMachineCost);
} }
oItem.setUnitPriceChf(distributedUnitPrice); oItem.setUnitPriceChf(distributedUnitPrice);
oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(qItem.getQuantity()))); 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.setBoundingBoxXMm(qItem.getBoundingBoxXMm());
@@ -240,18 +236,19 @@ public class OrderService {
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
oItem.setStoredRelativePath(relativePath); if (sourcePath == null || !Files.exists(sourcePath)) {
if (requiresStoredSourceFile(qItem)) {
if (qItem.getStoredPath() != null) { throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
}
} else {
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath);
try { try {
Path sourcePath = Paths.get(qItem.getStoredPath()); storageService.store(sourcePath, Paths.get(relativePath));
if (Files.exists(sourcePath)) { oItem.setFileSizeBytes(Files.size(sourcePath));
storageService.store(sourcePath, Paths.get(relativePath));
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);
} }
} }
@@ -260,9 +257,12 @@ public class OrderService {
subtotal = subtotal.add(oItem.getLineTotalChf()); subtotal = subtotal.add(oItem.getLineTotalChf());
} }
order.setSubtotalChf(subtotal); order.setSubtotalChf(subtotal.add(cadTotal));
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());
@@ -321,6 +321,36 @@ public class OrderService {
return "stl"; return "stl";
} }
private boolean requiresStoredSourceFile(QuoteLineItem qItem) {
return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase(
qItem.getLineItemType() != null ? qItem.getLineItemType() : ""
);
}
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 resolveOrderSourceType(QuoteSession session) {
if (session != null && "SHOP_CART".equalsIgnoreCase(session.getSessionType())) {
return "SHOP";
}
return "CALCULATOR";
}
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()) {

View File

@@ -7,10 +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.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;
@@ -20,16 +24,21 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.Set; 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 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;
@@ -68,6 +77,61 @@ public class ProfileManager {
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)) { if (!Files.isDirectory(resolvedProfilesRoot)) {
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot); logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
@@ -215,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

@@ -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,25 +2,54 @@ 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.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.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
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.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.Matcher;
import java.util.regex.Pattern; 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 {
@@ -31,16 +60,19 @@ public class SlicerService {
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\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 trustedSlicerPath; 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.trustedSlicerPath = normalizeExecutablePath(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;
@@ -87,7 +119,8 @@ public class SlicerService {
String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path"); String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path");
String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path"); String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path");
String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path"); String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path");
String inputStlPath = requireSafeArgument(inputStl.getAbsolutePath(), "input STL 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}) {
@@ -110,7 +143,7 @@ public class SlicerService {
command.add("0"); command.add("0");
command.add("--outputdir"); command.add("--outputdir");
command.add(outputDirPath); command.add(outputDirPath);
command.add(inputStlPath); 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));
@@ -124,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) {
@@ -136,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();
@@ -145,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();
@@ -274,6 +320,659 @@ public class SlicerService {
|| 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) { private String normalizeExecutablePath(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) { if (configuredPath == null || configuredPath.isBlank()) {
throw new IllegalArgumentException("slicer.path is required"); throw new IllegalArgumentException("slicer.path is required");

View File

@@ -0,0 +1,354 @@
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());
String fallbackColorLabel = firstNonBlank(
normalizeOptional(payload.getColorLabelIt()),
normalizeOptional(payload.getColorLabelEn()),
normalizeOptional(payload.getColorLabelDe()),
normalizeOptional(payload.getColorLabelFr()),
normalizedColorName
);
variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName);
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
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 String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
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.setColorLabelIt(variant.getColorLabelIt());
dto.setColorLabelEn(variant.getColorLabelEn());
dto.setColorLabelDe(variant.getColorLabelDe());
dto.setColorLabelFr(variant.getColorLabelFr());
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;
}
}

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