sync: migrate erp-mvp to Gitea (2026-08-10)
This commit is contained in:
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
POSTGRES_DB=erp_mvp
|
||||
POSTGRES_USER=erp_user
|
||||
POSTGRES_PASSWORD=erp_password
|
||||
JWT_SECRET=dev-secret-change-me-dev-secret-change-me
|
||||
ADMIN_EMAIL=admin@erp.local
|
||||
ADMIN_PASSWORD=admin12345
|
||||
DEMO_DATA_ENABLED=true
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
# Backend
|
||||
backend/build/
|
||||
backend/.gradle/
|
||||
backend/out/
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
@@ -0,0 +1,923 @@
|
||||
# ERP MVP
|
||||
|
||||
Интегрированный monorepo ERP MVP v1 для демонстрации базового операционного цикла: справочники, закупки, продажи, складские движения, PDF-заглушки документов, dashboard и demo data.
|
||||
|
||||
## Структура
|
||||
|
||||
```text
|
||||
erp-mvp/
|
||||
backend/
|
||||
frontend/
|
||||
.env.example
|
||||
docker-compose.yml
|
||||
README.md
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
Опционально можно скопировать пример переменных окружения:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Для production обязательно замените `JWT_SECRET`, пароли PostgreSQL и dev credentials.
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
После запуска:
|
||||
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend health: http://localhost:8080/api/health
|
||||
- PostgreSQL: localhost:5432
|
||||
|
||||
## Backend
|
||||
|
||||
Backend использует переменные окружения для подключения к PostgreSQL:
|
||||
|
||||
- `POSTGRES_DB`
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
- `SPRING_DATASOURCE_URL`
|
||||
- `SPRING_DATASOURCE_USERNAME`
|
||||
- `SPRING_DATASOURCE_PASSWORD`
|
||||
- `CORS_ALLOWED_ORIGINS`
|
||||
- `JWT_SECRET`
|
||||
- `JWT_EXPIRATION_MINUTES`
|
||||
- `ADMIN_EMAIL`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `DEMO_DATA_ENABLED`
|
||||
|
||||
По умолчанию frontend разрешен с `http://localhost:5173`.
|
||||
|
||||
## Backend Foundation
|
||||
|
||||
Backend содержит базовую инфраструктуру для дальнейшей разработки ERP-модулей:
|
||||
|
||||
- расширяемую package structure с `common`, `config`, `health` и заготовками `modules`;
|
||||
- единый API response envelope;
|
||||
- global exception handler;
|
||||
- базовые custom exceptions;
|
||||
- pagination DTO;
|
||||
- JPA auditing foundation через `BaseEntity`;
|
||||
- OpenAPI / Swagger UI;
|
||||
- Flyway migrations для системной инициализации.
|
||||
|
||||
## Auth + Roles
|
||||
|
||||
Backend содержит stateless JWT authentication foundation:
|
||||
|
||||
- таблица `app_users`;
|
||||
- роли `ADMIN`, `MANAGER`, `WAREHOUSE`, `FINANCE`;
|
||||
- BCrypt password hashing;
|
||||
- seed admin user через Spring Boot initializer;
|
||||
- JWT Bearer auth для защищенных `/api/**` endpoint-ов;
|
||||
- Swagger Bearer JWT authorization scheme.
|
||||
|
||||
Dev credentials по умолчанию:
|
||||
|
||||
- Email: `admin@erp.local`
|
||||
- Password: `admin12345`
|
||||
|
||||
Значения можно переопределить через переменные окружения:
|
||||
|
||||
- `ADMIN_EMAIL`
|
||||
- `ADMIN_PASSWORD`
|
||||
- `JWT_SECRET`
|
||||
- `JWT_EXPIRATION_MINUTES`
|
||||
|
||||
## API Response Format
|
||||
|
||||
Успешный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {},
|
||||
"error": null,
|
||||
"timestamp": "2026-05-14T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Ответ с ошибкой:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Validation failed",
|
||||
"details": []
|
||||
},
|
||||
"timestamp": "2026-05-14T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## API URLs
|
||||
|
||||
- Swagger UI: http://localhost:8080/swagger-ui/index.html
|
||||
- Health check: http://localhost:8080/api/health
|
||||
- OpenAPI JSON: http://localhost:8080/v3/api-docs
|
||||
- Frontend login: http://localhost:5173/login
|
||||
|
||||
## Auth Endpoints
|
||||
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/me`
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/auth/protected-test`
|
||||
|
||||
Login:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}'
|
||||
```
|
||||
|
||||
Protected endpoint with JWT:
|
||||
|
||||
```bash
|
||||
TOKEN="<paste-access-token>"
|
||||
|
||||
curl "http://localhost:8080/api/auth/protected-test" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Catalog Module
|
||||
|
||||
Catalog module содержит базовые ERP-справочники без закупок, заказов, остатков и документов:
|
||||
|
||||
- Products / SKU
|
||||
- Suppliers
|
||||
- B2B Customers
|
||||
- Warehouses
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
- `GET /api/catalog/products`
|
||||
- `GET /api/catalog/products/{id}`
|
||||
- `POST /api/catalog/products`
|
||||
- `PUT /api/catalog/products/{id}`
|
||||
- `DELETE /api/catalog/products/{id}`
|
||||
- `GET /api/catalog/suppliers`
|
||||
- `GET /api/catalog/suppliers/{id}`
|
||||
- `POST /api/catalog/suppliers`
|
||||
- `PUT /api/catalog/suppliers/{id}`
|
||||
- `DELETE /api/catalog/suppliers/{id}`
|
||||
- `GET /api/catalog/customers`
|
||||
- `GET /api/catalog/customers/{id}`
|
||||
- `POST /api/catalog/customers`
|
||||
- `PUT /api/catalog/customers/{id}`
|
||||
- `DELETE /api/catalog/customers/{id}`
|
||||
- `GET /api/catalog/warehouses`
|
||||
- `GET /api/catalog/warehouses/{id}`
|
||||
- `POST /api/catalog/warehouses`
|
||||
- `PUT /api/catalog/warehouses/{id}`
|
||||
- `DELETE /api/catalog/warehouses/{id}`
|
||||
|
||||
List endpoints support:
|
||||
|
||||
```text
|
||||
?page=0&size=20&search=&active=true
|
||||
```
|
||||
|
||||
Role access:
|
||||
|
||||
- `GET`: any authenticated user.
|
||||
- `POST`, `PUT`, `DELETE`: `ADMIN` and `MANAGER`.
|
||||
- `DELETE` performs soft delete by setting `active=false`.
|
||||
|
||||
Frontend URLs:
|
||||
|
||||
- Products: http://localhost:5173/catalog/products
|
||||
- Suppliers: http://localhost:5173/catalog/suppliers
|
||||
- Customers: http://localhost:5173/catalog/customers
|
||||
- Warehouses: http://localhost:5173/catalog/warehouses
|
||||
|
||||
Create product example:
|
||||
|
||||
```bash
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}')
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.accessToken')
|
||||
|
||||
curl -X POST "http://localhost:8080/api/catalog/products" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"sku": "SKU-001",
|
||||
"name": "Demo product",
|
||||
"category": "General",
|
||||
"unit": "pcs",
|
||||
"barcode": "1234567890",
|
||||
"description": "Demo catalog item"
|
||||
}'
|
||||
```
|
||||
|
||||
## Procurement Module
|
||||
|
||||
Procurement module содержит простой процесс закупок без продаж, документов и оплат:
|
||||
|
||||
- purchase order header;
|
||||
- supplier from Catalog;
|
||||
- optional receiving warehouse from Catalog;
|
||||
- product items from Catalog;
|
||||
- quantity, unit price, line total;
|
||||
- status flow and status history.
|
||||
- stock receipt when `ORDERED` moves to `RECEIVED`.
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
- `GET /api/procurement/purchase-orders`
|
||||
- `GET /api/procurement/purchase-orders/{id}`
|
||||
- `POST /api/procurement/purchase-orders`
|
||||
- `PUT /api/procurement/purchase-orders/{id}`
|
||||
- `PATCH /api/procurement/purchase-orders/{id}/status`
|
||||
- `GET /api/procurement/purchase-orders/{id}/status-history`
|
||||
- `DELETE /api/procurement/purchase-orders/{id}`
|
||||
|
||||
List endpoint supports:
|
||||
|
||||
```text
|
||||
?page=0&size=20&search=&status=DRAFT&supplierId=&fromDate=&toDate=
|
||||
```
|
||||
|
||||
Statuses:
|
||||
|
||||
- `DRAFT`
|
||||
- `APPROVED`
|
||||
- `ORDERED`
|
||||
- `RECEIVED`
|
||||
- `CANCELLED`
|
||||
|
||||
Allowed transitions:
|
||||
|
||||
- `DRAFT` to `APPROVED`
|
||||
- `APPROVED` to `ORDERED`
|
||||
- `ORDERED` to `RECEIVED`
|
||||
- `DRAFT` to `CANCELLED`
|
||||
- `APPROVED` to `CANCELLED`
|
||||
- `ORDERED` to `CANCELLED`
|
||||
|
||||
Role access:
|
||||
|
||||
- Read: `ADMIN`, `MANAGER`, `WAREHOUSE`, `FINANCE`.
|
||||
- Create/update/cancel: `ADMIN`, `MANAGER`.
|
||||
- Mark received: `ADMIN`, `MANAGER`, `WAREHOUSE`.
|
||||
- `FINANCE` can only read.
|
||||
|
||||
Frontend URLs:
|
||||
|
||||
- Purchase orders: http://localhost:5173/procurement/purchase-orders
|
||||
- Purchase order details: http://localhost:5173/procurement/purchase-orders/{id}
|
||||
|
||||
Create purchase order example:
|
||||
|
||||
```bash
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}')
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.accessToken')
|
||||
|
||||
# Create an active supplier, warehouse, and product first in Catalog, then use their ids below.
|
||||
SUPPLIER_ID="<supplier-id>"
|
||||
WAREHOUSE_ID="<warehouse-id>"
|
||||
PRODUCT_ID="<product-id>"
|
||||
|
||||
CREATE_PO_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/procurement/purchase-orders" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "{
|
||||
\"supplierId\": \"$SUPPLIER_ID\",
|
||||
\"warehouseId\": \"$WAREHOUSE_ID\",
|
||||
\"expectedDeliveryDate\": \"2026-06-01\",
|
||||
\"notes\": \"Initial procurement request\",
|
||||
\"items\": [
|
||||
{
|
||||
\"productId\": \"$PRODUCT_ID\",
|
||||
\"quantity\": 10,
|
||||
\"unitPrice\": 1250.00
|
||||
}
|
||||
]
|
||||
}")
|
||||
|
||||
PO_ID=$(echo "$CREATE_PO_RESPONSE" | jq -r '.data.id')
|
||||
|
||||
curl -X PATCH "http://localhost:8080/api/procurement/purchase-orders/$PO_ID/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"APPROVED","comment":"Approved for ordering"}'
|
||||
```
|
||||
|
||||
## Sales Module
|
||||
|
||||
Sales module содержит простой B2B customer order process без документов, оплат и внешних интеграций:
|
||||
|
||||
- customer order header;
|
||||
- customer from Catalog;
|
||||
- optional warehouse from Catalog;
|
||||
- product items from Catalog;
|
||||
- quantity, unit price, line total;
|
||||
- status flow and status history.
|
||||
- stock issue when `IN_PROGRESS` moves to `SHIPPED`.
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
- `GET /api/sales/customer-orders`
|
||||
- `GET /api/sales/customer-orders/{id}`
|
||||
- `POST /api/sales/customer-orders`
|
||||
- `PUT /api/sales/customer-orders/{id}`
|
||||
- `PATCH /api/sales/customer-orders/{id}/status`
|
||||
- `GET /api/sales/customer-orders/{id}/status-history`
|
||||
- `DELETE /api/sales/customer-orders/{id}`
|
||||
|
||||
List endpoint supports:
|
||||
|
||||
```text
|
||||
?page=0&size=20&search=&status=NEW&customerId=&warehouseId=&fromDate=&toDate=
|
||||
```
|
||||
|
||||
Statuses:
|
||||
|
||||
- `NEW`
|
||||
- `CONFIRMED`
|
||||
- `IN_PROGRESS`
|
||||
- `SHIPPED`
|
||||
- `CLOSED`
|
||||
- `CANCELLED`
|
||||
|
||||
Allowed transitions:
|
||||
|
||||
- `NEW` to `CONFIRMED`
|
||||
- `CONFIRMED` to `IN_PROGRESS`
|
||||
- `IN_PROGRESS` to `SHIPPED`
|
||||
- `SHIPPED` to `CLOSED`
|
||||
- `NEW` to `CANCELLED`
|
||||
- `CONFIRMED` to `CANCELLED`
|
||||
- `IN_PROGRESS` to `CANCELLED`
|
||||
|
||||
Role access:
|
||||
|
||||
- Read: `ADMIN`, `MANAGER`, `WAREHOUSE`, `FINANCE`.
|
||||
- Create/update/cancel: `ADMIN`, `MANAGER`.
|
||||
- Status changes: `ADMIN`, `MANAGER`.
|
||||
- Warehouse status changes: `CONFIRMED` to `IN_PROGRESS`, `IN_PROGRESS` to `SHIPPED`.
|
||||
- `FINANCE` can only read.
|
||||
|
||||
Frontend URLs:
|
||||
|
||||
- Customer orders: http://localhost:5173/sales/customer-orders
|
||||
- Customer order details: http://localhost:5173/sales/customer-orders/{id}
|
||||
|
||||
Create customer order example:
|
||||
|
||||
```bash
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}')
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.accessToken')
|
||||
|
||||
# Create an active customer and product first in Catalog, then use their ids below.
|
||||
# WAREHOUSE_ID is optional.
|
||||
CUSTOMER_ID="<customer-id>"
|
||||
PRODUCT_ID="<product-id>"
|
||||
WAREHOUSE_ID="<warehouse-id>"
|
||||
|
||||
CREATE_ORDER_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/sales/customer-orders" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "{
|
||||
\"customerId\": \"$CUSTOMER_ID\",
|
||||
\"warehouseId\": \"$WAREHOUSE_ID\",
|
||||
\"requestedDeliveryDate\": \"2026-06-10\",
|
||||
\"notes\": \"Initial B2B order\",
|
||||
\"items\": [
|
||||
{
|
||||
\"productId\": \"$PRODUCT_ID\",
|
||||
\"quantity\": 5,
|
||||
\"unitPrice\": 1800.00
|
||||
}
|
||||
]
|
||||
}")
|
||||
|
||||
ORDER_ID=$(echo "$CREATE_ORDER_RESPONSE" | jq -r '.data.id')
|
||||
|
||||
curl -X PATCH "http://localhost:8080/api/sales/customer-orders/$ORDER_ID/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"CONFIRMED","comment":"Confirmed with customer"}'
|
||||
```
|
||||
|
||||
## Warehouse Module
|
||||
|
||||
Warehouse module содержит складские остатки и историю движений без документов, оплат и внешних интеграций:
|
||||
|
||||
- stock balance by warehouse and product;
|
||||
- stock movement history;
|
||||
- inbound movement when purchase order becomes `RECEIVED`;
|
||||
- outbound movement when customer order becomes `SHIPPED`;
|
||||
- manual stock adjustments;
|
||||
- insufficient stock check before shipping.
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
- `GET /api/warehouse/stock-balances`
|
||||
- `GET /api/warehouse/stock-movements`
|
||||
- `POST /api/warehouse/stock-adjustments`
|
||||
|
||||
Stock balances list supports:
|
||||
|
||||
```text
|
||||
?page=0&size=20&warehouseId=&productId=&search=
|
||||
```
|
||||
|
||||
Stock movements list supports:
|
||||
|
||||
```text
|
||||
?page=0&size=20&warehouseId=&productId=&movementType=INBOUND&sourceType=PURCHASE_ORDER&sourceId=&fromDate=&toDate=
|
||||
```
|
||||
|
||||
Movement types:
|
||||
|
||||
- `INBOUND`
|
||||
- `OUTBOUND`
|
||||
- `ADJUSTMENT_IN`
|
||||
- `ADJUSTMENT_OUT`
|
||||
|
||||
Source types:
|
||||
|
||||
- `PURCHASE_ORDER`
|
||||
- `CUSTOMER_ORDER`
|
||||
- `MANUAL_ADJUSTMENT`
|
||||
|
||||
Business rules:
|
||||
|
||||
- `PurchaseOrder` must have `warehouseId` before marking it as `RECEIVED`.
|
||||
- `RECEIVED` creates `INBOUND` stock movement per PO item and increases stock.
|
||||
- `CustomerOrder` must have `warehouseId` before marking it as `SHIPPED`.
|
||||
- `SHIPPED` creates `OUTBOUND` stock movement per SO item and decreases stock.
|
||||
- Shipping is blocked if stock balance does not exist or quantity is insufficient.
|
||||
- Manual `ADJUSTMENT_OUT` is blocked if it would make stock negative.
|
||||
|
||||
Role access:
|
||||
|
||||
- Read stock balances/movements: `ADMIN`, `MANAGER`, `WAREHOUSE`, `FINANCE`.
|
||||
- Manual stock adjustment: `ADMIN`, `WAREHOUSE`.
|
||||
- PO receipt: `ADMIN`, `MANAGER`, `WAREHOUSE`.
|
||||
- SO shipment: `ADMIN`, `MANAGER`, `WAREHOUSE`.
|
||||
- `FINANCE` is read-only.
|
||||
|
||||
Frontend URLs:
|
||||
|
||||
- Stock balances: http://localhost:5173/warehouse/stock-balances
|
||||
- Stock movements: http://localhost:5173/warehouse/stock-movements
|
||||
|
||||
Manual adjustment example:
|
||||
|
||||
```bash
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}')
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.accessToken')
|
||||
|
||||
WAREHOUSE_ID="<warehouse-id>"
|
||||
PRODUCT_ID="<product-id>"
|
||||
|
||||
curl -X POST "http://localhost:8080/api/warehouse/stock-adjustments" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d "{
|
||||
\"warehouseId\": \"$WAREHOUSE_ID\",
|
||||
\"productId\": \"$PRODUCT_ID\",
|
||||
\"type\": \"ADJUSTMENT_IN\",
|
||||
\"quantity\": 25.000,
|
||||
\"comment\": \"Opening stock balance\"
|
||||
}"
|
||||
```
|
||||
|
||||
Mark PO as received:
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:8080/api/procurement/purchase-orders/$PO_ID/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"RECEIVED","comment":"Received into warehouse"}'
|
||||
```
|
||||
|
||||
Mark SO as shipped:
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:8080/api/sales/customer-orders/$ORDER_ID/status" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"status":"SHIPPED","comment":"Shipped from warehouse"}'
|
||||
```
|
||||
|
||||
## Documents Module
|
||||
|
||||
Documents module генерирует простые PDF placeholders для customer orders без email, подписей, оплат и внешних сервисов.
|
||||
|
||||
Document types:
|
||||
|
||||
- `INVOICE`
|
||||
- `CONTRACT`
|
||||
- `DELIVERY_NOTE`
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
- `GET /api/documents`
|
||||
- `GET /api/documents/{id}`
|
||||
- `GET /api/documents/customer-orders/{customerOrderId}`
|
||||
- `POST /api/documents/customer-orders/{customerOrderId}/generate`
|
||||
- `GET /api/documents/{id}/download`
|
||||
|
||||
List endpoint supports:
|
||||
|
||||
```text
|
||||
?page=0&size=20&documentType=INVOICE&sourceType=CUSTOMER_ORDER&sourceId=&status=GENERATED&fromDate=&toDate=
|
||||
```
|
||||
|
||||
Business rules:
|
||||
|
||||
- Customer order must exist.
|
||||
- Documents cannot be generated for `CANCELLED` customer orders.
|
||||
- `DELIVERY_NOTE` can be generated only for `SHIPPED` or `CLOSED` orders.
|
||||
- `INVOICE` and `CONTRACT` can be generated for any status except `CANCELLED`.
|
||||
- Regenerating the same document type for the same customer order updates the existing PDF and keeps the document number.
|
||||
|
||||
Role access:
|
||||
|
||||
- Read/list/download: `ADMIN`, `MANAGER`, `WAREHOUSE`, `FINANCE`.
|
||||
- Generate `INVOICE` and `CONTRACT`: `ADMIN`, `MANAGER`, `FINANCE`.
|
||||
- Generate `DELIVERY_NOTE`: `ADMIN`, `MANAGER`, `WAREHOUSE`.
|
||||
|
||||
Frontend URLs:
|
||||
|
||||
- Documents: http://localhost:5173/documents
|
||||
- Customer order details documents block: http://localhost:5173/sales/customer-orders/{id}
|
||||
|
||||
Generate invoice example:
|
||||
|
||||
```bash
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}')
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.accessToken')
|
||||
CUSTOMER_ORDER_ID="<customer-order-id>"
|
||||
|
||||
GENERATE_DOCUMENT_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/documents/customer-orders/$CUSTOMER_ORDER_ID/generate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"documentType":"INVOICE"}')
|
||||
|
||||
DOCUMENT_ID=$(echo "$GENERATE_DOCUMENT_RESPONSE" | jq -r '.data.id')
|
||||
```
|
||||
|
||||
Download PDF example:
|
||||
|
||||
```bash
|
||||
curl -L "http://localhost:8080/api/documents/$DOCUMENT_ID/download" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-o invoice-placeholder.pdf
|
||||
```
|
||||
|
||||
## Dashboard / Analytics
|
||||
|
||||
Dashboard module содержит read-only ERP analytics для руководителя без Power BI, внешней BI-системы и изменения бизнес-логики.
|
||||
|
||||
Backend endpoints:
|
||||
|
||||
- `GET /api/dashboard/summary`
|
||||
- `GET /api/dashboard/sales`
|
||||
- `GET /api/dashboard/procurement`
|
||||
- `GET /api/dashboard/warehouse`
|
||||
- `GET /api/dashboard/recent-activities`
|
||||
- `GET /api/dashboard/low-stock`
|
||||
|
||||
Metrics included:
|
||||
|
||||
- catalog counts: products, active products, suppliers, customers, warehouses;
|
||||
- sales: customer order counts, active orders, total sales amount, status breakdown, daily sales, top customers;
|
||||
- procurement: purchase order counts, active orders, total procurement amount, status breakdown, daily procurement, top suppliers;
|
||||
- warehouse: stock items, quantity on hand, low stock, movements, stock by warehouse, movements by type;
|
||||
- documents: total generated documents and documents by type;
|
||||
- recent activities across customer orders, purchase orders, stock movements, and documents.
|
||||
|
||||
Query examples:
|
||||
|
||||
```text
|
||||
GET /api/dashboard/sales?fromDate=2026-05-01&toDate=2026-05-31
|
||||
GET /api/dashboard/procurement?fromDate=2026-05-01&toDate=2026-05-31
|
||||
GET /api/dashboard/recent-activities?limit=20
|
||||
GET /api/dashboard/low-stock?threshold=10&page=0&size=20
|
||||
```
|
||||
|
||||
Role access:
|
||||
|
||||
- `ADMIN`
|
||||
- `MANAGER`
|
||||
- `WAREHOUSE`
|
||||
- `FINANCE`
|
||||
|
||||
Frontend URL:
|
||||
|
||||
- Dashboard: http://localhost:5173/dashboard
|
||||
|
||||
Dashboard sections:
|
||||
|
||||
- KPI cards
|
||||
- Sales by Status
|
||||
- Procurement by Status
|
||||
- Warehouse Overview
|
||||
- Low Stock Items
|
||||
- Recent Activities
|
||||
|
||||
Summary curl example:
|
||||
|
||||
```bash
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "http://localhost:8080/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@erp.local","password":"admin12345"}')
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.accessToken')
|
||||
|
||||
curl "http://localhost:8080/api/dashboard/summary" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
Low stock curl example:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/dashboard/low-stock?threshold=10&page=0&size=20" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Demo Data
|
||||
|
||||
Demo seed заполняет dev/demo окружение тестовыми данными для демонстрации ERP без ручной подготовки справочников, заказов, остатков и документов.
|
||||
|
||||
Seed выключен по умолчанию:
|
||||
|
||||
```yaml
|
||||
app:
|
||||
demo-data:
|
||||
enabled: ${DEMO_DATA_ENABLED:false}
|
||||
```
|
||||
|
||||
Включить можно через env:
|
||||
|
||||
```bash
|
||||
DEMO_DATA_ENABLED=true
|
||||
```
|
||||
|
||||
В dev `docker-compose.yml` для backend уже задано:
|
||||
|
||||
```yaml
|
||||
DEMO_DATA_ENABLED: ${DEMO_DATA_ENABLED:-true}
|
||||
```
|
||||
|
||||
Demo users:
|
||||
|
||||
- `admin@erp.local` / `admin12345` / `ADMIN`
|
||||
- `manager@erp.local` / `manager12345` / `MANAGER`
|
||||
- `warehouse@erp.local` / `warehouse12345` / `WAREHOUSE`
|
||||
- `finance@erp.local` / `finance12345` / `FINANCE`
|
||||
|
||||
Seeded data:
|
||||
|
||||
- catalog: 8 products, 3 suppliers, 3 customers, 2 warehouses;
|
||||
- procurement: 3 purchase orders, including one `RECEIVED` order that creates inbound stock movements;
|
||||
- sales: 4 customer orders, including one `SHIPPED` order that creates outbound stock movements;
|
||||
- warehouse: manual low-stock adjustments for `Water 0.5L` and `Apple Juice 1L`;
|
||||
- documents: invoice, contract and delivery note for shipped order; invoice and contract for confirmed order.
|
||||
|
||||
Idempotency:
|
||||
|
||||
- users are matched by email;
|
||||
- products by SKU;
|
||||
- warehouses by code;
|
||||
- suppliers/customers by BIN or company name;
|
||||
- purchase/customer orders by `DEMO-SEED-*` marker in notes;
|
||||
- manual stock movements by source, warehouse, product and comment;
|
||||
- documents reuse the existing unique `(sourceType, sourceId, documentType)` row.
|
||||
|
||||
How to verify:
|
||||
|
||||
1. Run `docker compose up --build`.
|
||||
2. Open http://localhost:5173/login.
|
||||
3. Use any demo credential button.
|
||||
4. Check Dashboard, Catalog, Purchase Orders, Customer Orders, Stock Balances, Stock Movements, and Documents.
|
||||
|
||||
## UI / UX
|
||||
|
||||
Frontend содержит единый ERP-style интерфейс без внешней UI-библиотеки:
|
||||
|
||||
- общий layout с grouped sidebar и top header;
|
||||
- reusable UI components в `frontend/src/components/ui`;
|
||||
- единые table/loading/error/empty/pagination states;
|
||||
- единые status badges для закупок, продаж, документов и складских движений;
|
||||
- форматтеры в `frontend/src/utils/formatters.ts` для сумм, количеств, дат, статусов, типов документов и движений;
|
||||
- role-aware UI: frontend скрывает недоступные кнопки, backend остается главным источником прав.
|
||||
|
||||
Available frontend routes:
|
||||
|
||||
- `/login`
|
||||
- `/dashboard`
|
||||
- `/catalog/products`
|
||||
- `/catalog/suppliers`
|
||||
- `/catalog/customers`
|
||||
- `/catalog/warehouses`
|
||||
- `/procurement/purchase-orders`
|
||||
- `/procurement/purchase-orders/{id}`
|
||||
- `/sales/customer-orders`
|
||||
- `/sales/customer-orders/{id}`
|
||||
- `/warehouse/stock-balances`
|
||||
- `/warehouse/stock-movements`
|
||||
- `/documents`
|
||||
|
||||
Role-aware UI behavior:
|
||||
|
||||
- `FINANCE`: read-only UI for catalog, purchase orders, customer orders, warehouse; can generate invoice/contract documents.
|
||||
- `WAREHOUSE`: no catalog create/edit UI; can receive PO, process/ship SO, create warehouse adjustments, generate delivery notes.
|
||||
- `ADMIN` and `MANAGER`: main operational actions for catalog, procurement, sales, and documents.
|
||||
- Warehouse manual `ADJUSTMENT_OUT`, catalog deactivate, PO cancel, and SO cancel use confirmation dialogs.
|
||||
|
||||
Common UI components:
|
||||
|
||||
- `Button`, `Input`, `Select`, `Textarea`
|
||||
- `Card`, `Badge`, `PageHeader`
|
||||
- `EmptyState`, `LoadingState`, `ErrorState`
|
||||
- `ConfirmDialog`, `FormField`, `Pagination`
|
||||
|
||||
## Debug Error Endpoint
|
||||
|
||||
Dev-only endpoint для проверки единого формата ошибок:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/debug/error?type=validation"
|
||||
curl "http://localhost:8080/api/debug/error?type=not-found"
|
||||
curl "http://localhost:8080/api/debug/error?type=business"
|
||||
curl "http://localhost:8080/api/debug/error?type=generic"
|
||||
```
|
||||
|
||||
Endpoint должен быть удален или отключен перед production.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Stop:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Reset database:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Backend logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f backend
|
||||
```
|
||||
|
||||
Frontend logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f frontend
|
||||
```
|
||||
|
||||
Postgres logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
Server deployment compose:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.server.yml up -d --build
|
||||
```
|
||||
|
||||
`docker-compose.server.yml` does not publish app ports directly. It expects an external Docker network named `common_network` and an edge reverse proxy, for example nginx, to route HTTPS traffic to `erp-frontend` and `/api` traffic to `erp-backend`.
|
||||
|
||||
# ERP MVP Demo Script
|
||||
|
||||
Pitch:
|
||||
|
||||
> This MVP demonstrates the core ERP operating loop: catalog data, procurement, sales orders, warehouse stock movements, document generation and management dashboard in one integrated system.
|
||||
|
||||
5-7 minute flow:
|
||||
|
||||
1. Login as Admin: `admin@erp.local` / `admin12345`.
|
||||
2. Open Dashboard and show KPI cards for catalog, sales, procurement, warehouse, documents and low stock.
|
||||
3. Open Catalog -> Products, Suppliers, Customers, Warehouses and show the seeded master data.
|
||||
4. Open Purchase Orders and open the received PO.
|
||||
5. Explain that moving PO to `RECEIVED` created inbound stock movements.
|
||||
6. Open Stock Balances and show updated warehouse quantities.
|
||||
7. Open Customer Orders and open the shipped order.
|
||||
8. Explain that moving SO to `SHIPPED` created outbound stock movements and checked available stock.
|
||||
9. Open Documents and show generated invoice, contract and delivery note placeholders.
|
||||
10. Download one PDF from Documents or Customer Order details.
|
||||
11. Return to Dashboard and show recent activities, low stock and updated analytics.
|
||||
|
||||
## Manual QA Checklist
|
||||
|
||||
Backend:
|
||||
|
||||
- [ ] `/api/health` returns `UP`.
|
||||
- [ ] Swagger opens at http://localhost:8080/swagger-ui/index.html.
|
||||
- [ ] Login works.
|
||||
- [ ] Catalog CRUD works.
|
||||
- [ ] Purchase order flow works.
|
||||
- [ ] Customer order flow works.
|
||||
- [ ] Stock balances update correctly.
|
||||
- [ ] Documents generate and download.
|
||||
- [ ] Dashboard loads.
|
||||
|
||||
Frontend:
|
||||
|
||||
- [ ] Login works.
|
||||
- [ ] Sidebar navigation works.
|
||||
- [ ] Dashboard loads.
|
||||
- [ ] Forms save data.
|
||||
- [ ] Status transitions work.
|
||||
- [ ] Errors display properly.
|
||||
- [ ] Download PDF works.
|
||||
- [ ] Logout works.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
PostgreSQL port already in use:
|
||||
|
||||
- Stop the local PostgreSQL process or change the `5432:5432` mapping in `docker-compose.yml`.
|
||||
|
||||
Backend cannot connect to database:
|
||||
|
||||
- Check `docker compose logs -f backend` and confirm `SPRING_DATASOURCE_URL`, `POSTGRES_DB`, `POSTGRES_USER` and `POSTGRES_PASSWORD` match.
|
||||
|
||||
Flyway migration failed:
|
||||
|
||||
- For dev data loss is acceptable, reset with `docker compose down -v` and then `docker compose up --build`.
|
||||
|
||||
Frontend cannot call backend due to CORS:
|
||||
|
||||
- Confirm `CORS_ALLOWED_ORIGINS` includes `http://localhost:5173`.
|
||||
|
||||
JWT expired or unauthorized:
|
||||
|
||||
- Logout, login again, and check that requests include `Authorization: Bearer <token>`.
|
||||
|
||||
Demo data not visible:
|
||||
|
||||
- Ensure `DEMO_DATA_ENABLED=true`, then reset the dev database if it was initialized before enabling demo data.
|
||||
|
||||
PDF download blocked by browser:
|
||||
|
||||
- Try downloading from the Documents page again and make sure pop-up/download blocking is not preventing the file save.
|
||||
|
||||
Database credentials changed but old volume remains:
|
||||
|
||||
- Run `docker compose down -v` so PostgreSQL can initialize with the new `.env` values.
|
||||
|
||||
# MVP Scope
|
||||
|
||||
Included:
|
||||
|
||||
- Auth and roles.
|
||||
- Catalog.
|
||||
- Procurement.
|
||||
- Sales.
|
||||
- Warehouse stock.
|
||||
- Documents.
|
||||
- Dashboard.
|
||||
- Demo data.
|
||||
|
||||
Not included in MVP v1:
|
||||
|
||||
- Real 1C integration.
|
||||
- Real WMS/TMS integration.
|
||||
- Payment module.
|
||||
- Email/WhatsApp sending.
|
||||
- Electronic signature.
|
||||
- Advanced approval workflow.
|
||||
- Advanced BI/Power BI.
|
||||
- Multi-company accounting.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
.gradle
|
||||
build
|
||||
out
|
||||
*.iml
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM gradle:8.14.3-jdk21-alpine AS build
|
||||
|
||||
WORKDIR /home/gradle/src
|
||||
|
||||
COPY --chown=gradle:gradle settings.gradle build.gradle ./
|
||||
COPY --chown=gradle:gradle src ./src
|
||||
|
||||
RUN gradle bootJar --no-daemon
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /home/gradle/src/build/libs/*.jar app.jar
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,46 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.5.13'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'com.example'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||
implementation 'io.jsonwebtoken:jjwt-api:0.13.0'
|
||||
implementation 'org.apache.pdfbox:pdfbox:3.0.5'
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.17'
|
||||
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = 'erp-backend'
|
||||
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
package com.example.erpmvp;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
@EnableJpaAuditing
|
||||
@SpringBootApplication
|
||||
public class ErpMvpApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ErpMvpApplication.class, args);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
package com.example.erpmvp.common.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ApiError(
|
||||
String code,
|
||||
String message,
|
||||
List<FieldValidationError> details
|
||||
) {
|
||||
|
||||
public static ApiError of(String code, String message) {
|
||||
return new ApiError(code, message, List.of());
|
||||
}
|
||||
|
||||
public static ApiError of(String code, String message, List<FieldValidationError> details) {
|
||||
return new ApiError(code, message, details == null ? List.of() : details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.erpmvp.common.api;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ApiResponse<T>(
|
||||
boolean success,
|
||||
T data,
|
||||
ApiError error,
|
||||
Instant timestamp
|
||||
) {
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>(true, data, null, Instant.now());
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(ApiError error) {
|
||||
return new ApiResponse<>(false, null, error, Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.erpmvp.common.api;
|
||||
|
||||
public record FieldValidationError(
|
||||
String field,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
package com.example.erpmvp.common.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
@MappedSuperclass
|
||||
public abstract class BaseEntity {
|
||||
|
||||
@Id
|
||||
@Column(nullable = false, updatable = false)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
Instant now = Instant.now();
|
||||
|
||||
if (id == null) {
|
||||
id = UUID.randomUUID();
|
||||
}
|
||||
|
||||
if (createdAt == null) {
|
||||
createdAt = now;
|
||||
}
|
||||
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
public class BadRequestException extends BusinessException {
|
||||
|
||||
public BadRequestException(String message) {
|
||||
super("BAD_REQUEST", message);
|
||||
}
|
||||
|
||||
public BadRequestException(String errorCode, String message) {
|
||||
super(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final String errorCode;
|
||||
|
||||
public BusinessException(String errorCode, String message) {
|
||||
super(message);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
public class ConflictException extends BusinessException {
|
||||
|
||||
public ConflictException(String message) {
|
||||
super("CONFLICT", message);
|
||||
}
|
||||
|
||||
public ConflictException(String errorCode, String message) {
|
||||
super(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class DebugErrorController {
|
||||
|
||||
// Dev-only endpoint for manual error format checks. Remove or disable it in production.
|
||||
@GetMapping("/api/debug/error")
|
||||
public void triggerError(@RequestParam(defaultValue = "generic") String type) {
|
||||
switch (type) {
|
||||
case "validation" -> throw new ConstraintViolationException("Debug validation error", Set.of());
|
||||
case "not-found" -> throw new NotFoundException("DEBUG_NOT_FOUND", "Debug resource was not found");
|
||||
case "business" -> throw new BusinessException("DEBUG_BUSINESS_ERROR", "Debug business rule failed");
|
||||
case "generic" -> throw new RuntimeException("Debug generic error");
|
||||
default -> throw new BadRequestException("UNSUPPORTED_DEBUG_ERROR_TYPE", "Unsupported debug error type: " + type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.example.erpmvp.common.api.ApiError;
|
||||
import com.example.erpmvp.common.api.ApiResponse;
|
||||
import com.example.erpmvp.common.api.FieldValidationError;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) {
|
||||
List<FieldValidationError> details = exception.getBindingResult()
|
||||
.getFieldErrors()
|
||||
.stream()
|
||||
.map(this::toFieldValidationError)
|
||||
.toList();
|
||||
|
||||
return buildErrorResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"VALIDATION_ERROR",
|
||||
"Validation failed",
|
||||
details
|
||||
);
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConstraintViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(ConstraintViolationException exception) {
|
||||
List<FieldValidationError> details = exception.getConstraintViolations()
|
||||
.stream()
|
||||
.map(violation -> new FieldValidationError(
|
||||
violation.getPropertyPath().toString(),
|
||||
violation.getMessage()
|
||||
))
|
||||
.toList();
|
||||
|
||||
return buildErrorResponse(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"VALIDATION_ERROR",
|
||||
exception.getMessage(),
|
||||
details
|
||||
);
|
||||
}
|
||||
|
||||
@ExceptionHandler(NotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNotFound(NotFoundException exception) {
|
||||
return buildErrorResponse(HttpStatus.NOT_FOUND, exception.getErrorCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnauthorized(UnauthorizedException exception) {
|
||||
return buildErrorResponse(HttpStatus.UNAUTHORIZED, exception.getErrorCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConflictException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleConflict(ConflictException exception) {
|
||||
return buildErrorResponse(HttpStatus.CONFLICT, exception.getErrorCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(BadRequestException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBadRequest(BadRequestException exception) {
|
||||
return buildErrorResponse(HttpStatus.BAD_REQUEST, exception.getErrorCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException exception) {
|
||||
return buildErrorResponse(HttpStatus.UNPROCESSABLE_ENTITY, exception.getErrorCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException exception) {
|
||||
return buildErrorResponse(HttpStatus.BAD_REQUEST, "BAD_REQUEST", exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(NoResourceFoundException exception) {
|
||||
return buildErrorResponse(HttpStatus.NOT_FOUND, "NOT_FOUND", "Resource not found");
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception exception) {
|
||||
return buildErrorResponse(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
"Unexpected internal server error"
|
||||
);
|
||||
}
|
||||
|
||||
private FieldValidationError toFieldValidationError(FieldError fieldError) {
|
||||
return new FieldValidationError(
|
||||
fieldError.getField(),
|
||||
fieldError.getDefaultMessage()
|
||||
);
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> buildErrorResponse(HttpStatus status, String code, String message) {
|
||||
return buildErrorResponse(status, code, message, List.of());
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> buildErrorResponse(
|
||||
HttpStatus status,
|
||||
String code,
|
||||
String message,
|
||||
List<FieldValidationError> details
|
||||
) {
|
||||
ApiError error = ApiError.of(code, message, details);
|
||||
return ResponseEntity.status(status).body(ApiResponse.error(error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
public class NotFoundException extends BusinessException {
|
||||
|
||||
public NotFoundException(String message) {
|
||||
super("NOT_FOUND", message);
|
||||
}
|
||||
|
||||
public NotFoundException(String errorCode, String message) {
|
||||
super(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.erpmvp.common.error;
|
||||
|
||||
public class UnauthorizedException extends BusinessException {
|
||||
|
||||
public UnauthorizedException(String message) {
|
||||
super("UNAUTHORIZED", message);
|
||||
}
|
||||
|
||||
public UnauthorizedException(String errorCode, String message) {
|
||||
super(errorCode, message);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,38 @@
|
||||
package com.example.erpmvp.common.pagination;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
public class PageRequestDto {
|
||||
|
||||
@Min(0)
|
||||
private int page = 0;
|
||||
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
private int size = 20;
|
||||
|
||||
public Pageable toPageable() {
|
||||
return PageRequest.of(page, size);
|
||||
}
|
||||
|
||||
public int getPage() {
|
||||
return page;
|
||||
}
|
||||
|
||||
public void setPage(int page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(int size) {
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.erpmvp.common.pagination;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
public record PageResponseDto<T>(
|
||||
List<T> items,
|
||||
int page,
|
||||
int size,
|
||||
long totalElements,
|
||||
int totalPages,
|
||||
boolean hasNext,
|
||||
boolean hasPrevious
|
||||
) {
|
||||
|
||||
public static <T> PageResponseDto<T> from(Page<T> page) {
|
||||
return new PageResponseDto<>(
|
||||
page.getContent(),
|
||||
page.getNumber(),
|
||||
page.getSize(),
|
||||
page.getTotalElements(),
|
||||
page.getTotalPages(),
|
||||
page.hasNext(),
|
||||
page.hasPrevious()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,34 @@
|
||||
package com.example.erpmvp.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer corsConfigurer(
|
||||
@Value("${app.cors.allowed-origins}") String allowedOrigins
|
||||
) {
|
||||
String[] origins = Arrays.stream(allowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(origin -> !origin.isBlank())
|
||||
.toArray(String[]::new);
|
||||
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins(origins)
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.exposedHeaders("Content-Disposition");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.example.erpmvp.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI erpMvpOpenApi() {
|
||||
return new OpenAPI()
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("bearer-jwt", new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("JWT")))
|
||||
.addSecurityItem(new SecurityRequirement().addList("bearer-jwt"))
|
||||
.info(new Info()
|
||||
.title("ERP MVP API")
|
||||
.version("v1")
|
||||
.description("API documentation for ERP MVP backend"));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
package com.example.erpmvp.config.demo;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.demo-data")
|
||||
public class DemoDataProperties {
|
||||
|
||||
private boolean enabled;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
package com.example.erpmvp.config.demo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.example.erpmvp.modules.auth.domain.Role;
|
||||
import com.example.erpmvp.modules.auth.domain.User;
|
||||
import com.example.erpmvp.modules.auth.repository.UserRepository;
|
||||
import com.example.erpmvp.modules.catalog.domain.Customer;
|
||||
import com.example.erpmvp.modules.catalog.domain.Product;
|
||||
import com.example.erpmvp.modules.catalog.domain.Supplier;
|
||||
import com.example.erpmvp.modules.catalog.domain.Warehouse;
|
||||
import com.example.erpmvp.modules.catalog.repository.CustomerRepository;
|
||||
import com.example.erpmvp.modules.catalog.repository.ProductRepository;
|
||||
import com.example.erpmvp.modules.catalog.repository.SupplierRepository;
|
||||
import com.example.erpmvp.modules.catalog.repository.WarehouseRepository;
|
||||
import com.example.erpmvp.modules.documents.domain.DocumentType;
|
||||
import com.example.erpmvp.modules.documents.service.DocumentService;
|
||||
import com.example.erpmvp.modules.procurement.domain.PurchaseOrder;
|
||||
import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus;
|
||||
import com.example.erpmvp.modules.procurement.dto.ChangePurchaseOrderStatusRequest;
|
||||
import com.example.erpmvp.modules.procurement.dto.CreatePurchaseOrderItemRequest;
|
||||
import com.example.erpmvp.modules.procurement.dto.CreatePurchaseOrderRequest;
|
||||
import com.example.erpmvp.modules.procurement.repository.PurchaseOrderRepository;
|
||||
import com.example.erpmvp.modules.procurement.service.PurchaseOrderService;
|
||||
import com.example.erpmvp.modules.sales.domain.CustomerOrder;
|
||||
import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus;
|
||||
import com.example.erpmvp.modules.sales.dto.ChangeCustomerOrderStatusRequest;
|
||||
import com.example.erpmvp.modules.sales.dto.CreateCustomerOrderItemRequest;
|
||||
import com.example.erpmvp.modules.sales.dto.CreateCustomerOrderRequest;
|
||||
import com.example.erpmvp.modules.sales.repository.CustomerOrderRepository;
|
||||
import com.example.erpmvp.modules.sales.service.CustomerOrderService;
|
||||
import com.example.erpmvp.modules.warehouse.domain.StockMovementSourceType;
|
||||
import com.example.erpmvp.modules.warehouse.domain.StockMovementType;
|
||||
import com.example.erpmvp.modules.warehouse.dto.ManualStockAdjustmentRequest;
|
||||
import com.example.erpmvp.modules.warehouse.repository.StockMovementRepository;
|
||||
import com.example.erpmvp.modules.warehouse.service.WarehouseStockService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "app.demo-data", name = "enabled", havingValue = "true")
|
||||
public class DemoDataSeeder implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DemoDataSeeder.class);
|
||||
|
||||
private static final String PO_1_MARKER = "DEMO-SEED-PO-1";
|
||||
private static final String PO_2_MARKER = "DEMO-SEED-PO-2";
|
||||
private static final String PO_3_MARKER = "DEMO-SEED-PO-3";
|
||||
private static final String SO_1_MARKER = "DEMO-SEED-SO-1";
|
||||
private static final String SO_2_MARKER = "DEMO-SEED-SO-2";
|
||||
private static final String SO_3_MARKER = "DEMO-SEED-SO-3";
|
||||
private static final String SO_4_MARKER = "DEMO-SEED-SO-4";
|
||||
|
||||
private final DemoDataProperties properties;
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ProductRepository productRepository;
|
||||
private final SupplierRepository supplierRepository;
|
||||
private final CustomerRepository customerRepository;
|
||||
private final WarehouseRepository warehouseRepository;
|
||||
private final PurchaseOrderRepository purchaseOrderRepository;
|
||||
private final CustomerOrderRepository customerOrderRepository;
|
||||
private final StockMovementRepository stockMovementRepository;
|
||||
private final PurchaseOrderService purchaseOrderService;
|
||||
private final CustomerOrderService customerOrderService;
|
||||
private final WarehouseStockService warehouseStockService;
|
||||
private final DocumentService documentService;
|
||||
|
||||
public DemoDataSeeder(
|
||||
DemoDataProperties properties,
|
||||
UserRepository userRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
ProductRepository productRepository,
|
||||
SupplierRepository supplierRepository,
|
||||
CustomerRepository customerRepository,
|
||||
WarehouseRepository warehouseRepository,
|
||||
PurchaseOrderRepository purchaseOrderRepository,
|
||||
CustomerOrderRepository customerOrderRepository,
|
||||
StockMovementRepository stockMovementRepository,
|
||||
PurchaseOrderService purchaseOrderService,
|
||||
CustomerOrderService customerOrderService,
|
||||
WarehouseStockService warehouseStockService,
|
||||
DocumentService documentService
|
||||
) {
|
||||
this.properties = properties;
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.productRepository = productRepository;
|
||||
this.supplierRepository = supplierRepository;
|
||||
this.customerRepository = customerRepository;
|
||||
this.warehouseRepository = warehouseRepository;
|
||||
this.purchaseOrderRepository = purchaseOrderRepository;
|
||||
this.customerOrderRepository = customerOrderRepository;
|
||||
this.stockMovementRepository = stockMovementRepository;
|
||||
this.purchaseOrderService = purchaseOrderService;
|
||||
this.customerOrderService = customerOrderService;
|
||||
this.warehouseStockService = warehouseStockService;
|
||||
this.documentService = documentService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
if (!properties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Demo data seed is enabled. Seeding demo ERP data.");
|
||||
|
||||
DemoUsers users = seedUsers();
|
||||
DemoCatalog catalog = seedCatalog();
|
||||
seedProcurement(catalog, users.admin());
|
||||
DemoSales sales = seedSales(catalog, users.admin());
|
||||
seedManualStockAdjustments(catalog, users.warehouse());
|
||||
seedDocuments(sales, users.admin());
|
||||
|
||||
log.info("Demo data seed completed.");
|
||||
}
|
||||
|
||||
private DemoUsers seedUsers() {
|
||||
User admin = findOrCreateUser(
|
||||
"admin@erp.local",
|
||||
"admin12345",
|
||||
"System Administrator",
|
||||
Role.ADMIN
|
||||
);
|
||||
User manager = findOrCreateUser(
|
||||
"manager@erp.local",
|
||||
"manager12345",
|
||||
"Demo Manager",
|
||||
Role.MANAGER
|
||||
);
|
||||
User warehouse = findOrCreateUser(
|
||||
"warehouse@erp.local",
|
||||
"warehouse12345",
|
||||
"Demo Warehouse Operator",
|
||||
Role.WAREHOUSE
|
||||
);
|
||||
User finance = findOrCreateUser(
|
||||
"finance@erp.local",
|
||||
"finance12345",
|
||||
"Demo Finance Specialist",
|
||||
Role.FINANCE
|
||||
);
|
||||
|
||||
return new DemoUsers(admin, manager, warehouse, finance);
|
||||
}
|
||||
|
||||
private User findOrCreateUser(String email, String password, String fullName, Role role) {
|
||||
String normalizedEmail = User.normalizeEmail(email);
|
||||
return userRepository.findByEmail(normalizedEmail)
|
||||
.orElseGet(() -> userRepository.save(new User(
|
||||
normalizedEmail,
|
||||
fullName,
|
||||
passwordEncoder.encode(password),
|
||||
role
|
||||
)));
|
||||
}
|
||||
|
||||
private DemoCatalog seedCatalog() {
|
||||
Product apple = upsertProduct("SKU-FRESH-001", "Apple Golden", "Fresh Fruits", "kg");
|
||||
Product banana = upsertProduct("SKU-FRESH-002", "Banana Premium", "Fresh Fruits", "kg");
|
||||
Product tomato = upsertProduct("SKU-FRESH-003", "Tomato Local", "Vegetables", "kg");
|
||||
Product cucumber = upsertProduct("SKU-FRESH-004", "Cucumber Fresh", "Vegetables", "kg");
|
||||
Product rice = upsertProduct("SKU-DRY-001", "Rice 5kg", "Grocery", "pcs");
|
||||
Product sugar = upsertProduct("SKU-DRY-002", "Sugar 1kg", "Grocery", "pcs");
|
||||
Product juice = upsertProduct("SKU-DRINK-001", "Apple Juice 1L", "Drinks", "pcs");
|
||||
Product water = upsertProduct("SKU-DRINK-002", "Water 0.5L", "Drinks", "pcs");
|
||||
|
||||
Supplier freshImport = upsertSupplier("Fresh Import Kazakhstan", "111111111111");
|
||||
Supplier almatyAgro = upsertSupplier("Almaty Agro Supply", "222222222222");
|
||||
Supplier groceryTrade = upsertSupplier("Grocery Trade LLP", "333333333333");
|
||||
|
||||
Customer miniMarket = upsertCustomer("Mini Market Alatau", "444444444444");
|
||||
Customer greenStore = upsertCustomer("Green Store B2B", "555555555555");
|
||||
Customer cityFood = upsertCustomer("City Food Retail", "666666666666");
|
||||
|
||||
Warehouse mainWarehouse = upsertWarehouse("WH-ALM-01", "Almaty Main Warehouse");
|
||||
Warehouse freshWarehouse = upsertWarehouse("WH-ALM-02", "Almaty Fresh Warehouse");
|
||||
|
||||
return new DemoCatalog(
|
||||
apple,
|
||||
banana,
|
||||
tomato,
|
||||
cucumber,
|
||||
rice,
|
||||
sugar,
|
||||
juice,
|
||||
water,
|
||||
freshImport,
|
||||
almatyAgro,
|
||||
groceryTrade,
|
||||
miniMarket,
|
||||
greenStore,
|
||||
cityFood,
|
||||
mainWarehouse,
|
||||
freshWarehouse
|
||||
);
|
||||
}
|
||||
|
||||
private Product upsertProduct(String sku, String name, String category, String unit) {
|
||||
String normalizedSku = Product.normalizeCode(sku);
|
||||
Product product = productRepository.findBySku(normalizedSku)
|
||||
.orElseGet(() -> new Product(normalizedSku, name, category, unit, null, "Demo product"));
|
||||
product.update(name, category, unit, product.getBarcode(), "Demo product", true);
|
||||
return productRepository.save(product);
|
||||
}
|
||||
|
||||
private Supplier upsertSupplier(String companyName, String bin) {
|
||||
Supplier supplier = supplierRepository.findByBin(bin)
|
||||
.or(() -> supplierRepository.findByCompanyName(companyName))
|
||||
.orElseGet(() -> new Supplier(
|
||||
companyName,
|
||||
bin,
|
||||
"Demo contact",
|
||||
"+7 700 000 0000",
|
||||
null,
|
||||
"Almaty, Kazakhstan"
|
||||
));
|
||||
supplier.update(
|
||||
companyName,
|
||||
bin,
|
||||
supplier.getContactName() == null ? "Demo contact" : supplier.getContactName(),
|
||||
supplier.getPhone() == null ? "+7 700 000 0000" : supplier.getPhone(),
|
||||
supplier.getEmail(),
|
||||
supplier.getAddress() == null ? "Almaty, Kazakhstan" : supplier.getAddress(),
|
||||
true
|
||||
);
|
||||
return supplierRepository.save(supplier);
|
||||
}
|
||||
|
||||
private Customer upsertCustomer(String companyName, String bin) {
|
||||
Customer customer = customerRepository.findByBin(bin)
|
||||
.or(() -> customerRepository.findByCompanyName(companyName))
|
||||
.orElseGet(() -> new Customer(
|
||||
companyName,
|
||||
bin,
|
||||
"Demo contact",
|
||||
"+7 701 000 0000",
|
||||
null,
|
||||
"Almaty, Kazakhstan"
|
||||
));
|
||||
customer.update(
|
||||
companyName,
|
||||
bin,
|
||||
customer.getContactName() == null ? "Demo contact" : customer.getContactName(),
|
||||
customer.getPhone() == null ? "+7 701 000 0000" : customer.getPhone(),
|
||||
customer.getEmail(),
|
||||
customer.getAddress() == null ? "Almaty, Kazakhstan" : customer.getAddress(),
|
||||
true
|
||||
);
|
||||
return customerRepository.save(customer);
|
||||
}
|
||||
|
||||
private Warehouse upsertWarehouse(String code, String name) {
|
||||
String normalizedCode = Warehouse.normalizeCode(code);
|
||||
Warehouse warehouse = warehouseRepository.findByCode(normalizedCode)
|
||||
.orElseGet(() -> new Warehouse(normalizedCode, name, "Almaty, Kazakhstan"));
|
||||
warehouse.update(name, warehouse.getAddress() == null ? "Almaty, Kazakhstan" : warehouse.getAddress(), true);
|
||||
return warehouseRepository.save(warehouse);
|
||||
}
|
||||
|
||||
private void seedProcurement(DemoCatalog catalog, User admin) {
|
||||
seedPurchaseOrder(
|
||||
PO_1_MARKER,
|
||||
catalog.freshImport(),
|
||||
catalog.freshWarehouse(),
|
||||
LocalDate.now().plusDays(5),
|
||||
List.of(
|
||||
poItem(catalog.apple(), "120", "450"),
|
||||
poItem(catalog.banana(), "90", "520")
|
||||
),
|
||||
PurchaseOrderStatus.RECEIVED,
|
||||
admin
|
||||
);
|
||||
seedPurchaseOrder(
|
||||
PO_2_MARKER,
|
||||
catalog.groceryTrade(),
|
||||
catalog.mainWarehouse(),
|
||||
LocalDate.now().plusDays(10),
|
||||
List.of(
|
||||
poItem(catalog.rice(), "40", "1800"),
|
||||
poItem(catalog.sugar(), "100", "390")
|
||||
),
|
||||
PurchaseOrderStatus.ORDERED,
|
||||
admin
|
||||
);
|
||||
seedPurchaseOrder(
|
||||
PO_3_MARKER,
|
||||
catalog.almatyAgro(),
|
||||
catalog.freshWarehouse(),
|
||||
LocalDate.now().plusDays(7),
|
||||
List.of(
|
||||
poItem(catalog.tomato(), "70", "380"),
|
||||
poItem(catalog.cucumber(), "60", "350")
|
||||
),
|
||||
PurchaseOrderStatus.DRAFT,
|
||||
admin
|
||||
);
|
||||
}
|
||||
|
||||
private UUID seedPurchaseOrder(
|
||||
String marker,
|
||||
Supplier supplier,
|
||||
Warehouse warehouse,
|
||||
LocalDate expectedDeliveryDate,
|
||||
List<CreatePurchaseOrderItemRequest> items,
|
||||
PurchaseOrderStatus targetStatus,
|
||||
User admin
|
||||
) {
|
||||
PurchaseOrder existing = purchaseOrderRepository.findFirstByNotesContaining(marker).orElse(null);
|
||||
UUID purchaseOrderId = existing == null
|
||||
? purchaseOrderService.create(new CreatePurchaseOrderRequest(
|
||||
supplier.getId(),
|
||||
warehouse.getId(),
|
||||
expectedDeliveryDate,
|
||||
marker + " demo purchase order",
|
||||
items
|
||||
), admin).id()
|
||||
: existing.getId();
|
||||
|
||||
advancePurchaseOrder(purchaseOrderId, targetStatus, admin, marker);
|
||||
return purchaseOrderId;
|
||||
}
|
||||
|
||||
private CreatePurchaseOrderItemRequest poItem(Product product, String quantity, String unitPrice) {
|
||||
return new CreatePurchaseOrderItemRequest(
|
||||
product.getId(),
|
||||
new BigDecimal(quantity),
|
||||
new BigDecimal(unitPrice)
|
||||
);
|
||||
}
|
||||
|
||||
private void advancePurchaseOrder(UUID purchaseOrderId, PurchaseOrderStatus targetStatus, User admin, String marker) {
|
||||
PurchaseOrderStatus current = purchaseOrderRepository.findById(purchaseOrderId)
|
||||
.orElseThrow()
|
||||
.getStatus();
|
||||
|
||||
if (current == targetStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStatus == PurchaseOrderStatus.CANCELLED) {
|
||||
purchaseOrderService.changeStatus(
|
||||
purchaseOrderId,
|
||||
new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.CANCELLED, marker + " cancelled"),
|
||||
admin
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current == PurchaseOrderStatus.DRAFT && targetAtLeast(targetStatus, PurchaseOrderStatus.APPROVED)) {
|
||||
purchaseOrderService.changeStatus(
|
||||
purchaseOrderId,
|
||||
new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.APPROVED, marker + " approved"),
|
||||
admin
|
||||
);
|
||||
current = PurchaseOrderStatus.APPROVED;
|
||||
}
|
||||
|
||||
if (current == PurchaseOrderStatus.APPROVED && targetAtLeast(targetStatus, PurchaseOrderStatus.ORDERED)) {
|
||||
purchaseOrderService.changeStatus(
|
||||
purchaseOrderId,
|
||||
new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.ORDERED, marker + " ordered"),
|
||||
admin
|
||||
);
|
||||
current = PurchaseOrderStatus.ORDERED;
|
||||
}
|
||||
|
||||
if (current == PurchaseOrderStatus.ORDERED && targetStatus == PurchaseOrderStatus.RECEIVED) {
|
||||
purchaseOrderService.changeStatus(
|
||||
purchaseOrderId,
|
||||
new ChangePurchaseOrderStatusRequest(PurchaseOrderStatus.RECEIVED, marker + " received"),
|
||||
admin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean targetAtLeast(PurchaseOrderStatus target, PurchaseOrderStatus checkpoint) {
|
||||
return purchaseStatusRank(target) >= purchaseStatusRank(checkpoint);
|
||||
}
|
||||
|
||||
private int purchaseStatusRank(PurchaseOrderStatus status) {
|
||||
return switch (status) {
|
||||
case DRAFT -> 0;
|
||||
case APPROVED -> 1;
|
||||
case ORDERED -> 2;
|
||||
case RECEIVED -> 3;
|
||||
case CANCELLED -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
private DemoSales seedSales(DemoCatalog catalog, User admin) {
|
||||
UUID shippedOrderId = seedCustomerOrder(
|
||||
SO_1_MARKER,
|
||||
catalog.miniMarket(),
|
||||
catalog.freshWarehouse(),
|
||||
LocalDate.now().plusDays(3),
|
||||
List.of(
|
||||
soItem(catalog.apple(), "20", "650"),
|
||||
soItem(catalog.banana(), "15", "720")
|
||||
),
|
||||
CustomerOrderStatus.SHIPPED,
|
||||
admin
|
||||
);
|
||||
UUID confirmedOrderId = seedCustomerOrder(
|
||||
SO_2_MARKER,
|
||||
catalog.greenStore(),
|
||||
catalog.mainWarehouse(),
|
||||
LocalDate.now().plusDays(8),
|
||||
List.of(
|
||||
soItem(catalog.rice(), "10", "2400"),
|
||||
soItem(catalog.sugar(), "25", "550")
|
||||
),
|
||||
CustomerOrderStatus.CONFIRMED,
|
||||
admin
|
||||
);
|
||||
seedCustomerOrder(
|
||||
SO_3_MARKER,
|
||||
catalog.cityFood(),
|
||||
catalog.freshWarehouse(),
|
||||
LocalDate.now().plusDays(4),
|
||||
List.of(soItem(catalog.tomato(), "10", "590")),
|
||||
CustomerOrderStatus.NEW,
|
||||
admin
|
||||
);
|
||||
seedCustomerOrder(
|
||||
SO_4_MARKER,
|
||||
catalog.miniMarket(),
|
||||
catalog.freshWarehouse(),
|
||||
LocalDate.now().plusDays(6),
|
||||
List.of(soItem(catalog.cucumber(), "8", "540")),
|
||||
CustomerOrderStatus.CANCELLED,
|
||||
admin
|
||||
);
|
||||
|
||||
return new DemoSales(shippedOrderId, confirmedOrderId);
|
||||
}
|
||||
|
||||
private UUID seedCustomerOrder(
|
||||
String marker,
|
||||
Customer customer,
|
||||
Warehouse warehouse,
|
||||
LocalDate requestedDeliveryDate,
|
||||
List<CreateCustomerOrderItemRequest> items,
|
||||
CustomerOrderStatus targetStatus,
|
||||
User admin
|
||||
) {
|
||||
CustomerOrder existing = customerOrderRepository.findFirstByNotesContaining(marker).orElse(null);
|
||||
UUID customerOrderId = existing == null
|
||||
? customerOrderService.create(new CreateCustomerOrderRequest(
|
||||
customer.getId(),
|
||||
warehouse.getId(),
|
||||
requestedDeliveryDate,
|
||||
marker + " demo customer order",
|
||||
items
|
||||
), admin).id()
|
||||
: existing.getId();
|
||||
|
||||
advanceCustomerOrder(customerOrderId, targetStatus, admin, marker);
|
||||
return customerOrderId;
|
||||
}
|
||||
|
||||
private CreateCustomerOrderItemRequest soItem(Product product, String quantity, String unitPrice) {
|
||||
return new CreateCustomerOrderItemRequest(
|
||||
product.getId(),
|
||||
new BigDecimal(quantity),
|
||||
new BigDecimal(unitPrice)
|
||||
);
|
||||
}
|
||||
|
||||
private void advanceCustomerOrder(UUID customerOrderId, CustomerOrderStatus targetStatus, User admin, String marker) {
|
||||
CustomerOrderStatus current = customerOrderRepository.findById(customerOrderId)
|
||||
.orElseThrow()
|
||||
.getStatus();
|
||||
|
||||
if (current == targetStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetStatus == CustomerOrderStatus.CANCELLED) {
|
||||
if (current != CustomerOrderStatus.SHIPPED && current != CustomerOrderStatus.CLOSED) {
|
||||
customerOrderService.changeStatus(
|
||||
customerOrderId,
|
||||
new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.CANCELLED, marker + " cancelled"),
|
||||
admin
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current == CustomerOrderStatus.NEW && targetAtLeast(targetStatus, CustomerOrderStatus.CONFIRMED)) {
|
||||
customerOrderService.changeStatus(
|
||||
customerOrderId,
|
||||
new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.CONFIRMED, marker + " confirmed"),
|
||||
admin
|
||||
);
|
||||
current = CustomerOrderStatus.CONFIRMED;
|
||||
}
|
||||
|
||||
if (current == CustomerOrderStatus.CONFIRMED && targetAtLeast(targetStatus, CustomerOrderStatus.IN_PROGRESS)) {
|
||||
customerOrderService.changeStatus(
|
||||
customerOrderId,
|
||||
new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.IN_PROGRESS, marker + " in progress"),
|
||||
admin
|
||||
);
|
||||
current = CustomerOrderStatus.IN_PROGRESS;
|
||||
}
|
||||
|
||||
if (current == CustomerOrderStatus.IN_PROGRESS && targetAtLeast(targetStatus, CustomerOrderStatus.SHIPPED)) {
|
||||
customerOrderService.changeStatus(
|
||||
customerOrderId,
|
||||
new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.SHIPPED, marker + " shipped"),
|
||||
admin
|
||||
);
|
||||
current = CustomerOrderStatus.SHIPPED;
|
||||
}
|
||||
|
||||
if (current == CustomerOrderStatus.SHIPPED && targetStatus == CustomerOrderStatus.CLOSED) {
|
||||
customerOrderService.changeStatus(
|
||||
customerOrderId,
|
||||
new ChangeCustomerOrderStatusRequest(CustomerOrderStatus.CLOSED, marker + " closed"),
|
||||
admin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean targetAtLeast(CustomerOrderStatus target, CustomerOrderStatus checkpoint) {
|
||||
return customerStatusRank(target) >= customerStatusRank(checkpoint);
|
||||
}
|
||||
|
||||
private int customerStatusRank(CustomerOrderStatus status) {
|
||||
return switch (status) {
|
||||
case NEW -> 0;
|
||||
case CONFIRMED -> 1;
|
||||
case IN_PROGRESS -> 2;
|
||||
case SHIPPED -> 3;
|
||||
case CLOSED -> 4;
|
||||
case CANCELLED -> -1;
|
||||
};
|
||||
}
|
||||
|
||||
private void seedManualStockAdjustments(DemoCatalog catalog, User warehouseUser) {
|
||||
seedManualAdjustment(
|
||||
catalog.mainWarehouse(),
|
||||
catalog.water(),
|
||||
StockMovementType.ADJUSTMENT_IN,
|
||||
"8",
|
||||
"DEMO opening low stock",
|
||||
warehouseUser
|
||||
);
|
||||
seedManualAdjustment(
|
||||
catalog.mainWarehouse(),
|
||||
catalog.juice(),
|
||||
StockMovementType.ADJUSTMENT_IN,
|
||||
"5",
|
||||
"DEMO opening low stock",
|
||||
warehouseUser
|
||||
);
|
||||
}
|
||||
|
||||
private void seedManualAdjustment(
|
||||
Warehouse warehouse,
|
||||
Product product,
|
||||
StockMovementType type,
|
||||
String quantity,
|
||||
String comment,
|
||||
User warehouseUser
|
||||
) {
|
||||
if (stockMovementRepository.existsBySourceTypeAndWarehouse_IdAndProduct_IdAndComment(
|
||||
StockMovementSourceType.MANUAL_ADJUSTMENT,
|
||||
warehouse.getId(),
|
||||
product.getId(),
|
||||
comment
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
warehouseStockService.manualAdjustment(new ManualStockAdjustmentRequest(
|
||||
warehouse.getId(),
|
||||
product.getId(),
|
||||
type,
|
||||
new BigDecimal(quantity),
|
||||
comment
|
||||
), warehouseUser);
|
||||
}
|
||||
|
||||
private void seedDocuments(DemoSales sales, User admin) {
|
||||
CustomerOrder shippedOrder = customerOrderRepository.findById(sales.shippedOrderId()).orElse(null);
|
||||
if (shippedOrder != null
|
||||
&& (shippedOrder.getStatus() == CustomerOrderStatus.SHIPPED
|
||||
|| shippedOrder.getStatus() == CustomerOrderStatus.CLOSED)) {
|
||||
generateCustomerOrderDocument(shippedOrder.getId(), DocumentType.INVOICE, admin);
|
||||
generateCustomerOrderDocument(shippedOrder.getId(), DocumentType.CONTRACT, admin);
|
||||
generateCustomerOrderDocument(shippedOrder.getId(), DocumentType.DELIVERY_NOTE, admin);
|
||||
}
|
||||
|
||||
CustomerOrder confirmedOrder = customerOrderRepository.findById(sales.confirmedOrderId()).orElse(null);
|
||||
if (confirmedOrder != null && confirmedOrder.getStatus() != CustomerOrderStatus.CANCELLED) {
|
||||
generateCustomerOrderDocument(confirmedOrder.getId(), DocumentType.INVOICE, admin);
|
||||
generateCustomerOrderDocument(confirmedOrder.getId(), DocumentType.CONTRACT, admin);
|
||||
}
|
||||
}
|
||||
|
||||
private void generateCustomerOrderDocument(UUID customerOrderId, DocumentType documentType, User admin) {
|
||||
documentService.generateForCustomerOrder(customerOrderId, documentType, admin);
|
||||
}
|
||||
|
||||
private record DemoUsers(User admin, User manager, User warehouse, User finance) {
|
||||
}
|
||||
|
||||
private record DemoCatalog(
|
||||
Product apple,
|
||||
Product banana,
|
||||
Product tomato,
|
||||
Product cucumber,
|
||||
Product rice,
|
||||
Product sugar,
|
||||
Product juice,
|
||||
Product water,
|
||||
Supplier freshImport,
|
||||
Supplier almatyAgro,
|
||||
Supplier groceryTrade,
|
||||
Customer miniMarket,
|
||||
Customer greenStore,
|
||||
Customer cityFood,
|
||||
Warehouse mainWarehouse,
|
||||
Warehouse freshWarehouse
|
||||
) {
|
||||
}
|
||||
|
||||
private record DemoSales(UUID shippedOrderId, UUID confirmedOrderId) {
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
package com.example.erpmvp.health;
|
||||
|
||||
import com.example.erpmvp.common.api.ApiResponse;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class HealthController {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public HealthController(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<HealthResponse> health() {
|
||||
String databaseStatus = checkDatabase();
|
||||
String serviceStatus = "UP".equals(databaseStatus) ? "UP" : "DOWN";
|
||||
|
||||
return ApiResponse.success(new HealthResponse(serviceStatus, "erp-backend", "v1", databaseStatus));
|
||||
}
|
||||
|
||||
private String checkDatabase() {
|
||||
try {
|
||||
Integer result = jdbcTemplate.queryForObject("SELECT 1", Integer.class);
|
||||
return Integer.valueOf(1).equals(result) ? "UP" : "DOWN";
|
||||
} catch (Exception exception) {
|
||||
return "DOWN";
|
||||
}
|
||||
}
|
||||
|
||||
public record HealthResponse(String status, String service, String version, String database) {
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
package com.example.erpmvp.modules.auth.controller;
|
||||
|
||||
import com.example.erpmvp.common.api.ApiResponse;
|
||||
import com.example.erpmvp.modules.auth.dto.LoginRequest;
|
||||
import com.example.erpmvp.modules.auth.dto.LoginResponse;
|
||||
import com.example.erpmvp.modules.auth.dto.LogoutResponse;
|
||||
import com.example.erpmvp.modules.auth.dto.MeResponse;
|
||||
import com.example.erpmvp.modules.auth.dto.ProtectedTestResponse;
|
||||
import com.example.erpmvp.modules.auth.security.AuthUserDetails;
|
||||
import com.example.erpmvp.modules.auth.service.AuthService;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.success(authService.login(request));
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<MeResponse> me(@AuthenticationPrincipal AuthUserDetails principal) {
|
||||
return ApiResponse.success(MeResponse.from(principal.getUser()));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<LogoutResponse> logout() {
|
||||
return ApiResponse.success(new LogoutResponse("Logged out successfully"));
|
||||
}
|
||||
|
||||
@GetMapping("/protected-test")
|
||||
public ApiResponse<ProtectedTestResponse> protectedTest(@AuthenticationPrincipal AuthUserDetails principal) {
|
||||
return ApiResponse.success(new ProtectedTestResponse(
|
||||
"You are authenticated",
|
||||
principal.getUser().getEmail(),
|
||||
principal.getUser().getRole()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
package com.example.erpmvp.modules.auth.domain;
|
||||
|
||||
public enum Role {
|
||||
ADMIN,
|
||||
MANAGER,
|
||||
WAREHOUSE,
|
||||
FINANCE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.example.erpmvp.modules.auth.domain;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import com.example.erpmvp.common.audit.BaseEntity;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "app_users")
|
||||
public class User extends BaseEntity {
|
||||
|
||||
@Column(nullable = false, unique = true, length = 255)
|
||||
private String email;
|
||||
|
||||
@Column(name = "full_name", nullable = false, length = 255)
|
||||
private String fullName;
|
||||
|
||||
@Column(name = "password_hash", nullable = false, length = 255)
|
||||
private String passwordHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 50)
|
||||
private Role role;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean active = true;
|
||||
|
||||
protected User() {
|
||||
}
|
||||
|
||||
public User(String email, String fullName, String passwordHash, Role role) {
|
||||
this.email = normalizeEmail(email);
|
||||
this.fullName = fullName;
|
||||
this.passwordHash = passwordHash;
|
||||
this.role = role;
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public static String normalizeEmail(String email) {
|
||||
return email == null ? null : email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
package com.example.erpmvp.modules.auth.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import com.example.erpmvp.modules.auth.domain.Role;
|
||||
import com.example.erpmvp.modules.auth.domain.User;
|
||||
|
||||
public record AuthUserResponse(
|
||||
UUID id,
|
||||
String email,
|
||||
String fullName,
|
||||
Role role
|
||||
) {
|
||||
|
||||
public static AuthUserResponse from(User user) {
|
||||
return new AuthUserResponse(user.getId(), user.getEmail(), user.getFullName(), user.getRole());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.erpmvp.modules.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user