Workspace UI
This commit is contained in:
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
|
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||||
<component name="GradleSettings">
|
<component name="GradleSettings">
|
||||||
<option name="linkedExternalProjectsSettings">
|
<option name="linkedExternalProjectsSettings">
|
||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
|
|||||||
@@ -1,258 +1,216 @@
|
|||||||
VOYAGE – Nächste Projektschritte (Workspace + Login + Webblog) – UPDATE 2026-01-21
|
VOYAGE – Workspace Backend Dokumentation
|
||||||
===============================================================================
|
UPDATE 2026-01-21
|
||||||
|
=======================================
|
||||||
|
|
||||||
Ziel
|
ZIEL
|
||||||
----
|
----
|
||||||
Ein internes Workspace-System mit Login + Webinterface
|
Ein internes Workspace-System mit Login + Webinterface
|
||||||
und ein separates öffentliches Webblog für Content & Brand.
|
und ein separates öffentliches Webblog für Content & Brand.
|
||||||
|
|
||||||
|
|
||||||
Aktueller Stand
|
AKTUELLER STAND
|
||||||
---------------
|
---------------
|
||||||
Bereits implementiert:
|
Bereits implementiert:
|
||||||
|
|
||||||
|
DOMAIN
|
||||||
- Products
|
- Products
|
||||||
- SKUs (Varianten)
|
- SKUs (Varianten)
|
||||||
- InventoryMovements (Bestandsbuchungen)
|
- InventoryMovements (Bestandsbuchungen)
|
||||||
- Current Stock pro SKU (Aggregation)
|
- Current Stock pro SKU (Aggregation)
|
||||||
- Inventory Overview Endpoint (GET /api/inventory/overview)
|
- Inventory Overview Endpoint (GET /api/inventory/overview)
|
||||||
- Workspace UI (Next.js, minimal)
|
|
||||||
- Login-System (Spring Security, Form Login, Session)
|
WORKSPACE UI (Next.js, minimal)
|
||||||
- /api/** ist geschützt (nur eingeloggt)
|
- Inventory Übersicht
|
||||||
- Login per Browser (/login) und per curl (Cookie/JSESSIONID) funktioniert
|
- Tabelle mit allen SKUs + currentStock
|
||||||
- Logout invalidiert Session + löscht JSESSIONID
|
- Suche & Filter (Name, SKU, Kategorie)
|
||||||
|
- Inventory Movements (+ / -)
|
||||||
|
- PRODUCTION = positives Delta
|
||||||
|
- SALE = negatives Delta
|
||||||
|
|
||||||
|
SECURITY / LOGIN
|
||||||
|
- Spring Security
|
||||||
|
- Form Login (Session-basiert, kein JWT)
|
||||||
|
- /api/** ist geschützt
|
||||||
|
- Login per Browser (/login)
|
||||||
|
- Login per curl (Cookie/JSESSIONID)
|
||||||
|
- Logout invalidiert Session + löscht Cookie + Redirect ins UI
|
||||||
|
|
||||||
Damit ist das Domain-Fundament + Zugriffsschutz + UI-taugliche API abgeschlossen.
|
Damit ist das Domain-Fundament + Zugriffsschutz + UI-taugliche API abgeschlossen.
|
||||||
|
|
||||||
|
|
||||||
Wichtiger Hinweis zur Datenbank (AKTUELLER ZUSTAND)
|
DATENBANK (AKTUELL)
|
||||||
---------------------------------------------------
|
-------------------
|
||||||
Die Anwendung verwendet NICHT MEHR eine In-Memory Datenbank.
|
Die Anwendung verwendet KEINE In-Memory DB mehr.
|
||||||
|
|
||||||
Aktueller Stand:
|
Aktueller Stand:
|
||||||
- H2 File-basierte Datenbank (persistent)
|
- H2 File-basierte Datenbank (persistent)
|
||||||
- JDBC URL: jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE
|
- JDBC URL:
|
||||||
|
jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE
|
||||||
|
|
||||||
|
Eigenschaften:
|
||||||
- Daten liegen auf der Festplatte (data/voyage-db.mv.db)
|
- Daten liegen auf der Festplatte (data/voyage-db.mv.db)
|
||||||
- KEIN Datenverlust bei App-Restart
|
- KEIN Datenverlust bei App-Restart
|
||||||
|
- IDs bleiben stabil
|
||||||
Wichtig:
|
|
||||||
- Die DB überlebt Neustarts
|
|
||||||
- IDs (productId, skuId, userId) bleiben stabil
|
|
||||||
- AUTO_SERVER erlaubt parallelen Zugriff (App + H2 Console)
|
- AUTO_SERVER erlaubt parallelen Zugriff (App + H2 Console)
|
||||||
|
|
||||||
SEHR WICHTIG:
|
WICHTIG – H2 PASSWORT
|
||||||
- Das H2 Passwort wird beim ERSTEN Start festgelegt
|
--------------------
|
||||||
- Danach MUSS es gleich bleiben
|
- Das H2 Passwort wird beim ERSTEN Start der File-DB festgelegt (falls gesetzt).
|
||||||
- Falsches Passwort → App startet NICHT (Error 28000)
|
- Danach MUSS es identisch bleiben.
|
||||||
|
- Falsches Passwort → App startet NICHT (SQLState 28000).
|
||||||
|
|
||||||
Empfohlene Config:
|
Empfohlene Dev-Konfiguration:
|
||||||
- Username: sa
|
- spring.datasource.username=sa
|
||||||
- Password: leer (oder exakt das beim ersten Start gesetzte)
|
- spring.datasource.password= (leer)
|
||||||
|
|
||||||
H2 Console:
|
H2 CONSOLE
|
||||||
- URL: http://localhost:8080/h2-console
|
----------
|
||||||
|
URL:
|
||||||
|
- http://localhost:8080/h2-console
|
||||||
|
|
||||||
|
Login:
|
||||||
- JDBC URL: jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE
|
- JDBC URL: jdbc:h2:file:./data/voyage-db;AUTO_SERVER=TRUE
|
||||||
- User: sa
|
- User: sa
|
||||||
- Password: (leer oder wie initial gesetzt)
|
- Password: leer (oder exakt das initial gesetzte)
|
||||||
|
|
||||||
H2 Login ≠ Workspace Login (komplett getrennt)
|
WICHTIG:
|
||||||
|
- H2 Login ≠ Workspace Login
|
||||||
|
- H2 Login = DB Zugriff
|
||||||
|
- Workspace Login = App Zugriff
|
||||||
|
|
||||||
|
|
||||||
Grundprinzip
|
GRUNDPRINZIP
|
||||||
------------
|
------------
|
||||||
Workspace (intern) und Public Website (Blog) sind zwei verschiedene Welten
|
Workspace (intern) und Public Website (Blog) sind strikt getrennt.
|
||||||
und sollten technisch getrennt sein.
|
|
||||||
|
|
||||||
- Workspace = interne Realität (Inventory, Orders, Drops)
|
- Workspace = interne Realität
|
||||||
- Public Web = Content, Blog, Brand (keine sensiblen Daten)
|
(Inventory, Orders, Drops, Users)
|
||||||
|
- Public Web = Content, Blog, Brand
|
||||||
|
(keine sensiblen Daten)
|
||||||
|
|
||||||
|
|
||||||
Empfohlene Architektur
|
ARCHITEKTUR
|
||||||
----------------------
|
-----------
|
||||||
|
|
||||||
1) workspace-api (Spring Boot)
|
1) workspace-api (Spring Boot)
|
||||||
- Products / SKUs / Inventory
|
- Products / SKUs / Inventory
|
||||||
- Inventory Overview (aggregiert)
|
- Inventory Overview (aggregiert)
|
||||||
- Auth & Rollen
|
- Auth & Rollen
|
||||||
- persistente DB
|
- persistente DB
|
||||||
- später Orders / Drops
|
- Orders / Drops
|
||||||
|
|
||||||
2) workspace-ui (Webinterface)
|
2) workspace-ui (Next.js)
|
||||||
- internes Dashboard (Next.js)
|
- internes Dashboard
|
||||||
- spricht mit workspace-api
|
- Session-Cookies
|
||||||
- nutzt Session-Cookies (Browser)
|
- keine öffentliche Erreichbarkeit
|
||||||
|
|
||||||
3) public-web (Blog / Brand)
|
3) public-web
|
||||||
- öffentlich zugänglich
|
- öffentlich
|
||||||
- kein Login nötig
|
- kein Login
|
||||||
- getrennt vom Workspace
|
- Blog / Brand
|
||||||
|
|
||||||
|
|
||||||
Repo-Struktur
|
REPO-STRUKTUR (ZIEL)
|
||||||
-------------
|
-------------------
|
||||||
voyage/
|
voyage/
|
||||||
├─ apps/
|
├── apps/
|
||||||
│ ├─ workspace-api/ (Backend)
|
│ ├── workspace-api/
|
||||||
│ ├─ workspace-ui/ (internes UI)
|
│ ├── workspace-ui/
|
||||||
│ └─ public-web/ (Blog / Brand)
|
│ └── public-web/
|
||||||
└─ packages/
|
├── packages/
|
||||||
└─ shared/ (optional: DTOs / Types)
|
│ ├── db/ (Schema & Migrations)
|
||||||
|
│ └── shared/ (DTOs / Enums)
|
||||||
|
└── docs/
|
||||||
|
├── workspace.txt
|
||||||
|
├── inventory.txt
|
||||||
|
├── orders.txt
|
||||||
|
└── drops.txt
|
||||||
|
|
||||||
|
|
||||||
Empfohlene Reihenfolge (sehr wichtig)
|
LOGIN & USER
|
||||||
-------------------------------------
|
------------
|
||||||
|
|
||||||
SCHRITT 1: Login-System für Workspace
|
|
||||||
------------------------------------
|
|
||||||
Status: ERLEDIGT ✅
|
|
||||||
|
|
||||||
Ziel:
|
|
||||||
- Zugriff auf /api/** nur für eingeloggte Nutzer
|
|
||||||
- Workspace nicht öffentlich zugänglich
|
|
||||||
|
|
||||||
Implementiert:
|
|
||||||
- Spring Security
|
|
||||||
- Form Login
|
|
||||||
- Session-basiert (kein JWT)
|
|
||||||
- Logout mit Session-Invalidierung
|
|
||||||
- Redirect zurück ins Workspace UI
|
|
||||||
|
|
||||||
User-Modell:
|
User-Modell:
|
||||||
- UserAccount in DB
|
- UserAccount (DB)
|
||||||
- Rollen: ADMIN / WORKER
|
- role: ADMIN | WORKER
|
||||||
- active Flag (true / false)
|
- active: true | false
|
||||||
|
|
||||||
Inactive User:
|
ACTIVE = false:
|
||||||
- kann sich NICHT einloggen
|
- Login gesperrt
|
||||||
- bleibt für Historie erhalten
|
- Historie bleibt erhalten
|
||||||
|
|
||||||
|
USER ROLLEN
|
||||||
|
-----------
|
||||||
|
ADMIN:
|
||||||
|
- User anlegen / deaktivieren
|
||||||
|
- Inventory verwalten
|
||||||
|
- Orders verwalten
|
||||||
|
|
||||||
|
WORKER:
|
||||||
|
- Inventory sehen
|
||||||
|
- Inventory Movements buchen
|
||||||
|
|
||||||
|
|
||||||
SCHRITT 2: Workspace-taugliche Endpoints
|
ACTIVE / INACTIVE STATT LÖSCHEN
|
||||||
----------------------------------------
|
-------------------------------
|
||||||
Status: ERLEDIGT ✅
|
GRUNDREGEL:
|
||||||
|
|
||||||
Implementiert:
|
|
||||||
- GET /api/inventory/overview
|
|
||||||
|
|
||||||
Liefert pro SKU:
|
|
||||||
- Produktname
|
|
||||||
- Kategorie
|
|
||||||
- Größe / Farbe
|
|
||||||
- Preis
|
|
||||||
- Aktueller Bestand (SUM der InventoryMovements)
|
|
||||||
|
|
||||||
Bedeutung:
|
|
||||||
- Ein Request = komplette Inventory Tabelle
|
|
||||||
- Keine Aggregation im Frontend nötig
|
|
||||||
- Grundlage für UI, Low-Stock, Drops
|
|
||||||
|
|
||||||
|
|
||||||
SCHRITT 3: Workspace UI (minimal starten)
|
|
||||||
-----------------------------------------
|
|
||||||
Status: ERLEDIGT (MINIMAL) ✅
|
|
||||||
|
|
||||||
Implementiert:
|
|
||||||
- Inventory Übersicht
|
|
||||||
- Tabelle mit allen SKUs + currentStock
|
|
||||||
- Suche & Filter (Name, SKU, Kategorie)
|
|
||||||
- Inventory Movements (+ / -)
|
|
||||||
- SALE erzeugt negatives Delta
|
|
||||||
- PRODUCTION positives Delta
|
|
||||||
|
|
||||||
Ergebnis:
|
|
||||||
Ein echtes internes Tool, sofort nutzbar.
|
|
||||||
|
|
||||||
|
|
||||||
SCHRITT 4: Active / Inactive statt Löschen
|
|
||||||
------------------------------------------
|
|
||||||
Status: IMPLEMENTIERT / EMPFOHLEN ✅
|
|
||||||
|
|
||||||
Grundregel:
|
|
||||||
- NIEMALS löschen, wenn Historie existiert
|
- NIEMALS löschen, wenn Historie existiert
|
||||||
|
|
||||||
Umsetzung:
|
Umsetzung:
|
||||||
- Products und SKUs haben active Flag
|
- Products haben active Flag
|
||||||
- active = false:
|
- SKUs haben active Flag
|
||||||
- wird im Workspace ausgeblendet
|
|
||||||
- bleibt in DB für Historie
|
active = false:
|
||||||
|
- im Workspace ausgeblendet
|
||||||
|
- bleibt in DB für Historie
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
- Caps werden nicht mehr verkauft
|
- Caps werden nicht mehr verkauft
|
||||||
- Product/SKUs auf inactive setzen
|
- Product + SKUs inactive setzen
|
||||||
- Inventory bleibt korrekt nachvollziehbar
|
- Inventory-Historie bleibt korrekt
|
||||||
|
|
||||||
|
|
||||||
SCHRITT 5: Worker-User
|
INVENTORY
|
||||||
---------------------
|
|
||||||
Status: IMPLEMENTIERT (Basis) ✅
|
|
||||||
|
|
||||||
User-Typen:
|
|
||||||
- ADMIN: volle Rechte, Userverwaltung
|
|
||||||
- WORKER: Inventory sehen & buchen
|
|
||||||
|
|
||||||
Worker anlegen:
|
|
||||||
- per Admin Endpoint oder DB
|
|
||||||
- Passwort wird BCrypt-gehasht
|
|
||||||
- active = true notwendig
|
|
||||||
|
|
||||||
Worker deaktivieren:
|
|
||||||
- active = false
|
|
||||||
- Login gesperrt
|
|
||||||
- Daten bleiben erhalten
|
|
||||||
|
|
||||||
|
|
||||||
SCHRITT 6: Public Webblog (parallel, aber getrennt)
|
|
||||||
---------------------------------------------------
|
|
||||||
Status: PARALLEL MÖGLICH ✅
|
|
||||||
|
|
||||||
Empfohlener Start:
|
|
||||||
- Eigenes Frontend (public-web)
|
|
||||||
- Content als Markdown im Repo
|
|
||||||
- Git als CMS
|
|
||||||
|
|
||||||
Vorteile:
|
|
||||||
- Kein Admin-UI nötig
|
|
||||||
- Keine Auth
|
|
||||||
- Sehr schneller Workflow
|
|
||||||
|
|
||||||
|
|
||||||
Warum diese Reihenfolge sinnvoll ist
|
|
||||||
------------------------------------
|
|
||||||
- Workspace erzeugt operativen Wert
|
|
||||||
- Login schützt eure Arbeit
|
|
||||||
- Inventory Overview macht das System sofort nutzbar
|
|
||||||
- UI basiert auf stabiler API
|
|
||||||
- Blog kann unabhängig wachsen
|
|
||||||
|
|
||||||
|
|
||||||
Langfristige Erweiterungen
|
|
||||||
--------------------------
|
|
||||||
- Orders → automatische SALE Movements
|
|
||||||
- Drops → Production → initialer Bestand
|
|
||||||
- Low-Stock Alerts
|
|
||||||
- Rollenfeingranularität
|
|
||||||
- Flyway Migrations
|
|
||||||
- Postgres als DB
|
|
||||||
- Public Shop (optional)
|
|
||||||
|
|
||||||
|
|
||||||
Merksätze
|
|
||||||
---------
|
---------
|
||||||
- Workspace zuerst, Public später
|
Inventory wird NIE direkt geändert.
|
||||||
- Login früh einbauen (erledigt)
|
|
||||||
- Inventory niemals direkt ändern
|
Regel:
|
||||||
- SKUs sind die operative Wahrheit
|
- Bestand = SUM aller InventoryMovements
|
||||||
- Löschen ist fast immer falsch → inactive ist richtig
|
|
||||||
- H2 Passwort niemals ändern ohne DB Reset
|
MovementReason:
|
||||||
|
- PRODUCTION (+)
|
||||||
|
- SALE (-)
|
||||||
|
- CORRECTION (+ / -)
|
||||||
|
|
||||||
|
Vorteil:
|
||||||
|
- Vollständige Historie
|
||||||
|
- Audit-fähig
|
||||||
|
- Fehler rückgängig machbar
|
||||||
|
|
||||||
|
|
||||||
Nächster konkreter Schritt
|
ORDERS (BACKEND)
|
||||||
--------------------------
|
----------------
|
||||||
- Admin UI für User + Active/Inactive
|
Order Flow:
|
||||||
- Deactivate-Flow für Products & SKUs
|
1) Stock-Check pro SKU
|
||||||
- Orders mit automatischen InventoryMovements
|
2) Order + OrderItems speichern
|
||||||
|
3) SALE InventoryMovements erzeugen (negatives Delta)
|
||||||
|
4) Alles in einer Transaction
|
||||||
|
|
||||||
Curls
|
WICHTIG:
|
||||||
-----------------
|
- Kein negativer Bestand erlaubt
|
||||||
|
- Inaktive SKUs dürfen nicht verkauft werden
|
||||||
|
|
||||||
|
TECHNISCHER HINWEIS:
|
||||||
|
- Entities dürfen nicht direkt als JSON zurückgegeben werden
|
||||||
|
- DTOs oder @JsonIgnore nutzen
|
||||||
|
- Sonst: Endlos-JSON (Order <-> Items)
|
||||||
|
|
||||||
|
|
||||||
|
CURL – ADMIN USER ANLEGEN
|
||||||
|
------------------------
|
||||||
|
Voraussetzung:
|
||||||
|
- Als ADMIN eingeloggt
|
||||||
|
- cookies.txt enthält Session
|
||||||
|
|
||||||
|
ADMIN:
|
||||||
curl -X POST http://localhost:8080/api/admin/users \
|
curl -X POST http://localhost:8080/api/admin/users \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-b cookies.txt \
|
-b cookies.txt \
|
||||||
@@ -263,12 +221,49 @@ curl -X POST http://localhost:8080/api/admin/users \
|
|||||||
"active": true
|
"active": true
|
||||||
}'
|
}'
|
||||||
|
|
||||||
curl -X POST http://localhost:8080/api/admin/users \
|
WORKER:
|
||||||
-H "Content-Type: application/json" \
|
curl -X POST http://localhost:8080/api/admin/users \
|
||||||
-b cookies.txt \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-b cookies.txt \
|
||||||
"username": "worker1",
|
-d '{
|
||||||
"password": "worker123!",
|
"username": "worker1",
|
||||||
"role": "WORKER",
|
"password": "worker123!",
|
||||||
"active": true
|
"role": "WORKER",
|
||||||
}'
|
"active": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
|
||||||
|
NÄCHSTE SCHRITTE
|
||||||
|
----------------
|
||||||
|
1) docs/
|
||||||
|
- orders.txt finalisieren (Flows + Beispiele)
|
||||||
|
- drops.txt vorbereiten
|
||||||
|
|
||||||
|
2) packages/shared/
|
||||||
|
- Enums (Role, Category, MovementReason)
|
||||||
|
- DTOs statt Entities
|
||||||
|
|
||||||
|
3) packages/db/
|
||||||
|
- Flyway vorbereiten
|
||||||
|
- Migrationen versionieren
|
||||||
|
|
||||||
|
4) workspace-api/
|
||||||
|
- Orders finalisieren
|
||||||
|
- Cancel / Refund Flow (Reverse Movements)
|
||||||
|
- Active/Inactive Endpoints
|
||||||
|
- Admin User Listing
|
||||||
|
|
||||||
|
5) workspace-ui/
|
||||||
|
- Admin Page (User Management)
|
||||||
|
- Active/Inactive Toggle
|
||||||
|
- bessere UX für Movements
|
||||||
|
|
||||||
|
|
||||||
|
MERKSÄTZE
|
||||||
|
---------
|
||||||
|
- Workspace zuerst, Public später
|
||||||
|
- Inventory niemals direkt ändern
|
||||||
|
- SKUs sind die operative Wahrheit
|
||||||
|
- Löschen ist fast immer falsch
|
||||||
|
- inactive ist richtig
|
||||||
|
- H2 Passwort niemals ändern ohne DB Reset
|
||||||
@@ -20,8 +20,9 @@ public class InventoryMovement {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int delta;
|
private int delta;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String reason; // PRODUCTION, SALE, RETURN, ADJUSTMENT
|
private MovementReason reason;
|
||||||
|
|
||||||
private String reference;
|
private String reference;
|
||||||
|
|
||||||
@@ -33,12 +34,12 @@ public class InventoryMovement {
|
|||||||
public Long getId() { return id; }
|
public Long getId() { return id; }
|
||||||
public Sku getSku() { return sku; }
|
public Sku getSku() { return sku; }
|
||||||
public int getDelta() { return delta; }
|
public int getDelta() { return delta; }
|
||||||
public String getReason() { return reason; }
|
public MovementReason getReason() { return reason; }
|
||||||
public String getReference() { return reference; }
|
public String getReference() { return reference; }
|
||||||
public Instant getCreatedAt() { return createdAt; }
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
|
||||||
public void setSku(Sku sku) { this.sku = sku; }
|
public void setSku(Sku sku) { this.sku = sku; }
|
||||||
public void setDelta(int delta) { this.delta = delta; }
|
public void setDelta(int delta) { this.delta = delta; }
|
||||||
public void setReason(String reason) { this.reason = reason; }
|
public void setReason(MovementReason reason) { this.reason = reason; }
|
||||||
public void setReference(String reference) { this.reference = reference; }
|
public void setReference(String reference) { this.reference = reference; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ package com.voyage.workspace.inventory;
|
|||||||
public record InventoryMovementCreateRequest(
|
public record InventoryMovementCreateRequest(
|
||||||
Long skuId,
|
Long skuId,
|
||||||
int delta,
|
int delta,
|
||||||
String reason,
|
MovementReason reason,
|
||||||
String reference
|
String reference
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.voyage.workspace.inventory;
|
||||||
|
|
||||||
|
public enum MovementReason {
|
||||||
|
PRODUCTION,
|
||||||
|
SALE,
|
||||||
|
RETURN,
|
||||||
|
ADJUSTMENT
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "orders")
|
||||||
|
public class Order {
|
||||||
|
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String customerRef;
|
||||||
|
|
||||||
|
private Instant createdAt = Instant.now();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<OrderItem> items = new ArrayList<>();
|
||||||
|
|
||||||
|
public void addItem(OrderItem item) {
|
||||||
|
items.add(item);
|
||||||
|
item.setOrder(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getters/setters
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getCustomerRef() { return customerRef; }
|
||||||
|
public void setCustomerRef(String customerRef) { this.customerRef = customerRef; }
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public List<OrderItem> getItems() { return items; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/orders")
|
||||||
|
public class OrderController {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepo;
|
||||||
|
private final OrderService orderService;
|
||||||
|
|
||||||
|
public OrderController(OrderRepository orderRepo, OrderService orderService) {
|
||||||
|
this.orderRepo = orderRepo;
|
||||||
|
this.orderService = orderService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody OrderCreateRequest req) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(orderService.create(req));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<Order> list() {
|
||||||
|
return orderRepo.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<?> get(@PathVariable Long id) {
|
||||||
|
return orderRepo.findById(id)
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record OrderCreateRequest(
|
||||||
|
String customerRef,
|
||||||
|
List<OrderItemRequest> items
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.voyage.workspace.products.Sku;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "order_items")
|
||||||
|
public class OrderItem {
|
||||||
|
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.EAGER)
|
||||||
|
private Sku sku;
|
||||||
|
|
||||||
|
private int qty;
|
||||||
|
|
||||||
|
@Column(precision = 10, scale = 2)
|
||||||
|
private BigDecimal unitPrice;
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "order_id")
|
||||||
|
@JsonIgnore
|
||||||
|
private Order order;
|
||||||
|
|
||||||
|
// getters/setters
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public Order getOrder() { return order; }
|
||||||
|
public void setOrder(Order order) { this.order = order; }
|
||||||
|
public Sku getSku() { return sku; }
|
||||||
|
public void setSku(Sku sku) { this.sku = sku; }
|
||||||
|
public int getQty() { return qty; }
|
||||||
|
public void setQty(int qty) { this.qty = qty; }
|
||||||
|
public BigDecimal getUnitPrice() { return unitPrice; }
|
||||||
|
public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record OrderItemRequest(
|
||||||
|
Long skuId,
|
||||||
|
int qty,
|
||||||
|
BigDecimal unitPrice
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface OrderRepository extends JpaRepository<Order, Long> {}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.voyage.workspace.orders;
|
||||||
|
|
||||||
|
import com.voyage.workspace.inventory.InventoryMovement;
|
||||||
|
import com.voyage.workspace.inventory.InventoryMovementRepository;
|
||||||
|
import com.voyage.workspace.inventory.MovementReason;
|
||||||
|
import com.voyage.workspace.products.SkuRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepo;
|
||||||
|
private final SkuRepository skuRepo;
|
||||||
|
private final InventoryMovementRepository movementRepo;
|
||||||
|
|
||||||
|
public OrderService(OrderRepository orderRepo, SkuRepository skuRepo, InventoryMovementRepository movementRepo) {
|
||||||
|
this.orderRepo = orderRepo;
|
||||||
|
this.skuRepo = skuRepo;
|
||||||
|
this.movementRepo = movementRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Order create(OrderCreateRequest req) {
|
||||||
|
if (req.items() == null || req.items().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Order must have at least 1 item");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Stock check first (fail fast)
|
||||||
|
for (var item : req.items()) {
|
||||||
|
var sku = skuRepo.findById(item.skuId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Unknown skuId: " + item.skuId()));
|
||||||
|
|
||||||
|
if (!sku.isActive()) {
|
||||||
|
throw new IllegalArgumentException("SKU is inactive: " + sku.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
int current = movementRepo.currentStock(sku.getId());
|
||||||
|
if (item.qty() <= 0) throw new IllegalArgumentException("qty must be > 0");
|
||||||
|
|
||||||
|
if (current < item.qty()) {
|
||||||
|
throw new IllegalArgumentException("Insufficient stock for skuId=" + sku.getId()
|
||||||
|
+ " (current=" + current + ", requested=" + item.qty() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Create order + items
|
||||||
|
Order order = new Order();
|
||||||
|
order.setCustomerRef(req.customerRef());
|
||||||
|
|
||||||
|
for (var item : req.items()) {
|
||||||
|
var sku = skuRepo.findById(item.skuId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Unknown skuId: " + item.skuId()));
|
||||||
|
|
||||||
|
OrderItem oi = new OrderItem();
|
||||||
|
oi.setSku(sku);
|
||||||
|
oi.setQty(item.qty());
|
||||||
|
oi.setUnitPrice(item.unitPrice());
|
||||||
|
order.addItem(oi);
|
||||||
|
}
|
||||||
|
|
||||||
|
Order saved = orderRepo.save(order);
|
||||||
|
|
||||||
|
// 3) Create SALE movements (negative)
|
||||||
|
for (var oi : saved.getItems()) {
|
||||||
|
InventoryMovement m = new InventoryMovement();
|
||||||
|
m.setSku(oi.getSku());
|
||||||
|
m.setDelta(-oi.getQty());
|
||||||
|
m.setReason(MovementReason.SALE);
|
||||||
|
m.setReference("ORDER:" + saved.getId());
|
||||||
|
movementRepo.save(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
# https://curl.se/docs/http-cookies.html
|
# https://curl.se/docs/http-cookies.html
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 0 JSESSIONID 4284693C7D8D78CD97E889740B09B5CD
|
#HttpOnly_localhost FALSE / FALSE 0 JSESSIONID 16F8C7E74E2089A6245502447C4589BF
|
||||||
|
|||||||
Reference in New Issue
Block a user