commit 1b792f02ae25eb546f4c71dc133a440fd23fd64c Author: konturai-ops Date: Mon Aug 10 15:26:55 2026 +0000 sync: migrate erp-mvp to Gitea (2026-08-10) diff --git a/._. b/._. new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/._. differ diff --git a/._.env.example b/._.env.example new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/._.env.example differ diff --git a/._.gitignore b/._.gitignore new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/._.gitignore differ diff --git a/._README.md b/._README.md new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/._README.md differ diff --git a/._backend b/._backend new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/._backend differ diff --git a/._docker-compose.server.yml b/._docker-compose.server.yml new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/._docker-compose.server.yml differ diff --git a/._docker-compose.yml b/._docker-compose.yml new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/._docker-compose.yml differ diff --git a/._frontend b/._frontend new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/._frontend differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..95e6d34 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0b1a61 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a7fce1 --- /dev/null +++ b/README.md @@ -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="" + +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="" +WAREHOUSE_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="" +PRODUCT_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="" +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="" + +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 `. + +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. diff --git a/backend/._.dockerignore b/backend/._.dockerignore new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/._.dockerignore differ diff --git a/backend/._Dockerfile b/backend/._Dockerfile new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/._Dockerfile differ diff --git a/backend/._build.gradle b/backend/._build.gradle new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/._build.gradle differ diff --git a/backend/._settings.gradle b/backend/._settings.gradle new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/._settings.gradle differ diff --git a/backend/._src b/backend/._src new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/._src differ diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ec59b18 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +.gradle +build +out +*.iml + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6c340bb --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 0000000..eb04d81 --- /dev/null +++ b/backend/build.gradle @@ -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() +} diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 0000000..75a2d5d --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = 'erp-backend' + diff --git a/backend/src/._main b/backend/src/._main new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/._main differ diff --git a/backend/src/main/._java b/backend/src/main/._java new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/._java differ diff --git a/backend/src/main/._resources b/backend/src/main/._resources new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/._resources differ diff --git a/backend/src/main/java/._com b/backend/src/main/java/._com new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/._com differ diff --git a/backend/src/main/java/com/._example b/backend/src/main/java/com/._example new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/._example differ diff --git a/backend/src/main/java/com/example/._erpmvp b/backend/src/main/java/com/example/._erpmvp new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/._erpmvp differ diff --git a/backend/src/main/java/com/example/erpmvp/._ErpMvpApplication.java b/backend/src/main/java/com/example/erpmvp/._ErpMvpApplication.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/._ErpMvpApplication.java differ diff --git a/backend/src/main/java/com/example/erpmvp/._common b/backend/src/main/java/com/example/erpmvp/._common new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/._common differ diff --git a/backend/src/main/java/com/example/erpmvp/._config b/backend/src/main/java/com/example/erpmvp/._config new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/._config differ diff --git a/backend/src/main/java/com/example/erpmvp/._health b/backend/src/main/java/com/example/erpmvp/._health new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/._health differ diff --git a/backend/src/main/java/com/example/erpmvp/._modules b/backend/src/main/java/com/example/erpmvp/._modules new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/._modules differ diff --git a/backend/src/main/java/com/example/erpmvp/ErpMvpApplication.java b/backend/src/main/java/com/example/erpmvp/ErpMvpApplication.java new file mode 100644 index 0000000..967f849 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/ErpMvpApplication.java @@ -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); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/common/._api b/backend/src/main/java/com/example/erpmvp/common/._api new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/._api differ diff --git a/backend/src/main/java/com/example/erpmvp/common/._audit b/backend/src/main/java/com/example/erpmvp/common/._audit new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/._audit differ diff --git a/backend/src/main/java/com/example/erpmvp/common/._error b/backend/src/main/java/com/example/erpmvp/common/._error new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/._error differ diff --git a/backend/src/main/java/com/example/erpmvp/common/._pagination b/backend/src/main/java/com/example/erpmvp/common/._pagination new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/._pagination differ diff --git a/backend/src/main/java/com/example/erpmvp/common/api/._ApiError.java b/backend/src/main/java/com/example/erpmvp/common/api/._ApiError.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/api/._ApiError.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/api/._ApiResponse.java b/backend/src/main/java/com/example/erpmvp/common/api/._ApiResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/api/._ApiResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/api/._FieldValidationError.java b/backend/src/main/java/com/example/erpmvp/common/api/._FieldValidationError.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/api/._FieldValidationError.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/api/ApiError.java b/backend/src/main/java/com/example/erpmvp/common/api/ApiError.java new file mode 100644 index 0000000..c757c7b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/api/ApiError.java @@ -0,0 +1,19 @@ +package com.example.erpmvp.common.api; + +import java.util.List; + +public record ApiError( + String code, + String message, + List 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 details) { + return new ApiError(code, message, details == null ? List.of() : details); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java b/backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java new file mode 100644 index 0000000..39550e6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/api/ApiResponse.java @@ -0,0 +1,20 @@ +package com.example.erpmvp.common.api; + +import java.time.Instant; + +public record ApiResponse( + boolean success, + T data, + ApiError error, + Instant timestamp +) { + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null, Instant.now()); + } + + public static ApiResponse error(ApiError error) { + return new ApiResponse<>(false, null, error, Instant.now()); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java b/backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java new file mode 100644 index 0000000..a35bc61 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/api/FieldValidationError.java @@ -0,0 +1,7 @@ +package com.example.erpmvp.common.api; + +public record FieldValidationError( + String field, + String message +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/common/audit/._BaseEntity.java b/backend/src/main/java/com/example/erpmvp/common/audit/._BaseEntity.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/audit/._BaseEntity.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/audit/BaseEntity.java b/backend/src/main/java/com/example/erpmvp/common/audit/BaseEntity.java new file mode 100644 index 0000000..ed1ed2e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/audit/BaseEntity.java @@ -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; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._BadRequestException.java b/backend/src/main/java/com/example/erpmvp/common/error/._BadRequestException.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._BadRequestException.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._BusinessException.java b/backend/src/main/java/com/example/erpmvp/common/error/._BusinessException.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._BusinessException.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._ConflictException.java b/backend/src/main/java/com/example/erpmvp/common/error/._ConflictException.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._ConflictException.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._DebugErrorController.java b/backend/src/main/java/com/example/erpmvp/common/error/._DebugErrorController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._DebugErrorController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._GlobalExceptionHandler.java b/backend/src/main/java/com/example/erpmvp/common/error/._GlobalExceptionHandler.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._GlobalExceptionHandler.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._NotFoundException.java b/backend/src/main/java/com/example/erpmvp/common/error/._NotFoundException.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._NotFoundException.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/._UnauthorizedException.java b/backend/src/main/java/com/example/erpmvp/common/error/._UnauthorizedException.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/error/._UnauthorizedException.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/error/BadRequestException.java b/backend/src/main/java/com/example/erpmvp/common/error/BadRequestException.java new file mode 100644 index 0000000..b7d8176 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/BadRequestException.java @@ -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); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/BusinessException.java b/backend/src/main/java/com/example/erpmvp/common/error/BusinessException.java new file mode 100644 index 0000000..ed8e0ce --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/BusinessException.java @@ -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; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/ConflictException.java b/backend/src/main/java/com/example/erpmvp/common/error/ConflictException.java new file mode 100644 index 0000000..cbb4d78 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/ConflictException.java @@ -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); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/DebugErrorController.java b/backend/src/main/java/com/example/erpmvp/common/error/DebugErrorController.java new file mode 100644 index 0000000..a1935ce --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/DebugErrorController.java @@ -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); + } + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java b/backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..863af01 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/GlobalExceptionHandler.java @@ -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> handleMethodArgumentNotValid(MethodArgumentNotValidException exception) { + List details = exception.getBindingResult() + .getFieldErrors() + .stream() + .map(this::toFieldValidationError) + .toList(); + + return buildErrorResponse( + HttpStatus.BAD_REQUEST, + "VALIDATION_ERROR", + "Validation failed", + details + ); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolation(ConstraintViolationException exception) { + List 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> handleNotFound(NotFoundException exception) { + return buildErrorResponse(HttpStatus.NOT_FOUND, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> handleUnauthorized(UnauthorizedException exception) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(ConflictException.class) + public ResponseEntity> handleConflict(ConflictException exception) { + return buildErrorResponse(HttpStatus.CONFLICT, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity> handleBadRequest(BadRequestException exception) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException exception) { + return buildErrorResponse(HttpStatus.UNPROCESSABLE_ENTITY, exception.getErrorCode(), exception.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException exception) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, "BAD_REQUEST", exception.getMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound(NoResourceFoundException exception) { + return buildErrorResponse(HttpStatus.NOT_FOUND, "NOT_FOUND", "Resource not found"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> 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> buildErrorResponse(HttpStatus status, String code, String message) { + return buildErrorResponse(status, code, message, List.of()); + } + + private ResponseEntity> buildErrorResponse( + HttpStatus status, + String code, + String message, + List details + ) { + ApiError error = ApiError.of(code, message, details); + return ResponseEntity.status(status).body(ApiResponse.error(error)); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java b/backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java new file mode 100644 index 0000000..bd623a3 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/NotFoundException.java @@ -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); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java b/backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java new file mode 100644 index 0000000..812bcff --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/error/UnauthorizedException.java @@ -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); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/pagination/._PageRequestDto.java b/backend/src/main/java/com/example/erpmvp/common/pagination/._PageRequestDto.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/pagination/._PageRequestDto.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/pagination/._PageResponseDto.java b/backend/src/main/java/com/example/erpmvp/common/pagination/._PageResponseDto.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/common/pagination/._PageResponseDto.java differ diff --git a/backend/src/main/java/com/example/erpmvp/common/pagination/PageRequestDto.java b/backend/src/main/java/com/example/erpmvp/common/pagination/PageRequestDto.java new file mode 100644 index 0000000..cc3ed9b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/pagination/PageRequestDto.java @@ -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; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/common/pagination/PageResponseDto.java b/backend/src/main/java/com/example/erpmvp/common/pagination/PageResponseDto.java new file mode 100644 index 0000000..9c712b9 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/common/pagination/PageResponseDto.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.common.pagination; + +import java.util.List; + +import org.springframework.data.domain.Page; + +public record PageResponseDto( + List items, + int page, + int size, + long totalElements, + int totalPages, + boolean hasNext, + boolean hasPrevious +) { + + public static PageResponseDto from(Page page) { + return new PageResponseDto<>( + page.getContent(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/config/._CorsConfig.java b/backend/src/main/java/com/example/erpmvp/config/._CorsConfig.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/config/._CorsConfig.java differ diff --git a/backend/src/main/java/com/example/erpmvp/config/._OpenApiConfig.java b/backend/src/main/java/com/example/erpmvp/config/._OpenApiConfig.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/config/._OpenApiConfig.java differ diff --git a/backend/src/main/java/com/example/erpmvp/config/._demo b/backend/src/main/java/com/example/erpmvp/config/._demo new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/config/._demo differ diff --git a/backend/src/main/java/com/example/erpmvp/config/CorsConfig.java b/backend/src/main/java/com/example/erpmvp/config/CorsConfig.java new file mode 100644 index 0000000..b77f6d0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/config/CorsConfig.java @@ -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"); + } + }; + } +} diff --git a/backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java b/backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java new file mode 100644 index 0000000..575fb9b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/config/OpenApiConfig.java @@ -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")); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataProperties.java b/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataProperties.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataProperties.java differ diff --git a/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataSeeder.java b/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataSeeder.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/config/demo/._DemoDataSeeder.java differ diff --git a/backend/src/main/java/com/example/erpmvp/config/demo/DemoDataProperties.java b/backend/src/main/java/com/example/erpmvp/config/demo/DemoDataProperties.java new file mode 100644 index 0000000..878b6ca --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/config/demo/DemoDataProperties.java @@ -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; + } +} diff --git a/backend/src/main/java/com/example/erpmvp/config/demo/DemoDataSeeder.java b/backend/src/main/java/com/example/erpmvp/config/demo/DemoDataSeeder.java new file mode 100644 index 0000000..0aade27 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/config/demo/DemoDataSeeder.java @@ -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 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 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) { + } +} diff --git a/backend/src/main/java/com/example/erpmvp/health/._HealthController.java b/backend/src/main/java/com/example/erpmvp/health/._HealthController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/health/._HealthController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/health/HealthController.java b/backend/src/main/java/com/example/erpmvp/health/HealthController.java new file mode 100644 index 0000000..508b84a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/health/HealthController.java @@ -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 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) { + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/._auth b/backend/src/main/java/com/example/erpmvp/modules/._auth new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._auth differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/._catalog b/backend/src/main/java/com/example/erpmvp/modules/._catalog new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._catalog differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/._dashboard b/backend/src/main/java/com/example/erpmvp/modules/._dashboard new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._dashboard differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/._documents b/backend/src/main/java/com/example/erpmvp/modules/._documents new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._documents differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/._procurement b/backend/src/main/java/com/example/erpmvp/modules/._procurement new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._procurement differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/._sales b/backend/src/main/java/com/example/erpmvp/modules/._sales new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._sales differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/._warehouse b/backend/src/main/java/com/example/erpmvp/modules/._warehouse new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/._warehouse differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/._controller b/backend/src/main/java/com/example/erpmvp/modules/auth/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/._domain b/backend/src/main/java/com/example/erpmvp/modules/auth/._domain new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/._domain differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/._dto b/backend/src/main/java/com/example/erpmvp/modules/auth/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/._repository b/backend/src/main/java/com/example/erpmvp/modules/auth/._repository new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/._repository differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/._security b/backend/src/main/java/com/example/erpmvp/modules/auth/._security new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/._security differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/._service b/backend/src/main/java/com/example/erpmvp/modules/auth/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/controller/._AuthController.java b/backend/src/main/java/com/example/erpmvp/modules/auth/controller/._AuthController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/controller/._AuthController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/controller/AuthController.java b/backend/src/main/java/com/example/erpmvp/modules/auth/controller/AuthController.java new file mode 100644 index 0000000..8f2cbc3 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/controller/AuthController.java @@ -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 login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.success(authService.login(request)); + } + + @GetMapping("/me") + public ApiResponse me(@AuthenticationPrincipal AuthUserDetails principal) { + return ApiResponse.success(MeResponse.from(principal.getUser())); + } + + @PostMapping("/logout") + public ApiResponse logout() { + return ApiResponse.success(new LogoutResponse("Logged out successfully")); + } + + @GetMapping("/protected-test") + public ApiResponse protectedTest(@AuthenticationPrincipal AuthUserDetails principal) { + return ApiResponse.success(new ProtectedTestResponse( + "You are authenticated", + principal.getUser().getEmail(), + principal.getUser().getRole() + )); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._Role.java b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._Role.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._Role.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._User.java b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._User.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/._User.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/domain/Role.java b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/Role.java new file mode 100644 index 0000000..4545890 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/Role.java @@ -0,0 +1,9 @@ +package com.example.erpmvp.modules.auth.domain; + +public enum Role { + ADMIN, + MANAGER, + WAREHOUSE, + FINANCE +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/domain/User.java b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/User.java new file mode 100644 index 0000000..3e41887 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/domain/User.java @@ -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); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._AuthUserResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._AuthUserResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._AuthUserResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginRequest.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LoginResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LogoutResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LogoutResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._LogoutResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._MeResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._MeResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._MeResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._ProtectedTestResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._ProtectedTestResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/._ProtectedTestResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/AuthUserResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/AuthUserResponse.java new file mode 100644 index 0000000..6ed11a9 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/AuthUserResponse.java @@ -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()); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginRequest.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginRequest.java new file mode 100644 index 0000000..4ab49f3 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginRequest.java @@ -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 +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginResponse.java new file mode 100644 index 0000000..0b659cf --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LoginResponse.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.auth.dto; + +public record LoginResponse( + String accessToken, + String tokenType, + long expiresInMinutes, + AuthUserResponse user +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LogoutResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LogoutResponse.java new file mode 100644 index 0000000..17144ed --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/LogoutResponse.java @@ -0,0 +1,5 @@ +package com.example.erpmvp.modules.auth.dto; + +public record LogoutResponse(String message) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/MeResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/MeResponse.java new file mode 100644 index 0000000..d6097bb --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/MeResponse.java @@ -0,0 +1,26 @@ +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 MeResponse( + UUID id, + String email, + String fullName, + Role role, + boolean active +) { + + public static MeResponse from(User user) { + return new MeResponse( + user.getId(), + user.getEmail(), + user.getFullName(), + user.getRole(), + user.isActive() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/dto/ProtectedTestResponse.java b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/ProtectedTestResponse.java new file mode 100644 index 0000000..5007c27 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/dto/ProtectedTestResponse.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.auth.dto; + +import com.example.erpmvp.modules.auth.domain.Role; + +public record ProtectedTestResponse( + String message, + String email, + Role role +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/repository/._UserRepository.java b/backend/src/main/java/com/example/erpmvp/modules/auth/repository/._UserRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/repository/._UserRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/repository/UserRepository.java b/backend/src/main/java/com/example/erpmvp/modules/auth/repository/UserRepository.java new file mode 100644 index 0000000..f2de2ed --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.auth.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.auth.domain.User; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetails.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetails.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetails.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetailsService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetailsService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._AuthUserDetailsService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAccessDeniedHandler.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAccessDeniedHandler.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAccessDeniedHandler.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationEntryPoint.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationEntryPoint.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationFilter.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationFilter.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._JwtAuthenticationFilter.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/._SecurityConfig.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._SecurityConfig.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/security/._SecurityConfig.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetails.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetails.java new file mode 100644 index 0000000..c5a8d43 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetails.java @@ -0,0 +1,44 @@ +package com.example.erpmvp.modules.auth.security; + +import java.util.Collection; +import java.util.List; + +import com.example.erpmvp.modules.auth.domain.User; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public class AuthUserDetails implements UserDetails { + + private final User user; + + public AuthUserDetails(User user) { + this.user = user; + } + + public User getUser() { + return user; + } + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + } + + @Override + public String getPassword() { + return user.getPasswordHash(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public boolean isEnabled() { + return user.isActive(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java new file mode 100644 index 0000000..f2a8f4c --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/AuthUserDetailsService.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.modules.auth.security; + +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.auth.repository.UserRepository; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class AuthUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public AuthUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) { + String email = User.normalizeEmail(username); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + + return new AuthUserDetails(user); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..0433cf1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.example.erpmvp.modules.auth.security; + +import java.io.IOException; + +import com.example.erpmvp.common.api.ApiError; +import com.example.erpmvp.common.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public JwtAccessDeniedHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + ApiResponse body = ApiResponse.error(ApiError.of("ACCESS_DENIED", "Access is denied")); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..8661a3a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.example.erpmvp.modules.auth.security; + +import java.io.IOException; + +import com.example.erpmvp.common.api.ApiError; +import com.example.erpmvp.common.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + ApiResponse body = ApiResponse.error(ApiError.of("UNAUTHORIZED", "Authentication is required")); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), body); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3a5be57 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.example.erpmvp.modules.auth.security; + +import java.io.IOException; + +import com.example.erpmvp.modules.auth.service.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtService jwtService; + private final AuthUserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, AuthUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authorizationHeader == null || !authorizationHeader.startsWith(BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.substring(BEARER_PREFIX.length()); + + try { + if (SecurityContextHolder.getContext().getAuthentication() == null && jwtService.validateToken(token)) { + String email = jwtService.extractEmail(token); + AuthUserDetails userDetails = (AuthUserDetails) userDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (UsernameNotFoundException exception) { + SecurityContextHolder.clearContext(); + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java b/backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java new file mode 100644 index 0000000..dc33a5f --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/security/SecurityConfig.java @@ -0,0 +1,56 @@ +package com.example.erpmvp.modules.auth.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter, + JwtAuthenticationEntryPoint authenticationEntryPoint, + JwtAccessDeniedHandler accessDeniedHandler + ) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/login", + "/api/health", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs", + "/v3/api-docs/**", + "/api/debug/error", + "/api/debug/error/**" + ).permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthDataInitializer.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthDataInitializer.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthDataInitializer.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._AuthService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/._JwtService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._JwtService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/auth/service/._JwtService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthDataInitializer.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthDataInitializer.java new file mode 100644 index 0000000..f7f5acb --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthDataInitializer.java @@ -0,0 +1,55 @@ +package com.example.erpmvp.modules.auth.service; + +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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class AuthDataInitializer implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(AuthDataInitializer.class); + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final String adminEmail; + private final String adminPassword; + + public AuthDataInitializer( + UserRepository userRepository, + PasswordEncoder passwordEncoder, + @Value("${app.seed.admin.email}") String adminEmail, + @Value("${app.seed.admin.password}") String adminPassword + ) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + @Override + @Transactional + public void run(String... args) { + if (userRepository.count() > 0) { + return; + } + + User admin = new User( + adminEmail, + "System Administrator", + passwordEncoder.encode(adminPassword), + Role.ADMIN + ); + + userRepository.save(admin); + log.info("Seeded default admin user: {}", admin.getEmail()); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java new file mode 100644 index 0000000..2bf8d0d --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/service/AuthService.java @@ -0,0 +1,47 @@ +package com.example.erpmvp.modules.auth.service; + +import com.example.erpmvp.common.error.UnauthorizedException; +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.auth.dto.AuthUserResponse; +import com.example.erpmvp.modules.auth.dto.LoginRequest; +import com.example.erpmvp.modules.auth.dto.LoginResponse; +import com.example.erpmvp.modules.auth.repository.UserRepository; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtService jwtService) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + } + + @Transactional(readOnly = true) + public LoginResponse login(LoginRequest request) { + String email = User.normalizeEmail(request.email()); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UnauthorizedException("INVALID_CREDENTIALS", "Invalid email or password")); + + if (!user.isActive() || !passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new UnauthorizedException("INVALID_CREDENTIALS", "Invalid email or password"); + } + + String token = jwtService.generateToken(user); + + return new LoginResponse( + token, + "Bearer", + jwtService.getExpirationMinutes(), + AuthUserResponse.from(user) + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java b/backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java new file mode 100644 index 0000000..d60b578 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/auth/service/JwtService.java @@ -0,0 +1,87 @@ +package com.example.erpmvp.modules.auth.service; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import javax.crypto.SecretKey; + +import com.example.erpmvp.modules.auth.domain.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + private final String secret; + private final long expirationMinutes; + + public JwtService( + @Value("${app.jwt.secret}") String secret, + @Value("${app.jwt.expiration-minutes}") long expirationMinutes + ) { + this.secret = secret; + this.expirationMinutes = expirationMinutes; + } + + @PostConstruct + void validateConfig() { + if (secret == null || secret.getBytes(StandardCharsets.UTF_8).length < 32) { + throw new IllegalStateException("JWT secret must contain at least 32 bytes"); + } + + if (expirationMinutes <= 0) { + throw new IllegalStateException("JWT expiration must be greater than zero"); + } + } + + public String generateToken(User user) { + Instant now = Instant.now(); + Instant expiresAt = now.plus(Duration.ofMinutes(expirationMinutes)); + + return Jwts.builder() + .subject(user.getEmail()) + .claim("role", user.getRole().name()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiresAt)) + .signWith(signingKey()) + .compact(); + } + + public boolean validateToken(String token) { + try { + Claims claims = parseClaims(token); + return claims.getExpiration().after(new Date()); + } catch (JwtException | IllegalArgumentException exception) { + return false; + } + } + + public String extractEmail(String token) { + return parseClaims(token).getSubject(); + } + + public long getExpirationMinutes() { + return expirationMinutes; + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey signingKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/._controller b/backend/src/main/java/com/example/erpmvp/modules/catalog/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/._domain b/backend/src/main/java/com/example/erpmvp/modules/catalog/._domain new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/._domain differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/._dto b/backend/src/main/java/com/example/erpmvp/modules/catalog/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/._repository b/backend/src/main/java/com/example/erpmvp/modules/catalog/._repository new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/._repository differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/._service b/backend/src/main/java/com/example/erpmvp/modules/catalog/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._CustomerController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._CustomerController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._CustomerController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._ProductController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._ProductController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._ProductController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._SupplierController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._SupplierController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._SupplierController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._WarehouseController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._WarehouseController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/._WarehouseController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/CustomerController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/CustomerController.java new file mode 100644 index 0000000..d61fd76 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/CustomerController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateCustomerRequest; +import com.example.erpmvp.modules.catalog.dto.CustomerResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateCustomerRequest; +import com.example.erpmvp.modules.catalog.service.CustomerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/customers") +@Tag(name = "Catalog Customers", description = "B2B customer directory") +public class CustomerController { + + private final CustomerService customerService; + + public CustomerController(CustomerService customerService) { + this.customerService = customerService; + } + + @GetMapping + @Operation(summary = "List customers") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(customerService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get customer by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(customerService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create customer") + public ApiResponse create(@Valid @RequestBody CreateCustomerRequest request) { + return ApiResponse.success(customerService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update customer") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateCustomerRequest request) { + return ApiResponse.success(customerService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate customer") + public ApiResponse delete(@PathVariable UUID id) { + customerService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java new file mode 100644 index 0000000..dbbdc86 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/ProductController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateProductRequest; +import com.example.erpmvp.modules.catalog.dto.ProductResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateProductRequest; +import com.example.erpmvp.modules.catalog.service.ProductService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/products") +@Tag(name = "Catalog Products", description = "Product and SKU directory") +public class ProductController { + + private final ProductService productService; + + public ProductController(ProductService productService) { + this.productService = productService; + } + + @GetMapping + @Operation(summary = "List products") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(productService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get product by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(productService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create product") + public ApiResponse create(@Valid @RequestBody CreateProductRequest request) { + return ApiResponse.success(productService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update product") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateProductRequest request) { + return ApiResponse.success(productService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate product") + public ApiResponse delete(@PathVariable UUID id) { + productService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java new file mode 100644 index 0000000..8c5ee2e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/SupplierController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateSupplierRequest; +import com.example.erpmvp.modules.catalog.dto.SupplierResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateSupplierRequest; +import com.example.erpmvp.modules.catalog.service.SupplierService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/suppliers") +@Tag(name = "Catalog Suppliers", description = "Supplier directory") +public class SupplierController { + + private final SupplierService supplierService; + + public SupplierController(SupplierService supplierService) { + this.supplierService = supplierService; + } + + @GetMapping + @Operation(summary = "List suppliers") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(supplierService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get supplier by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(supplierService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create supplier") + public ApiResponse create(@Valid @RequestBody CreateSupplierRequest request) { + return ApiResponse.success(supplierService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update supplier") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateSupplierRequest request) { + return ApiResponse.success(supplierService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate supplier") + public ApiResponse delete(@PathVariable UUID id) { + supplierService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java new file mode 100644 index 0000000..390bfd5 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/controller/WarehouseController.java @@ -0,0 +1,80 @@ +package com.example.erpmvp.modules.catalog.controller; + +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.dto.CreateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.UpdateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.WarehouseResponse; +import com.example.erpmvp.modules.catalog.service.WarehouseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/catalog/warehouses") +@Tag(name = "Catalog Warehouses", description = "Warehouse directory") +public class WarehouseController { + + private final WarehouseService warehouseService; + + public WarehouseController(WarehouseService warehouseService) { + this.warehouseService = warehouseService; + } + + @GetMapping + @Operation(summary = "List warehouses") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "true") Boolean active, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(warehouseService.list(search, active, page, size)); + } + + @GetMapping("/{id}") + @Operation(summary = "Get warehouse by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(warehouseService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create warehouse") + public ApiResponse create(@Valid @RequestBody CreateWarehouseRequest request) { + return ApiResponse.success(warehouseService.create(request)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update warehouse") + public ApiResponse update(@PathVariable UUID id, @Valid @RequestBody UpdateWarehouseRequest request) { + return ApiResponse.success(warehouseService.update(id, request)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Deactivate warehouse") + public ApiResponse delete(@PathVariable UUID id) { + warehouseService.delete(id); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Customer.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Customer.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Customer.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Product.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Product.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Product.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Supplier.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Supplier.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Supplier.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Warehouse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Warehouse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/._Warehouse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Customer.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Customer.java new file mode 100644 index 0000000..e6983ff --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Customer.java @@ -0,0 +1,101 @@ +package com.example.erpmvp.modules.catalog.domain; + +import java.util.Locale; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "catalog_customers") +public class Customer extends BaseEntity { + + @Column(name = "company_name", nullable = false, length = 255) + private String companyName; + + @Column(length = 50) + private String bin; + + @Column(name = "contact_name", length = 255) + private String contactName; + + @Column(length = 100) + private String phone; + + @Column(length = 255) + private String email; + + @Column(columnDefinition = "TEXT") + private String address; + + @Column(nullable = false) + private boolean active = true; + + protected Customer() { + } + + public Customer(String companyName, String bin, String contactName, String phone, String email, String address) { + this.companyName = trim(companyName); + this.bin = trim(bin); + this.contactName = trim(contactName); + this.phone = trim(phone); + this.email = normalizeEmail(email); + this.address = trim(address); + this.active = true; + } + + public void update(String companyName, String bin, String contactName, String phone, String email, String address, Boolean active) { + this.companyName = trim(companyName); + this.bin = trim(bin); + this.contactName = trim(contactName); + this.phone = trim(phone); + this.email = normalizeEmail(email); + this.address = trim(address); + + if (active != null) { + this.active = active; + } + } + + public void deactivate() { + this.active = false; + } + + public String getCompanyName() { + return companyName; + } + + public String getBin() { + return bin; + } + + public String getContactName() { + return contactName; + } + + public String getPhone() { + return phone; + } + + public String getEmail() { + return email; + } + + public String getAddress() { + return address; + } + + public boolean isActive() { + return active; + } + + private static String normalizeEmail(String value) { + return value == null ? null : value.trim().toLowerCase(Locale.ROOT); + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Product.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Product.java new file mode 100644 index 0000000..00ba0df --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Product.java @@ -0,0 +1,98 @@ +package com.example.erpmvp.modules.catalog.domain; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "catalog_products") +public class Product extends BaseEntity { + + @Column(nullable = false, unique = true, length = 100) + private String sku; + + @Column(nullable = false, length = 255) + private String name; + + @Column(length = 150) + private String category; + + @Column(nullable = false, length = 50) + private String unit; + + @Column(length = 100) + private String barcode; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private boolean active = true; + + protected Product() { + } + + public Product(String sku, String name, String category, String unit, String barcode, String description) { + this.sku = normalizeCode(sku); + this.name = trim(name); + this.category = trim(category); + this.unit = trim(unit); + this.barcode = trim(barcode); + this.description = trim(description); + this.active = true; + } + + public void update(String name, String category, String unit, String barcode, String description, Boolean active) { + this.name = trim(name); + this.category = trim(category); + this.unit = trim(unit); + this.barcode = trim(barcode); + this.description = trim(description); + + if (active != null) { + this.active = active; + } + } + + public void deactivate() { + this.active = false; + } + + public String getSku() { + return sku; + } + + public String getName() { + return name; + } + + public String getCategory() { + return category; + } + + public String getUnit() { + return unit; + } + + public String getBarcode() { + return barcode; + } + + public String getDescription() { + return description; + } + + public boolean isActive() { + return active; + } + + public static String normalizeCode(String value) { + return value == null ? null : value.trim().toUpperCase(); + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Supplier.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Supplier.java new file mode 100644 index 0000000..2013d1f --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Supplier.java @@ -0,0 +1,101 @@ +package com.example.erpmvp.modules.catalog.domain; + +import java.util.Locale; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "catalog_suppliers") +public class Supplier extends BaseEntity { + + @Column(name = "company_name", nullable = false, length = 255) + private String companyName; + + @Column(length = 50) + private String bin; + + @Column(name = "contact_name", length = 255) + private String contactName; + + @Column(length = 100) + private String phone; + + @Column(length = 255) + private String email; + + @Column(columnDefinition = "TEXT") + private String address; + + @Column(nullable = false) + private boolean active = true; + + protected Supplier() { + } + + public Supplier(String companyName, String bin, String contactName, String phone, String email, String address) { + this.companyName = trim(companyName); + this.bin = trim(bin); + this.contactName = trim(contactName); + this.phone = trim(phone); + this.email = normalizeEmail(email); + this.address = trim(address); + this.active = true; + } + + public void update(String companyName, String bin, String contactName, String phone, String email, String address, Boolean active) { + this.companyName = trim(companyName); + this.bin = trim(bin); + this.contactName = trim(contactName); + this.phone = trim(phone); + this.email = normalizeEmail(email); + this.address = trim(address); + + if (active != null) { + this.active = active; + } + } + + public void deactivate() { + this.active = false; + } + + public String getCompanyName() { + return companyName; + } + + public String getBin() { + return bin; + } + + public String getContactName() { + return contactName; + } + + public String getPhone() { + return phone; + } + + public String getEmail() { + return email; + } + + public String getAddress() { + return address; + } + + public boolean isActive() { + return active; + } + + private static String normalizeEmail(String value) { + return value == null ? null : value.trim().toLowerCase(Locale.ROOT); + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Warehouse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Warehouse.java new file mode 100644 index 0000000..04be07a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/domain/Warehouse.java @@ -0,0 +1,71 @@ +package com.example.erpmvp.modules.catalog.domain; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "catalog_warehouses") +public class Warehouse extends BaseEntity { + + @Column(nullable = false, unique = true, length = 100) + private String code; + + @Column(nullable = false, length = 255) + private String name; + + @Column(columnDefinition = "TEXT") + private String address; + + @Column(nullable = false) + private boolean active = true; + + protected Warehouse() { + } + + public Warehouse(String code, String name, String address) { + this.code = normalizeCode(code); + this.name = trim(name); + this.address = trim(address); + this.active = true; + } + + public void update(String name, String address, Boolean active) { + this.name = trim(name); + this.address = trim(address); + + if (active != null) { + this.active = active; + } + } + + public void deactivate() { + this.active = false; + } + + public String getCode() { + return code; + } + + public String getName() { + return name; + } + + public String getAddress() { + return address; + } + + public boolean isActive() { + return active; + } + + public static String normalizeCode(String value) { + return value == null ? null : value.trim().toUpperCase(); + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateCustomerRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateCustomerRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateCustomerRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateProductRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateProductRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateProductRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateSupplierRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateSupplierRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateSupplierRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateWarehouseRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateWarehouseRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CreateWarehouseRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CustomerResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CustomerResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._CustomerResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._ProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._ProductResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._ProductResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._SupplierResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._SupplierResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._SupplierResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateCustomerRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateCustomerRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateCustomerRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateProductRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateProductRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateProductRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateSupplierRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateSupplierRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateSupplierRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateWarehouseRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateWarehouseRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._UpdateWarehouseRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._WarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._WarehouseResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/._WarehouseResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateCustomerRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateCustomerRequest.java new file mode 100644 index 0000000..e1edd6a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateCustomerRequest.java @@ -0,0 +1,16 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateCustomerRequest( + @NotBlank @Size(max = 255) String companyName, + @Size(max = 50) String bin, + @Size(max = 255) String contactName, + @Size(max = 100) String phone, + @Email @Size(max = 255) String email, + String address +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateProductRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateProductRequest.java new file mode 100644 index 0000000..71563aa --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateProductRequest.java @@ -0,0 +1,15 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateProductRequest( + @NotBlank @Size(max = 100) String sku, + @NotBlank @Size(max = 255) String name, + @Size(max = 150) String category, + @NotBlank @Size(max = 50) String unit, + @Size(max = 100) String barcode, + String description +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateSupplierRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateSupplierRequest.java new file mode 100644 index 0000000..34001a6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateSupplierRequest.java @@ -0,0 +1,16 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateSupplierRequest( + @NotBlank @Size(max = 255) String companyName, + @Size(max = 50) String bin, + @Size(max = 255) String contactName, + @Size(max = 100) String phone, + @Email @Size(max = 255) String email, + String address +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateWarehouseRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateWarehouseRequest.java new file mode 100644 index 0000000..00bcc33 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CreateWarehouseRequest.java @@ -0,0 +1,12 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateWarehouseRequest( + @NotBlank @Size(max = 100) String code, + @NotBlank @Size(max = 255) String name, + String address +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CustomerResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CustomerResponse.java new file mode 100644 index 0000000..c732f04 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/CustomerResponse.java @@ -0,0 +1,36 @@ +package com.example.erpmvp.modules.catalog.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Customer; + +public record CustomerResponse( + UUID id, + String companyName, + String bin, + String contactName, + String phone, + String email, + String address, + boolean active, + Instant createdAt, + Instant updatedAt +) { + + public static CustomerResponse from(Customer customer) { + return new CustomerResponse( + customer.getId(), + customer.getCompanyName(), + customer.getBin(), + customer.getContactName(), + customer.getPhone(), + customer.getEmail(), + customer.getAddress(), + customer.isActive(), + customer.getCreatedAt(), + customer.getUpdatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/ProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/ProductResponse.java new file mode 100644 index 0000000..27da0b7 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/ProductResponse.java @@ -0,0 +1,36 @@ +package com.example.erpmvp.modules.catalog.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +public record ProductResponse( + UUID id, + String sku, + String name, + String category, + String unit, + String barcode, + String description, + boolean active, + Instant createdAt, + Instant updatedAt +) { + + public static ProductResponse from(Product product) { + return new ProductResponse( + product.getId(), + product.getSku(), + product.getName(), + product.getCategory(), + product.getUnit(), + product.getBarcode(), + product.getDescription(), + product.isActive(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/SupplierResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/SupplierResponse.java new file mode 100644 index 0000000..4e6a788 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/SupplierResponse.java @@ -0,0 +1,36 @@ +package com.example.erpmvp.modules.catalog.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Supplier; + +public record SupplierResponse( + UUID id, + String companyName, + String bin, + String contactName, + String phone, + String email, + String address, + boolean active, + Instant createdAt, + Instant updatedAt +) { + + public static SupplierResponse from(Supplier supplier) { + return new SupplierResponse( + supplier.getId(), + supplier.getCompanyName(), + supplier.getBin(), + supplier.getContactName(), + supplier.getPhone(), + supplier.getEmail(), + supplier.getAddress(), + supplier.isActive(), + supplier.getCreatedAt(), + supplier.getUpdatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateCustomerRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateCustomerRequest.java new file mode 100644 index 0000000..84b820b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateCustomerRequest.java @@ -0,0 +1,17 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateCustomerRequest( + @NotBlank @Size(max = 255) String companyName, + @Size(max = 50) String bin, + @Size(max = 255) String contactName, + @Size(max = 100) String phone, + @Email @Size(max = 255) String email, + String address, + Boolean active +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateProductRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateProductRequest.java new file mode 100644 index 0000000..8a3917d --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateProductRequest.java @@ -0,0 +1,15 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateProductRequest( + @NotBlank @Size(max = 255) String name, + @Size(max = 150) String category, + @NotBlank @Size(max = 50) String unit, + @Size(max = 100) String barcode, + String description, + Boolean active +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateSupplierRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateSupplierRequest.java new file mode 100644 index 0000000..81d342d --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateSupplierRequest.java @@ -0,0 +1,17 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateSupplierRequest( + @NotBlank @Size(max = 255) String companyName, + @Size(max = 50) String bin, + @Size(max = 255) String contactName, + @Size(max = 100) String phone, + @Email @Size(max = 255) String email, + String address, + Boolean active +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateWarehouseRequest.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateWarehouseRequest.java new file mode 100644 index 0000000..db4f25b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/UpdateWarehouseRequest.java @@ -0,0 +1,12 @@ +package com.example.erpmvp.modules.catalog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record UpdateWarehouseRequest( + @NotBlank @Size(max = 255) String name, + String address, + Boolean active +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/WarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/WarehouseResponse.java new file mode 100644 index 0000000..062f3af --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/dto/WarehouseResponse.java @@ -0,0 +1,30 @@ +package com.example.erpmvp.modules.catalog.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +public record WarehouseResponse( + UUID id, + String code, + String name, + String address, + boolean active, + Instant createdAt, + Instant updatedAt +) { + + public static WarehouseResponse from(Warehouse warehouse) { + return new WarehouseResponse( + warehouse.getId(), + warehouse.getCode(), + warehouse.getName(), + warehouse.getAddress(), + warehouse.isActive(), + warehouse.getCreatedAt(), + warehouse.getUpdatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._CustomerRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._CustomerRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._CustomerRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._ProductRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._ProductRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._ProductRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._SupplierRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._SupplierRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._SupplierRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._WarehouseRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._WarehouseRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/._WarehouseRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/CustomerRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/CustomerRepository.java new file mode 100644 index 0000000..cb30a12 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/CustomerRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Customer; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CustomerRepository extends JpaRepository { + + Optional findByBin(String bin); + + Optional findByCompanyName(String companyName); + + @Query(""" + select c from Customer c + where (:active is null or c.active = :active) + and ( + :search is null + or :search = '' + or lower(c.companyName) like lower(concat('%', :search, '%')) + or lower(coalesce(c.bin, '')) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java new file mode 100644 index 0000000..ee0f9c1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/ProductRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ProductRepository extends JpaRepository { + + boolean existsBySku(String sku); + + Optional findBySku(String sku); + + @Query(""" + select p from Product p + where (:active is null or p.active = :active) + and ( + :search is null + or :search = '' + or lower(p.sku) like lower(concat('%', :search, '%')) + or lower(p.name) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java new file mode 100644 index 0000000..2599a43 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/SupplierRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Supplier; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SupplierRepository extends JpaRepository { + + Optional findByBin(String bin); + + Optional findByCompanyName(String companyName); + + @Query(""" + select s from Supplier s + where (:active is null or s.active = :active) + and ( + :search is null + or :search = '' + or lower(s.companyName) like lower(concat('%', :search, '%')) + or lower(coalesce(s.bin, '')) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java new file mode 100644 index 0000000..47b7eb4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/repository/WarehouseRepository.java @@ -0,0 +1,35 @@ +package com.example.erpmvp.modules.catalog.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface WarehouseRepository extends JpaRepository { + + boolean existsByCode(String code); + + Optional findByCode(String code); + + @Query(""" + select w from Warehouse w + where (:active is null or w.active = :active) + and ( + :search is null + or :search = '' + or lower(w.code) like lower(concat('%', :search, '%')) + or lower(w.name) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("search") String search, + @Param("active") Boolean active, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._CustomerService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._CustomerService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._CustomerService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._ProductService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._ProductService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._ProductService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._SupplierService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._SupplierService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._SupplierService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._WarehouseService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._WarehouseService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/._WarehouseService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/CustomerService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/CustomerService.java new file mode 100644 index 0000000..e160524 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/CustomerService.java @@ -0,0 +1,83 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Customer; +import com.example.erpmvp.modules.catalog.dto.CreateCustomerRequest; +import com.example.erpmvp.modules.catalog.dto.CustomerResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateCustomerRequest; +import com.example.erpmvp.modules.catalog.repository.CustomerRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CustomerService { + + private final CustomerRepository customerRepository; + + public CustomerService(CustomerRepository customerRepository) { + this.customerRepository = customerRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(customerRepository.search(normalizeSearch(search), active, pageable).map(CustomerResponse::from)); + } + + @Transactional(readOnly = true) + public CustomerResponse getById(UUID id) { + return CustomerResponse.from(findCustomer(id)); + } + + @Transactional + public CustomerResponse create(CreateCustomerRequest request) { + Customer customer = new Customer( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address() + ); + + return CustomerResponse.from(customerRepository.save(customer)); + } + + @Transactional + public CustomerResponse update(UUID id, UpdateCustomerRequest request) { + Customer customer = findCustomer(id); + customer.update( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address(), + request.active() + ); + + return CustomerResponse.from(customer); + } + + @Transactional + public void delete(UUID id) { + Customer customer = findCustomer(id); + customer.deactivate(); + } + + private Customer findCustomer(UUID id) { + return customerRepository.findById(id) + .orElseThrow(() -> new NotFoundException("CUSTOMER_NOT_FOUND", "Customer not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java new file mode 100644 index 0000000..cfb8a8b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/ProductService.java @@ -0,0 +1,89 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.ConflictException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Product; +import com.example.erpmvp.modules.catalog.dto.CreateProductRequest; +import com.example.erpmvp.modules.catalog.dto.ProductResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateProductRequest; +import com.example.erpmvp.modules.catalog.repository.ProductRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProductService { + + private final ProductRepository productRepository; + + public ProductService(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(productRepository.search(normalizeSearch(search), active, pageable).map(ProductResponse::from)); + } + + @Transactional(readOnly = true) + public ProductResponse getById(UUID id) { + return ProductResponse.from(findProduct(id)); + } + + @Transactional + public ProductResponse create(CreateProductRequest request) { + String sku = Product.normalizeCode(request.sku()); + + if (productRepository.existsBySku(sku)) { + throw new ConflictException("PRODUCT_SKU_ALREADY_EXISTS", "Product SKU already exists"); + } + + Product product = new Product( + request.sku(), + request.name(), + request.category(), + request.unit(), + request.barcode(), + request.description() + ); + + return ProductResponse.from(productRepository.save(product)); + } + + @Transactional + public ProductResponse update(UUID id, UpdateProductRequest request) { + Product product = findProduct(id); + product.update( + request.name(), + request.category(), + request.unit(), + request.barcode(), + request.description(), + request.active() + ); + + return ProductResponse.from(product); + } + + @Transactional + public void delete(UUID id) { + Product product = findProduct(id); + product.deactivate(); + } + + private Product findProduct(UUID id) { + return productRepository.findById(id) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java new file mode 100644 index 0000000..18924e6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/SupplierService.java @@ -0,0 +1,83 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Supplier; +import com.example.erpmvp.modules.catalog.dto.CreateSupplierRequest; +import com.example.erpmvp.modules.catalog.dto.SupplierResponse; +import com.example.erpmvp.modules.catalog.dto.UpdateSupplierRequest; +import com.example.erpmvp.modules.catalog.repository.SupplierRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SupplierService { + + private final SupplierRepository supplierRepository; + + public SupplierService(SupplierRepository supplierRepository) { + this.supplierRepository = supplierRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(supplierRepository.search(normalizeSearch(search), active, pageable).map(SupplierResponse::from)); + } + + @Transactional(readOnly = true) + public SupplierResponse getById(UUID id) { + return SupplierResponse.from(findSupplier(id)); + } + + @Transactional + public SupplierResponse create(CreateSupplierRequest request) { + Supplier supplier = new Supplier( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address() + ); + + return SupplierResponse.from(supplierRepository.save(supplier)); + } + + @Transactional + public SupplierResponse update(UUID id, UpdateSupplierRequest request) { + Supplier supplier = findSupplier(id); + supplier.update( + request.companyName(), + request.bin(), + request.contactName(), + request.phone(), + request.email(), + request.address(), + request.active() + ); + + return SupplierResponse.from(supplier); + } + + @Transactional + public void delete(UUID id) { + Supplier supplier = findSupplier(id); + supplier.deactivate(); + } + + private Supplier findSupplier(UUID id) { + return supplierRepository.findById(id) + .orElseThrow(() -> new NotFoundException("SUPPLIER_NOT_FOUND", "Supplier not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java new file mode 100644 index 0000000..da9de91 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/catalog/service/WarehouseService.java @@ -0,0 +1,75 @@ +package com.example.erpmvp.modules.catalog.service; + +import java.util.UUID; + +import com.example.erpmvp.common.error.ConflictException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import com.example.erpmvp.modules.catalog.dto.CreateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.UpdateWarehouseRequest; +import com.example.erpmvp.modules.catalog.dto.WarehouseResponse; +import com.example.erpmvp.modules.catalog.repository.WarehouseRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WarehouseService { + + private final WarehouseRepository warehouseRepository; + + public WarehouseService(WarehouseRepository warehouseRepository) { + this.warehouseRepository = warehouseRepository; + } + + @Transactional(readOnly = true) + public PageResponseDto list(String search, Boolean active, int page, int size) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(warehouseRepository.search(normalizeSearch(search), active, pageable).map(WarehouseResponse::from)); + } + + @Transactional(readOnly = true) + public WarehouseResponse getById(UUID id) { + return WarehouseResponse.from(findWarehouse(id)); + } + + @Transactional + public WarehouseResponse create(CreateWarehouseRequest request) { + String code = Warehouse.normalizeCode(request.code()); + + if (warehouseRepository.existsByCode(code)) { + throw new ConflictException("WAREHOUSE_CODE_ALREADY_EXISTS", "Warehouse code already exists"); + } + + Warehouse warehouse = new Warehouse(request.code(), request.name(), request.address()); + + return WarehouseResponse.from(warehouseRepository.save(warehouse)); + } + + @Transactional + public WarehouseResponse update(UUID id, UpdateWarehouseRequest request) { + Warehouse warehouse = findWarehouse(id); + warehouse.update(request.name(), request.address(), request.active()); + + return WarehouseResponse.from(warehouse); + } + + @Transactional + public void delete(UUID id) { + Warehouse warehouse = findWarehouse(id); + warehouse.deactivate(); + } + + private Warehouse findWarehouse(UUID id) { + return warehouseRepository.findById(id) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/._controller b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/._dto b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/._service b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/._DashboardController.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/._DashboardController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/._DashboardController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/DashboardController.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/DashboardController.java new file mode 100644 index 0000000..bc1fe09 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/controller/DashboardController.java @@ -0,0 +1,95 @@ +package com.example.erpmvp.modules.dashboard.controller; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.dashboard.dto.DashboardProcurementResponse; +import com.example.erpmvp.modules.dashboard.dto.DashboardSalesResponse; +import com.example.erpmvp.modules.dashboard.dto.DashboardSummaryResponse; +import com.example.erpmvp.modules.dashboard.dto.DashboardWarehouseResponse; +import com.example.erpmvp.modules.dashboard.dto.LowStockItemResponse; +import com.example.erpmvp.modules.dashboard.dto.RecentActivityResponse; +import com.example.erpmvp.modules.dashboard.service.DashboardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/dashboard") +@Tag(name = "Dashboard", description = "Read-only ERP analytics") +public class DashboardController { + + private final DashboardService dashboardService; + + public DashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + @GetMapping("/summary") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get dashboard summary") + public ApiResponse summary() { + return ApiResponse.success(dashboardService.summary()); + } + + @GetMapping("/sales") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get sales analytics") + public ApiResponse sales( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate + ) { + return ApiResponse.success(dashboardService.sales(fromDate, toDate)); + } + + @GetMapping("/procurement") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get procurement analytics") + public ApiResponse procurement( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate + ) { + return ApiResponse.success(dashboardService.procurement(fromDate, toDate)); + } + + @GetMapping("/warehouse") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get warehouse analytics") + public ApiResponse warehouse() { + return ApiResponse.success(dashboardService.warehouse()); + } + + @GetMapping("/recent-activities") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get recent ERP activities") + public ApiResponse> recentActivities( + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit + ) { + return ApiResponse.success(dashboardService.recentActivities(limit)); + } + + @GetMapping("/low-stock") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get low stock items") + public ApiResponse> lowStock( + @RequestParam(defaultValue = "10") @DecimalMin("0.0") BigDecimal threshold, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(dashboardService.lowStock(threshold, page, size)); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DailyAmountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DailyAmountMetric.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DailyAmountMetric.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardProcurementResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardProcurementResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardProcurementResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSalesResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSalesResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSalesResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSummaryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSummaryResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardSummaryResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardWarehouseResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._DashboardWarehouseResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._LowStockItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._LowStockItemResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._LowStockItemResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._MovementTypeMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._MovementTypeMetric.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._MovementTypeMetric.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._RecentActivityResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._RecentActivityResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._RecentActivityResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._StatusAmountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._StatusAmountMetric.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._StatusAmountMetric.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TopPartyMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TopPartyMetric.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TopPartyMetric.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TypeCountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TypeCountMetric.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._TypeCountMetric.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._WarehouseStockMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._WarehouseStockMetric.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/._WarehouseStockMetric.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DailyAmountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DailyAmountMetric.java new file mode 100644 index 0000000..b89dfc5 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DailyAmountMetric.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record DailyAmountMetric( + LocalDate date, + long ordersCount, + BigDecimal totalAmount +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardProcurementResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardProcurementResponse.java new file mode 100644 index 0000000..a13b49b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardProcurementResponse.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardProcurementResponse( + long totalPurchaseOrders, + BigDecimal totalAmount, + BigDecimal averagePurchaseOrderAmount, + List purchaseOrdersByStatus, + List dailyProcurement, + List topSuppliers +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java new file mode 100644 index 0000000..d10870e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSalesResponse.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardSalesResponse( + long totalOrders, + BigDecimal totalAmount, + BigDecimal averageOrderAmount, + List ordersByStatus, + List dailySales, + List topCustomers +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java new file mode 100644 index 0000000..cfa05b6 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardSummaryResponse.java @@ -0,0 +1,31 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardSummaryResponse( + long productsCount, + long activeProductsCount, + long suppliersCount, + long customersCount, + long warehousesCount, + long customerOrdersCount, + long activeCustomerOrdersCount, + BigDecimal totalSalesAmount, + List salesOrdersByStatus, + long purchaseOrdersCount, + long activePurchaseOrdersCount, + BigDecimal totalProcurementAmount, + List purchaseOrdersByStatus, + long stockItemsCount, + BigDecimal totalQuantityOnHand, + long lowStockItemsCount, + long stockMovementsCount, + long documentsCount, + List documentsByType, + List recentCustomerOrders, + List recentPurchaseOrders, + List recentStockMovements, + List recentDocuments +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java new file mode 100644 index 0000000..cbba7f0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/DashboardWarehouseResponse.java @@ -0,0 +1,18 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.List; + +public record DashboardWarehouseResponse( + long stockItemsCount, + BigDecimal totalQuantityOnHand, + long lowStockItemsCount, + long movementsCount, + BigDecimal inboundQuantity, + BigDecimal outboundQuantity, + BigDecimal adjustmentInQuantity, + BigDecimal adjustmentOutQuantity, + List stockByWarehouse, + List movementsByType +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java new file mode 100644 index 0000000..a2a4eba --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/LowStockItemResponse.java @@ -0,0 +1,17 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record LowStockItemResponse( + UUID warehouseId, + String warehouseCode, + String warehouseName, + UUID productId, + String productSku, + String productName, + String unit, + BigDecimal quantityOnHand, + BigDecimal threshold +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java new file mode 100644 index 0000000..4c358d1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/MovementTypeMetric.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; + +public record MovementTypeMetric( + String movementType, + long count, + BigDecimal totalQuantity +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java new file mode 100644 index 0000000..7ba9573 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/RecentActivityResponse.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.time.Instant; +import java.util.UUID; + +public record RecentActivityResponse( + UUID id, + String type, + String title, + String description, + Instant createdAt, + String link +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java new file mode 100644 index 0000000..30ffdb1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/StatusAmountMetric.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; + +public record StatusAmountMetric( + String status, + long count, + BigDecimal totalAmount +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java new file mode 100644 index 0000000..6a30871 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TopPartyMetric.java @@ -0,0 +1,12 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record TopPartyMetric( + UUID partyId, + String companyName, + long ordersCount, + BigDecimal totalAmount +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java new file mode 100644 index 0000000..a3e1e15 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/TypeCountMetric.java @@ -0,0 +1,7 @@ +package com.example.erpmvp.modules.dashboard.dto; + +public record TypeCountMetric( + String type, + long count +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java new file mode 100644 index 0000000..1a9d5f9 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/dto/WarehouseStockMetric.java @@ -0,0 +1,13 @@ +package com.example.erpmvp.modules.dashboard.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record WarehouseStockMetric( + UUID warehouseId, + String warehouseCode, + String warehouseName, + long productsCount, + BigDecimal totalQuantityOnHand +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/._DashboardService.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/._DashboardService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/._DashboardService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/DashboardService.java b/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/DashboardService.java new file mode 100644 index 0000000..dd7ecf1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/dashboard/service/DashboardService.java @@ -0,0 +1,557 @@ +package com.example.erpmvp.modules.dashboard.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.error.BadRequestException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.dashboard.dto.DailyAmountMetric; +import com.example.erpmvp.modules.dashboard.dto.DashboardProcurementResponse; +import com.example.erpmvp.modules.dashboard.dto.DashboardSalesResponse; +import com.example.erpmvp.modules.dashboard.dto.DashboardSummaryResponse; +import com.example.erpmvp.modules.dashboard.dto.DashboardWarehouseResponse; +import com.example.erpmvp.modules.dashboard.dto.LowStockItemResponse; +import com.example.erpmvp.modules.dashboard.dto.MovementTypeMetric; +import com.example.erpmvp.modules.dashboard.dto.RecentActivityResponse; +import com.example.erpmvp.modules.dashboard.dto.StatusAmountMetric; +import com.example.erpmvp.modules.dashboard.dto.TopPartyMetric; +import com.example.erpmvp.modules.dashboard.dto.TypeCountMetric; +import com.example.erpmvp.modules.dashboard.dto.WarehouseStockMetric; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DashboardService { + + private static final BigDecimal DEFAULT_LOW_STOCK_THRESHOLD = BigDecimal.TEN; + + private final JdbcTemplate jdbcTemplate; + + public DashboardService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(readOnly = true) + public DashboardSummaryResponse summary() { + return new DashboardSummaryResponse( + count("catalog_products"), + countWhere("catalog_products", "active = true"), + count("catalog_suppliers"), + count("catalog_customers"), + count("catalog_warehouses"), + count("sales_customer_orders"), + countWhere("sales_customer_orders", "status not in ('CLOSED', 'CANCELLED')"), + sum("sales_customer_orders", "total_amount"), + salesOrdersByStatus(null, null), + count("procurement_purchase_orders"), + countWhere("procurement_purchase_orders", "status not in ('RECEIVED', 'CANCELLED')"), + sum("procurement_purchase_orders", "total_amount"), + purchaseOrdersByStatus(null, null), + count("warehouse_stock_balances"), + sum("warehouse_stock_balances", "quantity_on_hand"), + countLowStock(DEFAULT_LOW_STOCK_THRESHOLD), + count("warehouse_stock_movements"), + count("erp_documents"), + documentsByType(), + recentCustomerOrders(5), + recentPurchaseOrders(5), + recentStockMovements(5), + recentDocuments(5) + ); + } + + @Transactional(readOnly = true) + public DashboardSalesResponse sales(LocalDate fromDate, LocalDate toDate) { + DateRange range = resolveDateRange(fromDate, toDate); + long totalOrders = countBetween("sales_customer_orders", "created_at", range); + BigDecimal totalAmount = sumBetween("sales_customer_orders", "total_amount", "created_at", range); + + return new DashboardSalesResponse( + totalOrders, + totalAmount, + average(totalAmount, totalOrders), + salesOrdersByStatus(range.fromInstant(), range.toExclusiveInstant()), + dailySales(range), + topCustomers(range) + ); + } + + @Transactional(readOnly = true) + public DashboardProcurementResponse procurement(LocalDate fromDate, LocalDate toDate) { + DateRange range = resolveDateRange(fromDate, toDate); + long totalOrders = countBetween("procurement_purchase_orders", "created_at", range); + BigDecimal totalAmount = sumBetween("procurement_purchase_orders", "total_amount", "created_at", range); + + return new DashboardProcurementResponse( + totalOrders, + totalAmount, + average(totalAmount, totalOrders), + purchaseOrdersByStatus(range.fromInstant(), range.toExclusiveInstant()), + dailyProcurement(range), + topSuppliers(range) + ); + } + + @Transactional(readOnly = true) + public DashboardWarehouseResponse warehouse() { + List movementsByType = movementsByType(); + + return new DashboardWarehouseResponse( + count("warehouse_stock_balances"), + sum("warehouse_stock_balances", "quantity_on_hand"), + countLowStock(DEFAULT_LOW_STOCK_THRESHOLD), + count("warehouse_stock_movements"), + movementQuantity(movementsByType, "INBOUND"), + movementQuantity(movementsByType, "OUTBOUND"), + movementQuantity(movementsByType, "ADJUSTMENT_IN"), + movementQuantity(movementsByType, "ADJUSTMENT_OUT"), + stockByWarehouse(), + movementsByType + ); + } + + @Transactional(readOnly = true) + public List recentActivities(int limit) { + validateLimit(limit); + + return jdbcTemplate.query(""" + select id, type, title, description, activity_at, link + from ( + select id, + 'CUSTOMER_ORDER' as type, + 'Customer order ' || order_number || ' created' as title, + 'Status ' || status as description, + created_at as activity_at, + '/sales/customer-orders/' || id::text as link + from sales_customer_orders + union all + select id, + 'PURCHASE_ORDER' as type, + 'Purchase order ' || po_number || ' status ' || status as title, + 'Supplier id ' || supplier_id::text as description, + updated_at as activity_at, + '/procurement/purchase-orders/' || id::text as link + from procurement_purchase_orders + union all + select id, + 'STOCK_MOVEMENT' as type, + 'Stock movement ' || movement_number || ' ' || movement_type as title, + 'Quantity ' || quantity as description, + created_at as activity_at, + '/warehouse/stock-movements' as link + from warehouse_stock_movements + union all + select id, + 'DOCUMENT' as type, + 'Document ' || document_number || ' generated' as title, + document_type || ' for ' || source_type as description, + generated_at as activity_at, + '/documents' as link + from erp_documents + ) activities + order by activity_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + rs.getString("type"), + rs.getString("title"), + rs.getString("description"), + instant(rs, "activity_at"), + rs.getString("link") + ), limit); + } + + @Transactional(readOnly = true) + public PageResponseDto lowStock(BigDecimal threshold, int page, int size) { + validateThreshold(threshold); + int offset = page * size; + long totalElements = jdbcTemplate.queryForObject(""" + select count(*) + from warehouse_stock_balances sb + where sb.quantity_on_hand <= ? + """, Long.class, threshold); + + List items = jdbcTemplate.query(""" + select w.id as warehouse_id, + w.code as warehouse_code, + w.name as warehouse_name, + p.id as product_id, + p.sku as product_sku, + p.name as product_name, + p.unit as unit, + sb.quantity_on_hand + from warehouse_stock_balances sb + join catalog_warehouses w on w.id = sb.warehouse_id + join catalog_products p on p.id = sb.product_id + where sb.quantity_on_hand <= ? + order by sb.quantity_on_hand asc, w.code asc, p.sku asc + limit ? offset ? + """, (rs, rowNum) -> new LowStockItemResponse( + uuid(rs, "warehouse_id"), + rs.getString("warehouse_code"), + rs.getString("warehouse_name"), + uuid(rs, "product_id"), + rs.getString("product_sku"), + rs.getString("product_name"), + rs.getString("unit"), + decimal(rs, "quantity_on_hand"), + threshold + ), threshold, size, offset); + + int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + return new PageResponseDto<>( + items, + page, + size, + totalElements, + totalPages, + page + 1 < totalPages, + page > 0 + ); + } + + private List salesOrdersByStatus(Instant fromInstant, Instant toExclusiveInstant) { + return statusAmounts( + "sales_customer_orders", + "status", + "total_amount", + "created_at", + fromInstant, + toExclusiveInstant + ); + } + + private List purchaseOrdersByStatus(Instant fromInstant, Instant toExclusiveInstant) { + return statusAmounts( + "procurement_purchase_orders", + "status", + "total_amount", + "created_at", + fromInstant, + toExclusiveInstant + ); + } + + private List statusAmounts( + String table, + String statusColumn, + String amountColumn, + String dateColumn, + Instant fromInstant, + Instant toExclusiveInstant + ) { + String whereClause = fromInstant == null ? "" : " where " + dateColumn + " >= ? and " + dateColumn + " < ?"; + Object[] args = fromInstant == null ? new Object[]{} : new Object[]{timestamp(fromInstant), timestamp(toExclusiveInstant)}; + + return jdbcTemplate.query(""" + select %s as status, count(*) as records_count, coalesce(sum(%s), 0) as total_amount + from %s + %s + group by %s + order by %s + """.formatted(statusColumn, amountColumn, table, whereClause, statusColumn, statusColumn), + (rs, rowNum) -> new StatusAmountMetric( + rs.getString("status"), + rs.getLong("records_count"), + decimal(rs, "total_amount") + ), + args + ); + } + + private List documentsByType() { + return jdbcTemplate.query(""" + select document_type as type, count(*) as records_count + from erp_documents + group by document_type + order by document_type + """, (rs, rowNum) -> new TypeCountMetric( + rs.getString("type"), + rs.getLong("records_count") + )); + } + + private List dailySales(DateRange range) { + return dailyAmounts("sales_customer_orders", "created_at", "total_amount", range); + } + + private List dailyProcurement(DateRange range) { + return dailyAmounts("procurement_purchase_orders", "created_at", "total_amount", range); + } + + private List dailyAmounts(String table, String dateColumn, String amountColumn, DateRange range) { + return jdbcTemplate.query(""" + select cast(%s as date) as activity_date, + count(*) as records_count, + coalesce(sum(%s), 0) as total_amount + from %s + where %s >= ? and %s < ? + group by cast(%s as date) + order by activity_date + """.formatted(dateColumn, amountColumn, table, dateColumn, dateColumn, dateColumn), + (rs, rowNum) -> new DailyAmountMetric( + rs.getDate("activity_date").toLocalDate(), + rs.getLong("records_count"), + decimal(rs, "total_amount") + ), + timestamp(range.fromInstant()), + timestamp(range.toExclusiveInstant()) + ); + } + + private List topCustomers(DateRange range) { + return jdbcTemplate.query(""" + select c.id as party_id, + c.company_name, + count(co.id) as records_count, + coalesce(sum(co.total_amount), 0) as total_amount + from sales_customer_orders co + join catalog_customers c on c.id = co.customer_id + where co.created_at >= ? and co.created_at < ? + group by c.id, c.company_name + order by total_amount desc, records_count desc + limit 5 + """, (rs, rowNum) -> topParty(rs), timestamp(range.fromInstant()), timestamp(range.toExclusiveInstant())); + } + + private List topSuppliers(DateRange range) { + return jdbcTemplate.query(""" + select s.id as party_id, + s.company_name, + count(po.id) as records_count, + coalesce(sum(po.total_amount), 0) as total_amount + from procurement_purchase_orders po + join catalog_suppliers s on s.id = po.supplier_id + where po.created_at >= ? and po.created_at < ? + group by s.id, s.company_name + order by total_amount desc, records_count desc + limit 5 + """, (rs, rowNum) -> topParty(rs), timestamp(range.fromInstant()), timestamp(range.toExclusiveInstant())); + } + + private List stockByWarehouse() { + return jdbcTemplate.query(""" + select w.id as warehouse_id, + w.code as warehouse_code, + w.name as warehouse_name, + count(sb.product_id) as products_count, + coalesce(sum(sb.quantity_on_hand), 0) as total_quantity + from catalog_warehouses w + left join warehouse_stock_balances sb on sb.warehouse_id = w.id + group by w.id, w.code, w.name + order by w.code + """, (rs, rowNum) -> new WarehouseStockMetric( + uuid(rs, "warehouse_id"), + rs.getString("warehouse_code"), + rs.getString("warehouse_name"), + rs.getLong("products_count"), + decimal(rs, "total_quantity") + )); + } + + private List movementsByType() { + return jdbcTemplate.query(""" + select movement_type, + count(*) as records_count, + coalesce(sum(quantity), 0) as total_quantity + from warehouse_stock_movements + group by movement_type + order by movement_type + """, (rs, rowNum) -> new MovementTypeMetric( + rs.getString("movement_type"), + rs.getLong("records_count"), + decimal(rs, "total_quantity") + )); + } + + private List recentCustomerOrders(int limit) { + return jdbcTemplate.query(""" + select id, order_number, status, created_at + from sales_customer_orders + order by created_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "CUSTOMER_ORDER", + "Customer order " + rs.getString("order_number") + " created", + "Status " + rs.getString("status"), + instant(rs, "created_at"), + "/sales/customer-orders/" + rs.getString("id") + ), limit); + } + + private List recentPurchaseOrders(int limit) { + return jdbcTemplate.query(""" + select id, po_number, status, updated_at + from procurement_purchase_orders + order by updated_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "PURCHASE_ORDER", + "Purchase order " + rs.getString("po_number") + " status " + rs.getString("status"), + "Purchase order updated", + instant(rs, "updated_at"), + "/procurement/purchase-orders/" + rs.getString("id") + ), limit); + } + + private List recentStockMovements(int limit) { + return jdbcTemplate.query(""" + select id, movement_number, movement_type, quantity, created_at + from warehouse_stock_movements + order by created_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "STOCK_MOVEMENT", + "Stock movement " + rs.getString("movement_number") + " " + rs.getString("movement_type"), + "Quantity " + rs.getBigDecimal("quantity"), + instant(rs, "created_at"), + "/warehouse/stock-movements" + ), limit); + } + + private List recentDocuments(int limit) { + return jdbcTemplate.query(""" + select id, document_number, document_type, generated_at + from erp_documents + order by generated_at desc + limit ? + """, (rs, rowNum) -> new RecentActivityResponse( + uuid(rs, "id"), + "DOCUMENT", + "Document " + rs.getString("document_number") + " generated", + rs.getString("document_type"), + instant(rs, "generated_at"), + "/documents" + ), limit); + } + + private long count(String table) { + return jdbcTemplate.queryForObject("select count(*) from " + table, Long.class); + } + + private long countWhere(String table, String whereClause) { + return jdbcTemplate.queryForObject("select count(*) from " + table + " where " + whereClause, Long.class); + } + + private long countBetween(String table, String dateColumn, DateRange range) { + return jdbcTemplate.queryForObject( + "select count(*) from " + table + " where " + dateColumn + " >= ? and " + dateColumn + " < ?", + Long.class, + timestamp(range.fromInstant()), + timestamp(range.toExclusiveInstant()) + ); + } + + private BigDecimal sum(String table, String column) { + return jdbcTemplate.queryForObject("select coalesce(sum(" + column + "), 0) from " + table, BigDecimal.class); + } + + private BigDecimal sumBetween(String table, String amountColumn, String dateColumn, DateRange range) { + return jdbcTemplate.queryForObject( + "select coalesce(sum(" + amountColumn + "), 0) from " + table + " where " + dateColumn + " >= ? and " + dateColumn + " < ?", + BigDecimal.class, + timestamp(range.fromInstant()), + timestamp(range.toExclusiveInstant()) + ); + } + + private long countLowStock(BigDecimal threshold) { + return jdbcTemplate.queryForObject( + "select count(*) from warehouse_stock_balances where quantity_on_hand <= ?", + Long.class, + threshold + ); + } + + private BigDecimal movementQuantity(List metrics, String movementType) { + return metrics.stream() + .filter(metric -> movementType.equals(metric.movementType())) + .map(MovementTypeMetric::totalQuantity) + .findFirst() + .orElse(BigDecimal.ZERO); + } + + private TopPartyMetric topParty(java.sql.ResultSet rs) throws java.sql.SQLException { + return new TopPartyMetric( + uuid(rs, "party_id"), + rs.getString("company_name"), + rs.getLong("records_count"), + decimal(rs, "total_amount") + ); + } + + private DateRange resolveDateRange(LocalDate fromDate, LocalDate toDate) { + LocalDate today = LocalDate.now(ZoneOffset.UTC); + LocalDate resolvedFromDate = fromDate == null ? today.minusDays(29) : fromDate; + LocalDate resolvedToDate = toDate == null ? today : toDate; + validateDateRange(resolvedFromDate, resolvedToDate); + return new DateRange( + resolvedFromDate, + resolvedToDate, + resolvedFromDate.atStartOfDay(ZoneOffset.UTC).toInstant(), + resolvedToDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() + ); + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private void validateLimit(int limit) { + if (limit < 1 || limit > 100) { + throw new BadRequestException("INVALID_LIMIT", "limit must be between 1 and 100"); + } + } + + private void validateThreshold(BigDecimal threshold) { + if (threshold.signum() < 0) { + throw new BadRequestException("INVALID_THRESHOLD", "threshold must be greater than or equal to 0"); + } + } + + private BigDecimal average(BigDecimal totalAmount, long count) { + if (count == 0) { + return BigDecimal.ZERO; + } + + return totalAmount.divide(BigDecimal.valueOf(count), 2, RoundingMode.HALF_UP); + } + + private Timestamp timestamp(Instant instant) { + return Timestamp.from(instant); + } + + private Instant instant(java.sql.ResultSet rs, String column) throws java.sql.SQLException { + return rs.getTimestamp(column).toInstant(); + } + + private UUID uuid(java.sql.ResultSet rs, String column) throws java.sql.SQLException { + return rs.getObject(column, UUID.class); + } + + private BigDecimal decimal(java.sql.ResultSet rs, String column) throws java.sql.SQLException { + BigDecimal value = rs.getBigDecimal(column); + return value == null ? BigDecimal.ZERO : value; + } + + private record DateRange( + LocalDate fromDate, + LocalDate toDate, + Instant fromInstant, + Instant toExclusiveInstant + ) { + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/._controller b/backend/src/main/java/com/example/erpmvp/modules/documents/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/._domain b/backend/src/main/java/com/example/erpmvp/modules/documents/._domain new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/._domain differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/._dto b/backend/src/main/java/com/example/erpmvp/modules/documents/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/._repository b/backend/src/main/java/com/example/erpmvp/modules/documents/._repository new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/._repository differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/._service b/backend/src/main/java/com/example/erpmvp/modules/documents/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/controller/._DocumentController.java b/backend/src/main/java/com/example/erpmvp/modules/documents/controller/._DocumentController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/controller/._DocumentController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/controller/DocumentController.java b/backend/src/main/java/com/example/erpmvp/modules/documents/controller/DocumentController.java new file mode 100644 index 0000000..32013d5 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/controller/DocumentController.java @@ -0,0 +1,117 @@ +package com.example.erpmvp.modules.documents.controller; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.security.AuthUserDetails; +import com.example.erpmvp.modules.documents.domain.DocumentSourceType; +import com.example.erpmvp.modules.documents.domain.DocumentStatus; +import com.example.erpmvp.modules.documents.domain.DocumentType; +import com.example.erpmvp.modules.documents.dto.DocumentDownload; +import com.example.erpmvp.modules.documents.dto.DocumentResponse; +import com.example.erpmvp.modules.documents.dto.GenerateCustomerOrderDocumentRequest; +import com.example.erpmvp.modules.documents.service.DocumentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/documents") +@Tag(name = "Documents", description = "Generated PDF placeholder documents") +public class DocumentController { + + private final DocumentService documentService; + + public DocumentController(DocumentService documentService) { + this.documentService = documentService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List generated documents") + public ApiResponse> list( + @RequestParam(required = false) DocumentType documentType, + @RequestParam(required = false) DocumentSourceType sourceType, + @RequestParam(required = false) UUID sourceId, + @RequestParam(required = false) DocumentStatus status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(documentService.list(documentType, sourceType, sourceId, status, fromDate, toDate, page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get document metadata by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(documentService.getById(id)); + } + + @GetMapping("/customer-orders/{customerOrderId}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List documents generated for customer order") + public ApiResponse> getCustomerOrderDocuments(@PathVariable UUID customerOrderId) { + return ApiResponse.success(documentService.getDocumentsForCustomerOrder(customerOrderId)); + } + + @PostMapping("/customer-orders/{customerOrderId}/generate") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Generate or refresh customer order PDF document") + public ApiResponse generateForCustomerOrder( + @PathVariable UUID customerOrderId, + @Valid @RequestBody GenerateCustomerOrderDocumentRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(documentService.generateForCustomerOrder( + customerOrderId, + request.documentType(), + currentUser.getUser() + )); + } + + @GetMapping("/{id}/download") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation( + summary = "Download generated PDF", + responses = @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "PDF file", + content = @Content(mediaType = "application/pdf") + ) + ) + public ResponseEntity download(@PathVariable UUID id) { + DocumentDownload download = documentService.download(id); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(download.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(download.fileName()) + .build() + .toString()) + .body(download.data()); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._Document.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._Document.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._Document.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentSourceType.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentSourceType.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentSourceType.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentStatus.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentStatus.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentStatus.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentType.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentType.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/._DocumentType.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/Document.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/Document.java new file mode 100644 index 0000000..a9370c5 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/Document.java @@ -0,0 +1,140 @@ +package com.example.erpmvp.modules.documents.domain; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "erp_documents", + uniqueConstraints = @UniqueConstraint( + name = "uq_erp_documents_source_type_source_id_document_type", + columnNames = {"source_type", "source_id", "document_type"} + ) +) +public class Document extends BaseEntity { + + @Column(name = "document_number", nullable = false, unique = true, length = 100) + private String documentNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "document_type", nullable = false, length = 50) + private DocumentType documentType; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 50) + private DocumentSourceType sourceType; + + @Column(name = "source_id", nullable = false) + private UUID sourceId; + + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + @Column(name = "file_size", nullable = false) + private long fileSize; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private DocumentStatus status; + + @Column(name = "pdf_data", nullable = false, columnDefinition = "BYTEA") + private byte[] pdfData; + + @Column(name = "generated_by_user_id") + private UUID generatedByUserId; + + @Column(name = "generated_at", nullable = false) + private Instant generatedAt; + + protected Document() { + } + + public Document( + String documentNumber, + DocumentType documentType, + DocumentSourceType sourceType, + UUID sourceId, + String fileName, + String contentType, + byte[] pdfData, + UUID generatedByUserId, + Instant generatedAt + ) { + this.documentNumber = documentNumber; + this.documentType = documentType; + this.sourceType = sourceType; + this.sourceId = sourceId; + updateGeneratedContent(fileName, contentType, pdfData, generatedByUserId, generatedAt); + } + + public void updateGeneratedContent( + String fileName, + String contentType, + byte[] pdfData, + UUID generatedByUserId, + Instant generatedAt + ) { + this.fileName = fileName; + this.contentType = contentType; + this.pdfData = pdfData; + this.fileSize = pdfData.length; + this.generatedByUserId = generatedByUserId; + this.generatedAt = generatedAt; + this.status = DocumentStatus.GENERATED; + } + + public String getDocumentNumber() { + return documentNumber; + } + + public DocumentType getDocumentType() { + return documentType; + } + + public DocumentSourceType getSourceType() { + return sourceType; + } + + public UUID getSourceId() { + return sourceId; + } + + public String getFileName() { + return fileName; + } + + public String getContentType() { + return contentType; + } + + public long getFileSize() { + return fileSize; + } + + public DocumentStatus getStatus() { + return status; + } + + public byte[] getPdfData() { + return pdfData; + } + + public UUID getGeneratedByUserId() { + return generatedByUserId; + } + + public Instant getGeneratedAt() { + return generatedAt; + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentSourceType.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentSourceType.java new file mode 100644 index 0000000..d35fc93 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentSourceType.java @@ -0,0 +1,5 @@ +package com.example.erpmvp.modules.documents.domain; + +public enum DocumentSourceType { + CUSTOMER_ORDER +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentStatus.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentStatus.java new file mode 100644 index 0000000..2200848 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentStatus.java @@ -0,0 +1,5 @@ +package com.example.erpmvp.modules.documents.domain; + +public enum DocumentStatus { + GENERATED +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentType.java b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentType.java new file mode 100644 index 0000000..463959e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/domain/DocumentType.java @@ -0,0 +1,7 @@ +package com.example.erpmvp.modules.documents.domain; + +public enum DocumentType { + INVOICE, + CONTRACT, + DELIVERY_NOTE +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentDownload.java b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentDownload.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentDownload.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentResponse.java b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._DocumentResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._GenerateCustomerOrderDocumentRequest.java b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._GenerateCustomerOrderDocumentRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/._GenerateCustomerOrderDocumentRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentDownload.java b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentDownload.java new file mode 100644 index 0000000..43c178b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentDownload.java @@ -0,0 +1,8 @@ +package com.example.erpmvp.modules.documents.dto; + +public record DocumentDownload( + String fileName, + String contentType, + byte[] data +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentResponse.java b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentResponse.java new file mode 100644 index 0000000..5b666bb --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/DocumentResponse.java @@ -0,0 +1,44 @@ +package com.example.erpmvp.modules.documents.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.documents.domain.Document; +import com.example.erpmvp.modules.documents.domain.DocumentSourceType; +import com.example.erpmvp.modules.documents.domain.DocumentStatus; +import com.example.erpmvp.modules.documents.domain.DocumentType; + +public record DocumentResponse( + UUID id, + String documentNumber, + DocumentType documentType, + DocumentSourceType sourceType, + UUID sourceId, + String fileName, + String contentType, + long fileSize, + DocumentStatus status, + UUID generatedByUserId, + Instant generatedAt, + Instant createdAt, + Instant updatedAt +) { + + public static DocumentResponse from(Document document) { + return new DocumentResponse( + document.getId(), + document.getDocumentNumber(), + document.getDocumentType(), + document.getSourceType(), + document.getSourceId(), + document.getFileName(), + document.getContentType(), + document.getFileSize(), + document.getStatus(), + document.getGeneratedByUserId(), + document.getGeneratedAt(), + document.getCreatedAt(), + document.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/dto/GenerateCustomerOrderDocumentRequest.java b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/GenerateCustomerOrderDocumentRequest.java new file mode 100644 index 0000000..202198d --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/dto/GenerateCustomerOrderDocumentRequest.java @@ -0,0 +1,9 @@ +package com.example.erpmvp.modules.documents.dto; + +import com.example.erpmvp.modules.documents.domain.DocumentType; +import jakarta.validation.constraints.NotNull; + +public record GenerateCustomerOrderDocumentRequest( + @NotNull DocumentType documentType +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/repository/._DocumentRepository.java b/backend/src/main/java/com/example/erpmvp/modules/documents/repository/._DocumentRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/repository/._DocumentRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/repository/DocumentRepository.java b/backend/src/main/java/com/example/erpmvp/modules/documents/repository/DocumentRepository.java new file mode 100644 index 0000000..20dbaed --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/repository/DocumentRepository.java @@ -0,0 +1,28 @@ +package com.example.erpmvp.modules.documents.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.documents.domain.Document; +import com.example.erpmvp.modules.documents.domain.DocumentSourceType; +import com.example.erpmvp.modules.documents.domain.DocumentStatus; +import com.example.erpmvp.modules.documents.domain.DocumentType; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface DocumentRepository extends JpaRepository, JpaSpecificationExecutor { + + boolean existsByDocumentNumber(String documentNumber); + + long countByDocumentNumberStartingWith(String documentNumberPrefix); + + List findBySourceTypeAndSourceIdOrderByGeneratedAtDesc(DocumentSourceType sourceType, UUID sourceId); + + Optional findBySourceTypeAndSourceIdAndDocumentType( + DocumentSourceType sourceType, + UUID sourceId, + DocumentType documentType + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/service/._CustomerOrderPdfGenerator.java b/backend/src/main/java/com/example/erpmvp/modules/documents/service/._CustomerOrderPdfGenerator.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/service/._CustomerOrderPdfGenerator.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/service/._DocumentService.java b/backend/src/main/java/com/example/erpmvp/modules/documents/service/._DocumentService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/documents/service/._DocumentService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/service/CustomerOrderPdfGenerator.java b/backend/src/main/java/com/example/erpmvp/modules/documents/service/CustomerOrderPdfGenerator.java new file mode 100644 index 0000000..a33c060 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/service/CustomerOrderPdfGenerator.java @@ -0,0 +1,166 @@ +package com.example.erpmvp.modules.documents.service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.modules.documents.domain.DocumentType; +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderItem; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; + +import org.springframework.stereotype.Component; + +@Component +public class CustomerOrderPdfGenerator { + + private static final float MARGIN = 50; + private static final float LINE_HEIGHT = 15; + private static final float FONT_SIZE = 10; + + private final PDType1Font titleFont = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + private final PDType1Font textFont = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + private final PDType1Font tableFont = new PDType1Font(Standard14Fonts.FontName.COURIER); + + public byte[] generate( + DocumentType documentType, + String documentNumber, + CustomerOrder customerOrder, + Instant generatedAt + ) { + try (PDDocument document = new PDDocument(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + List lines = buildLines(documentType, documentNumber, customerOrder, generatedAt); + writeLines(document, titleFor(documentType), lines); + document.save(outputStream); + return outputStream.toByteArray(); + } catch (IOException exception) { + throw new BusinessException("PDF_GENERATION_FAILED", "Failed to generate PDF document"); + } + } + + private List buildLines( + DocumentType documentType, + String documentNumber, + CustomerOrder customerOrder, + Instant generatedAt + ) { + List lines = new ArrayList<>(); + lines.add("Document number: " + documentNumber); + lines.add("Document type: " + documentType); + lines.add("Generated at: " + generatedAt); + lines.add(""); + lines.add("Customer order number: " + customerOrder.getOrderNumber()); + lines.add("Customer company name: " + customerOrder.getCustomer().getCompanyName()); + lines.add("Customer BIN: " + blankIfNull(customerOrder.getCustomer().getBin())); + lines.add("Order status: " + customerOrder.getStatus()); + lines.add("Total amount: " + customerOrder.getTotalAmount()); + lines.add("Requested delivery date: " + blankIfNull(customerOrder.getRequestedDeliveryDate())); + lines.add(""); + lines.add("Items:"); + lines.add(String.format("%-14s %-28s %10s %12s %12s", "SKU", "Product name", "Quantity", "Unit price", "Line total")); + lines.add("--------------------------------------------------------------------------------"); + + for (CustomerOrderItem item : customerOrder.getItems()) { + lines.add(String.format( + "%-14s %-28s %10s %12s %12s", + truncate(item.getProduct().getSku(), 14), + truncate(item.getProduct().getName(), 28), + formatDecimal(item.getQuantity()), + formatDecimal(item.getUnitPrice()), + formatDecimal(item.getLineTotal()) + )); + } + + lines.add(""); + lines.add("This document is a placeholder generated by ERP MVP."); + return lines; + } + + private void writeLines(PDDocument document, String title, List lines) throws IOException { + PDPage page = addPage(document); + PDPageContentStream stream = new PDPageContentStream(document, page); + float y = page.getMediaBox().getHeight() - MARGIN; + + stream.beginText(); + stream.setFont(titleFont, 16); + stream.newLineAtOffset(MARGIN, y); + stream.showText(title); + stream.endText(); + y -= LINE_HEIGHT * 2; + + stream.setFont(textFont, FONT_SIZE); + + for (String line : lines) { + if (y <= MARGIN) { + stream.close(); + page = addPage(document); + stream = new PDPageContentStream(document, page); + y = page.getMediaBox().getHeight() - MARGIN; + } + + stream.beginText(); + stream.setFont(line.startsWith("SKU") || line.startsWith("-") ? tableFont : textFont, FONT_SIZE); + stream.newLineAtOffset(MARGIN, y); + stream.showText(sanitize(line)); + stream.endText(); + y -= LINE_HEIGHT; + } + + stream.close(); + } + + private PDPage addPage(PDDocument document) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + return page; + } + + private String titleFor(DocumentType documentType) { + return switch (documentType) { + case INVOICE -> "INVOICE PLACEHOLDER"; + case CONTRACT -> "CONTRACT PLACEHOLDER"; + case DELIVERY_NOTE -> "DELIVERY NOTE PLACEHOLDER"; + }; + } + + private String filePrefixFor(DocumentType documentType) { + return switch (documentType) { + case INVOICE -> "invoice"; + case CONTRACT -> "contract"; + case DELIVERY_NOTE -> "delivery-note"; + }; + } + + public String fileNameFor(DocumentType documentType, String documentNumber) { + return filePrefixFor(documentType) + "-" + documentNumber + ".pdf"; + } + + private String blankIfNull(Object value) { + return value == null ? "" : value.toString(); + } + + private String formatDecimal(BigDecimal value) { + return value == null ? "" : value.toPlainString(); + } + + private String truncate(String value, int length) { + if (value == null) { + return ""; + } + + return value.length() <= length ? value : value.substring(0, length - 1); + } + + private String sanitize(String value) { + return value.replaceAll("[^\\x20-\\x7E]", " "); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java b/backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java new file mode 100644 index 0000000..677e4b1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/documents/service/DocumentService.java @@ -0,0 +1,242 @@ +package com.example.erpmvp.modules.documents.service; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.error.BadRequestException; +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.domain.Role; +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.documents.domain.Document; +import com.example.erpmvp.modules.documents.domain.DocumentSourceType; +import com.example.erpmvp.modules.documents.domain.DocumentStatus; +import com.example.erpmvp.modules.documents.domain.DocumentType; +import com.example.erpmvp.modules.documents.dto.DocumentDownload; +import com.example.erpmvp.modules.documents.dto.DocumentResponse; +import com.example.erpmvp.modules.documents.repository.DocumentRepository; +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import com.example.erpmvp.modules.sales.repository.CustomerOrderRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class DocumentService { + + private static final String PDF_CONTENT_TYPE = "application/pdf"; + private static final DateTimeFormatter DOCUMENT_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + private final DocumentRepository documentRepository; + private final CustomerOrderRepository customerOrderRepository; + private final CustomerOrderPdfGenerator pdfGenerator; + + public DocumentService( + DocumentRepository documentRepository, + CustomerOrderRepository customerOrderRepository, + CustomerOrderPdfGenerator pdfGenerator + ) { + this.documentRepository = documentRepository; + this.customerOrderRepository = customerOrderRepository; + this.pdfGenerator = pdfGenerator; + } + + @Transactional(readOnly = true) + public PageResponseDto list( + DocumentType documentType, + DocumentSourceType sourceType, + UUID sourceId, + DocumentStatus status, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "generatedAt")); + return PageResponseDto.from(documentRepository.findAll(documentFilter( + documentType, + sourceType, + sourceId, + status, + toStartInstant(fromDate), + toExclusiveEndInstant(toDate) + ), pageable).map(DocumentResponse::from)); + } + + @Transactional(readOnly = true) + public DocumentResponse getById(UUID id) { + return DocumentResponse.from(findDocument(id)); + } + + @Transactional(readOnly = true) + public List getDocumentsForCustomerOrder(UUID customerOrderId) { + if (!customerOrderRepository.existsById(customerOrderId)) { + throw new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found"); + } + + return documentRepository.findBySourceTypeAndSourceIdOrderByGeneratedAtDesc( + DocumentSourceType.CUSTOMER_ORDER, + customerOrderId + ) + .stream() + .map(DocumentResponse::from) + .toList(); + } + + @Transactional + public DocumentResponse generateForCustomerOrder(UUID customerOrderId, DocumentType documentType, User currentUser) { + ensureRoleCanGenerate(documentType, currentUser.getRole()); + + CustomerOrder customerOrder = customerOrderRepository.findById(customerOrderId) + .orElseThrow(() -> new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found")); + + ensureCanGenerateForOrder(customerOrder, documentType); + + Instant generatedAt = Instant.now(); + Document document = documentRepository.findBySourceTypeAndSourceIdAndDocumentType( + DocumentSourceType.CUSTOMER_ORDER, + customerOrder.getId(), + documentType + ) + .orElse(null); + String documentNumber = document == null ? generateDocumentNumber(documentType) : document.getDocumentNumber(); + byte[] pdfData = pdfGenerator.generate(documentType, documentNumber, customerOrder, generatedAt); + String fileName = pdfGenerator.fileNameFor(documentType, documentNumber); + + if (document == null) { + document = new Document( + documentNumber, + documentType, + DocumentSourceType.CUSTOMER_ORDER, + customerOrder.getId(), + fileName, + PDF_CONTENT_TYPE, + pdfData, + currentUser.getId(), + generatedAt + ); + } else { + document.updateGeneratedContent(fileName, PDF_CONTENT_TYPE, pdfData, currentUser.getId(), generatedAt); + } + + return DocumentResponse.from(documentRepository.save(document)); + } + + @Transactional(readOnly = true) + public DocumentDownload download(UUID id) { + Document document = findDocument(id); + return new DocumentDownload(document.getFileName(), document.getContentType(), document.getPdfData()); + } + + private Document findDocument(UUID id) { + return documentRepository.findById(id) + .orElseThrow(() -> new NotFoundException("DOCUMENT_NOT_FOUND", "Document not found")); + } + + private Specification documentFilter( + DocumentType documentType, + DocumentSourceType sourceType, + UUID sourceId, + DocumentStatus status, + Instant fromDateTime, + Instant toDateTime + ) { + return (root, query, criteriaBuilder) -> { + List predicates = new ArrayList<>(); + + if (documentType != null) { + predicates.add(criteriaBuilder.equal(root.get("documentType"), documentType)); + } + if (sourceType != null) { + predicates.add(criteriaBuilder.equal(root.get("sourceType"), sourceType)); + } + if (sourceId != null) { + predicates.add(criteriaBuilder.equal(root.get("sourceId"), sourceId)); + } + if (status != null) { + predicates.add(criteriaBuilder.equal(root.get("status"), status)); + } + if (fromDateTime != null) { + predicates.add(criteriaBuilder.greaterThanOrEqualTo(root.get("generatedAt"), fromDateTime)); + } + if (toDateTime != null) { + predicates.add(criteriaBuilder.lessThan(root.get("generatedAt"), toDateTime)); + } + + return criteriaBuilder.and(predicates.toArray(jakarta.persistence.criteria.Predicate[]::new)); + }; + } + + private void ensureCanGenerateForOrder(CustomerOrder customerOrder, DocumentType documentType) { + if (customerOrder.getStatus() == CustomerOrderStatus.CANCELLED) { + throw new BusinessException("DOCUMENT_GENERATION_NOT_ALLOWED", "Cannot generate documents for cancelled order"); + } + + if (documentType == DocumentType.DELIVERY_NOTE + && customerOrder.getStatus() != CustomerOrderStatus.SHIPPED + && customerOrder.getStatus() != CustomerOrderStatus.CLOSED) { + throw new BusinessException( + "DOCUMENT_GENERATION_NOT_ALLOWED", + "Delivery note can be generated only for SHIPPED or CLOSED orders" + ); + } + } + + private void ensureRoleCanGenerate(DocumentType documentType, Role role) { + boolean allowed = switch (documentType) { + case INVOICE, CONTRACT -> role == Role.ADMIN || role == Role.MANAGER || role == Role.FINANCE; + case DELIVERY_NOTE -> role == Role.ADMIN || role == Role.MANAGER || role == Role.WAREHOUSE; + }; + + if (!allowed) { + throw new BusinessException("DOCUMENT_GENERATION_FORBIDDEN", "Current role cannot generate this document type"); + } + } + + private String generateDocumentNumber(DocumentType documentType) { + String prefix = documentPrefix(documentType) + "-" + LocalDate.now().format(DOCUMENT_DATE_FORMAT) + "-"; + long nextNumber = documentRepository.countByDocumentNumberStartingWith(prefix) + 1; + + String documentNumber; + do { + documentNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (documentRepository.existsByDocumentNumber(documentNumber)); + + return documentNumber; + } + + private String documentPrefix(DocumentType documentType) { + return switch (documentType) { + case INVOICE -> "INV"; + case CONTRACT -> "CTR"; + case DELIVERY_NOTE -> "DN"; + }; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private Instant toStartInstant(LocalDate date) { + return date == null ? null : date.atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private Instant toExclusiveEndInstant(LocalDate date) { + return date == null ? null : date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/._controller b/backend/src/main/java/com/example/erpmvp/modules/procurement/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/._domain b/backend/src/main/java/com/example/erpmvp/modules/procurement/._domain new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/._domain differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/._dto b/backend/src/main/java/com/example/erpmvp/modules/procurement/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/._repository b/backend/src/main/java/com/example/erpmvp/modules/procurement/._repository new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/._repository differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/._service b/backend/src/main/java/com/example/erpmvp/modules/procurement/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/controller/._PurchaseOrderController.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/controller/._PurchaseOrderController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/controller/._PurchaseOrderController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/controller/PurchaseOrderController.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/controller/PurchaseOrderController.java new file mode 100644 index 0000000..5c069a1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/controller/PurchaseOrderController.java @@ -0,0 +1,121 @@ +package com.example.erpmvp.modules.procurement.controller; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.security.AuthUserDetails; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; +import com.example.erpmvp.modules.procurement.dto.ChangePurchaseOrderStatusRequest; +import com.example.erpmvp.modules.procurement.dto.CreatePurchaseOrderRequest; +import com.example.erpmvp.modules.procurement.dto.PurchaseOrderResponse; +import com.example.erpmvp.modules.procurement.dto.PurchaseOrderStatusHistoryResponse; +import com.example.erpmvp.modules.procurement.dto.UpdatePurchaseOrderRequest; +import com.example.erpmvp.modules.procurement.service.PurchaseOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/procurement/purchase-orders") +@Tag(name = "Procurement - Purchase Orders", description = "Purchase order process") +public class PurchaseOrderController { + + private final PurchaseOrderService purchaseOrderService; + + public PurchaseOrderController(PurchaseOrderService purchaseOrderService) { + this.purchaseOrderService = purchaseOrderService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List purchase orders") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(required = false) UUID supplierId, + @RequestParam(required = false) PurchaseOrderStatus status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(purchaseOrderService.list(search, supplierId, status, fromDate, toDate, page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get purchase order by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(purchaseOrderService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create purchase order") + public ApiResponse create( + @Valid @RequestBody CreatePurchaseOrderRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(purchaseOrderService.create(request, currentUser.getUser())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update draft purchase order") + public ApiResponse update( + @PathVariable UUID id, + @Valid @RequestBody UpdatePurchaseOrderRequest request + ) { + return ApiResponse.success(purchaseOrderService.update(id, request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE')") + @Operation(summary = "Change purchase order status") + public ApiResponse changeStatus( + @PathVariable UUID id, + @Valid @RequestBody ChangePurchaseOrderStatusRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(purchaseOrderService.changeStatus(id, request, currentUser.getUser())); + } + + @GetMapping("/{id}/status-history") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get purchase order status history") + public ApiResponse> getStatusHistory(@PathVariable UUID id) { + return ApiResponse.success(purchaseOrderService.getStatusHistory(id)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Cancel purchase order") + public ApiResponse delete( + @PathVariable UUID id, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + purchaseOrderService.delete(id, currentUser.getUser()); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrder.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrder.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrder.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderItem.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderItem.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderItem.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatus.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatus.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatus.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatusHistory.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatusHistory.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/._PurchaseOrderStatusHistory.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrder.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrder.java new file mode 100644 index 0000000..cb00df4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrder.java @@ -0,0 +1,185 @@ +package com.example.erpmvp.modules.procurement.domain; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Supplier; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +@Entity +@Table(name = "procurement_purchase_orders") +public class PurchaseOrder extends BaseEntity { + + @Column(name = "po_number", nullable = false, unique = true, length = 100) + private String poNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "supplier_id", nullable = false) + private Supplier supplier; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "warehouse_id") + private Warehouse warehouse; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private PurchaseOrderStatus status; + + @Column(name = "expected_delivery_date") + private LocalDate expectedDeliveryDate; + + @Column(columnDefinition = "TEXT") + private String notes; + + @Column(name = "total_amount", nullable = false, precision = 19, scale = 2) + private BigDecimal totalAmount = BigDecimal.ZERO; + + @Column(name = "created_by_user_id") + private UUID createdByUserId; + + @Column(name = "approved_by_user_id") + private UUID approvedByUserId; + + @Column(name = "ordered_at") + private Instant orderedAt; + + @Column(name = "received_at") + private Instant receivedAt; + + @Column(name = "cancelled_at") + private Instant cancelledAt; + + @OneToMany(mappedBy = "purchaseOrder", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + protected PurchaseOrder() { + } + + public PurchaseOrder( + String poNumber, + Supplier supplier, + Warehouse warehouse, + LocalDate expectedDeliveryDate, + String notes, + UUID createdByUserId + ) { + this.poNumber = poNumber; + this.supplier = supplier; + this.warehouse = warehouse; + this.expectedDeliveryDate = expectedDeliveryDate; + this.notes = trim(notes); + this.createdByUserId = createdByUserId; + this.status = PurchaseOrderStatus.DRAFT; + this.totalAmount = BigDecimal.ZERO; + } + + public void updateDraft(Supplier supplier, Warehouse warehouse, LocalDate expectedDeliveryDate, String notes) { + this.supplier = supplier; + this.warehouse = warehouse; + this.expectedDeliveryDate = expectedDeliveryDate; + this.notes = trim(notes); + } + + public void replaceItems(List newItems) { + items.clear(); + newItems.forEach(this::addItem); + recalculateTotal(); + } + + public void addItem(PurchaseOrderItem item) { + item.setPurchaseOrder(this); + items.add(item); + } + + public void recalculateTotal() { + totalAmount = items.stream() + .map(PurchaseOrderItem::getLineTotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public void changeStatus(PurchaseOrderStatus newStatus, UUID changedByUserId) { + status = newStatus; + Instant now = Instant.now(); + + if (newStatus == PurchaseOrderStatus.APPROVED) { + approvedByUserId = changedByUserId; + } else if (newStatus == PurchaseOrderStatus.ORDERED) { + orderedAt = now; + } else if (newStatus == PurchaseOrderStatus.RECEIVED) { + receivedAt = now; + } else if (newStatus == PurchaseOrderStatus.CANCELLED) { + cancelledAt = now; + } + } + + public String getPoNumber() { + return poNumber; + } + + public Supplier getSupplier() { + return supplier; + } + + public Warehouse getWarehouse() { + return warehouse; + } + + public PurchaseOrderStatus getStatus() { + return status; + } + + public LocalDate getExpectedDeliveryDate() { + return expectedDeliveryDate; + } + + public String getNotes() { + return notes; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public UUID getCreatedByUserId() { + return createdByUserId; + } + + public UUID getApprovedByUserId() { + return approvedByUserId; + } + + public Instant getOrderedAt() { + return orderedAt; + } + + public Instant getReceivedAt() { + return receivedAt; + } + + public Instant getCancelledAt() { + return cancelledAt; + } + + public List getItems() { + return items; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java new file mode 100644 index 0000000..e8f33ec --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderItem.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.procurement.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Product; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "procurement_purchase_order_items") +public class PurchaseOrderItem extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "purchase_order_id", nullable = false) + private PurchaseOrder purchaseOrder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(nullable = false, precision = 19, scale = 3) + private BigDecimal quantity; + + @Column(name = "unit_price", nullable = false, precision = 19, scale = 2) + private BigDecimal unitPrice; + + @Column(name = "line_total", nullable = false, precision = 19, scale = 2) + private BigDecimal lineTotal; + + protected PurchaseOrderItem() { + } + + public PurchaseOrderItem(Product product, BigDecimal quantity, BigDecimal unitPrice) { + this.product = product; + this.quantity = quantity; + this.unitPrice = unitPrice; + recalculateLineTotal(); + } + + public void setPurchaseOrder(PurchaseOrder purchaseOrder) { + this.purchaseOrder = purchaseOrder; + } + + public Product getProduct() { + return product; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public BigDecimal getLineTotal() { + return lineTotal; + } + + private void recalculateLineTotal() { + lineTotal = quantity.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java new file mode 100644 index 0000000..5ae9ccf --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatus.java @@ -0,0 +1,10 @@ +package com.example.erpmvp.modules.procurement.domain; + +public enum PurchaseOrderStatus { + DRAFT, + APPROVED, + ORDERED, + RECEIVED, + CANCELLED +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java new file mode 100644 index 0000000..f59e74a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/domain/PurchaseOrderStatusHistory.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.procurement.domain; + +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "procurement_purchase_order_status_history") +public class PurchaseOrderStatusHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "purchase_order_id", nullable = false) + private PurchaseOrder purchaseOrder; + + @Enumerated(EnumType.STRING) + @Column(name = "old_status", length = 50) + private PurchaseOrderStatus oldStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "new_status", nullable = false, length = 50) + private PurchaseOrderStatus newStatus; + + @Column(name = "changed_by_user_id") + private UUID changedByUserId; + + @Column(columnDefinition = "TEXT") + private String comment; + + protected PurchaseOrderStatusHistory() { + } + + public PurchaseOrderStatusHistory( + PurchaseOrder purchaseOrder, + PurchaseOrderStatus oldStatus, + PurchaseOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + this.purchaseOrder = purchaseOrder; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.changedByUserId = changedByUserId; + this.comment = comment == null ? null : comment.trim(); + } + + public PurchaseOrderStatus getOldStatus() { + return oldStatus; + } + + public PurchaseOrderStatus getNewStatus() { + return newStatus; + } + + public UUID getChangedByUserId() { + return changedByUserId; + } + + public String getComment() { + return comment; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._ChangePurchaseOrderStatusRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._ChangePurchaseOrderStatusRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._ChangePurchaseOrderStatusRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderItemRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderItemRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderItemRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._CreatePurchaseOrderRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderItemResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderItemResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderProductResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderProductResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderStatusHistoryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderStatusHistoryResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderStatusHistoryResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderSupplierResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderSupplierResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderSupplierResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderWarehouseResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._PurchaseOrderWarehouseResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._UpdatePurchaseOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._UpdatePurchaseOrderRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/._UpdatePurchaseOrderRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/ChangePurchaseOrderStatusRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/ChangePurchaseOrderStatusRequest.java new file mode 100644 index 0000000..e7b520b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/ChangePurchaseOrderStatusRequest.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.procurement.dto; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; +import jakarta.validation.constraints.NotNull; + +public record ChangePurchaseOrderStatusRequest( + @NotNull PurchaseOrderStatus status, + String comment +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderItemRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderItemRequest.java new file mode 100644 index 0000000..90ee683 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderItemRequest.java @@ -0,0 +1,16 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +public record CreatePurchaseOrderItemRequest( + @NotNull UUID productId, + @NotNull @Positive BigDecimal quantity, + @NotNull @PositiveOrZero BigDecimal unitPrice +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderRequest.java new file mode 100644 index 0000000..3853095 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/CreatePurchaseOrderRequest.java @@ -0,0 +1,18 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record CreatePurchaseOrderRequest( + @NotNull UUID supplierId, + UUID warehouseId, + LocalDate expectedDeliveryDate, + String notes, + @NotEmpty @Valid List items +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java new file mode 100644 index 0000000..09658ca --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderItemResponse.java @@ -0,0 +1,26 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderItem; + +public record PurchaseOrderItemResponse( + UUID id, + PurchaseOrderProductResponse product, + BigDecimal quantity, + BigDecimal unitPrice, + BigDecimal lineTotal +) { + + public static PurchaseOrderItemResponse from(PurchaseOrderItem item) { + return new PurchaseOrderItemResponse( + item.getId(), + PurchaseOrderProductResponse.from(item.getProduct()), + item.getQuantity(), + item.getUnitPrice(), + item.getLineTotal() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java new file mode 100644 index 0000000..edf8258 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderProductResponse.java @@ -0,0 +1,23 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +public record PurchaseOrderProductResponse( + UUID id, + String sku, + String name, + String unit +) { + + public static PurchaseOrderProductResponse from(Product product) { + return new PurchaseOrderProductResponse( + product.getId(), + product.getSku(), + product.getName(), + product.getUnit() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java new file mode 100644 index 0000000..c11b228 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderResponse.java @@ -0,0 +1,51 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrder; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; + +public record PurchaseOrderResponse( + UUID id, + String poNumber, + PurchaseOrderSupplierResponse supplier, + PurchaseOrderWarehouseResponse warehouse, + PurchaseOrderStatus status, + LocalDate expectedDeliveryDate, + String notes, + BigDecimal totalAmount, + UUID createdByUserId, + UUID approvedByUserId, + Instant orderedAt, + Instant receivedAt, + Instant cancelledAt, + Instant createdAt, + Instant updatedAt, + List items +) { + + public static PurchaseOrderResponse from(PurchaseOrder purchaseOrder) { + return new PurchaseOrderResponse( + purchaseOrder.getId(), + purchaseOrder.getPoNumber(), + PurchaseOrderSupplierResponse.from(purchaseOrder.getSupplier()), + PurchaseOrderWarehouseResponse.from(purchaseOrder.getWarehouse()), + purchaseOrder.getStatus(), + purchaseOrder.getExpectedDeliveryDate(), + purchaseOrder.getNotes(), + purchaseOrder.getTotalAmount(), + purchaseOrder.getCreatedByUserId(), + purchaseOrder.getApprovedByUserId(), + purchaseOrder.getOrderedAt(), + purchaseOrder.getReceivedAt(), + purchaseOrder.getCancelledAt(), + purchaseOrder.getCreatedAt(), + purchaseOrder.getUpdatedAt(), + purchaseOrder.getItems().stream().map(PurchaseOrderItemResponse::from).toList() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java new file mode 100644 index 0000000..5f4b7da --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderStatusHistoryResponse.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatusHistory; + +public record PurchaseOrderStatusHistoryResponse( + UUID id, + PurchaseOrderStatus oldStatus, + PurchaseOrderStatus newStatus, + UUID changedByUserId, + String comment, + Instant createdAt +) { + + public static PurchaseOrderStatusHistoryResponse from(PurchaseOrderStatusHistory history) { + return new PurchaseOrderStatusHistoryResponse( + history.getId(), + history.getOldStatus(), + history.getNewStatus(), + history.getChangedByUserId(), + history.getComment(), + history.getCreatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java new file mode 100644 index 0000000..d444d22 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderSupplierResponse.java @@ -0,0 +1,21 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Supplier; + +public record PurchaseOrderSupplierResponse( + UUID id, + String companyName, + String bin +) { + + public static PurchaseOrderSupplierResponse from(Supplier supplier) { + return new PurchaseOrderSupplierResponse( + supplier.getId(), + supplier.getCompanyName(), + supplier.getBin() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java new file mode 100644 index 0000000..0dd1ad0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/PurchaseOrderWarehouseResponse.java @@ -0,0 +1,24 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +public record PurchaseOrderWarehouseResponse( + UUID id, + String code, + String name +) { + + public static PurchaseOrderWarehouseResponse from(Warehouse warehouse) { + if (warehouse == null) { + return null; + } + + return new PurchaseOrderWarehouseResponse( + warehouse.getId(), + warehouse.getCode(), + warehouse.getName() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java new file mode 100644 index 0000000..768d04c --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/dto/UpdatePurchaseOrderRequest.java @@ -0,0 +1,18 @@ +package com.example.erpmvp.modules.procurement.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record UpdatePurchaseOrderRequest( + @NotNull UUID supplierId, + UUID warehouseId, + LocalDate expectedDeliveryDate, + String notes, + @NotEmpty @Valid List items +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderItemRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderItemRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderItemRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderStatusHistoryRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderStatusHistoryRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/._PurchaseOrderStatusHistoryRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderItemRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderItemRepository.java new file mode 100644 index 0000000..07e3a95 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderItemRepository.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.procurement.repository; + +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderItem; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PurchaseOrderItemRepository extends JpaRepository { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java new file mode 100644 index 0000000..b12ea95 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderRepository.java @@ -0,0 +1,42 @@ +package com.example.erpmvp.modules.procurement.repository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrder; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PurchaseOrderRepository extends JpaRepository { + + boolean existsByPoNumber(String poNumber); + + long countByPoNumberStartingWith(String poNumberPrefix); + + Optional findFirstByNotesContaining(String marker); + + @EntityGraph(attributePaths = {"supplier", "warehouse"}) + @Query(""" + select po from PurchaseOrder po + where (:search is null or :search = '' or lower(po.poNumber) like lower(concat('%', :search, '%'))) + and (:supplierId is null or po.supplier.id = :supplierId) + and (:status is null or po.status = :status) + and (:fromDate is null or po.expectedDeliveryDate >= :fromDate) + and (:toDate is null or po.expectedDeliveryDate <= :toDate) + """) + Page search( + @Param("search") String search, + @Param("supplierId") UUID supplierId, + @Param("status") PurchaseOrderStatus status, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java new file mode 100644 index 0000000..1f82825 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/repository/PurchaseOrderStatusHistoryRepository.java @@ -0,0 +1,13 @@ +package com.example.erpmvp.modules.procurement.repository; + +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatusHistory; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PurchaseOrderStatusHistoryRepository extends JpaRepository { + + List findByPurchaseOrder_IdOrderByCreatedAtAsc(UUID purchaseOrderId); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/service/._PurchaseOrderService.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/service/._PurchaseOrderService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/procurement/service/._PurchaseOrderService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/procurement/service/PurchaseOrderService.java b/backend/src/main/java/com/example/erpmvp/modules/procurement/service/PurchaseOrderService.java new file mode 100644 index 0000000..65da1f4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/procurement/service/PurchaseOrderService.java @@ -0,0 +1,311 @@ +package com.example.erpmvp.modules.procurement.service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.error.BadRequestException; +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.domain.Role; +import com.example.erpmvp.modules.auth.domain.User; +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.ProductRepository; +import com.example.erpmvp.modules.catalog.repository.SupplierRepository; +import com.example.erpmvp.modules.catalog.repository.WarehouseRepository; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrder; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderItem; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatus; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderStatusHistory; +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.dto.PurchaseOrderResponse; +import com.example.erpmvp.modules.procurement.dto.PurchaseOrderStatusHistoryResponse; +import com.example.erpmvp.modules.procurement.dto.UpdatePurchaseOrderRequest; +import com.example.erpmvp.modules.procurement.repository.PurchaseOrderRepository; +import com.example.erpmvp.modules.procurement.repository.PurchaseOrderStatusHistoryRepository; +import com.example.erpmvp.modules.warehouse.service.WarehouseStockService; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PurchaseOrderService { + + private static final DateTimeFormatter PO_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + private final PurchaseOrderRepository purchaseOrderRepository; + private final PurchaseOrderStatusHistoryRepository statusHistoryRepository; + private final SupplierRepository supplierRepository; + private final ProductRepository productRepository; + private final WarehouseRepository warehouseRepository; + private final WarehouseStockService warehouseStockService; + + public PurchaseOrderService( + PurchaseOrderRepository purchaseOrderRepository, + PurchaseOrderStatusHistoryRepository statusHistoryRepository, + SupplierRepository supplierRepository, + ProductRepository productRepository, + WarehouseRepository warehouseRepository, + WarehouseStockService warehouseStockService + ) { + this.purchaseOrderRepository = purchaseOrderRepository; + this.statusHistoryRepository = statusHistoryRepository; + this.supplierRepository = supplierRepository; + this.productRepository = productRepository; + this.warehouseRepository = warehouseRepository; + this.warehouseStockService = warehouseStockService; + } + + @Transactional(readOnly = true) + public PageResponseDto list( + String search, + UUID supplierId, + PurchaseOrderStatus status, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(purchaseOrderRepository.search( + normalizeSearch(search), + supplierId, + status, + fromDate, + toDate, + pageable + ).map(PurchaseOrderResponse::from)); + } + + @Transactional(readOnly = true) + public PurchaseOrderResponse getById(UUID id) { + return PurchaseOrderResponse.from(findPurchaseOrder(id)); + } + + @Transactional + public PurchaseOrderResponse create(CreatePurchaseOrderRequest request, User currentUser) { + Supplier supplier = findActiveSupplier(request.supplierId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + PurchaseOrder purchaseOrder = new PurchaseOrder( + generatePoNumber(), + supplier, + warehouse, + request.expectedDeliveryDate(), + request.notes(), + currentUser.getId() + ); + purchaseOrder.replaceItems(buildItems(request.items())); + + PurchaseOrder saved = purchaseOrderRepository.save(purchaseOrder); + addHistory(saved, null, PurchaseOrderStatus.DRAFT, currentUser.getId(), "Created"); + + return PurchaseOrderResponse.from(saved); + } + + @Transactional + public PurchaseOrderResponse update(UUID id, UpdatePurchaseOrderRequest request) { + PurchaseOrder purchaseOrder = findPurchaseOrder(id); + ensureDraft(purchaseOrder); + + Supplier supplier = findActiveSupplier(request.supplierId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + purchaseOrder.updateDraft(supplier, warehouse, request.expectedDeliveryDate(), request.notes()); + purchaseOrder.replaceItems(buildItems(request.items())); + + return PurchaseOrderResponse.from(purchaseOrder); + } + + @Transactional + public PurchaseOrderResponse changeStatus(UUID id, ChangePurchaseOrderStatusRequest request, User currentUser) { + PurchaseOrder purchaseOrder = findPurchaseOrder(id); + PurchaseOrderStatus oldStatus = purchaseOrder.getStatus(); + PurchaseOrderStatus newStatus = request.status(); + + ensureStatusChangeAllowedForRole(newStatus, currentUser.getRole()); + ensureTransitionAllowed(oldStatus, newStatus); + + if (newStatus == PurchaseOrderStatus.RECEIVED) { + ensureReceivingWarehouseSelected(purchaseOrder); + warehouseStockService.receivePurchaseOrder(purchaseOrder, currentUser); + } + + purchaseOrder.changeStatus(newStatus, currentUser.getId()); + addHistory(purchaseOrder, oldStatus, newStatus, currentUser.getId(), request.comment()); + + return PurchaseOrderResponse.from(purchaseOrder); + } + + @Transactional + public void delete(UUID id, User currentUser) { + PurchaseOrder purchaseOrder = findPurchaseOrder(id); + + if (purchaseOrder.getStatus() == PurchaseOrderStatus.CANCELLED) { + return; + } + + ensureTransitionAllowed(purchaseOrder.getStatus(), PurchaseOrderStatus.CANCELLED); + PurchaseOrderStatus oldStatus = purchaseOrder.getStatus(); + purchaseOrder.changeStatus(PurchaseOrderStatus.CANCELLED, currentUser.getId()); + addHistory(purchaseOrder, oldStatus, PurchaseOrderStatus.CANCELLED, currentUser.getId(), "Cancelled via DELETE"); + } + + @Transactional(readOnly = true) + public List getStatusHistory(UUID id) { + if (!purchaseOrderRepository.existsById(id)) { + throw new NotFoundException("PURCHASE_ORDER_NOT_FOUND", "Purchase order not found"); + } + + return statusHistoryRepository.findByPurchaseOrder_IdOrderByCreatedAtAsc(id) + .stream() + .map(PurchaseOrderStatusHistoryResponse::from) + .toList(); + } + + private PurchaseOrder findPurchaseOrder(UUID id) { + return purchaseOrderRepository.findById(id) + .orElseThrow(() -> new NotFoundException("PURCHASE_ORDER_NOT_FOUND", "Purchase order not found")); + } + + private Supplier findActiveSupplier(UUID supplierId) { + Supplier supplier = supplierRepository.findById(supplierId) + .orElseThrow(() -> new NotFoundException("SUPPLIER_NOT_FOUND", "Supplier not found")); + + if (!supplier.isActive()) { + throw new BusinessException("SUPPLIER_INACTIVE", "Supplier is inactive"); + } + + return supplier; + } + + private Warehouse findActiveWarehouseOrNull(UUID warehouseId) { + if (warehouseId == null) { + return null; + } + + Warehouse warehouse = warehouseRepository.findById(warehouseId) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + + if (!warehouse.isActive()) { + throw new BusinessException("WAREHOUSE_INACTIVE", "Warehouse is inactive"); + } + + return warehouse; + } + + private Product findActiveProduct(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + + if (!product.isActive()) { + throw new BusinessException("PRODUCT_INACTIVE", "Product is inactive"); + } + + return product; + } + + private List buildItems(List itemRequests) { + return itemRequests.stream() + .map(item -> new PurchaseOrderItem( + findActiveProduct(item.productId()), + item.quantity(), + item.unitPrice() + )) + .toList(); + } + + private void ensureDraft(PurchaseOrder purchaseOrder) { + if (purchaseOrder.getStatus() != PurchaseOrderStatus.DRAFT) { + throw new BusinessException( + "PURCHASE_ORDER_NOT_EDITABLE", + "Only DRAFT purchase orders can be edited" + ); + } + } + + private void ensureStatusChangeAllowedForRole(PurchaseOrderStatus newStatus, Role role) { + if (role == Role.WAREHOUSE && newStatus != PurchaseOrderStatus.RECEIVED) { + throw new BusinessException( + "STATUS_CHANGE_NOT_ALLOWED", + "WAREHOUSE role can only mark purchase orders as RECEIVED" + ); + } + } + + private void ensureReceivingWarehouseSelected(PurchaseOrder purchaseOrder) { + if (purchaseOrder.getWarehouse() == null) { + throw new BusinessException( + "RECEIVING_WAREHOUSE_REQUIRED", + "Receiving warehouse is required before marking purchase order as RECEIVED" + ); + } + } + + private void ensureTransitionAllowed(PurchaseOrderStatus oldStatus, PurchaseOrderStatus newStatus) { + if (oldStatus == newStatus) { + throw new BusinessException("INVALID_STATUS_TRANSITION", "Purchase order is already in status " + newStatus); + } + + boolean allowed = switch (oldStatus) { + case DRAFT -> newStatus == PurchaseOrderStatus.APPROVED || newStatus == PurchaseOrderStatus.CANCELLED; + case APPROVED -> newStatus == PurchaseOrderStatus.ORDERED || newStatus == PurchaseOrderStatus.CANCELLED; + case ORDERED -> newStatus == PurchaseOrderStatus.RECEIVED || newStatus == PurchaseOrderStatus.CANCELLED; + case RECEIVED, CANCELLED -> false; + }; + + if (!allowed) { + throw new BusinessException( + "INVALID_STATUS_TRANSITION", + "Invalid status transition from " + oldStatus + " to " + newStatus + ); + } + } + + private void addHistory( + PurchaseOrder purchaseOrder, + PurchaseOrderStatus oldStatus, + PurchaseOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + statusHistoryRepository.save(new PurchaseOrderStatusHistory( + purchaseOrder, + oldStatus, + newStatus, + changedByUserId, + comment + )); + } + + private String generatePoNumber() { + String prefix = "PO-" + LocalDate.now().format(PO_DATE_FORMAT) + "-"; + long nextNumber = purchaseOrderRepository.countByPoNumberStartingWith(prefix) + 1; + + String poNumber; + do { + poNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (purchaseOrderRepository.existsByPoNumber(poNumber)); + + return poNumber; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/._controller b/backend/src/main/java/com/example/erpmvp/modules/sales/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/._domain b/backend/src/main/java/com/example/erpmvp/modules/sales/._domain new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/._domain differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/._dto b/backend/src/main/java/com/example/erpmvp/modules/sales/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/._repository b/backend/src/main/java/com/example/erpmvp/modules/sales/._repository new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/._repository differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/._service b/backend/src/main/java/com/example/erpmvp/modules/sales/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/controller/._CustomerOrderController.java b/backend/src/main/java/com/example/erpmvp/modules/sales/controller/._CustomerOrderController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/controller/._CustomerOrderController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/controller/CustomerOrderController.java b/backend/src/main/java/com/example/erpmvp/modules/sales/controller/CustomerOrderController.java new file mode 100644 index 0000000..852f4e3 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/controller/CustomerOrderController.java @@ -0,0 +1,122 @@ +package com.example.erpmvp.modules.sales.controller; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.security.AuthUserDetails; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import com.example.erpmvp.modules.sales.dto.ChangeCustomerOrderStatusRequest; +import com.example.erpmvp.modules.sales.dto.CreateCustomerOrderRequest; +import com.example.erpmvp.modules.sales.dto.CustomerOrderResponse; +import com.example.erpmvp.modules.sales.dto.CustomerOrderStatusHistoryResponse; +import com.example.erpmvp.modules.sales.dto.UpdateCustomerOrderRequest; +import com.example.erpmvp.modules.sales.service.CustomerOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/sales/customer-orders") +@Tag(name = "Sales - Customer Orders", description = "B2B customer order process") +public class CustomerOrderController { + + private final CustomerOrderService customerOrderService; + + public CustomerOrderController(CustomerOrderService customerOrderService) { + this.customerOrderService = customerOrderService; + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List customer orders") + public ApiResponse> list( + @RequestParam(required = false) String search, + @RequestParam(required = false) UUID customerId, + @RequestParam(required = false) UUID warehouseId, + @RequestParam(required = false) CustomerOrderStatus status, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(customerOrderService.list(search, customerId, warehouseId, status, fromDate, toDate, page, size)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get customer order by id") + public ApiResponse getById(@PathVariable UUID id) { + return ApiResponse.success(customerOrderService.getById(id)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Create customer order") + public ApiResponse create( + @Valid @RequestBody CreateCustomerOrderRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(customerOrderService.create(request, currentUser.getUser())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Update new customer order") + public ApiResponse update( + @PathVariable UUID id, + @Valid @RequestBody UpdateCustomerOrderRequest request + ) { + return ApiResponse.success(customerOrderService.update(id, request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE')") + @Operation(summary = "Change customer order status") + public ApiResponse changeStatus( + @PathVariable UUID id, + @Valid @RequestBody ChangeCustomerOrderStatusRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(customerOrderService.changeStatus(id, request, currentUser.getUser())); + } + + @GetMapping("/{id}/status-history") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "Get customer order status history") + public ApiResponse> getStatusHistory(@PathVariable UUID id) { + return ApiResponse.success(customerOrderService.getStatusHistory(id)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')") + @Operation(summary = "Cancel customer order") + public ApiResponse delete( + @PathVariable UUID id, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + customerOrderService.delete(id, currentUser.getUser()); + return ApiResponse.success(null); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrder.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrder.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrder.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderItem.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderItem.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderItem.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatus.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatus.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatus.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatusHistory.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatusHistory.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/._CustomerOrderStatusHistory.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrder.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrder.java new file mode 100644 index 0000000..cb462cf --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrder.java @@ -0,0 +1,203 @@ +package com.example.erpmvp.modules.sales.domain; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Customer; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +@Entity +@Table(name = "sales_customer_orders") +public class CustomerOrder extends BaseEntity { + + @Column(name = "order_number", nullable = false, unique = true, length = 100) + private String orderNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_id", nullable = false) + private Customer customer; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "warehouse_id") + private Warehouse warehouse; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private CustomerOrderStatus status; + + @Column(name = "requested_delivery_date") + private LocalDate requestedDeliveryDate; + + @Column(columnDefinition = "TEXT") + private String notes; + + @Column(name = "total_amount", nullable = false, precision = 19, scale = 2) + private BigDecimal totalAmount = BigDecimal.ZERO; + + @Column(name = "created_by_user_id") + private UUID createdByUserId; + + @Column(name = "confirmed_by_user_id") + private UUID confirmedByUserId; + + @Column(name = "confirmed_at") + private Instant confirmedAt; + + @Column(name = "in_progress_at") + private Instant inProgressAt; + + @Column(name = "shipped_at") + private Instant shippedAt; + + @Column(name = "closed_at") + private Instant closedAt; + + @Column(name = "cancelled_at") + private Instant cancelledAt; + + @OneToMany(mappedBy = "customerOrder", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + protected CustomerOrder() { + } + + public CustomerOrder( + String orderNumber, + Customer customer, + Warehouse warehouse, + LocalDate requestedDeliveryDate, + String notes, + UUID createdByUserId + ) { + this.orderNumber = orderNumber; + this.customer = customer; + this.warehouse = warehouse; + this.requestedDeliveryDate = requestedDeliveryDate; + this.notes = trim(notes); + this.createdByUserId = createdByUserId; + this.status = CustomerOrderStatus.NEW; + this.totalAmount = BigDecimal.ZERO; + } + + public void updateNew(Customer customer, Warehouse warehouse, LocalDate requestedDeliveryDate, String notes) { + this.customer = customer; + this.warehouse = warehouse; + this.requestedDeliveryDate = requestedDeliveryDate; + this.notes = trim(notes); + } + + public void replaceItems(List newItems) { + items.clear(); + newItems.forEach(this::addItem); + recalculateTotal(); + } + + public void addItem(CustomerOrderItem item) { + item.setCustomerOrder(this); + items.add(item); + } + + public void recalculateTotal() { + totalAmount = items.stream() + .map(CustomerOrderItem::getLineTotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public void changeStatus(CustomerOrderStatus newStatus, UUID changedByUserId) { + status = newStatus; + Instant now = Instant.now(); + + if (newStatus == CustomerOrderStatus.CONFIRMED) { + confirmedByUserId = changedByUserId; + confirmedAt = now; + } else if (newStatus == CustomerOrderStatus.IN_PROGRESS) { + inProgressAt = now; + } else if (newStatus == CustomerOrderStatus.SHIPPED) { + shippedAt = now; + } else if (newStatus == CustomerOrderStatus.CLOSED) { + closedAt = now; + } else if (newStatus == CustomerOrderStatus.CANCELLED) { + cancelledAt = now; + } + } + + public String getOrderNumber() { + return orderNumber; + } + + public Customer getCustomer() { + return customer; + } + + public Warehouse getWarehouse() { + return warehouse; + } + + public CustomerOrderStatus getStatus() { + return status; + } + + public LocalDate getRequestedDeliveryDate() { + return requestedDeliveryDate; + } + + public String getNotes() { + return notes; + } + + public BigDecimal getTotalAmount() { + return totalAmount; + } + + public UUID getCreatedByUserId() { + return createdByUserId; + } + + public UUID getConfirmedByUserId() { + return confirmedByUserId; + } + + public Instant getConfirmedAt() { + return confirmedAt; + } + + public Instant getInProgressAt() { + return inProgressAt; + } + + public Instant getShippedAt() { + return shippedAt; + } + + public Instant getClosedAt() { + return closedAt; + } + + public Instant getCancelledAt() { + return cancelledAt; + } + + public List getItems() { + return items; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java new file mode 100644 index 0000000..bbbfa74 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderItem.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.sales.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Product; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "sales_customer_order_items") +public class CustomerOrderItem extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_order_id", nullable = false) + private CustomerOrder customerOrder; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(nullable = false, precision = 19, scale = 3) + private BigDecimal quantity; + + @Column(name = "unit_price", nullable = false, precision = 19, scale = 2) + private BigDecimal unitPrice; + + @Column(name = "line_total", nullable = false, precision = 19, scale = 2) + private BigDecimal lineTotal; + + protected CustomerOrderItem() { + } + + public CustomerOrderItem(Product product, BigDecimal quantity, BigDecimal unitPrice) { + this.product = product; + this.quantity = quantity; + this.unitPrice = unitPrice; + recalculateLineTotal(); + } + + public void setCustomerOrder(CustomerOrder customerOrder) { + this.customerOrder = customerOrder; + } + + public Product getProduct() { + return product; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public BigDecimal getUnitPrice() { + return unitPrice; + } + + public BigDecimal getLineTotal() { + return lineTotal; + } + + private void recalculateLineTotal() { + lineTotal = quantity.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java new file mode 100644 index 0000000..2f825ee --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatus.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.sales.domain; + +public enum CustomerOrderStatus { + NEW, + CONFIRMED, + IN_PROGRESS, + SHIPPED, + CLOSED, + CANCELLED +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java new file mode 100644 index 0000000..42d57aa --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/domain/CustomerOrderStatusHistory.java @@ -0,0 +1,70 @@ +package com.example.erpmvp.modules.sales.domain; + +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "sales_customer_order_status_history") +public class CustomerOrderStatusHistory extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_order_id", nullable = false) + private CustomerOrder customerOrder; + + @Enumerated(EnumType.STRING) + @Column(name = "old_status", length = 50) + private CustomerOrderStatus oldStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "new_status", nullable = false, length = 50) + private CustomerOrderStatus newStatus; + + @Column(name = "changed_by_user_id") + private UUID changedByUserId; + + @Column(columnDefinition = "TEXT") + private String comment; + + protected CustomerOrderStatusHistory() { + } + + public CustomerOrderStatusHistory( + CustomerOrder customerOrder, + CustomerOrderStatus oldStatus, + CustomerOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + this.customerOrder = customerOrder; + this.oldStatus = oldStatus; + this.newStatus = newStatus; + this.changedByUserId = changedByUserId; + this.comment = comment == null ? null : comment.trim(); + } + + public CustomerOrderStatus getOldStatus() { + return oldStatus; + } + + public CustomerOrderStatus getNewStatus() { + return newStatus; + } + + public UUID getChangedByUserId() { + return changedByUserId; + } + + public String getComment() { + return comment; + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._ChangeCustomerOrderStatusRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._ChangeCustomerOrderStatusRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._ChangeCustomerOrderStatusRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderItemRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderItemRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderItemRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CreateCustomerOrderRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderCustomerResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderCustomerResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderCustomerResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderItemResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderItemResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderProductResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderProductResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderStatusHistoryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderStatusHistoryResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderStatusHistoryResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderWarehouseResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._CustomerOrderWarehouseResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._UpdateCustomerOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._UpdateCustomerOrderRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/._UpdateCustomerOrderRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/ChangeCustomerOrderStatusRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/ChangeCustomerOrderStatusRequest.java new file mode 100644 index 0000000..f86077a --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/ChangeCustomerOrderStatusRequest.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.sales.dto; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import jakarta.validation.constraints.NotNull; + +public record ChangeCustomerOrderStatusRequest( + @NotNull CustomerOrderStatus status, + String comment +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderItemRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderItemRequest.java new file mode 100644 index 0000000..83fc6b4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderItemRequest.java @@ -0,0 +1,16 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; + +public record CreateCustomerOrderItemRequest( + @NotNull UUID productId, + @NotNull @Positive BigDecimal quantity, + @NotNull @PositiveOrZero BigDecimal unitPrice +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderRequest.java new file mode 100644 index 0000000..1adddf2 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CreateCustomerOrderRequest.java @@ -0,0 +1,19 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record CreateCustomerOrderRequest( + @NotNull UUID customerId, + UUID warehouseId, + LocalDate requestedDeliveryDate, + String notes, + @NotEmpty @Valid List items +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java new file mode 100644 index 0000000..9d1f24b --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderCustomerResponse.java @@ -0,0 +1,21 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Customer; + +public record CustomerOrderCustomerResponse( + UUID id, + String companyName, + String bin +) { + + public static CustomerOrderCustomerResponse from(Customer customer) { + return new CustomerOrderCustomerResponse( + customer.getId(), + customer.getCompanyName(), + customer.getBin() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java new file mode 100644 index 0000000..b844502 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderItemResponse.java @@ -0,0 +1,26 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderItem; + +public record CustomerOrderItemResponse( + UUID id, + CustomerOrderProductResponse product, + BigDecimal quantity, + BigDecimal unitPrice, + BigDecimal lineTotal +) { + + public static CustomerOrderItemResponse from(CustomerOrderItem item) { + return new CustomerOrderItemResponse( + item.getId(), + CustomerOrderProductResponse.from(item.getProduct()), + item.getQuantity(), + item.getUnitPrice(), + item.getLineTotal() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java new file mode 100644 index 0000000..da25bb0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderProductResponse.java @@ -0,0 +1,23 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +public record CustomerOrderProductResponse( + UUID id, + String sku, + String name, + String unit +) { + + public static CustomerOrderProductResponse from(Product product) { + return new CustomerOrderProductResponse( + product.getId(), + product.getSku(), + product.getName(), + product.getUnit() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java new file mode 100644 index 0000000..f37d948 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderResponse.java @@ -0,0 +1,56 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; + +public record CustomerOrderResponse( + UUID id, + String orderNumber, + CustomerOrderCustomerResponse customer, + CustomerOrderWarehouseResponse warehouse, + CustomerOrderStatus status, + LocalDate requestedDeliveryDate, + String notes, + BigDecimal totalAmount, + UUID createdByUserId, + UUID confirmedByUserId, + Instant confirmedAt, + Instant inProgressAt, + Instant shippedAt, + Instant closedAt, + Instant cancelledAt, + Instant createdAt, + Instant updatedAt, + List items +) { + + public static CustomerOrderResponse from(CustomerOrder customerOrder) { + return new CustomerOrderResponse( + customerOrder.getId(), + customerOrder.getOrderNumber(), + CustomerOrderCustomerResponse.from(customerOrder.getCustomer()), + CustomerOrderWarehouseResponse.from(customerOrder.getWarehouse()), + customerOrder.getStatus(), + customerOrder.getRequestedDeliveryDate(), + customerOrder.getNotes(), + customerOrder.getTotalAmount(), + customerOrder.getCreatedByUserId(), + customerOrder.getConfirmedByUserId(), + customerOrder.getConfirmedAt(), + customerOrder.getInProgressAt(), + customerOrder.getShippedAt(), + customerOrder.getClosedAt(), + customerOrder.getCancelledAt(), + customerOrder.getCreatedAt(), + customerOrder.getUpdatedAt(), + customerOrder.getItems().stream().map(CustomerOrderItemResponse::from).toList() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java new file mode 100644 index 0000000..ffc6ae1 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderStatusHistoryResponse.java @@ -0,0 +1,29 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatusHistory; + +public record CustomerOrderStatusHistoryResponse( + UUID id, + CustomerOrderStatus oldStatus, + CustomerOrderStatus newStatus, + UUID changedByUserId, + String comment, + Instant createdAt +) { + + public static CustomerOrderStatusHistoryResponse from(CustomerOrderStatusHistory history) { + return new CustomerOrderStatusHistoryResponse( + history.getId(), + history.getOldStatus(), + history.getNewStatus(), + history.getChangedByUserId(), + history.getComment(), + history.getCreatedAt() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java new file mode 100644 index 0000000..d63b8a4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/CustomerOrderWarehouseResponse.java @@ -0,0 +1,25 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +public record CustomerOrderWarehouseResponse( + UUID id, + String code, + String name +) { + + public static CustomerOrderWarehouseResponse from(Warehouse warehouse) { + if (warehouse == null) { + return null; + } + + return new CustomerOrderWarehouseResponse( + warehouse.getId(), + warehouse.getCode(), + warehouse.getName() + ); + } +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java new file mode 100644 index 0000000..35c32f2 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/dto/UpdateCustomerOrderRequest.java @@ -0,0 +1,19 @@ +package com.example.erpmvp.modules.sales.dto; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record UpdateCustomerOrderRequest( + @NotNull UUID customerId, + UUID warehouseId, + LocalDate requestedDeliveryDate, + String notes, + @NotEmpty @Valid List items +) { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderItemRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderItemRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderItemRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderStatusHistoryRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderStatusHistoryRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/._CustomerOrderStatusHistoryRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderItemRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderItemRepository.java new file mode 100644 index 0000000..fd604e7 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderItemRepository.java @@ -0,0 +1,11 @@ +package com.example.erpmvp.modules.sales.repository; + +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderItem; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomerOrderItemRepository extends JpaRepository { +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java new file mode 100644 index 0000000..f27af64 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderRepository.java @@ -0,0 +1,44 @@ +package com.example.erpmvp.modules.sales.repository; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CustomerOrderRepository extends JpaRepository { + + boolean existsByOrderNumber(String orderNumber); + + long countByOrderNumberStartingWith(String orderNumberPrefix); + + Optional findFirstByNotesContaining(String marker); + + @EntityGraph(attributePaths = {"customer", "warehouse"}) + @Query(""" + select co from CustomerOrder co + where (:search is null or :search = '' or lower(co.orderNumber) like lower(concat('%', :search, '%'))) + and (:customerId is null or co.customer.id = :customerId) + and (:warehouseId is null or co.warehouse.id = :warehouseId) + and (:status is null or co.status = :status) + and (:fromDate is null or co.requestedDeliveryDate >= :fromDate) + and (:toDate is null or co.requestedDeliveryDate <= :toDate) + """) + Page search( + @Param("search") String search, + @Param("customerId") UUID customerId, + @Param("warehouseId") UUID warehouseId, + @Param("status") CustomerOrderStatus status, + @Param("fromDate") LocalDate fromDate, + @Param("toDate") LocalDate toDate, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java new file mode 100644 index 0000000..b2c7015 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/repository/CustomerOrderStatusHistoryRepository.java @@ -0,0 +1,14 @@ +package com.example.erpmvp.modules.sales.repository; + +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatusHistory; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CustomerOrderStatusHistoryRepository extends JpaRepository { + + List findByCustomerOrder_IdOrderByCreatedAtAsc(UUID customerOrderId); +} + diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/service/._CustomerOrderService.java b/backend/src/main/java/com/example/erpmvp/modules/sales/service/._CustomerOrderService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/sales/service/._CustomerOrderService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/sales/service/CustomerOrderService.java b/backend/src/main/java/com/example/erpmvp/modules/sales/service/CustomerOrderService.java new file mode 100644 index 0000000..82947e4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/sales/service/CustomerOrderService.java @@ -0,0 +1,326 @@ +package com.example.erpmvp.modules.sales.service; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; + +import com.example.erpmvp.common.error.BadRequestException; +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.domain.Role; +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.catalog.domain.Customer; +import com.example.erpmvp.modules.catalog.domain.Product; +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.WarehouseRepository; +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderItem; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatus; +import com.example.erpmvp.modules.sales.domain.CustomerOrderStatusHistory; +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.dto.CustomerOrderResponse; +import com.example.erpmvp.modules.sales.dto.CustomerOrderStatusHistoryResponse; +import com.example.erpmvp.modules.sales.dto.UpdateCustomerOrderRequest; +import com.example.erpmvp.modules.sales.repository.CustomerOrderRepository; +import com.example.erpmvp.modules.sales.repository.CustomerOrderStatusHistoryRepository; +import com.example.erpmvp.modules.warehouse.service.WarehouseStockService; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CustomerOrderService { + + private static final DateTimeFormatter ORDER_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + private final CustomerOrderRepository customerOrderRepository; + private final CustomerOrderStatusHistoryRepository statusHistoryRepository; + private final CustomerRepository customerRepository; + private final WarehouseRepository warehouseRepository; + private final ProductRepository productRepository; + private final WarehouseStockService warehouseStockService; + + public CustomerOrderService( + CustomerOrderRepository customerOrderRepository, + CustomerOrderStatusHistoryRepository statusHistoryRepository, + CustomerRepository customerRepository, + WarehouseRepository warehouseRepository, + ProductRepository productRepository, + WarehouseStockService warehouseStockService + ) { + this.customerOrderRepository = customerOrderRepository; + this.statusHistoryRepository = statusHistoryRepository; + this.customerRepository = customerRepository; + this.warehouseRepository = warehouseRepository; + this.productRepository = productRepository; + this.warehouseStockService = warehouseStockService; + } + + @Transactional(readOnly = true) + public PageResponseDto list( + String search, + UUID customerId, + UUID warehouseId, + CustomerOrderStatus status, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(customerOrderRepository.search( + normalizeSearch(search), + customerId, + warehouseId, + status, + fromDate, + toDate, + pageable + ).map(CustomerOrderResponse::from)); + } + + @Transactional(readOnly = true) + public CustomerOrderResponse getById(UUID id) { + return CustomerOrderResponse.from(findCustomerOrder(id)); + } + + @Transactional + public CustomerOrderResponse create(CreateCustomerOrderRequest request, User currentUser) { + Customer customer = findActiveCustomer(request.customerId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + + CustomerOrder customerOrder = new CustomerOrder( + generateOrderNumber(), + customer, + warehouse, + request.requestedDeliveryDate(), + request.notes(), + currentUser.getId() + ); + customerOrder.replaceItems(buildItems(request.items())); + + CustomerOrder saved = customerOrderRepository.save(customerOrder); + addHistory(saved, null, CustomerOrderStatus.NEW, currentUser.getId(), "Created"); + + return CustomerOrderResponse.from(saved); + } + + @Transactional + public CustomerOrderResponse update(UUID id, UpdateCustomerOrderRequest request) { + CustomerOrder customerOrder = findCustomerOrder(id); + ensureNew(customerOrder); + + Customer customer = findActiveCustomer(request.customerId()); + Warehouse warehouse = findActiveWarehouseOrNull(request.warehouseId()); + customerOrder.updateNew(customer, warehouse, request.requestedDeliveryDate(), request.notes()); + customerOrder.replaceItems(buildItems(request.items())); + + return CustomerOrderResponse.from(customerOrder); + } + + @Transactional + public CustomerOrderResponse changeStatus(UUID id, ChangeCustomerOrderStatusRequest request, User currentUser) { + CustomerOrder customerOrder = findCustomerOrder(id); + CustomerOrderStatus oldStatus = customerOrder.getStatus(); + CustomerOrderStatus newStatus = request.status(); + + ensureStatusChangeAllowedForRole(oldStatus, newStatus, currentUser.getRole()); + ensureTransitionAllowed(oldStatus, newStatus); + + if (newStatus == CustomerOrderStatus.SHIPPED) { + ensureShippingWarehouseSelected(customerOrder); + warehouseStockService.shipCustomerOrder(customerOrder, currentUser); + } + + customerOrder.changeStatus(newStatus, currentUser.getId()); + addHistory(customerOrder, oldStatus, newStatus, currentUser.getId(), request.comment()); + + return CustomerOrderResponse.from(customerOrder); + } + + @Transactional + public void delete(UUID id, User currentUser) { + CustomerOrder customerOrder = findCustomerOrder(id); + + if (customerOrder.getStatus() == CustomerOrderStatus.CANCELLED) { + return; + } + + if (customerOrder.getStatus() == CustomerOrderStatus.CLOSED || customerOrder.getStatus() == CustomerOrderStatus.SHIPPED) { + throw new BusinessException("CUSTOMER_ORDER_NOT_CANCELLABLE", "SHIPPED or CLOSED customer orders cannot be cancelled"); + } + + ensureTransitionAllowed(customerOrder.getStatus(), CustomerOrderStatus.CANCELLED); + CustomerOrderStatus oldStatus = customerOrder.getStatus(); + customerOrder.changeStatus(CustomerOrderStatus.CANCELLED, currentUser.getId()); + addHistory(customerOrder, oldStatus, CustomerOrderStatus.CANCELLED, currentUser.getId(), "Cancelled via DELETE"); + } + + @Transactional(readOnly = true) + public List getStatusHistory(UUID id) { + if (!customerOrderRepository.existsById(id)) { + throw new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found"); + } + + return statusHistoryRepository.findByCustomerOrder_IdOrderByCreatedAtAsc(id) + .stream() + .map(CustomerOrderStatusHistoryResponse::from) + .toList(); + } + + private CustomerOrder findCustomerOrder(UUID id) { + return customerOrderRepository.findById(id) + .orElseThrow(() -> new NotFoundException("CUSTOMER_ORDER_NOT_FOUND", "Customer order not found")); + } + + private Customer findActiveCustomer(UUID customerId) { + Customer customer = customerRepository.findById(customerId) + .orElseThrow(() -> new NotFoundException("CUSTOMER_NOT_FOUND", "Customer not found")); + + if (!customer.isActive()) { + throw new BusinessException("CUSTOMER_INACTIVE", "Customer is inactive"); + } + + return customer; + } + + private Warehouse findActiveWarehouseOrNull(UUID warehouseId) { + if (warehouseId == null) { + return null; + } + + Warehouse warehouse = warehouseRepository.findById(warehouseId) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + + if (!warehouse.isActive()) { + throw new BusinessException("WAREHOUSE_INACTIVE", "Warehouse is inactive"); + } + + return warehouse; + } + + private Product findActiveProduct(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + + if (!product.isActive()) { + throw new BusinessException("PRODUCT_INACTIVE", "Product is inactive"); + } + + return product; + } + + private List buildItems(List itemRequests) { + return itemRequests.stream() + .map(item -> new CustomerOrderItem( + findActiveProduct(item.productId()), + item.quantity(), + item.unitPrice() + )) + .toList(); + } + + private void ensureNew(CustomerOrder customerOrder) { + if (customerOrder.getStatus() != CustomerOrderStatus.NEW) { + throw new BusinessException( + "CUSTOMER_ORDER_NOT_EDITABLE", + "Only NEW customer orders can be edited" + ); + } + } + + private void ensureStatusChangeAllowedForRole(CustomerOrderStatus oldStatus, CustomerOrderStatus newStatus, Role role) { + if (role != Role.WAREHOUSE) { + return; + } + + boolean allowed = oldStatus == CustomerOrderStatus.CONFIRMED && newStatus == CustomerOrderStatus.IN_PROGRESS + || oldStatus == CustomerOrderStatus.IN_PROGRESS && newStatus == CustomerOrderStatus.SHIPPED; + + if (!allowed) { + throw new BusinessException( + "STATUS_CHANGE_NOT_ALLOWED", + "WAREHOUSE role can only move CONFIRMED to IN_PROGRESS and IN_PROGRESS to SHIPPED" + ); + } + } + + private void ensureShippingWarehouseSelected(CustomerOrder customerOrder) { + if (customerOrder.getWarehouse() == null) { + throw new BusinessException( + "SHIPPING_WAREHOUSE_REQUIRED", + "Warehouse is required before marking customer order as SHIPPED" + ); + } + } + + private void ensureTransitionAllowed(CustomerOrderStatus oldStatus, CustomerOrderStatus newStatus) { + if (oldStatus == newStatus) { + throw new BusinessException("INVALID_STATUS_TRANSITION", "Customer order is already in status " + newStatus); + } + + boolean allowed = switch (oldStatus) { + case NEW -> newStatus == CustomerOrderStatus.CONFIRMED || newStatus == CustomerOrderStatus.CANCELLED; + case CONFIRMED -> newStatus == CustomerOrderStatus.IN_PROGRESS || newStatus == CustomerOrderStatus.CANCELLED; + case IN_PROGRESS -> newStatus == CustomerOrderStatus.SHIPPED || newStatus == CustomerOrderStatus.CANCELLED; + case SHIPPED -> newStatus == CustomerOrderStatus.CLOSED; + case CLOSED, CANCELLED -> false; + }; + + if (!allowed) { + throw new BusinessException( + "INVALID_STATUS_TRANSITION", + "Invalid status transition from " + oldStatus + " to " + newStatus + ); + } + } + + private void addHistory( + CustomerOrder customerOrder, + CustomerOrderStatus oldStatus, + CustomerOrderStatus newStatus, + UUID changedByUserId, + String comment + ) { + statusHistoryRepository.save(new CustomerOrderStatusHistory( + customerOrder, + oldStatus, + newStatus, + changedByUserId, + comment + )); + } + + private String generateOrderNumber() { + String prefix = "SO-" + LocalDate.now().format(ORDER_DATE_FORMAT) + "-"; + long nextNumber = customerOrderRepository.countByOrderNumberStartingWith(prefix) + 1; + + String orderNumber; + do { + orderNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (customerOrderRepository.existsByOrderNumber(orderNumber)); + + return orderNumber; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/._controller b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._controller new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._controller differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/._domain b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._domain new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._domain differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/._dto b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._dto new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._dto differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/._repository b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._repository new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._repository differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/._service b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._service new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/._service differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/._WarehouseStockController.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/._WarehouseStockController.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/._WarehouseStockController.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/WarehouseStockController.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/WarehouseStockController.java new file mode 100644 index 0000000..dc4cbac --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/controller/WarehouseStockController.java @@ -0,0 +1,93 @@ +package com.example.erpmvp.modules.warehouse.controller; + +import java.time.LocalDate; +import java.util.UUID; + +import com.example.erpmvp.common.api.ApiResponse; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.security.AuthUserDetails; +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.dto.StockBalanceResponse; +import com.example.erpmvp.modules.warehouse.dto.StockMovementResponse; +import com.example.erpmvp.modules.warehouse.service.WarehouseStockService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/warehouse") +@Tag(name = "Warehouse - Stock", description = "Stock balances and stock movements") +public class WarehouseStockController { + + private final WarehouseStockService warehouseStockService; + + public WarehouseStockController(WarehouseStockService warehouseStockService) { + this.warehouseStockService = warehouseStockService; + } + + @GetMapping("/stock-balances") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List stock balances") + public ApiResponse> listBalances( + @RequestParam(required = false) UUID warehouseId, + @RequestParam(required = false) UUID productId, + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(warehouseStockService.listBalances(warehouseId, productId, search, page, size)); + } + + @GetMapping("/stock-movements") + @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER', 'WAREHOUSE', 'FINANCE')") + @Operation(summary = "List stock movements") + public ApiResponse> listMovements( + @RequestParam(required = false) UUID warehouseId, + @RequestParam(required = false) UUID productId, + @RequestParam(required = false) StockMovementType movementType, + @RequestParam(required = false) StockMovementSourceType sourceType, + @RequestParam(required = false) UUID sourceId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ApiResponse.success(warehouseStockService.listMovements( + warehouseId, + productId, + movementType, + sourceType, + sourceId, + fromDate, + toDate, + page, + size + )); + } + + @PostMapping("/stock-adjustments") + @PreAuthorize("hasAnyRole('ADMIN', 'WAREHOUSE')") + @Operation(summary = "Create manual stock adjustment") + public ApiResponse manualAdjustment( + @Valid @RequestBody ManualStockAdjustmentRequest request, + @AuthenticationPrincipal AuthUserDetails currentUser + ) { + return ApiResponse.success(warehouseStockService.manualAdjustment(request, currentUser.getUser())); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockBalance.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockBalance.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockBalance.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovement.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovement.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovement.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementSourceType.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementSourceType.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementSourceType.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementType.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementType.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/._StockMovementType.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockBalance.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockBalance.java new file mode 100644 index 0000000..52a9e2d --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockBalance.java @@ -0,0 +1,77 @@ +package com.example.erpmvp.modules.warehouse.domain; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.modules.catalog.domain.Product; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "warehouse_stock_balances", + uniqueConstraints = @UniqueConstraint(name = "uq_warehouse_stock_balances_warehouse_product", columnNames = {"warehouse_id", "product_id"}) +) +public class StockBalance extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "warehouse_id", nullable = false) + private Warehouse warehouse; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Column(name = "quantity_on_hand", nullable = false, precision = 19, scale = 3) + private BigDecimal quantityOnHand = BigDecimal.ZERO.setScale(3, RoundingMode.HALF_UP); + + protected StockBalance() { + } + + public StockBalance(Warehouse warehouse, Product product) { + this.warehouse = warehouse; + this.product = product; + this.quantityOnHand = BigDecimal.ZERO.setScale(3, RoundingMode.HALF_UP); + } + + public void increase(BigDecimal quantity) { + quantityOnHand = normalize(quantityOnHand.add(quantity)); + } + + public void decrease(BigDecimal quantity) { + BigDecimal updatedQuantity = quantityOnHand.subtract(quantity); + + if (updatedQuantity.signum() < 0) { + throw new BusinessException( + "INSUFFICIENT_STOCK", + "Insufficient stock for product " + product.getSku() + " in warehouse " + warehouse.getCode() + ); + } + + quantityOnHand = normalize(updatedQuantity); + } + + public Warehouse getWarehouse() { + return warehouse; + } + + public Product getProduct() { + return product; + } + + public BigDecimal getQuantityOnHand() { + return quantityOnHand; + } + + private static BigDecimal normalize(BigDecimal value) { + return value.setScale(3, RoundingMode.HALF_UP); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovement.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovement.java new file mode 100644 index 0000000..9042394 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovement.java @@ -0,0 +1,135 @@ +package com.example.erpmvp.modules.warehouse.domain; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.example.erpmvp.common.audit.BaseEntity; +import com.example.erpmvp.modules.catalog.domain.Product; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "warehouse_stock_movements") +public class StockMovement extends BaseEntity { + + @Column(name = "movement_number", nullable = false, unique = true, length = 100) + private String movementNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "warehouse_id", nullable = false) + private Warehouse warehouse; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Enumerated(EnumType.STRING) + @Column(name = "movement_type", nullable = false, length = 50) + private StockMovementType movementType; + + @Column(nullable = false, precision = 19, scale = 3) + private BigDecimal quantity; + + @Column(name = "quantity_before", nullable = false, precision = 19, scale = 3) + private BigDecimal quantityBefore; + + @Column(name = "quantity_after", nullable = false, precision = 19, scale = 3) + private BigDecimal quantityAfter; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", length = 50) + private StockMovementSourceType sourceType; + + @Column(name = "source_id") + private UUID sourceId; + + @Column(columnDefinition = "TEXT") + private String comment; + + @Column(name = "created_by_user_id") + private UUID createdByUserId; + + protected StockMovement() { + } + + public StockMovement( + String movementNumber, + Warehouse warehouse, + Product product, + StockMovementType movementType, + BigDecimal quantity, + BigDecimal quantityBefore, + BigDecimal quantityAfter, + StockMovementSourceType sourceType, + UUID sourceId, + String comment, + UUID createdByUserId + ) { + this.movementNumber = movementNumber; + this.warehouse = warehouse; + this.product = product; + this.movementType = movementType; + this.quantity = quantity; + this.quantityBefore = quantityBefore; + this.quantityAfter = quantityAfter; + this.sourceType = sourceType; + this.sourceId = sourceId; + this.comment = trim(comment); + this.createdByUserId = createdByUserId; + } + + public String getMovementNumber() { + return movementNumber; + } + + public Warehouse getWarehouse() { + return warehouse; + } + + public Product getProduct() { + return product; + } + + public StockMovementType getMovementType() { + return movementType; + } + + public BigDecimal getQuantity() { + return quantity; + } + + public BigDecimal getQuantityBefore() { + return quantityBefore; + } + + public BigDecimal getQuantityAfter() { + return quantityAfter; + } + + public StockMovementSourceType getSourceType() { + return sourceType; + } + + public UUID getSourceId() { + return sourceId; + } + + public String getComment() { + return comment; + } + + public UUID getCreatedByUserId() { + return createdByUserId; + } + + private static String trim(String value) { + return value == null ? null : value.trim(); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementSourceType.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementSourceType.java new file mode 100644 index 0000000..08fb0f4 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementSourceType.java @@ -0,0 +1,7 @@ +package com.example.erpmvp.modules.warehouse.domain; + +public enum StockMovementSourceType { + PURCHASE_ORDER, + CUSTOMER_ORDER, + MANUAL_ADJUSTMENT +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementType.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementType.java new file mode 100644 index 0000000..56c4d13 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/domain/StockMovementType.java @@ -0,0 +1,8 @@ +package com.example.erpmvp.modules.warehouse.domain; + +public enum StockMovementType { + INBOUND, + OUTBOUND, + ADJUSTMENT_IN, + ADJUSTMENT_OUT +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._ManualStockAdjustmentRequest.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._ManualStockAdjustmentRequest.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._ManualStockAdjustmentRequest.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockBalanceResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockBalanceResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockBalanceResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockMovementResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockMovementResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockMovementResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockProductResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockProductResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockWarehouseResponse.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/._StockWarehouseResponse.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/ManualStockAdjustmentRequest.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/ManualStockAdjustmentRequest.java new file mode 100644 index 0000000..94c9574 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/ManualStockAdjustmentRequest.java @@ -0,0 +1,18 @@ +package com.example.erpmvp.modules.warehouse.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +import com.example.erpmvp.modules.warehouse.domain.StockMovementType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +public record ManualStockAdjustmentRequest( + @NotNull UUID warehouseId, + @NotNull UUID productId, + @NotNull StockMovementType type, + @NotNull @Positive BigDecimal quantity, + @NotBlank String comment +) { +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockBalanceResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockBalanceResponse.java new file mode 100644 index 0000000..8c13a88 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockBalanceResponse.java @@ -0,0 +1,28 @@ +package com.example.erpmvp.modules.warehouse.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.warehouse.domain.StockBalance; + +public record StockBalanceResponse( + UUID id, + StockWarehouseResponse warehouse, + StockProductResponse product, + BigDecimal quantityOnHand, + Instant createdAt, + Instant updatedAt +) { + + public static StockBalanceResponse from(StockBalance stockBalance) { + return new StockBalanceResponse( + stockBalance.getId(), + StockWarehouseResponse.from(stockBalance.getWarehouse()), + StockProductResponse.from(stockBalance.getProduct()), + stockBalance.getQuantityOnHand(), + stockBalance.getCreatedAt(), + stockBalance.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockMovementResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockMovementResponse.java new file mode 100644 index 0000000..10ea33e --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockMovementResponse.java @@ -0,0 +1,44 @@ +package com.example.erpmvp.modules.warehouse.dto; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.warehouse.domain.StockMovement; +import com.example.erpmvp.modules.warehouse.domain.StockMovementSourceType; +import com.example.erpmvp.modules.warehouse.domain.StockMovementType; + +public record StockMovementResponse( + UUID id, + String movementNumber, + StockWarehouseResponse warehouse, + StockProductResponse product, + StockMovementType movementType, + BigDecimal quantity, + BigDecimal quantityBefore, + BigDecimal quantityAfter, + StockMovementSourceType sourceType, + UUID sourceId, + String comment, + UUID createdByUserId, + Instant createdAt +) { + + public static StockMovementResponse from(StockMovement stockMovement) { + return new StockMovementResponse( + stockMovement.getId(), + stockMovement.getMovementNumber(), + StockWarehouseResponse.from(stockMovement.getWarehouse()), + StockProductResponse.from(stockMovement.getProduct()), + stockMovement.getMovementType(), + stockMovement.getQuantity(), + stockMovement.getQuantityBefore(), + stockMovement.getQuantityAfter(), + stockMovement.getSourceType(), + stockMovement.getSourceId(), + stockMovement.getComment(), + stockMovement.getCreatedByUserId(), + stockMovement.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockProductResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockProductResponse.java new file mode 100644 index 0000000..cb98008 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockProductResponse.java @@ -0,0 +1,22 @@ +package com.example.erpmvp.modules.warehouse.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Product; + +public record StockProductResponse( + UUID id, + String sku, + String name, + String unit +) { + + public static StockProductResponse from(Product product) { + return new StockProductResponse( + product.getId(), + product.getSku(), + product.getName(), + product.getUnit() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockWarehouseResponse.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockWarehouseResponse.java new file mode 100644 index 0000000..48da240 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/dto/StockWarehouseResponse.java @@ -0,0 +1,20 @@ +package com.example.erpmvp.modules.warehouse.dto; + +import java.util.UUID; + +import com.example.erpmvp.modules.catalog.domain.Warehouse; + +public record StockWarehouseResponse( + UUID id, + String code, + String name +) { + + public static StockWarehouseResponse from(Warehouse warehouse) { + return new StockWarehouseResponse( + warehouse.getId(), + warehouse.getCode(), + warehouse.getName() + ); + } +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockBalanceRepository.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockBalanceRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockBalanceRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockMovementRepository.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockMovementRepository.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/._StockMovementRepository.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockBalanceRepository.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockBalanceRepository.java new file mode 100644 index 0000000..a7c01c2 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockBalanceRepository.java @@ -0,0 +1,56 @@ +package com.example.erpmvp.modules.warehouse.repository; + +import java.util.Optional; +import java.util.UUID; + +import com.example.erpmvp.modules.warehouse.domain.StockBalance; +import jakarta.persistence.LockModeType; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StockBalanceRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"warehouse", "product"}) + Optional findByWarehouse_IdAndProduct_Id(UUID warehouseId, UUID productId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = {"warehouse", "product"}) + @Query(""" + select sb from StockBalance sb + where sb.warehouse.id = :warehouseId + and sb.product.id = :productId + """) + Optional findLockedByWarehouseIdAndProductId( + @Param("warehouseId") UUID warehouseId, + @Param("productId") UUID productId + ); + + @EntityGraph(attributePaths = {"warehouse", "product"}) + @Query(""" + select sb from StockBalance sb + join sb.warehouse w + join sb.product p + where (:warehouseId is null or w.id = :warehouseId) + and (:productId is null or p.id = :productId) + and ( + :search is null + or :search = '' + or lower(w.code) like lower(concat('%', :search, '%')) + or lower(w.name) like lower(concat('%', :search, '%')) + or lower(p.sku) like lower(concat('%', :search, '%')) + or lower(p.name) like lower(concat('%', :search, '%')) + ) + """) + Page search( + @Param("warehouseId") UUID warehouseId, + @Param("productId") UUID productId, + @Param("search") String search, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java new file mode 100644 index 0000000..0fbeee0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/repository/StockMovementRepository.java @@ -0,0 +1,51 @@ +package com.example.erpmvp.modules.warehouse.repository; + +import java.time.Instant; +import java.util.UUID; + +import com.example.erpmvp.modules.warehouse.domain.StockMovement; +import com.example.erpmvp.modules.warehouse.domain.StockMovementSourceType; +import com.example.erpmvp.modules.warehouse.domain.StockMovementType; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StockMovementRepository extends JpaRepository { + + boolean existsByMovementNumber(String movementNumber); + + boolean existsBySourceTypeAndWarehouse_IdAndProduct_IdAndComment( + StockMovementSourceType sourceType, + UUID warehouseId, + UUID productId, + String comment + ); + + long countByMovementNumberStartingWith(String movementNumberPrefix); + + @EntityGraph(attributePaths = {"warehouse", "product"}) + @Query(""" + select sm from StockMovement sm + where (:warehouseId is null or sm.warehouse.id = :warehouseId) + and (:productId is null or sm.product.id = :productId) + and (:movementType is null or sm.movementType = :movementType) + and (:sourceType is null or sm.sourceType = :sourceType) + and (:sourceId is null or sm.sourceId = :sourceId) + and (:fromDateTime is null or sm.createdAt >= :fromDateTime) + and (:toDateTime is null or sm.createdAt < :toDateTime) + """) + Page search( + @Param("warehouseId") UUID warehouseId, + @Param("productId") UUID productId, + @Param("movementType") StockMovementType movementType, + @Param("sourceType") StockMovementSourceType sourceType, + @Param("sourceId") UUID sourceId, + @Param("fromDateTime") Instant fromDateTime, + @Param("toDateTime") Instant toDateTime, + Pageable pageable + ); +} diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/._WarehouseStockService.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/._WarehouseStockService.java new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/._WarehouseStockService.java differ diff --git a/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/WarehouseStockService.java b/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/WarehouseStockService.java new file mode 100644 index 0000000..f179ad0 --- /dev/null +++ b/backend/src/main/java/com/example/erpmvp/modules/warehouse/service/WarehouseStockService.java @@ -0,0 +1,326 @@ +package com.example.erpmvp.modules.warehouse.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +import com.example.erpmvp.common.error.BadRequestException; +import com.example.erpmvp.common.error.BusinessException; +import com.example.erpmvp.common.error.NotFoundException; +import com.example.erpmvp.common.pagination.PageResponseDto; +import com.example.erpmvp.modules.auth.domain.User; +import com.example.erpmvp.modules.catalog.domain.Product; +import com.example.erpmvp.modules.catalog.domain.Warehouse; +import com.example.erpmvp.modules.catalog.repository.ProductRepository; +import com.example.erpmvp.modules.catalog.repository.WarehouseRepository; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrder; +import com.example.erpmvp.modules.procurement.domain.PurchaseOrderItem; +import com.example.erpmvp.modules.sales.domain.CustomerOrder; +import com.example.erpmvp.modules.sales.domain.CustomerOrderItem; +import com.example.erpmvp.modules.warehouse.domain.StockBalance; +import com.example.erpmvp.modules.warehouse.domain.StockMovement; +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.dto.StockBalanceResponse; +import com.example.erpmvp.modules.warehouse.dto.StockMovementResponse; +import com.example.erpmvp.modules.warehouse.repository.StockBalanceRepository; +import com.example.erpmvp.modules.warehouse.repository.StockMovementRepository; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class WarehouseStockService { + + private static final DateTimeFormatter MOVEMENT_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE; + + private final StockBalanceRepository stockBalanceRepository; + private final StockMovementRepository stockMovementRepository; + private final WarehouseRepository warehouseRepository; + private final ProductRepository productRepository; + + public WarehouseStockService( + StockBalanceRepository stockBalanceRepository, + StockMovementRepository stockMovementRepository, + WarehouseRepository warehouseRepository, + ProductRepository productRepository + ) { + this.stockBalanceRepository = stockBalanceRepository; + this.stockMovementRepository = stockMovementRepository; + this.warehouseRepository = warehouseRepository; + this.productRepository = productRepository; + } + + @Transactional(readOnly = true) + public StockBalanceResponse getBalance(UUID warehouseId, UUID productId) { + StockBalance stockBalance = stockBalanceRepository.findByWarehouse_IdAndProduct_Id(warehouseId, productId) + .orElseThrow(() -> new NotFoundException("STOCK_BALANCE_NOT_FOUND", "Stock balance not found")); + + return StockBalanceResponse.from(stockBalance); + } + + @Transactional(readOnly = true) + public PageResponseDto listBalances( + UUID warehouseId, + UUID productId, + String search, + int page, + int size + ) { + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")); + return PageResponseDto.from(stockBalanceRepository.search( + warehouseId, + productId, + normalizeSearch(search), + pageable + ).map(StockBalanceResponse::from)); + } + + @Transactional(readOnly = true) + public PageResponseDto listMovements( + UUID warehouseId, + UUID productId, + StockMovementType movementType, + StockMovementSourceType sourceType, + UUID sourceId, + LocalDate fromDate, + LocalDate toDate, + int page, + int size + ) { + validateDateRange(fromDate, toDate); + + PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return PageResponseDto.from(stockMovementRepository.search( + warehouseId, + productId, + movementType, + sourceType, + sourceId, + toStartInstant(fromDate), + toExclusiveEndInstant(toDate), + pageable + ).map(StockMovementResponse::from)); + } + + @Transactional + public void receivePurchaseOrder(PurchaseOrder purchaseOrder, User currentUser) { + Warehouse warehouse = purchaseOrder.getWarehouse(); + + if (warehouse == null) { + throw new BusinessException( + "RECEIVING_WAREHOUSE_REQUIRED", + "Receiving warehouse is required before marking purchase order as RECEIVED" + ); + } + + ensureActiveWarehouse(warehouse); + + for (PurchaseOrderItem item : purchaseOrder.getItems()) { + Product product = item.getProduct(); + ensureActiveProduct(product); + StockBalance balance = getOrCreateLockedBalance(warehouse, product); + applyMovement( + balance, + StockMovementType.INBOUND, + item.getQuantity(), + StockMovementSourceType.PURCHASE_ORDER, + purchaseOrder.getId(), + "Received from purchase order " + purchaseOrder.getPoNumber(), + currentUser.getId() + ); + } + } + + @Transactional + public void shipCustomerOrder(CustomerOrder customerOrder, User currentUser) { + Warehouse warehouse = customerOrder.getWarehouse(); + + if (warehouse == null) { + throw new BusinessException( + "SHIPPING_WAREHOUSE_REQUIRED", + "Warehouse is required before marking customer order as SHIPPED" + ); + } + + ensureActiveWarehouse(warehouse); + + for (CustomerOrderItem item : customerOrder.getItems()) { + Product product = item.getProduct(); + ensureActiveProduct(product); + StockBalance balance = findLockedBalanceOrThrow(warehouse, product); + applyMovement( + balance, + StockMovementType.OUTBOUND, + item.getQuantity(), + StockMovementSourceType.CUSTOMER_ORDER, + customerOrder.getId(), + "Shipped for customer order " + customerOrder.getOrderNumber(), + currentUser.getId() + ); + } + } + + @Transactional + public StockMovementResponse manualAdjustment(ManualStockAdjustmentRequest request, User currentUser) { + ensureManualAdjustmentType(request.type()); + + Warehouse warehouse = findActiveWarehouse(request.warehouseId()); + Product product = findActiveProduct(request.productId()); + StockBalance balance = request.type() == StockMovementType.ADJUSTMENT_IN + ? getOrCreateLockedBalance(warehouse, product) + : findLockedBalanceOrThrow(warehouse, product); + + StockMovement movement = applyMovement( + balance, + request.type(), + request.quantity(), + StockMovementSourceType.MANUAL_ADJUSTMENT, + null, + request.comment(), + currentUser.getId() + ); + + return StockMovementResponse.from(movement); + } + + private StockMovement applyMovement( + StockBalance balance, + StockMovementType movementType, + BigDecimal quantity, + StockMovementSourceType sourceType, + UUID sourceId, + String comment, + UUID createdByUserId + ) { + BigDecimal normalizedQuantity = normalizeQuantity(quantity); + BigDecimal quantityBefore = balance.getQuantityOnHand(); + + if (movementType == StockMovementType.INBOUND || movementType == StockMovementType.ADJUSTMENT_IN) { + balance.increase(normalizedQuantity); + } else { + ensureSufficientStock(balance, normalizedQuantity); + balance.decrease(normalizedQuantity); + } + + StockMovement movement = new StockMovement( + generateMovementNumber(), + balance.getWarehouse(), + balance.getProduct(), + movementType, + normalizedQuantity, + quantityBefore, + balance.getQuantityOnHand(), + sourceType, + sourceId, + comment, + createdByUserId + ); + + return stockMovementRepository.save(movement); + } + + private StockBalance getOrCreateLockedBalance(Warehouse warehouse, Product product) { + return stockBalanceRepository.findLockedByWarehouseIdAndProductId(warehouse.getId(), product.getId()) + .orElseGet(() -> stockBalanceRepository.saveAndFlush(new StockBalance(warehouse, product))); + } + + private StockBalance findLockedBalanceOrThrow(Warehouse warehouse, Product product) { + return stockBalanceRepository.findLockedByWarehouseIdAndProductId(warehouse.getId(), product.getId()) + .orElseThrow(() -> insufficientStock(product, warehouse)); + } + + private Warehouse findActiveWarehouse(UUID warehouseId) { + Warehouse warehouse = warehouseRepository.findById(warehouseId) + .orElseThrow(() -> new NotFoundException("WAREHOUSE_NOT_FOUND", "Warehouse not found")); + + ensureActiveWarehouse(warehouse); + + return warehouse; + } + + private Product findActiveProduct(UUID productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException("PRODUCT_NOT_FOUND", "Product not found")); + + ensureActiveProduct(product); + + return product; + } + + private void ensureActiveWarehouse(Warehouse warehouse) { + if (!warehouse.isActive()) { + throw new BusinessException("WAREHOUSE_INACTIVE", "Warehouse is inactive"); + } + } + + private void ensureActiveProduct(Product product) { + if (!product.isActive()) { + throw new BusinessException("PRODUCT_INACTIVE", "Product is inactive"); + } + } + + private void ensureSufficientStock(StockBalance balance, BigDecimal quantity) { + if (balance.getQuantityOnHand().compareTo(quantity) < 0) { + throw insufficientStock(balance.getProduct(), balance.getWarehouse()); + } + } + + private BusinessException insufficientStock(Product product, Warehouse warehouse) { + return new BusinessException( + "INSUFFICIENT_STOCK", + "Insufficient stock for product " + product.getSku() + " in warehouse " + warehouse.getCode() + ); + } + + private void ensureManualAdjustmentType(StockMovementType type) { + if (type != StockMovementType.ADJUSTMENT_IN && type != StockMovementType.ADJUSTMENT_OUT) { + throw new BadRequestException( + "INVALID_STOCK_ADJUSTMENT_TYPE", + "Manual adjustment type must be ADJUSTMENT_IN or ADJUSTMENT_OUT" + ); + } + } + + private String generateMovementNumber() { + String prefix = "SM-" + LocalDate.now().format(MOVEMENT_DATE_FORMAT) + "-"; + long nextNumber = stockMovementRepository.countByMovementNumberStartingWith(prefix) + 1; + + String movementNumber; + do { + movementNumber = prefix + String.format("%04d", nextNumber); + nextNumber++; + } while (stockMovementRepository.existsByMovementNumber(movementNumber)); + + return movementNumber; + } + + private void validateDateRange(LocalDate fromDate, LocalDate toDate) { + if (fromDate != null && toDate != null && fromDate.isAfter(toDate)) { + throw new BadRequestException("INVALID_DATE_RANGE", "fromDate must be before or equal to toDate"); + } + } + + private Instant toStartInstant(LocalDate date) { + return date == null ? null : date.atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private Instant toExclusiveEndInstant(LocalDate date) { + return date == null ? null : date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); + } + + private BigDecimal normalizeQuantity(BigDecimal quantity) { + return quantity.setScale(3, RoundingMode.HALF_UP); + } + + private String normalizeSearch(String search) { + return search == null ? null : search.trim(); + } +} diff --git a/backend/src/main/resources/._application.yml b/backend/src/main/resources/._application.yml new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/._application.yml differ diff --git a/backend/src/main/resources/._db b/backend/src/main/resources/._db new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/._db differ diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..5147ddf --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,36 @@ +server: + port: 8080 + +spring: + application: + name: erp-backend + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/erp_mvp} + username: ${SPRING_DATASOURCE_USERNAME:erp} + password: ${SPRING_DATASOURCE_PASSWORD:erp} + driver-class-name: org.postgresql.Driver + flyway: + enabled: true + jpa: + hibernate: + ddl-auto: none + open-in-view: false + +management: + endpoints: + web: + exposure: + include: health,info + +app: + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173} + jwt: + secret: ${JWT_SECRET:dev-secret-change-me-dev-secret-change-me} + expiration-minutes: ${JWT_EXPIRATION_MINUTES:1440} + seed: + admin: + email: ${ADMIN_EMAIL:admin@erp.local} + password: ${ADMIN_PASSWORD:admin12345} + demo-data: + enabled: ${DEMO_DATA_ENABLED:false} diff --git a/backend/src/main/resources/db/._migration b/backend/src/main/resources/db/._migration new file mode 100755 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/._migration differ diff --git a/backend/src/main/resources/db/migration/._V1__init.sql b/backend/src/main/resources/db/migration/._V1__init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V1__init.sql differ diff --git a/backend/src/main/resources/db/migration/._V2__system_init.sql b/backend/src/main/resources/db/migration/._V2__system_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V2__system_init.sql differ diff --git a/backend/src/main/resources/db/migration/._V3__auth_init.sql b/backend/src/main/resources/db/migration/._V3__auth_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V3__auth_init.sql differ diff --git a/backend/src/main/resources/db/migration/._V4__catalog_init.sql b/backend/src/main/resources/db/migration/._V4__catalog_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V4__catalog_init.sql differ diff --git a/backend/src/main/resources/db/migration/._V5__procurement_init.sql b/backend/src/main/resources/db/migration/._V5__procurement_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V5__procurement_init.sql differ diff --git a/backend/src/main/resources/db/migration/._V6__sales_init.sql b/backend/src/main/resources/db/migration/._V6__sales_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V6__sales_init.sql differ diff --git a/backend/src/main/resources/db/migration/._V7__warehouse_stock_init.sql b/backend/src/main/resources/db/migration/._V7__warehouse_stock_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V7__warehouse_stock_init.sql differ diff --git a/backend/src/main/resources/db/migration/._V8__documents_init.sql b/backend/src/main/resources/db/migration/._V8__documents_init.sql new file mode 100644 index 0000000..e34c7db Binary files /dev/null and b/backend/src/main/resources/db/migration/._V8__documents_init.sql differ diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..b2dbb41 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,2 @@ +-- Empty initial migration. ERP schema objects will be added in future versions. + diff --git a/backend/src/main/resources/db/migration/V2__system_init.sql b/backend/src/main/resources/db/migration/V2__system_init.sql new file mode 100644 index 0000000..84165d8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__system_init.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS system_migrations_check ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + diff --git a/backend/src/main/resources/db/migration/V3__auth_init.sql b/backend/src/main/resources/db/migration/V3__auth_init.sql new file mode 100644 index 0000000..aa3c6cb --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__auth_init.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS app_users ( + id UUID PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + full_name VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (email); + diff --git a/backend/src/main/resources/db/migration/V4__catalog_init.sql b/backend/src/main/resources/db/migration/V4__catalog_init.sql new file mode 100644 index 0000000..8053e2c --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__catalog_init.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS catalog_products ( + id UUID PRIMARY KEY, + sku VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + category VARCHAR(150), + unit VARCHAR(50) NOT NULL, + barcode VARCHAR(100), + description TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS catalog_suppliers ( + id UUID PRIMARY KEY, + company_name VARCHAR(255) NOT NULL, + bin VARCHAR(50), + contact_name VARCHAR(255), + phone VARCHAR(100), + email VARCHAR(255), + address TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS catalog_customers ( + id UUID PRIMARY KEY, + company_name VARCHAR(255) NOT NULL, + bin VARCHAR(50), + contact_name VARCHAR(255), + phone VARCHAR(100), + email VARCHAR(255), + address TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS catalog_warehouses ( + id UUID PRIMARY KEY, + code VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + address TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_catalog_products_sku ON catalog_products (sku); +CREATE INDEX IF NOT EXISTS idx_catalog_products_name ON catalog_products (name); +CREATE INDEX IF NOT EXISTS idx_catalog_suppliers_company_name ON catalog_suppliers (company_name); +CREATE INDEX IF NOT EXISTS idx_catalog_customers_company_name ON catalog_customers (company_name); +CREATE INDEX IF NOT EXISTS idx_catalog_warehouses_code ON catalog_warehouses (code); + diff --git a/backend/src/main/resources/db/migration/V5__procurement_init.sql b/backend/src/main/resources/db/migration/V5__procurement_init.sql new file mode 100644 index 0000000..5f94f02 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__procurement_init.sql @@ -0,0 +1,54 @@ +CREATE TABLE IF NOT EXISTS procurement_purchase_orders ( + id UUID PRIMARY KEY, + po_number VARCHAR(100) NOT NULL UNIQUE, + supplier_id UUID NOT NULL REFERENCES catalog_suppliers(id), + status VARCHAR(50) NOT NULL, + expected_delivery_date DATE, + notes TEXT, + total_amount NUMERIC(19,2) NOT NULL DEFAULT 0, + created_by_user_id UUID REFERENCES app_users(id), + approved_by_user_id UUID REFERENCES app_users(id), + ordered_at TIMESTAMP, + received_at TIMESTAMP, + cancelled_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS procurement_purchase_order_items ( + id UUID PRIMARY KEY, + purchase_order_id UUID NOT NULL REFERENCES procurement_purchase_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES catalog_products(id), + quantity NUMERIC(19,3) NOT NULL, + unit_price NUMERIC(19,2) NOT NULL DEFAULT 0, + line_total NUMERIC(19,2) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS procurement_purchase_order_status_history ( + id UUID PRIMARY KEY, + purchase_order_id UUID NOT NULL REFERENCES procurement_purchase_orders(id) ON DELETE CASCADE, + old_status VARCHAR(50), + new_status VARCHAR(50) NOT NULL, + changed_by_user_id UUID REFERENCES app_users(id), + comment TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_procurement_purchase_orders_po_number + ON procurement_purchase_orders (po_number); +CREATE INDEX IF NOT EXISTS idx_procurement_purchase_orders_supplier_id + ON procurement_purchase_orders (supplier_id); +CREATE INDEX IF NOT EXISTS idx_procurement_purchase_orders_status + ON procurement_purchase_orders (status); +CREATE INDEX IF NOT EXISTS idx_procurement_purchase_orders_expected_delivery_date + ON procurement_purchase_orders (expected_delivery_date); +CREATE INDEX IF NOT EXISTS idx_procurement_purchase_order_items_purchase_order_id + ON procurement_purchase_order_items (purchase_order_id); +CREATE INDEX IF NOT EXISTS idx_procurement_purchase_order_items_product_id + ON procurement_purchase_order_items (product_id); +CREATE INDEX IF NOT EXISTS idx_procurement_purchase_order_status_history_purchase_order_id + ON procurement_purchase_order_status_history (purchase_order_id); + diff --git a/backend/src/main/resources/db/migration/V6__sales_init.sql b/backend/src/main/resources/db/migration/V6__sales_init.sql new file mode 100644 index 0000000..21a062e --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__sales_init.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS sales_customer_orders ( + id UUID PRIMARY KEY, + order_number VARCHAR(100) NOT NULL UNIQUE, + customer_id UUID NOT NULL REFERENCES catalog_customers(id), + warehouse_id UUID REFERENCES catalog_warehouses(id), + status VARCHAR(50) NOT NULL, + requested_delivery_date DATE, + notes TEXT, + total_amount NUMERIC(19,2) NOT NULL DEFAULT 0, + created_by_user_id UUID REFERENCES app_users(id), + confirmed_by_user_id UUID REFERENCES app_users(id), + confirmed_at TIMESTAMP, + in_progress_at TIMESTAMP, + shipped_at TIMESTAMP, + closed_at TIMESTAMP, + cancelled_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_customer_order_items ( + id UUID PRIMARY KEY, + customer_order_id UUID NOT NULL REFERENCES sales_customer_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES catalog_products(id), + quantity NUMERIC(19,3) NOT NULL, + unit_price NUMERIC(19,2) NOT NULL DEFAULT 0, + line_total NUMERIC(19,2) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS sales_customer_order_status_history ( + id UUID PRIMARY KEY, + customer_order_id UUID NOT NULL REFERENCES sales_customer_orders(id) ON DELETE CASCADE, + old_status VARCHAR(50), + new_status VARCHAR(50) NOT NULL, + changed_by_user_id UUID REFERENCES app_users(id), + comment TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_sales_customer_orders_order_number + ON sales_customer_orders (order_number); +CREATE INDEX IF NOT EXISTS idx_sales_customer_orders_customer_id + ON sales_customer_orders (customer_id); +CREATE INDEX IF NOT EXISTS idx_sales_customer_orders_warehouse_id + ON sales_customer_orders (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_sales_customer_orders_status + ON sales_customer_orders (status); +CREATE INDEX IF NOT EXISTS idx_sales_customer_orders_requested_delivery_date + ON sales_customer_orders (requested_delivery_date); +CREATE INDEX IF NOT EXISTS idx_sales_customer_order_items_customer_order_id + ON sales_customer_order_items (customer_order_id); +CREATE INDEX IF NOT EXISTS idx_sales_customer_order_items_product_id + ON sales_customer_order_items (product_id); +CREATE INDEX IF NOT EXISTS idx_sales_customer_order_status_history_customer_order_id + ON sales_customer_order_status_history (customer_order_id); + diff --git a/backend/src/main/resources/db/migration/V7__warehouse_stock_init.sql b/backend/src/main/resources/db/migration/V7__warehouse_stock_init.sql new file mode 100644 index 0000000..bb1a38e --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__warehouse_stock_init.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS warehouse_stock_balances ( + id UUID PRIMARY KEY, + warehouse_id UUID NOT NULL REFERENCES catalog_warehouses(id), + product_id UUID NOT NULL REFERENCES catalog_products(id), + quantity_on_hand NUMERIC(19,3) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uq_warehouse_stock_balances_warehouse_product UNIQUE (warehouse_id, product_id), + CONSTRAINT chk_warehouse_stock_balances_quantity_non_negative CHECK (quantity_on_hand >= 0) +); + +CREATE TABLE IF NOT EXISTS warehouse_stock_movements ( + id UUID PRIMARY KEY, + movement_number VARCHAR(100) NOT NULL UNIQUE, + warehouse_id UUID NOT NULL REFERENCES catalog_warehouses(id), + product_id UUID NOT NULL REFERENCES catalog_products(id), + movement_type VARCHAR(50) NOT NULL, + quantity NUMERIC(19,3) NOT NULL, + quantity_before NUMERIC(19,3) NOT NULL, + quantity_after NUMERIC(19,3) NOT NULL, + source_type VARCHAR(50), + source_id UUID, + comment TEXT, + created_by_user_id UUID REFERENCES app_users(id), + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_balances_warehouse_id + ON warehouse_stock_balances (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_balances_product_id + ON warehouse_stock_balances (product_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_balances_warehouse_product + ON warehouse_stock_balances (warehouse_id, product_id); + +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_movement_number + ON warehouse_stock_movements (movement_number); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_warehouse_id + ON warehouse_stock_movements (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_product_id + ON warehouse_stock_movements (product_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_movement_type + ON warehouse_stock_movements (movement_type); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_source + ON warehouse_stock_movements (source_type, source_id); +CREATE INDEX IF NOT EXISTS idx_warehouse_stock_movements_created_at + ON warehouse_stock_movements (created_at); + +ALTER TABLE procurement_purchase_orders + ADD COLUMN IF NOT EXISTS warehouse_id UUID REFERENCES catalog_warehouses(id); + +CREATE INDEX IF NOT EXISTS idx_procurement_po_warehouse_id + ON procurement_purchase_orders (warehouse_id); diff --git a/backend/src/main/resources/db/migration/V8__documents_init.sql b/backend/src/main/resources/db/migration/V8__documents_init.sql new file mode 100644 index 0000000..5eb3fc4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__documents_init.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS erp_documents ( + id UUID PRIMARY KEY, + document_number VARCHAR(100) NOT NULL UNIQUE, + document_type VARCHAR(50) NOT NULL, + source_type VARCHAR(50) NOT NULL, + source_id UUID NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL, + pdf_data BYTEA NOT NULL, + generated_by_user_id UUID REFERENCES app_users(id), + generated_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uq_erp_documents_source_type_source_id_document_type UNIQUE (source_type, source_id, document_type) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_erp_documents_document_number + ON erp_documents (document_number); +CREATE INDEX IF NOT EXISTS idx_erp_documents_document_type + ON erp_documents (document_type); +CREATE INDEX IF NOT EXISTS idx_erp_documents_source + ON erp_documents (source_type, source_id); +CREATE INDEX IF NOT EXISTS idx_erp_documents_generated_at + ON erp_documents (generated_at); diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..2a97593 --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,66 @@ +name: erp-mvp + +services: + postgres: + image: postgres:16-alpine + container_name: erp-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-erp_mvp} + POSTGRES_USER: ${POSTGRES_USER:-erp_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp_password} + volumes: + - erp_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp_user} -d ${POSTGRES_DB:-erp_mvp}"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - erp_internal + + backend: + build: + context: ./backend + container_name: erp-backend + restart: unless-stopped + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-erp_mvp} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-erp_user} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-erp_password} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-https://erp.konturai.kz,http://localhost:5173} + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-dev-secret-change-me} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@erp.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin12345} + DEMO_DATA_ENABLED: ${DEMO_DATA_ENABLED:-true} + depends_on: + postgres: + condition: service_healthy + networks: + erp_internal: + common_network: + aliases: + - erp-backend + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.server + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-https://erp.konturai.kz} + container_name: erp-frontend + restart: unless-stopped + depends_on: + - backend + networks: + common_network: + aliases: + - erp-frontend + +volumes: + erp_postgres_data: + +networks: + erp_internal: + common_network: + external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16bc5c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + postgres: + image: postgres:16-alpine + container_name: erp-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-erp_mvp} + POSTGRES_USER: ${POSTGRES_USER:-erp} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp} -d ${POSTGRES_DB:-erp_mvp}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: ./backend + container_name: erp-backend + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-erp_mvp} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-erp} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-erp} + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:5173} + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-dev-secret-change-me} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@erp.local} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin12345} + DEMO_DATA_ENABLED: ${DEMO_DATA_ENABLED:-true} + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + container_name: erp-frontend + environment: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:8080} + ports: + - "5173:5173" + depends_on: + - backend + +volumes: + postgres_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..8ce2dde --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.vite +npm-debug.log + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..d7ca890 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.server b/frontend/Dockerfile.server new file mode 100644 index 0000000..0e7d0c4 --- /dev/null +++ b/frontend/Dockerfile.server @@ -0,0 +1,19 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +ARG VITE_API_BASE_URL=https://erp.konturai.kz +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine + +COPY nginx.server.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0d35036 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ERP MVP + + +
+ + + diff --git a/frontend/nginx.server.conf b/frontend/nginx.server.conf new file mode 100644 index 0000000..d043fd2 --- /dev/null +++ b/frontend/nginx.server.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location = /health { + access_log off; + return 200 "OK\n"; + add_header Content-Type text/plain; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8f1e28b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2443 @@ +{ + "name": "erp-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "erp-frontend", + "version": "0.0.1", + "dependencies": { + "axios": "^1.16.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "vite": "^8.0.12" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.354", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", + "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz", + "integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e436dfc --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "erp-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc --noEmit && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "axios": "^1.16.1", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "vite": "^8.0.12" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..1d92651 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..463a26c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,42 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { ProtectedRoute } from './auth/ProtectedRoute'; +import { CustomersPage } from './pages/CustomersPage'; +import { CustomerOrderDetailsPage } from './pages/CustomerOrderDetailsPage'; +import { CustomerOrdersPage } from './pages/CustomerOrdersPage'; +import { DashboardPage } from './pages/DashboardPage'; +import { DocumentsPage } from './pages/DocumentsPage'; +import { LoginPage } from './pages/LoginPage'; +import { PurchaseOrderDetailsPage } from './pages/PurchaseOrderDetailsPage'; +import { PurchaseOrdersPage } from './pages/PurchaseOrdersPage'; +import { ProductsPage } from './pages/ProductsPage'; +import { StockBalancesPage } from './pages/StockBalancesPage'; +import { StockMovementsPage } from './pages/StockMovementsPage'; +import { SuppliersPage } from './pages/SuppliersPage'; +import { WarehousesPage } from './pages/WarehousesPage'; + +function App() { + return ( + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export default App; diff --git a/frontend/src/api/catalogApi.ts b/frontend/src/api/catalogApi.ts new file mode 100644 index 0000000..cfb316a --- /dev/null +++ b/frontend/src/api/catalogApi.ts @@ -0,0 +1,140 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type CatalogListParams = { + page?: number; + size?: number; + search?: string; + active?: boolean; +}; + +export type Product = { + id: string; + sku: string; + name: string; + category: string | null; + unit: string; + barcode: string | null; + description: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ProductPayload = { + sku?: string; + name: string; + category?: string; + unit: string; + barcode?: string; + description?: string; +}; + +export type Supplier = { + id: string; + companyName: string; + bin: string | null; + contactName: string | null; + phone: string | null; + email: string | null; + address: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type SupplierPayload = { + companyName: string; + bin?: string; + contactName?: string; + phone?: string; + email?: string; + address?: string; +}; + +export type Customer = Supplier; +export type CustomerPayload = SupplierPayload; + +export type Warehouse = { + id: string; + code: string; + name: string; + address: string | null; + active: boolean; + createdAt: string; + updatedAt: string; +}; + +export type WarehousePayload = { + code?: string; + name: string; + address?: string; +}; + +async function listResource(path: string, params: CatalogListParams) { + const response = await apiClient.get>>(path, { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + search: params.search ?? '', + active: params.active ?? true, + }, + }); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось загрузить данные справочника'); + } + + return response.data.data; +} + +async function createResource(path: string, payload: TPayload) { + const response = await apiClient.post>(path, payload); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось создать запись'); + } + + return response.data.data; +} + +async function updateResource(path: string, id: string, payload: TPayload) { + const response = await apiClient.put>(`${path}/${id}`, payload); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось обновить запись'); + } + + return response.data.data; +} + +async function deleteResource(path: string, id: string) { + await apiClient.delete(`${path}/${id}`); +} + +export const catalogApi = { + listProducts: (params: CatalogListParams) => listResource('/api/catalog/products', params), + createProduct: (payload: ProductPayload) => createResource('/api/catalog/products', payload), + updateProduct: (id: string, payload: Omit) => + updateResource, Product>('/api/catalog/products', id, payload), + deleteProduct: (id: string) => deleteResource('/api/catalog/products', id), + + listSuppliers: (params: CatalogListParams) => listResource('/api/catalog/suppliers', params), + createSupplier: (payload: SupplierPayload) => createResource('/api/catalog/suppliers', payload), + updateSupplier: (id: string, payload: SupplierPayload) => + updateResource('/api/catalog/suppliers', id, payload), + deleteSupplier: (id: string) => deleteResource('/api/catalog/suppliers', id), + + listCustomers: (params: CatalogListParams) => listResource('/api/catalog/customers', params), + createCustomer: (payload: CustomerPayload) => createResource('/api/catalog/customers', payload), + updateCustomer: (id: string, payload: CustomerPayload) => + updateResource('/api/catalog/customers', id, payload), + deleteCustomer: (id: string) => deleteResource('/api/catalog/customers', id), + + listWarehouses: (params: CatalogListParams) => listResource('/api/catalog/warehouses', params), + createWarehouse: (payload: WarehousePayload) => + createResource('/api/catalog/warehouses', payload), + updateWarehouse: (id: string, payload: Omit) => + updateResource, Warehouse>('/api/catalog/warehouses', id, payload), + deleteWarehouse: (id: string) => deleteResource('/api/catalog/warehouses', id), +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..ed0b9c4 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,34 @@ +import axios from 'axios'; + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'; + +export const apiClient = axios.create({ + baseURL: apiBaseUrl, +}); + +let unauthorizedHandler: (() => void) | null = null; + +export function setUnauthorizedHandler(handler: (() => void) | null) { + unauthorizedHandler = handler; +} + +export function setAuthToken(token: string | null) { + if (token) { + apiClient.defaults.headers.common.Authorization = `Bearer ${token}`; + return; + } + + delete apiClient.defaults.headers.common.Authorization; +} + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (axios.isAxiosError(error) && error.response?.status === 401) { + setAuthToken(null); + unauthorizedHandler?.(); + } + + return Promise.reject(error); + }, +); diff --git a/frontend/src/api/dashboardApi.ts b/frontend/src/api/dashboardApi.ts new file mode 100644 index 0000000..d997295 --- /dev/null +++ b/frontend/src/api/dashboardApi.ts @@ -0,0 +1,171 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type StatusAmountMetric = { + status: string; + count: number; + totalAmount: number; +}; + +export type TypeCountMetric = { + type: string; + count: number; +}; + +export type DailyAmountMetric = { + date: string; + ordersCount: number; + totalAmount: number; +}; + +export type TopPartyMetric = { + partyId: string; + companyName: string; + ordersCount: number; + totalAmount: number; +}; + +export type WarehouseStockMetric = { + warehouseId: string; + warehouseCode: string; + warehouseName: string; + productsCount: number; + totalQuantityOnHand: number; +}; + +export type MovementTypeMetric = { + movementType: string; + count: number; + totalQuantity: number; +}; + +export type RecentActivity = { + id: string; + type: 'CUSTOMER_ORDER' | 'PURCHASE_ORDER' | 'STOCK_MOVEMENT' | 'DOCUMENT'; + title: string; + description: string; + createdAt: string; + link: string | null; +}; + +export type LowStockItem = { + warehouseId: string; + warehouseCode: string; + warehouseName: string; + productId: string; + productSku: string; + productName: string; + unit: string; + quantityOnHand: number; + threshold: number; +}; + +export type DashboardSummary = { + productsCount: number; + activeProductsCount: number; + suppliersCount: number; + customersCount: number; + warehousesCount: number; + customerOrdersCount: number; + activeCustomerOrdersCount: number; + totalSalesAmount: number; + salesOrdersByStatus: StatusAmountMetric[]; + purchaseOrdersCount: number; + activePurchaseOrdersCount: number; + totalProcurementAmount: number; + purchaseOrdersByStatus: StatusAmountMetric[]; + stockItemsCount: number; + totalQuantityOnHand: number; + lowStockItemsCount: number; + stockMovementsCount: number; + documentsCount: number; + documentsByType: TypeCountMetric[]; + recentCustomerOrders: RecentActivity[]; + recentPurchaseOrders: RecentActivity[]; + recentStockMovements: RecentActivity[]; + recentDocuments: RecentActivity[]; +}; + +export type DashboardSales = { + totalOrders: number; + totalAmount: number; + averageOrderAmount: number; + ordersByStatus: StatusAmountMetric[]; + dailySales: DailyAmountMetric[]; + topCustomers: TopPartyMetric[]; +}; + +export type DashboardProcurement = { + totalPurchaseOrders: number; + totalAmount: number; + averagePurchaseOrderAmount: number; + purchaseOrdersByStatus: StatusAmountMetric[]; + dailyProcurement: DailyAmountMetric[]; + topSuppliers: TopPartyMetric[]; +}; + +export type DashboardWarehouse = { + stockItemsCount: number; + totalQuantityOnHand: number; + lowStockItemsCount: number; + movementsCount: number; + inboundQuantity: number; + outboundQuantity: number; + adjustmentInQuantity: number; + adjustmentOutQuantity: number; + stockByWarehouse: WarehouseStockMetric[]; + movementsByType: MovementTypeMetric[]; +}; + +export type PeriodParams = { + fromDate?: string; + toDate?: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const dashboardApi = { + async getDashboardSummary() { + const response = await apiClient.get>('/api/dashboard/summary'); + return unwrap(response.data, 'Не удалось загрузить сводку панели управления'); + }, + + async getSalesDashboard(params: PeriodParams = {}) { + const response = await apiClient.get>('/api/dashboard/sales', { + params, + }); + return unwrap(response.data, 'Не удалось загрузить аналитику продаж'); + }, + + async getProcurementDashboard(params: PeriodParams = {}) { + const response = await apiClient.get>('/api/dashboard/procurement', { + params, + }); + return unwrap(response.data, 'Не удалось загрузить аналитику закупок'); + }, + + async getWarehouseDashboard() { + const response = await apiClient.get>('/api/dashboard/warehouse'); + return unwrap(response.data, 'Не удалось загрузить аналитику склада'); + }, + + async getRecentActivities(limit = 20) { + const response = await apiClient.get>('/api/dashboard/recent-activities', { + params: { limit }, + }); + return unwrap(response.data, 'Не удалось загрузить последние события'); + }, + + async getLowStock(threshold = 10, page = 0, size = 20) { + const response = await apiClient.get>>('/api/dashboard/low-stock', { + params: { threshold, page, size }, + }); + return unwrap(response.data, 'Не удалось загрузить низкие остатки'); + }, +}; diff --git a/frontend/src/api/documentsApi.ts b/frontend/src/api/documentsApi.ts new file mode 100644 index 0000000..1730209 --- /dev/null +++ b/frontend/src/api/documentsApi.ts @@ -0,0 +1,106 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type DocumentType = 'INVOICE' | 'CONTRACT' | 'DELIVERY_NOTE'; +export type DocumentSourceType = 'CUSTOMER_ORDER'; +export type DocumentStatus = 'GENERATED'; + +export type DocumentRecord = { + id: string; + documentNumber: string; + documentType: DocumentType; + sourceType: DocumentSourceType; + sourceId: string; + fileName: string; + contentType: string; + fileSize: number; + status: DocumentStatus; + generatedByUserId: string | null; + generatedAt: string; + createdAt: string; + updatedAt: string; +}; + +export type DocumentListParams = { + page?: number; + size?: number; + documentType?: DocumentType | ''; + sourceType?: DocumentSourceType | ''; + sourceId?: string; + status?: DocumentStatus | ''; + fromDate?: string; + toDate?: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +function fileNameFromContentDisposition(value: string | undefined, fallback: string) { + if (!value) { + return fallback; + } + + const match = value.match(/filename="?([^"]+)"?/i); + return match?.[1] || fallback; +} + +export const documentsApi = { + async listDocuments(params: DocumentListParams) { + const response = await apiClient.get>>('/api/documents', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + documentType: params.documentType || undefined, + sourceType: params.sourceType || undefined, + sourceId: params.sourceId || undefined, + status: params.status || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить документы'); + }, + + async getDocument(id: string) { + const response = await apiClient.get>(`/api/documents/${id}`); + return unwrap(response.data, 'Не удалось загрузить документ'); + }, + + async getCustomerOrderDocuments(customerOrderId: string) { + const response = await apiClient.get>(`/api/documents/customer-orders/${customerOrderId}`); + return unwrap(response.data, 'Не удалось загрузить документы заказа'); + }, + + async generateCustomerOrderDocument(customerOrderId: string, documentType: DocumentType) { + const response = await apiClient.post>(`/api/documents/customer-orders/${customerOrderId}/generate`, { + documentType, + }); + return unwrap(response.data, 'Не удалось сформировать документ'); + }, + + async downloadDocument(id: string) { + const response = await apiClient.get(`/api/documents/${id}/download`, { + responseType: 'blob', + }); + + return { + blob: response.data, + fileName: fileNameFromContentDisposition(response.headers['content-disposition'], `document-${id}.pdf`), + }; + }, +}; + +export function saveBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts new file mode 100644 index 0000000..31aad1c --- /dev/null +++ b/frontend/src/api/errors.ts @@ -0,0 +1,50 @@ +import { AxiosError } from 'axios'; + +import type { ApiResponse } from './types'; + +export function getApiErrorMessage(error: unknown, fallback: string) { + if (error instanceof AxiosError) { + const response = error.response?.data as ApiResponse | undefined; + const backendMessage = response?.error?.message; + + if (backendMessage) { + return translateBackendMessage(backendMessage); + } + + if (error.response?.status === 401) { + return 'Сессия истекла. Войдите снова.'; + } + + return fallback; + } + + if (error instanceof Error) { + return error.message; + } + + return fallback; +} + +function translateBackendMessage(message: string) { + const exact: Record = { + 'Receiving warehouse is required before marking purchase order as RECEIVED': + 'Перед приемкой закупки нужно выбрать склад поступления.', + 'Warehouse is required before marking customer order as SHIPPED': + 'Перед отгрузкой клиентского заказа нужно выбрать склад.', + 'Cannot generate documents for cancelled order': 'Нельзя формировать документы для отмененного заказа.', + 'Delivery note can be generated only for SHIPPED or CLOSED orders': + 'Накладную можно сформировать только для отгруженного или закрытого заказа.', + }; + + if (exact[message]) { + return exact[message]; + } + + if (message.startsWith('Insufficient stock for product')) { + return message + .replace('Insufficient stock for product', 'Недостаточно остатка по товару') + .replace('in warehouse', 'на складе'); + } + + return message; +} diff --git a/frontend/src/api/procurementApi.ts b/frontend/src/api/procurementApi.ts new file mode 100644 index 0000000..9c97354 --- /dev/null +++ b/frontend/src/api/procurementApi.ts @@ -0,0 +1,143 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type PurchaseOrderStatus = 'DRAFT' | 'APPROVED' | 'ORDERED' | 'RECEIVED' | 'CANCELLED'; + +export type PurchaseOrderListParams = { + page?: number; + size?: number; + search?: string; + supplierId?: string; + status?: PurchaseOrderStatus | ''; + fromDate?: string; + toDate?: string; +}; + +export type PurchaseOrderSupplier = { + id: string; + companyName: string; + bin: string | null; +}; + +export type PurchaseOrderWarehouse = { + id: string; + code: string; + name: string; +} | null; + +export type PurchaseOrderProduct = { + id: string; + sku: string; + name: string; + unit: string; +}; + +export type PurchaseOrderItem = { + id: string; + product: PurchaseOrderProduct; + quantity: number; + unitPrice: number; + lineTotal: number; +}; + +export type PurchaseOrder = { + id: string; + poNumber: string; + supplier: PurchaseOrderSupplier; + warehouse: PurchaseOrderWarehouse; + status: PurchaseOrderStatus; + expectedDeliveryDate: string | null; + notes: string | null; + totalAmount: number; + createdByUserId: string | null; + approvedByUserId: string | null; + orderedAt: string | null; + receivedAt: string | null; + cancelledAt: string | null; + createdAt: string; + updatedAt: string; + items: PurchaseOrderItem[]; +}; + +export type PurchaseOrderItemPayload = { + productId: string; + quantity: number; + unitPrice: number; +}; + +export type PurchaseOrderPayload = { + supplierId: string; + warehouseId?: string; + expectedDeliveryDate?: string; + notes?: string; + items: PurchaseOrderItemPayload[]; +}; + +export type PurchaseOrderStatusHistory = { + id: string; + oldStatus: PurchaseOrderStatus | null; + newStatus: PurchaseOrderStatus; + changedByUserId: string | null; + comment: string | null; + createdAt: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const procurementApi = { + async listPurchaseOrders(params: PurchaseOrderListParams) { + const response = await apiClient.get>>('/api/procurement/purchase-orders', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + search: params.search || undefined, + supplierId: params.supplierId || undefined, + status: params.status || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить закупки'); + }, + + async getPurchaseOrder(id: string) { + const response = await apiClient.get>(`/api/procurement/purchase-orders/${id}`); + return unwrap(response.data, 'Не удалось загрузить закупку'); + }, + + async createPurchaseOrder(payload: PurchaseOrderPayload) { + const response = await apiClient.post>('/api/procurement/purchase-orders', payload); + return unwrap(response.data, 'Не удалось создать закупку'); + }, + + async updatePurchaseOrder(id: string, payload: PurchaseOrderPayload) { + const response = await apiClient.put>(`/api/procurement/purchase-orders/${id}`, payload); + return unwrap(response.data, 'Не удалось обновить закупку'); + }, + + async changePurchaseOrderStatus(id: string, status: PurchaseOrderStatus, comment?: string) { + const response = await apiClient.patch>(`/api/procurement/purchase-orders/${id}/status`, { + status, + comment, + }); + return unwrap(response.data, 'Не удалось изменить статус закупки'); + }, + + async getPurchaseOrderStatusHistory(id: string) { + const response = await apiClient.get>( + `/api/procurement/purchase-orders/${id}/status-history`, + ); + return unwrap(response.data, 'Не удалось загрузить историю статусов'); + }, + + async deletePurchaseOrder(id: string) { + await apiClient.delete(`/api/procurement/purchase-orders/${id}`); + }, +}; diff --git a/frontend/src/api/salesApi.ts b/frontend/src/api/salesApi.ts new file mode 100644 index 0000000..f61d11d --- /dev/null +++ b/frontend/src/api/salesApi.ts @@ -0,0 +1,147 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type CustomerOrderStatus = 'NEW' | 'CONFIRMED' | 'IN_PROGRESS' | 'SHIPPED' | 'CLOSED' | 'CANCELLED'; + +export type CustomerOrderListParams = { + page?: number; + size?: number; + search?: string; + customerId?: string; + warehouseId?: string; + status?: CustomerOrderStatus | ''; + fromDate?: string; + toDate?: string; +}; + +export type CustomerOrderCustomer = { + id: string; + companyName: string; + bin: string | null; +}; + +export type CustomerOrderWarehouse = { + id: string; + code: string; + name: string; +} | null; + +export type CustomerOrderProduct = { + id: string; + sku: string; + name: string; + unit: string; +}; + +export type CustomerOrderItem = { + id: string; + product: CustomerOrderProduct; + quantity: number; + unitPrice: number; + lineTotal: number; +}; + +export type CustomerOrder = { + id: string; + orderNumber: string; + customer: CustomerOrderCustomer; + warehouse: CustomerOrderWarehouse; + status: CustomerOrderStatus; + requestedDeliveryDate: string | null; + notes: string | null; + totalAmount: number; + createdByUserId: string | null; + confirmedByUserId: string | null; + confirmedAt: string | null; + inProgressAt: string | null; + shippedAt: string | null; + closedAt: string | null; + cancelledAt: string | null; + createdAt: string; + updatedAt: string; + items: CustomerOrderItem[]; +}; + +export type CustomerOrderItemPayload = { + productId: string; + quantity: number; + unitPrice: number; +}; + +export type CustomerOrderPayload = { + customerId: string; + warehouseId?: string; + requestedDeliveryDate?: string; + notes?: string; + items: CustomerOrderItemPayload[]; +}; + +export type CustomerOrderStatusHistory = { + id: string; + oldStatus: CustomerOrderStatus | null; + newStatus: CustomerOrderStatus; + changedByUserId: string | null; + comment: string | null; + createdAt: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const salesApi = { + async listCustomerOrders(params: CustomerOrderListParams) { + const response = await apiClient.get>>('/api/sales/customer-orders', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + search: params.search || undefined, + customerId: params.customerId || undefined, + warehouseId: params.warehouseId || undefined, + status: params.status || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить клиентские заказы'); + }, + + async getCustomerOrder(id: string) { + const response = await apiClient.get>(`/api/sales/customer-orders/${id}`); + return unwrap(response.data, 'Не удалось загрузить клиентский заказ'); + }, + + async createCustomerOrder(payload: CustomerOrderPayload) { + const response = await apiClient.post>('/api/sales/customer-orders', payload); + return unwrap(response.data, 'Не удалось создать клиентский заказ'); + }, + + async updateCustomerOrder(id: string, payload: CustomerOrderPayload) { + const response = await apiClient.put>(`/api/sales/customer-orders/${id}`, payload); + return unwrap(response.data, 'Не удалось обновить клиентский заказ'); + }, + + async changeCustomerOrderStatus(id: string, status: CustomerOrderStatus, comment?: string) { + const response = await apiClient.patch>(`/api/sales/customer-orders/${id}/status`, { + status, + comment, + }); + return unwrap(response.data, 'Не удалось изменить статус клиентского заказа'); + }, + + async getCustomerOrderStatusHistory(id: string) { + const response = await apiClient.get>( + `/api/sales/customer-orders/${id}/status-history`, + ); + return unwrap(response.data, 'Не удалось загрузить историю статусов'); + }, + + async deleteCustomerOrder(id: string) { + await apiClient.delete(`/api/sales/customer-orders/${id}`); + }, +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..aaf32f3 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,21 @@ +export type ApiResponse = { + success: boolean; + data: T | null; + error: { + code: string; + message: string; + details: unknown[]; + } | null; + timestamp: string; +}; + +export type PageResponse = { + items: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + hasNext: boolean; + hasPrevious: boolean; +}; + diff --git a/frontend/src/api/warehouseApi.ts b/frontend/src/api/warehouseApi.ts new file mode 100644 index 0000000..aecb6e6 --- /dev/null +++ b/frontend/src/api/warehouseApi.ts @@ -0,0 +1,118 @@ +import { apiClient } from './client'; +import type { ApiResponse, PageResponse } from './types'; + +export type StockMovementType = 'INBOUND' | 'OUTBOUND' | 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT'; +export type StockMovementSourceType = 'PURCHASE_ORDER' | 'CUSTOMER_ORDER' | 'MANUAL_ADJUSTMENT'; + +export type StockWarehouse = { + id: string; + code: string; + name: string; +}; + +export type StockProduct = { + id: string; + sku: string; + name: string; + unit: string; +}; + +export type StockBalance = { + id: string; + warehouse: StockWarehouse; + product: StockProduct; + quantityOnHand: number; + createdAt: string; + updatedAt: string; +}; + +export type StockMovement = { + id: string; + movementNumber: string; + warehouse: StockWarehouse; + product: StockProduct; + movementType: StockMovementType; + quantity: number; + quantityBefore: number; + quantityAfter: number; + sourceType: StockMovementSourceType | null; + sourceId: string | null; + comment: string | null; + createdByUserId: string | null; + createdAt: string; +}; + +export type StockBalanceListParams = { + page?: number; + size?: number; + warehouseId?: string; + productId?: string; + search?: string; +}; + +export type StockMovementListParams = { + page?: number; + size?: number; + warehouseId?: string; + productId?: string; + movementType?: StockMovementType | ''; + sourceType?: StockMovementSourceType | ''; + sourceId?: string; + fromDate?: string; + toDate?: string; +}; + +export type ManualStockAdjustmentPayload = { + warehouseId: string; + productId: string; + type: 'ADJUSTMENT_IN' | 'ADJUSTMENT_OUT'; + quantity: number; + comment: string; +}; + +function unwrap(response: ApiResponse, fallback: string) { + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? fallback); + } + + return response.data; +} + +export const warehouseApi = { + async listStockBalances(params: StockBalanceListParams) { + const response = await apiClient.get>>('/api/warehouse/stock-balances', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + warehouseId: params.warehouseId || undefined, + productId: params.productId || undefined, + search: params.search || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить остатки'); + }, + + async listStockMovements(params: StockMovementListParams) { + const response = await apiClient.get>>('/api/warehouse/stock-movements', { + params: { + page: params.page ?? 0, + size: params.size ?? 20, + warehouseId: params.warehouseId || undefined, + productId: params.productId || undefined, + movementType: params.movementType || undefined, + sourceType: params.sourceType || undefined, + sourceId: params.sourceId || undefined, + fromDate: params.fromDate || undefined, + toDate: params.toDate || undefined, + }, + }); + + return unwrap(response.data, 'Не удалось загрузить движения склада'); + }, + + async manualStockAdjustment(payload: ManualStockAdjustmentPayload) { + const response = await apiClient.post>('/api/warehouse/stock-adjustments', payload); + return unwrap(response.data, 'Не удалось создать корректировку'); + }, +}; diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..324d107 --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,141 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { apiClient, setAuthToken, setUnauthorizedHandler } from '../api/client'; +import type { ApiResponse } from '../api/types'; + +const TOKEN_STORAGE_KEY = 'erp_mvp_access_token'; + +export type AuthUser = { + id: string; + email: string; + fullName: string; + role: 'ADMIN' | 'MANAGER' | 'WAREHOUSE' | 'FINANCE'; + active?: boolean; +}; + +type LoginResponse = { + accessToken: string; + tokenType: 'Bearer'; + expiresInMinutes: number; + user: AuthUser; +}; + +type AuthContextValue = { + token: string | null; + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + refreshCurrentUser: () => Promise; +}; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_STORAGE_KEY)); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const storeToken = useCallback((nextToken: string | null) => { + setToken(nextToken); + setAuthToken(nextToken); + + if (nextToken) { + localStorage.setItem(TOKEN_STORAGE_KEY, nextToken); + return; + } + + localStorage.removeItem(TOKEN_STORAGE_KEY); + }, []); + + const refreshCurrentUser = useCallback(async () => { + const response = await apiClient.get>('/api/auth/me'); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось загрузить текущего пользователя'); + } + + setUser(response.data.data); + }, []); + + const logout = useCallback(() => { + storeToken(null); + setUser(null); + }, [storeToken]); + + const login = useCallback( + async (email: string, password: string) => { + const response = await apiClient.post>('/api/auth/login', { + email, + password, + }); + + if (!response.data.success || !response.data.data) { + throw new Error(response.data.error?.message ?? 'Не удалось войти'); + } + + storeToken(response.data.data.accessToken); + setUser(response.data.data.user); + }, + [storeToken], + ); + + useEffect(() => { + setUnauthorizedHandler(() => { + storeToken(null); + setUser(null); + navigate('/login', { replace: true }); + }); + + return () => setUnauthorizedHandler(null); + }, [navigate, storeToken]); + + useEffect(() => { + setAuthToken(token); + + if (!token) { + setIsLoading(false); + return; + } + + refreshCurrentUser() + .catch(() => logout()) + .finally(() => setIsLoading(false)); + }, [logout, refreshCurrentUser, token]); + + const value = useMemo( + () => ({ + token, + user, + isAuthenticated: Boolean(token && user), + isLoading, + login, + logout, + refreshCurrentUser, + }), + [isLoading, login, logout, refreshCurrentUser, token, user], + ); + + return {children}; +} + +export function useAuth() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuth must be used inside AuthProvider'); + } + + return context; +} diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..ae0a8eb --- /dev/null +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet, useLocation } from 'react-router-dom'; + +import { useAuth } from './AuthContext'; +import { LoadingState } from '../components/ui/LoadingState'; + +export function ProtectedRoute() { + const location = useLocation(); + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/frontend/src/components/CustomerOrderDocuments.tsx b/frontend/src/components/CustomerOrderDocuments.tsx new file mode 100644 index 0000000..9039225 --- /dev/null +++ b/frontend/src/components/CustomerOrderDocuments.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { + type DocumentRecord, + type DocumentType, + documentsApi, + saveBlob, +} from '../api/documentsApi'; +import { getApiErrorMessage } from '../api/errors'; +import { useAuth } from '../auth/AuthContext'; +import { formatDateTime, formatDocumentType } from '../utils/formatters'; +import { DataTable, type TableColumn } from './DataTable'; +import { Button } from './ui/Button'; +import { ErrorState } from './ui/ErrorState'; +import { LoadingState } from './ui/LoadingState'; + +type CustomerOrderDocumentsProps = { + customerOrderId: string; +}; + +const documentTypes: Array<{ label: string; value: DocumentType }> = [ + { label: 'Сформировать счет', value: 'INVOICE' }, + { label: 'Сформировать договор', value: 'CONTRACT' }, + { label: 'Сформировать накладную', value: 'DELIVERY_NOTE' }, +]; + +export function CustomerOrderDocuments({ customerOrderId }: CustomerOrderDocumentsProps) { + const { user } = useAuth(); + const [documents, setDocuments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [activeDocumentType, setActiveDocumentType] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const loadDocuments = useCallback(async () => { + setIsLoading(true); + setError(''); + + try { + const data = await documentsApi.getCustomerOrderDocuments(customerOrderId); + setDocuments(data); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось загрузить документы')); + } finally { + setIsLoading(false); + } + }, [customerOrderId]); + + useEffect(() => { + void loadDocuments(); + }, [loadDocuments]); + + const generateDocument = async (documentType: DocumentType) => { + setActiveDocumentType(documentType); + setError(''); + setSuccess(''); + + try { + const document = await documentsApi.generateCustomerOrderDocument(customerOrderId, documentType); + setSuccess(`${formatDocumentType(document.documentType)} сформирован`); + await loadDocuments(); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось сформировать документ')); + } finally { + setActiveDocumentType(null); + } + }; + + const downloadDocument = async (document: DocumentRecord) => { + setDownloadingId(document.id); + setError(''); + + try { + const { blob, fileName } = await documentsApi.downloadDocument(document.id); + saveBlob(blob, fileName || document.fileName); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось скачать документ')); + } finally { + setDownloadingId(null); + } + }; + + const canGenerate = (documentType: DocumentType) => { + if (!user) { + return false; + } + + if (documentType === 'DELIVERY_NOTE') { + return user.role === 'ADMIN' || user.role === 'MANAGER' || user.role === 'WAREHOUSE'; + } + + return user.role === 'ADMIN' || user.role === 'MANAGER' || user.role === 'FINANCE'; + }; + + const columns: TableColumn[] = [ + { key: 'documentNumber', label: 'Номер документа', render: (document) => document.documentNumber }, + { key: 'documentType', label: 'Тип', render: (document) => formatDocumentType(document.documentType) }, + { key: 'fileName', label: 'Файл', render: (document) => document.fileName }, + { key: 'generatedAt', label: 'Сформирован', render: (document) => formatDateTime(document.generatedAt) }, + ]; + + return ( +
+
+

Документы

+
+ {documentTypes.map((documentType) => + canGenerate(documentType.value) ? ( + + ) : null, + )} +
+
+ + {error && ( +
+ +
+ )} + + {success && ( +

+ {success} +

+ )} + + {isLoading ? ( +
+ +
+ ) : ( +
+ document.id} + emptyTitle="Документы еще не сформированы" + actions={(document) => ( + + )} + /> +
+ )} +
+ ); +} diff --git a/frontend/src/components/CustomerOrderForm.tsx b/frontend/src/components/CustomerOrderForm.tsx new file mode 100644 index 0000000..b5c18aa --- /dev/null +++ b/frontend/src/components/CustomerOrderForm.tsx @@ -0,0 +1,323 @@ +import { type FormEvent, useEffect, useMemo, useState } from 'react'; + +import { catalogApi, type Customer, type Product, type Warehouse } from '../api/catalogApi'; +import { getApiErrorMessage } from '../api/errors'; +import type { CustomerOrder, CustomerOrderPayload } from '../api/salesApi'; +import { formatMoney } from '../utils/formatters'; +import { Button } from './ui/Button'; +import { ErrorState } from './ui/ErrorState'; +import { LoadingState } from './ui/LoadingState'; + +type FormItem = { + productId: string; + quantity: string; + unitPrice: string; +}; + +type CustomerOrderFormProps = { + initialOrder?: CustomerOrder; + submitLabel: string; + onSubmit: (payload: CustomerOrderPayload) => Promise; + onCancel?: () => void; +}; + +export function CustomerOrderForm({ initialOrder, submitLabel, onSubmit, onCancel }: CustomerOrderFormProps) { + const [customers, setCustomers] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [products, setProducts] = useState([]); + const [customerId, setCustomerId] = useState(initialOrder?.customer.id ?? ''); + const [warehouseId, setWarehouseId] = useState(initialOrder?.warehouse?.id ?? ''); + const [requestedDeliveryDate, setRequestedDeliveryDate] = useState(initialOrder?.requestedDeliveryDate ?? ''); + const [notes, setNotes] = useState(initialOrder?.notes ?? ''); + const [items, setItems] = useState( + initialOrder?.items.map((item) => ({ + productId: item.product.id, + quantity: String(item.quantity), + unitPrice: String(item.unitPrice), + })) ?? [{ productId: '', quantity: '1', unitPrice: '0' }], + ); + const [isLoadingCatalog, setIsLoadingCatalog] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + let isMounted = true; + + async function loadCatalogData() { + setIsLoadingCatalog(true); + setError(''); + + try { + const [customerPage, warehousePage, productPage] = await Promise.all([ + catalogApi.listCustomers({ page: 0, size: 100, active: true }), + catalogApi.listWarehouses({ page: 0, size: 100, active: true }), + catalogApi.listProducts({ page: 0, size: 100, active: true }), + ]); + + if (!isMounted) { + return; + } + + setCustomers(customerPage.items); + setWarehouses(warehousePage.items); + setProducts(productPage.items); + setCustomerId((current) => current || customerPage.items[0]?.id || ''); + setItems((current) => + current.map((item, index) => ({ + ...item, + productId: item.productId || (index === 0 ? productPage.items[0]?.id ?? '' : ''), + })), + ); + } catch (caughtError) { + if (isMounted) { + setError(getApiErrorMessage(caughtError, 'Не удалось загрузить клиентов, склады и товары')); + } + } finally { + if (isMounted) { + setIsLoadingCatalog(false); + } + } + } + + void loadCatalogData(); + + return () => { + isMounted = false; + }; + }, []); + + const productById = useMemo(() => new Map(products.map((product) => [product.id, product])), [products]); + const hasCatalogData = customers.length > 0 && products.length > 0; + const totalAmount = items.reduce((sum, item) => sum + lineTotal(item), 0); + + const updateItem = (index: number, patch: Partial) => { + setItems((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item))); + }; + + const addItem = () => { + setItems((current) => [ + ...current, + { + productId: products[0]?.id ?? '', + quantity: '1', + unitPrice: '0', + }, + ]); + }; + + const removeItem = (index: number) => { + setItems((current) => current.filter((_, itemIndex) => itemIndex !== index)); + }; + + const lineTotal = (item: FormItem) => { + const quantity = Number(item.quantity || 0); + const unitPrice = Number(item.unitPrice || 0); + return quantity * unitPrice; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSaving(true); + setError(''); + + try { + await onSubmit({ + customerId, + warehouseId: warehouseId || undefined, + requestedDeliveryDate: requestedDeliveryDate || undefined, + notes: notes.trim() || undefined, + items: items.map((item) => ({ + productId: item.productId, + quantity: Number(item.quantity), + unitPrice: Number(item.unitPrice), + })), + }); + } catch (caughtError) { + setError(getApiErrorMessage(caughtError, 'Не удалось сохранить клиентский заказ')); + } finally { + setIsSaving(false); + } + }; + + if (isLoadingCatalog) { + return ; + } + + if (!hasCatalogData) { + return ( +
+ Сначала создайте клиентов и товары в справочниках. +
+ ); + } + + return ( +
+
+ + + + + +
+ +