This commit is contained in:
Aditya Siregar 2025-08-03 00:34:25 +07:00
parent a759e0f57c
commit 96743cf50b
25 changed files with 2049 additions and 2141 deletions

View File

@ -1,292 +0,0 @@
# Analytics API Documentation
This document describes the analytics APIs implemented for the POS system, providing insights into sales, payment methods, products, and overall business performance.
## Overview
The analytics APIs provide comprehensive business intelligence for POS operations, including:
- **Payment Method Analytics**: Track totals for each payment method by date
- **Sales Analytics**: Monitor sales performance over time
- **Product Analytics**: Analyze product performance and revenue
- **Dashboard Analytics**: Overview of key business metrics
## Authentication
All analytics endpoints require authentication and admin/manager privileges. Include the JWT token in the Authorization header:
```
Authorization: Bearer <your-jwt-token>
```
## Base URL
```
GET /api/v1/analytics/{endpoint}
```
## Endpoints
### 1. Payment Method Analytics
**Endpoint:** `GET /api/v1/analytics/payment-methods`
**Description:** Get payment method totals for a given date range. This is the primary endpoint for tracking payment method performance.
**Query Parameters:**
- `organization_id` (required): UUID of the organization
- `outlet_id` (optional): UUID of specific outlet to filter by
- `date_from` (required): Start date (ISO 8601 format: YYYY-MM-DD)
- `date_to` (required): End date (ISO 8601 format: YYYY-MM-DD)
- `group_by` (optional): Grouping interval - "day", "hour", "week", "month" (default: "day")
**Example Request:**
```bash
curl -X GET "http://localhost:8080/api/v1/analytics/payment-methods?organization_id=123e4567-e89b-12d3-a456-426614174000&date_from=2024-01-01&date_to=2024-01-31" \
-H "Authorization: Bearer <your-jwt-token>"
```
**Response:**
```json
{
"success": true,
"data": {
"organization_id": "123e4567-e89b-12d3-a456-426614174000",
"outlet_id": null,
"date_from": "2024-01-01T00:00:00Z",
"date_to": "2024-01-31T23:59:59Z",
"group_by": "day",
"summary": {
"total_amount": 15000.00,
"total_orders": 150,
"total_payments": 180,
"average_order_value": 100.00
},
"data": [
{
"payment_method_id": "456e7890-e89b-12d3-a456-426614174001",
"payment_method_name": "Cash",
"payment_method_type": "cash",
"total_amount": 8000.00,
"order_count": 80,
"payment_count": 80,
"percentage": 53.33
},
{
"payment_method_id": "789e0123-e89b-12d3-a456-426614174002",
"payment_method_name": "Credit Card",
"payment_method_type": "card",
"total_amount": 7000.00,
"order_count": 70,
"payment_count": 100,
"percentage": 46.67
}
]
}
}
```
### 2. Sales Analytics
**Endpoint:** `GET /api/v1/analytics/sales`
**Description:** Get sales performance data over time.
**Query Parameters:**
- `organization_id` (required): UUID of the organization
- `outlet_id` (optional): UUID of specific outlet to filter by
- `date_from` (required): Start date (ISO 8601 format: YYYY-MM-DD)
- `date_to` (required): End date (ISO 8601 format: YYYY-MM-DD)
- `group_by` (optional): Grouping interval - "day", "hour", "week", "month" (default: "day")
**Example Request:**
```bash
curl -X GET "http://localhost:8080/api/v1/analytics/sales?organization_id=123e4567-e89b-12d3-a456-426614174000&date_from=2024-01-01&date_to=2024-01-31&group_by=day" \
-H "Authorization: Bearer <your-jwt-token>"
```
**Response:**
```json
{
"success": true,
"data": {
"organization_id": "123e4567-e89b-12d3-a456-426614174000",
"outlet_id": null,
"date_from": "2024-01-01T00:00:00Z",
"date_to": "2024-01-31T23:59:59Z",
"group_by": "day",
"summary": {
"total_sales": 15000.00,
"total_orders": 150,
"total_items": 450,
"average_order_value": 100.00,
"total_tax": 1500.00,
"total_discount": 500.00,
"net_sales": 13000.00
},
"data": [
{
"date": "2024-01-01T00:00:00Z",
"sales": 500.00,
"orders": 5,
"items": 15,
"tax": 50.00,
"discount": 20.00,
"net_sales": 430.00
}
]
}
}
```
### 3. Product Analytics
**Endpoint:** `GET /api/v1/analytics/products`
**Description:** Get top-performing products by revenue.
**Query Parameters:**
- `organization_id` (required): UUID of the organization
- `outlet_id` (optional): UUID of specific outlet to filter by
- `date_from` (required): Start date (ISO 8601 format: YYYY-MM-DD)
- `date_to` (required): End date (ISO 8601 format: YYYY-MM-DD)
- `limit` (optional): Number of products to return (1-100, default: 10)
**Example Request:**
```bash
curl -X GET "http://localhost:8080/api/v1/analytics/products?organization_id=123e4567-e89b-12d3-a456-426614174000&date_from=2024-01-01&date_to=2024-01-31&limit=5" \
-H "Authorization: Bearer <your-jwt-token>"
```
**Response:**
```json
{
"success": true,
"data": {
"organization_id": "123e4567-e89b-12d3-a456-426614174000",
"outlet_id": null,
"date_from": "2024-01-01T00:00:00Z",
"date_to": "2024-01-31T23:59:59Z",
"data": [
{
"product_id": "abc123-e89b-12d3-a456-426614174000",
"product_name": "Coffee Latte",
"category_id": "cat123-e89b-12d3-a456-426614174000",
"category_name": "Beverages",
"quantity_sold": 100,
"revenue": 2500.00,
"average_price": 25.00,
"order_count": 80
}
]
}
}
```
### 4. Dashboard Analytics
**Endpoint:** `GET /api/v1/analytics/dashboard`
**Description:** Get comprehensive dashboard overview with key metrics.
**Query Parameters:**
- `organization_id` (required): UUID of the organization
- `outlet_id` (optional): UUID of specific outlet to filter by
- `date_from` (required): Start date (ISO 8601 format: YYYY-MM-DD)
- `date_to` (required): End date (ISO 8601 format: YYYY-MM-DD)
**Example Request:**
```bash
curl -X GET "http://localhost:8080/api/v1/analytics/dashboard?organization_id=123e4567-e89b-12d3-a456-426614174000&date_from=2024-01-01&date_to=2024-01-31" \
-H "Authorization: Bearer <your-jwt-token>"
```
**Response:**
```json
{
"success": true,
"data": {
"organization_id": "123e4567-e89b-12d3-a456-426614174000",
"outlet_id": null,
"date_from": "2024-01-01T00:00:00Z",
"date_to": "2024-01-31T23:59:59Z",
"overview": {
"total_sales": 15000.00,
"total_orders": 150,
"average_order_value": 100.00,
"total_customers": 120,
"voided_orders": 5,
"refunded_orders": 3
},
"top_products": [...],
"payment_methods": [...],
"recent_sales": [...]
}
}
```
## Error Responses
All endpoints return consistent error responses:
```json
{
"success": false,
"error": "error_type",
"message": "Error description"
}
```
Common error types:
- `invalid_request`: Invalid query parameters
- `validation_failed`: Request validation failed
- `internal_error`: Server-side error
- `unauthorized`: Authentication required
## Date Format
All date parameters should be in ISO 8601 format: `YYYY-MM-DD`
Examples:
- `2024-01-01` (January 1, 2024)
- `2024-12-31` (December 31, 2024)
## Filtering
- **Organization-level**: All analytics are scoped to a specific organization
- **Outlet-level**: Optional filtering by specific outlet
- **Date range**: Required date range for all analytics queries
- **Time grouping**: Flexible grouping by hour, day, week, or month
## Performance Considerations
- Analytics queries are optimized for read performance
- Large date ranges may take longer to process
- Consider using appropriate date ranges for optimal performance
- Results are cached where possible for better response times
## Use Cases
### Payment Method Analysis
- Track which payment methods are most popular
- Monitor payment method trends over time
- Identify payment method preferences by outlet
- Calculate payment method percentages for reporting
### Sales Performance
- Monitor daily/weekly/monthly sales trends
- Track order volumes and average order values
- Analyze tax and discount patterns
- Compare sales performance across outlets
### Product Performance
- Identify top-selling products
- Analyze product revenue and profitability
- Track product category performance
- Monitor product order frequency
### Business Intelligence
- Dashboard overview for management
- Key performance indicators (KPIs)
- Trend analysis and forecasting
- Operational insights for decision making

View File

@ -1,120 +0,0 @@
# Order Void Status Improvement
## Overview
This document describes the improved approach for handling order void status when all items are voided.
## Problem with Previous Approach
The previous implementation only set the `is_void` flag to `true` when voiding orders, but kept the original order status (e.g., "pending", "preparing", etc.). This approach had several issues:
1. **Poor Semantic Meaning**: Orders with status "pending" but `is_void = true` were confusing
2. **Difficult Querying**: Hard to filter voided orders by status alone
3. **Inconsistent State**: Order status didn't reflect the actual business state
4. **Audit Trail Issues**: No clear indication of when and why orders were voided
## Improved Approach
### 1. Status Update Strategy
When an order is voided (either entirely or when all items are voided), the system now:
- **Sets `is_void = true`** (for audit trail and void-specific operations)
- **Updates `status = 'cancelled'`** (for business logic and semantic clarity)
- **Records void metadata** (reason, timestamp, user who voided)
### 2. Benefits
#### **Clear Semantic Meaning**
- Voided orders have status "cancelled" which clearly indicates they are no longer active
- Business logic can rely on status for workflow decisions
- Frontend can easily display voided orders with appropriate styling
#### **Better Querying**
```sql
-- Find all cancelled/voided orders
SELECT * FROM orders WHERE status = 'cancelled';
-- Find all active orders (excluding voided)
SELECT * FROM orders WHERE status != 'cancelled';
-- Find voided orders with audit info
SELECT * FROM orders WHERE is_void = true;
```
#### **Consistent State Management**
- Order status always reflects the current business state
- No conflicting states (e.g., "pending" but voided)
- Easier to implement business rules and validations
#### **Enhanced Audit Trail**
- `is_void` flag for void-specific operations
- `void_reason`, `voided_at`, `voided_by` for detailed audit
- `status = 'cancelled'` for business workflow
### 3. Implementation Details
#### **New Repository Method**
```go
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error
```
This method updates both status and void flags in a single atomic transaction.
#### **Updated Processor Logic**
```go
// For "ALL" void type
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
// For "ITEM" void type when all items are voided
if allItemsVoided {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order after all items voided: %w", err)
}
}
```
#### **Database Migration**
Added migration `000021_add_paid_status_to_orders.up.sql` to include "paid" status in the constraint.
### 4. Status Flow
```
Order Created → pending
Items Added/Modified → pending
Order Processing → preparing → ready → completed
Order Voided → cancelled (with is_void = true)
```
### 5. Backward Compatibility
- Existing `is_void` flag is preserved for backward compatibility
- New approach is additive, not breaking
- Existing queries using `is_void` continue to work
- New queries can use `status = 'cancelled'` for better performance
### 6. Best Practices
#### **For Queries**
- Use `status = 'cancelled'` for business logic and filtering
- Use `is_void = true` for void-specific operations and audit trails
- Combine both when you need complete void information
#### **For Business Logic**
- Check `status != 'cancelled'` before allowing modifications
- Use `is_void` flag for void-specific validations
- Always include void reason and user for audit purposes
#### **For Frontend**
- Display cancelled orders with appropriate styling
- Show void reason and timestamp when available
- Disable actions on cancelled orders
## Conclusion
This improved approach provides better semantic meaning, easier querying, and more consistent state management while maintaining backward compatibility. The combination of status updates and void flags creates a robust system for handling order cancellations.

View File

@ -1,155 +0,0 @@
# Outlet-Based Tax Calculation Implementation
## Overview
This document describes the implementation of outlet-based tax calculation in the order processing system. The system now uses the tax rate configured for each outlet instead of a hardcoded tax rate.
## Feature Description
Previously, the system used a hardcoded 10% tax rate for all orders. Now, the tax calculation is based on the `tax_rate` field configured for each outlet, allowing for different tax rates across different locations.
## Implementation Details
### 1. Order Processor Changes
The `OrderProcessorImpl` has been updated to:
- Accept an `OutletRepository` dependency
- Fetch outlet information to get the tax rate
- Calculate tax using the outlet's specific tax rate
- Recalculate tax when adding items to existing orders
### 2. Tax Calculation Logic
```go
// Get outlet information for tax rate
outlet, err := p.outletRepo.GetByID(ctx, req.OutletID)
if err != nil {
return nil, fmt.Errorf("outlet not found: %w", err)
}
// Calculate tax using outlet's tax rate
taxAmount := subtotal * outlet.TaxRate
totalAmount := subtotal + taxAmount
```
### 3. Database Schema
The `outlets` table includes:
- `tax_rate`: Decimal field (DECIMAL(5,4)) for tax rate as a decimal (e.g., 0.085 for 8.5%)
- Constraint: `CHECK (tax_rate >= 0 AND tax_rate <= 1)` to ensure valid percentage
### 4. Tax Rate Examples
| Tax Rate (Decimal) | Percentage | Example Calculation |
|-------------------|------------|-------------------|
| 0.0000 | 0% | No tax |
| 0.0500 | 5% | $100 × 0.05 = $5.00 tax |
| 0.0850 | 8.5% | $100 × 0.085 = $8.50 tax |
| 0.1000 | 10% | $100 × 0.10 = $10.00 tax |
| 0.1500 | 15% | $100 × 0.15 = $15.00 tax |
### 5. API Usage
The tax calculation is automatic and transparent to the API consumer. When creating orders or adding items, the system:
1. Fetches the outlet's tax rate
2. Calculates tax based on the current subtotal
3. Updates the order with the correct tax amount
```json
{
"outlet_id": "uuid-of-outlet",
"order_items": [
{
"product_id": "uuid-of-product",
"quantity": 2
}
]
}
```
The response will include the calculated tax amount:
```json
{
"id": "order-uuid",
"outlet_id": "outlet-uuid",
"subtotal": 20.00,
"tax_amount": 1.70, // Based on outlet's tax rate
"total_amount": 21.70
}
```
### 6. Business Scenarios
#### Scenario 1: Different Tax Rates by Location
- **Downtown Location**: 8.5% tax rate
- **Suburban Location**: 6.5% tax rate
- **Airport Location**: 10.0% tax rate
#### Scenario 2: Tax-Exempt Locations
- **Wholesale Outlet**: 0% tax rate
- **Export Zone**: 0% tax rate
#### Scenario 3: Seasonal Tax Changes
- **Holiday Period**: Temporary tax rate adjustments
- **Promotional Period**: Reduced tax rates
### 7. Validation
The system includes several validation checks:
1. **Outlet Existence**: Verifies the outlet exists
2. **Tax Rate Range**: Database constraint ensures 0% ≤ tax rate ≤ 100%
3. **Tax Calculation**: Ensures positive tax amounts
### 8. Error Handling
Common error scenarios:
- `outlet not found`: When an invalid outlet ID is provided
- Database constraint violations for invalid tax rates
### 9. Testing
The implementation includes unit tests to verify:
- Correct tax calculation with different outlet tax rates
- Proper error handling for invalid outlets
- Tax recalculation when adding items to existing orders
### 10. Migration
The feature uses existing database schema from migration `000002_create_outlets_table.up.sql` which includes the `tax_rate` column.
### 11. Configuration
Outlet tax rates can be configured through:
1. **Outlet Creation API**: Set initial tax rate
2. **Outlet Update API**: Modify tax rate for existing outlets
3. **Database Direct Update**: For bulk changes
### 12. Future Enhancements
Potential improvements:
1. **Tax Rate History**: Track tax rate changes over time
2. **Conditional Tax Rates**: Different rates based on order type or customer type
3. **Tax Exemptions**: Support for tax-exempt customers or items
4. **Multi-Tax Support**: Support for multiple tax types (state, local, etc.)
5. **Tax Rate Validation**: Integration with tax authority APIs for rate validation
### 13. Performance Considerations
- Outlet information is fetched once per order creation/modification
- Tax calculation is performed in memory for efficiency
- Consider caching outlet information for high-volume scenarios
### 14. Compliance
- Tax rates should comply with local tax regulations
- Consider implementing tax rate validation against official sources
- Maintain audit trails for tax rate changes

View File

@ -1,157 +0,0 @@
# Product Stock Management
This document explains the new product stock management functionality that allows automatic inventory record creation when products are created or updated.
## Features
1. **Automatic Inventory Creation**: When creating a product, you can automatically create inventory records for all outlets in the organization
2. **Initial Stock Setting**: Set initial stock quantity for all outlets
3. **Reorder Level Management**: Set reorder levels for all outlets
4. **Bulk Inventory Updates**: Update reorder levels for all existing inventory records when updating a product
## API Usage
### Creating a Product with Stock Management
```json
POST /api/v1/products
{
"category_id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Premium Coffee",
"description": "High-quality coffee beans",
"price": 15.99,
"cost": 8.50,
"business_type": "restaurant",
"is_active": true,
"variants": [
{
"name": "Large",
"price_modifier": 2.00,
"cost": 1.00
}
],
"initial_stock": 100,
"reorder_level": 20,
"create_inventory": true
}
```
**Parameters:**
- `initial_stock` (optional): Initial stock quantity for all outlets (default: 0)
- `reorder_level` (optional): Reorder level for all outlets (default: 0)
- `create_inventory` (optional): Whether to create inventory records for all outlets (default: false)
### Updating a Product with Stock Management
```json
PUT /api/v1/products/{product_id}
{
"name": "Premium Coffee Updated",
"price": 16.99,
"reorder_level": 25
}
```
**Parameters:**
- `reorder_level` (optional): Updates the reorder level for all existing inventory records
## How It Works
### Product Creation Flow
1. **Validation**: Validates product data and checks for duplicates
2. **Product Creation**: Creates the product in the database
3. **Variant Creation**: Creates product variants if provided
4. **Inventory Creation** (if `create_inventory: true`):
- Fetches all outlets for the organization
- Creates inventory records for each outlet with:
- Initial stock quantity (if provided)
- Reorder level (if provided)
- Uses bulk creation for efficiency
### Product Update Flow
1. **Validation**: Validates update data
2. **Product Update**: Updates the product in the database
3. **Inventory Update** (if `reorder_level` provided):
- Fetches all existing inventory records for the product
- Updates reorder level for each inventory record
## Database Schema
### Products Table
- Standard product fields
- No changes to existing schema
### Inventory Table
- `outlet_id`: Reference to outlet
- `product_id`: Reference to product
- `quantity`: Current stock quantity
- `reorder_level`: Reorder threshold
- `updated_at`: Last update timestamp
## Error Handling
- **No Outlets**: If `create_inventory: true` but no outlets exist, returns an error
- **Duplicate Inventory**: Prevents creating duplicate inventory records for the same product-outlet combination
- **Validation**: Validates stock quantities and reorder levels are non-negative
## Performance Considerations
- **Bulk Operations**: Uses `CreateInBatches` for efficient bulk inventory creation
- **Transactions**: Inventory operations are wrapped in transactions for data consistency
- **Batch Size**: Default batch size of 100 for bulk operations
## Example Response
```json
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"organization_id": "550e8400-e29b-41d4-a716-446655440000",
"category_id": "550e8400-e29b-41d4-a716-446655440002",
"name": "Premium Coffee",
"description": "High-quality coffee beans",
"price": 15.99,
"cost": 8.50,
"business_type": "restaurant",
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z",
"category": {
"id": "550e8400-e29b-41d4-a716-446655440002",
"name": "Beverages"
},
"variants": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"name": "Large",
"price_modifier": 2.00,
"cost": 1.00
}
],
"inventory": [
{
"id": "550e8400-e29b-41d4-a716-446655440004",
"outlet_id": "550e8400-e29b-41d4-a716-446655440005",
"quantity": 100,
"reorder_level": 20
},
{
"id": "550e8400-e29b-41d4-a716-446655440006",
"outlet_id": "550e8400-e29b-41d4-a716-446655440007",
"quantity": 100,
"reorder_level": 20
}
]
}
```
## Migration Notes
This feature requires the existing database schema with:
- `products` table
- `inventory` table
- `outlets` table
- Proper foreign key relationships
No additional migrations are required as the feature uses existing tables.

View File

@ -1,127 +0,0 @@
# Product Variant Price Modifier Implementation
## Overview
This document describes the implementation of price modifier functionality for product variants in the order processing system.
## Feature Description
When a product variant is specified in an order item, the system now automatically applies the variant's price modifier to the base product price. This allows for flexible pricing based on product variations (e.g., size upgrades, add-ons, etc.).
## Implementation Details
### 1. Order Processor Changes
The `OrderProcessorImpl` has been updated to:
- Accept a `ProductVariantRepository` dependency
- Fetch product variant information when `ProductVariantID` is provided
- Apply the price modifier to the base product price
- Use variant-specific cost if available
### 2. Price Calculation Logic
```go
// Base price from product
unitPrice := product.Price
unitCost := product.Cost
// Apply variant price modifier if specified
if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
if err != nil {
return nil, fmt.Errorf("product variant not found: %w", err)
}
// Verify variant belongs to the product
if variant.ProductID != itemReq.ProductID {
return nil, fmt.Errorf("product variant does not belong to the specified product")
}
// Apply price modifier
unitPrice += variant.PriceModifier
// Use variant cost if available, otherwise use product cost
if variant.Cost > 0 {
unitCost = variant.Cost
}
}
```
### 3. Database Schema
The `product_variants` table includes:
- `price_modifier`: Decimal field for price adjustments (+/- values)
- `cost`: Optional variant-specific cost
- `product_id`: Foreign key to products table
### 4. API Usage
When creating orders or adding items to existing orders, you can specify a product variant:
```json
{
"order_items": [
{
"product_id": "uuid-of-product",
"product_variant_id": "uuid-of-variant",
"quantity": 2,
"notes": "Extra large size"
}
]
}
```
### 5. Example Scenarios
#### Scenario 1: Size Upgrade
- Base product: Coffee ($3.00)
- Variant: Large (+$1.00 modifier)
- Final price: $4.00
#### Scenario 2: Add-on
- Base product: Pizza ($12.00)
- Variant: Extra cheese (+$2.50 modifier)
- Final price: $14.50
#### Scenario 3: Discount
- Base product: Sandwich ($8.00)
- Variant: Student discount (-$1.00 modifier)
- Final price: $7.00
## Validation
The system includes several validation checks:
1. **Variant Existence**: Verifies the product variant exists
2. **Product Association**: Ensures the variant belongs to the specified product
3. **Price Integrity**: Maintains positive pricing (base price + modifier must be >= 0)
## Error Handling
Common error scenarios:
- `product variant not found`: When an invalid variant ID is provided
- `product variant does not belong to the specified product`: When variant-product mismatch occurs
## Testing
The implementation includes unit tests to verify:
- Correct price calculation with variants
- Proper error handling for invalid variants
- Cost calculation using variant-specific costs
## Migration
The feature uses existing database schema from migration `000013_add_cost_to_product_variants.up.sql` which adds the `cost` column to the `product_variants` table.
## Future Enhancements
Potential improvements:
1. **Percentage-based modifiers**: Support for percentage-based price adjustments
2. **Conditional modifiers**: Modifiers that apply based on order context
3. **Bulk variant pricing**: Tools for managing variant pricing across products
4. **Pricing history**: Track price modifier changes over time

View File

@ -1,241 +0,0 @@
# Profit/Loss Analytics API
This document describes the Profit/Loss Analytics API that provides comprehensive financial analysis for the POS system, including revenue, costs, and profitability metrics.
## Overview
The Profit/Loss Analytics API allows you to:
- Analyze profit and loss performance over time periods
- Track gross profit and net profit margins
- View product-wise profitability
- Monitor cost vs revenue trends
- Calculate profitability ratios
## Authentication
All analytics endpoints require authentication and admin/manager permissions.
## Endpoints
### Get Profit/Loss Analytics
**Endpoint:** `GET /api/v1/analytics/profit-loss`
**Description:** Retrieves comprehensive profit and loss analytics data including summary metrics, time-series data, and product profitability analysis.
**Query Parameters:**
- `outlet_id` (UUID, optional) - Filter by specific outlet
- `date_from` (string, required) - Start date in DD-MM-YYYY format
- `date_to` (string, required) - End date in DD-MM-YYYY format
- `group_by` (string, optional) - Time grouping: `hour`, `day`, `week`, `month` (default: `day`)
**Example Request:**
```bash
curl -X GET "http://localhost:8080/api/v1/analytics/profit-loss?date_from=01-12-2023&date_to=31-12-2023&group_by=day" \
-H "Authorization: Bearer <token>" \
-H "Organization-ID: <org-id>"
```
**Example Response:**
```json
{
"success": true,
"data": {
"organization_id": "123e4567-e89b-12d3-a456-426614174000",
"outlet_id": "123e4567-e89b-12d3-a456-426614174001",
"date_from": "2023-12-01T00:00:00Z",
"date_to": "2023-12-31T23:59:59Z",
"group_by": "day",
"summary": {
"total_revenue": 125000.00,
"total_cost": 75000.00,
"gross_profit": 50000.00,
"gross_profit_margin": 40.00,
"total_tax": 12500.00,
"total_discount": 2500.00,
"net_profit": 35000.00,
"net_profit_margin": 28.00,
"total_orders": 1250,
"average_profit": 28.00,
"profitability_ratio": 66.67
},
"data": [
{
"date": "2023-12-01T00:00:00Z",
"revenue": 4032.26,
"cost": 2419.35,
"gross_profit": 1612.91,
"gross_profit_margin": 40.00,
"tax": 403.23,
"discount": 80.65,
"net_profit": 1129.03,
"net_profit_margin": 28.00,
"orders": 40
},
{
"date": "2023-12-02T00:00:00Z",
"revenue": 3750.00,
"cost": 2250.00,
"gross_profit": 1500.00,
"gross_profit_margin": 40.00,
"tax": 375.00,
"discount": 75.00,
"net_profit": 1050.00,
"net_profit_margin": 28.00,
"orders": 35
}
],
"product_data": [
{
"product_id": "123e4567-e89b-12d3-a456-426614174002",
"product_name": "Premium Burger",
"category_id": "123e4567-e89b-12d3-a456-426614174003",
"category_name": "Main Course",
"quantity_sold": 150,
"revenue": 2250.00,
"cost": 900.00,
"gross_profit": 1350.00,
"gross_profit_margin": 60.00,
"average_price": 15.00,
"average_cost": 6.00,
"profit_per_unit": 9.00
},
{
"product_id": "123e4567-e89b-12d3-a456-426614174004",
"product_name": "Caesar Salad",
"category_id": "123e4567-e89b-12d3-a456-426614174005",
"category_name": "Salads",
"quantity_sold": 80,
"revenue": 960.00,
"cost": 384.00,
"gross_profit": 576.00,
"gross_profit_margin": 60.00,
"average_price": 12.00,
"average_cost": 4.80,
"profit_per_unit": 7.20
}
]
}
}
```
## Response Structure
### Summary Object
- `total_revenue` - Total revenue for the period
- `total_cost` - Total cost of goods sold
- `gross_profit` - Revenue minus cost (total_revenue - total_cost)
- `gross_profit_margin` - Gross profit as percentage of revenue
- `total_tax` - Total tax collected
- `total_discount` - Total discounts given
- `net_profit` - Profit after taxes and discounts
- `net_profit_margin` - Net profit as percentage of revenue
- `total_orders` - Number of completed orders
- `average_profit` - Average profit per order
- `profitability_ratio` - Gross profit as percentage of total cost
### Time Series Data
The `data` array contains profit/loss metrics grouped by the specified time period:
- `date` - Date/time for the data point
- `revenue` - Revenue for the period
- `cost` - Cost for the period
- `gross_profit` - Gross profit for the period
- `gross_profit_margin` - Gross profit margin percentage
- `tax` - Tax amount for the period
- `discount` - Discount amount for the period
- `net_profit` - Net profit for the period
- `net_profit_margin` - Net profit margin percentage
- `orders` - Number of orders in the period
### Product Profitability Data
The `product_data` array shows the top 20 most profitable products:
- `product_id` - Unique product identifier
- `product_name` - Product name
- `category_id` - Product category identifier
- `category_name` - Category name
- `quantity_sold` - Total units sold
- `revenue` - Total revenue from the product
- `cost` - Total cost for the product
- `gross_profit` - Total gross profit
- `gross_profit_margin` - Profit margin percentage
- `average_price` - Average selling price per unit
- `average_cost` - Average cost per unit
- `profit_per_unit` - Average profit per unit
## Key Metrics Explained
### Gross Profit Margin
Calculated as: `(Revenue - Cost) / Revenue × 100`
Shows the percentage of revenue retained after direct costs.
### Net Profit Margin
Calculated as: `(Revenue - Cost - Discount) / Revenue × 100`
Shows the percentage of revenue retained after all direct costs and discounts.
### Profitability Ratio
Calculated as: `Gross Profit / Total Cost × 100`
Shows the return on investment for costs incurred.
## Use Cases
1. **Financial Performance Analysis** - Track overall profitability trends
2. **Product Performance** - Identify most and least profitable products
3. **Cost Management** - Monitor cost ratios and margins
4. **Pricing Strategy** - Analyze impact of pricing on profitability
5. **Inventory Decisions** - Focus on high-margin products
6. **Business Intelligence** - Make data-driven financial decisions
## Error Responses
The API returns standard error responses with appropriate HTTP status codes:
**400 Bad Request:**
```json
{
"success": false,
"errors": [
{
"code": "invalid_request",
"entity": "AnalyticsHandler::GetProfitLossAnalytics",
"message": "date_from is required"
}
]
}
```
**401 Unauthorized:**
```json
{
"success": false,
"errors": [
{
"code": "unauthorized",
"entity": "AuthMiddleware",
"message": "Invalid or missing authentication token"
}
]
}
```
**403 Forbidden:**
```json
{
"success": false,
"errors": [
{
"code": "forbidden",
"entity": "AuthMiddleware",
"message": "Admin or manager role required"
}
]
}
```
## Notes
- Only completed and paid orders are included in profit/loss calculations
- Voided and refunded orders are excluded from the analysis
- Product profitability is sorted by gross profit in descending order
- Time series data is automatically filled for periods with no data (showing zero values)
- All monetary values are in the organization's base currency
- Margins and ratios are calculated as percentages with 2 decimal precision

View File

@ -1,330 +0,0 @@
# Table Management API
This document describes the Table Management API endpoints for managing restaurant tables in the POS system.
## Overview
The Table Management API allows you to:
- Create, read, update, and delete tables
- Manage table status (available, occupied, reserved, cleaning, maintenance)
- Occupy and release tables with orders
- Track table positions and capacity
- Get available and occupied tables for specific outlets
## Table Entity
A table has the following properties:
```json
{
"id": "uuid",
"organization_id": "uuid",
"outlet_id": "uuid",
"table_name": "string",
"start_time": "datetime (optional)",
"status": "available|occupied|reserved|cleaning|maintenance",
"order_id": "uuid (optional)",
"payment_amount": "decimal",
"position_x": "decimal",
"position_y": "decimal",
"capacity": "integer (1-20)",
"is_active": "boolean",
"metadata": "object",
"created_at": "datetime",
"updated_at": "datetime",
"order": "OrderResponse (optional)"
}
```
## API Endpoints
### 1. Create Table
**POST** `/api/v1/tables`
Create a new table for an outlet.
**Request Body:**
```json
{
"outlet_id": "uuid",
"table_name": "string",
"position_x": "decimal",
"position_y": "decimal",
"capacity": "integer (1-20)",
"metadata": "object (optional)"
}
```
**Response:** `201 Created`
```json
{
"id": "uuid",
"organization_id": "uuid",
"outlet_id": "uuid",
"table_name": "string",
"status": "available",
"position_x": "decimal",
"position_y": "decimal",
"capacity": "integer",
"is_active": true,
"metadata": "object",
"created_at": "datetime",
"updated_at": "datetime"
}
```
### 2. Get Table by ID
**GET** `/api/v1/tables/{id}`
Get table details by ID.
**Response:** `200 OK`
```json
{
"id": "uuid",
"organization_id": "uuid",
"outlet_id": "uuid",
"table_name": "string",
"start_time": "datetime (optional)",
"status": "string",
"order_id": "uuid (optional)",
"payment_amount": "decimal",
"position_x": "decimal",
"position_y": "decimal",
"capacity": "integer",
"is_active": "boolean",
"metadata": "object",
"created_at": "datetime",
"updated_at": "datetime",
"order": "OrderResponse (optional)"
}
```
### 3. Update Table
**PUT** `/api/v1/tables/{id}`
Update table details.
**Request Body:**
```json
{
"table_name": "string (optional)",
"status": "available|occupied|reserved|cleaning|maintenance (optional)",
"position_x": "decimal (optional)",
"position_y": "decimal (optional)",
"capacity": "integer (1-20) (optional)",
"is_active": "boolean (optional)",
"metadata": "object (optional)"
}
```
**Response:** `200 OK` - Updated table object
### 4. Delete Table
**DELETE** `/api/v1/tables/{id}`
Delete a table. Cannot delete occupied tables.
**Response:** `204 No Content`
### 5. List Tables
**GET** `/api/v1/tables`
Get paginated list of tables with optional filters.
**Query Parameters:**
- `organization_id` (optional): Filter by organization
- `outlet_id` (optional): Filter by outlet
- `status` (optional): Filter by status
- `is_active` (optional): Filter by active status
- `search` (optional): Search in table names
- `page` (default: 1): Page number
- `limit` (default: 10, max: 100): Page size
**Response:** `200 OK`
```json
{
"tables": [
{
"id": "uuid",
"table_name": "string",
"status": "string",
"capacity": "integer",
"is_active": "boolean",
"created_at": "datetime",
"updated_at": "datetime"
}
],
"total_count": "integer",
"page": "integer",
"limit": "integer",
"total_pages": "integer"
}
```
### 6. Occupy Table
**POST** `/api/v1/tables/{id}/occupy`
Occupy a table with an order.
**Request Body:**
```json
{
"order_id": "uuid",
"start_time": "datetime"
}
```
**Response:** `200 OK` - Updated table object with order information
### 7. Release Table
**POST** `/api/v1/tables/{id}/release`
Release a table and record payment amount.
**Request Body:**
```json
{
"payment_amount": "decimal"
}
```
**Response:** `200 OK` - Updated table object
### 8. Get Available Tables
**GET** `/api/v1/outlets/{outlet_id}/tables/available`
Get list of available tables for a specific outlet.
**Response:** `200 OK`
```json
[
{
"id": "uuid",
"table_name": "string",
"status": "available",
"capacity": "integer",
"position_x": "decimal",
"position_y": "decimal"
}
]
```
### 9. Get Occupied Tables
**GET** `/api/v1/outlets/{outlet_id}/tables/occupied`
Get list of occupied tables for a specific outlet.
**Response:** `200 OK`
```json
[
{
"id": "uuid",
"table_name": "string",
"status": "occupied",
"start_time": "datetime",
"order_id": "uuid",
"capacity": "integer",
"position_x": "decimal",
"position_y": "decimal",
"order": "OrderResponse"
}
]
```
## Table Statuses
- **available**: Table is free and ready for use
- **occupied**: Table is currently in use with an order
- **reserved**: Table is reserved for future use
- **cleaning**: Table is being cleaned
- **maintenance**: Table is under maintenance
## Business Rules
1. **Table Creation**: Tables must have unique names within an outlet
2. **Table Occupation**: Only available or cleaning tables can be occupied
3. **Table Release**: Only occupied tables can be released
4. **Table Deletion**: Occupied tables cannot be deleted
5. **Capacity**: Table capacity must be between 1 and 20
6. **Position**: Tables have X and Y coordinates for layout positioning
## Error Responses
**400 Bad Request:**
```json
{
"error": "Error description",
"message": "Detailed error message"
}
```
**404 Not Found:**
```json
{
"error": "Table not found",
"message": "Table with specified ID does not exist"
}
```
**500 Internal Server Error:**
```json
{
"error": "Failed to create table",
"message": "Database error or other internal error"
}
```
## Authentication
All endpoints require authentication via JWT token in the Authorization header:
```
Authorization: Bearer <jwt_token>
```
## Authorization
All table management endpoints require admin or manager role permissions.
## Example Usage
### Creating a Table
```bash
curl -X POST http://localhost:8080/api/v1/tables \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"outlet_id": "123e4567-e89b-12d3-a456-426614174000",
"table_name": "Table 1",
"position_x": 100.0,
"position_y": 200.0,
"capacity": 4
}'
```
### Occupying a Table
```bash
curl -X POST http://localhost:8080/api/v1/tables/123e4567-e89b-12d3-a456-426614174000/occupy \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{
"order_id": "123e4567-e89b-12d3-a456-426614174001",
"start_time": "2024-01-15T10:30:00Z"
}'
```
### Getting Available Tables
```bash
curl -X GET http://localhost:8080/api/v1/outlets/123e4567-e89b-12d3-a456-426614174000/tables/available \
-H "Authorization: Bearer <token>"
```

View File

@ -2,6 +2,7 @@ package app
import (
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/transformer"
"context"
"log"
"net/http"
@ -74,6 +75,7 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.paymentMethodValidator,
services.analyticsService,
services.tableService,
validators.tableValidator,
)
return nil
@ -213,7 +215,7 @@ type services struct {
fileService service.FileService
customerService service.CustomerService
analyticsService *service.AnalyticsServiceImpl
tableService *service.TableService
tableService *service.TableServiceImpl
}
func (a *App) initServices(processors *processors, cfg *config.Config) *services {

View File

@ -11,6 +11,7 @@ const (
MalformedFieldErrorCode = "310"
ValidationErrorCode = "304"
InvalidFieldErrorCode = "305"
NotFoundErrorCode = "404"
)
const (
@ -37,6 +38,7 @@ const (
PaymentMethodValidatorEntity = "payment_method_validator"
PaymentMethodHandlerEntity = "payment_method_handler"
OutletServiceEntity = "outlet_service"
TableEntity = "table"
)
var HttpErrorMap = map[string]int{
@ -45,6 +47,7 @@ var HttpErrorMap = map[string]int{
MalformedFieldErrorCode: http.StatusBadRequest,
ValidationErrorCode: http.StatusBadRequest,
InvalidFieldErrorCode: http.StatusBadRequest,
NotFoundErrorCode: http.StatusNotFound,
}
// Error messages

View File

@ -1,12 +1,10 @@
package contract
import (
"time"
"github.com/google/uuid"
"time"
)
// Inventory Request DTOs
type CreateInventoryRequest struct {
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
ProductID uuid.UUID `json:"product_id" validate:"required"`
@ -37,7 +35,6 @@ type ListInventoryRequest struct {
Limit int `json:"limit" validate:"required,min=1,max=100"`
}
// Inventory Response DTOs
type InventoryResponse struct {
ID uuid.UUID `json:"id"`
OutletID uuid.UUID `json:"outlet_id"`

View File

@ -36,7 +36,7 @@ func (t *Table) BeforeCreate(tx *gorm.DB) error {
return nil
}
func (Table) TableName() string {
func (Table) GetTableName() string {
return "tables"
}

View File

@ -1,9 +1,12 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/service"
"net/http"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"strconv"
"github.com/gin-gonic/gin"
@ -11,189 +14,126 @@ import (
)
type TableHandler struct {
tableService *service.TableService
tableService TableService
tableValidator *validator.TableValidator
}
func NewTableHandler(tableService *service.TableService) *TableHandler {
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator) *TableHandler {
return &TableHandler{
tableService: tableService,
tableValidator: tableValidator,
}
}
// CreateTable godoc
// @Summary Create a new table
// @Description Create a new table for the organization
// @Tags tables
// @Accept json
// @Produce json
// @Param table body contract.CreateTableRequest true "Table data"
// @Success 201 {object} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 401 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables [post]
func (h *TableHandler) Create(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreateTableRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid request body",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::Create -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::Create")
return
}
organizationID := c.GetString("organization_id")
orgID, err := uuid.Parse(organizationID)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid organization ID",
Message: err.Error(),
})
if err := h.tableValidator.ValidateCreateTableRequest(req); err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::Create -> validation failed")
validationResponseError := contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::Create")
return
}
response, err := h.tableService.Create(c.Request.Context(), req, orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to create table",
Message: err.Error(),
})
return
response := h.tableService.CreateTable(ctx, contextInfo, &req)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::Create -> service failed")
}
c.JSON(http.StatusCreated, response)
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::Create")
}
// GetTable godoc
// @Summary Get table by ID
// @Description Get table details by ID
// @Tags tables
// @Produce json
// @Param id path string true "Table ID"
// @Success 200 {object} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 404 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables/{id} [get]
func (h *TableHandler) GetByID(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tableID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid table ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::GetByID -> Invalid table ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid table ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::GetByID")
return
}
response, err := h.tableService.GetByID(c.Request.Context(), tableID)
if err != nil {
c.JSON(http.StatusNotFound, contract.ResponseError{
Error: "Table not found",
Message: err.Error(),
})
return
response := h.tableService.GetTableByID(ctx, tableID)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::GetByID -> service failed")
}
c.JSON(http.StatusOK, response)
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::GetByID")
}
// UpdateTable godoc
// @Summary Update table
// @Description Update table details
// @Tags tables
// @Accept json
// @Produce json
// @Param id path string true "Table ID"
// @Param table body contract.UpdateTableRequest true "Table update data"
// @Success 200 {object} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 404 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables/{id} [put]
func (h *TableHandler) Update(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tableID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid table ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::Update -> Invalid table ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid table ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::Update")
return
}
var req contract.UpdateTableRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid request body",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::Update -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::Update")
return
}
response, err := h.tableService.Update(c.Request.Context(), tableID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to update table",
Message: err.Error(),
})
if err := h.tableValidator.ValidateUpdateTableRequest(req); err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::Update -> validation failed")
validationResponseError := contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::Update")
return
}
c.JSON(http.StatusOK, response)
response := h.tableService.UpdateTable(ctx, tableID, &req)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::Update -> service failed")
}
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::Update")
}
// DeleteTable godoc
// @Summary Delete table
// @Description Delete table by ID
// @Tags tables
// @Produce json
// @Param id path string true "Table ID"
// @Success 204 "No Content"
// @Failure 400 {object} contract.ResponseError
// @Failure 404 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables/{id} [delete]
func (h *TableHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tableID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid table ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::Delete -> Invalid table ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid table ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::Delete")
return
}
err = h.tableService.Delete(c.Request.Context(), tableID)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to delete table",
Message: err.Error(),
})
return
response := h.tableService.DeleteTable(ctx, tableID)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::Delete -> service failed")
}
c.Status(http.StatusNoContent)
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::Delete")
}
// ListTables godoc
// @Summary List tables
// @Description Get paginated list of tables
// @Tags tables
// @Produce json
// @Param organization_id query string false "Organization ID"
// @Param outlet_id query string false "Outlet ID"
// @Param status query string false "Table status"
// @Param is_active query string false "Is active"
// @Param search query string false "Search term"
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Page size" default(10)
// @Success 200 {object} contract.ListTablesResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables [get]
func (h *TableHandler) List(c *gin.Context) {
ctx := c.Request.Context()
query := contract.ListTablesQuery{
OrganizationID: c.Query("organization_id"),
OutletID: c.Query("outlet_id"),
@ -216,170 +156,132 @@ func (h *TableHandler) List(c *gin.Context) {
}
}
response, err := h.tableService.List(c.Request.Context(), query)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to list tables",
Message: err.Error(),
})
if err := h.tableValidator.ValidateListTablesQuery(query); err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::List -> validation failed")
validationResponseError := contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::List")
return
}
c.JSON(http.StatusOK, response)
response := h.tableService.ListTables(ctx, &query)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::List -> service failed")
}
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::List")
}
// OccupyTable godoc
// @Summary Occupy table
// @Description Occupy a table with an order
// @Tags tables
// @Accept json
// @Produce json
// @Param id path string true "Table ID"
// @Param request body contract.OccupyTableRequest true "Occupy table data"
// @Success 200 {object} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 404 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables/{id}/occupy [post]
func (h *TableHandler) OccupyTable(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tableID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid table ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::OccupyTable -> Invalid table ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid table ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::OccupyTable")
return
}
var req contract.OccupyTableRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid request body",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::OccupyTable -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::OccupyTable")
return
}
response, err := h.tableService.OccupyTable(c.Request.Context(), tableID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to occupy table",
Message: err.Error(),
})
if err := h.tableValidator.ValidateOccupyTableRequest(req); err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::OccupyTable -> validation failed")
validationResponseError := contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::OccupyTable")
return
}
c.JSON(http.StatusOK, response)
response := h.tableService.OccupyTable(ctx, tableID, &req)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::OccupyTable -> service failed")
}
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::OccupyTable")
}
// ReleaseTable godoc
// @Summary Release table
// @Description Release a table and record payment amount
// @Tags tables
// @Accept json
// @Produce json
// @Param id path string true "Table ID"
// @Param request body contract.ReleaseTableRequest true "Release table data"
// @Success 200 {object} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 404 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /tables/{id}/release [post]
func (h *TableHandler) ReleaseTable(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tableID, err := uuid.Parse(id)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid table ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::ReleaseTable -> Invalid table ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid table ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::ReleaseTable")
return
}
var req contract.ReleaseTableRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid request body",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::ReleaseTable -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::ReleaseTable")
return
}
response, err := h.tableService.ReleaseTable(c.Request.Context(), tableID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to release table",
Message: err.Error(),
})
if err := h.tableValidator.ValidateReleaseTableRequest(req); err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::ReleaseTable -> validation failed")
validationResponseError := contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::ReleaseTable")
return
}
c.JSON(http.StatusOK, response)
response := h.tableService.ReleaseTable(ctx, tableID, &req)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::ReleaseTable -> service failed")
}
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::ReleaseTable")
}
// GetAvailableTables godoc
// @Summary Get available tables
// @Description Get list of available tables for an outlet
// @Tags tables
// @Produce json
// @Param outlet_id path string true "Outlet ID"
// @Success 200 {array} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /outlets/{outlet_id}/tables/available [get]
func (h *TableHandler) GetAvailableTables(c *gin.Context) {
ctx := c.Request.Context()
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid outlet ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::GetAvailableTables -> Invalid outlet ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::GetAvailableTables")
return
}
tables, err := h.tableService.GetAvailableTables(c.Request.Context(), outletID)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to get available tables",
Message: err.Error(),
})
return
response := h.tableService.GetAvailableTables(ctx, outletID)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::GetAvailableTables -> service failed")
}
c.JSON(http.StatusOK, tables)
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::GetAvailableTables")
}
// GetOccupiedTables godoc
// @Summary Get occupied tables
// @Description Get list of occupied tables for an outlet
// @Tags tables
// @Produce json
// @Param outlet_id path string true "Outlet ID"
// @Success 200 {array} contract.TableResponse
// @Failure 400 {object} contract.ResponseError
// @Failure 500 {object} contract.ResponseError
// @Router /outlets/{outlet_id}/tables/occupied [get]
func (h *TableHandler) GetOccupiedTables(c *gin.Context) {
ctx := c.Request.Context()
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, contract.ResponseError{
Error: "Invalid outlet ID",
Message: err.Error(),
})
logger.FromContext(ctx).WithError(err).Error("TableHandler::GetOccupiedTables -> Invalid outlet ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::GetOccupiedTables")
return
}
tables, err := h.tableService.GetOccupiedTables(c.Request.Context(), outletID)
if err != nil {
c.JSON(http.StatusInternalServerError, contract.ResponseError{
Error: "Failed to get occupied tables",
Message: err.Error(),
})
return
response := h.tableService.GetOccupiedTables(ctx, outletID)
if response.HasErrors() {
errorResp := response.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("TableHandler::GetOccupiedTables -> service failed")
}
c.JSON(http.StatusOK, tables)
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::GetOccupiedTables")
}

View File

@ -0,0 +1,20 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/contract"
"context"
"github.com/google/uuid"
)
type TableService interface {
CreateTable(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateTableRequest) *contract.Response
UpdateTable(ctx context.Context, id uuid.UUID, req *contract.UpdateTableRequest) *contract.Response
DeleteTable(ctx context.Context, id uuid.UUID) *contract.Response
GetTableByID(ctx context.Context, id uuid.UUID) *contract.Response
ListTables(ctx context.Context, req *contract.ListTablesQuery) *contract.Response
OccupyTable(ctx context.Context, tableID uuid.UUID, req *contract.OccupyTableRequest) *contract.Response
ReleaseTable(ctx context.Context, tableID uuid.UUID, req *contract.ReleaseTableRequest) *contract.Response
GetAvailableTables(ctx context.Context, outletID uuid.UUID) *contract.Response
GetOccupiedTables(ctx context.Context, outletID uuid.UUID) *contract.Response
}

View File

@ -14,10 +14,10 @@ import (
type TableProcessor struct {
tableRepo *repository.TableRepository
orderRepo *repository.OrderRepository
orderRepo repository.OrderRepository
}
func NewTableProcessor(tableRepo *repository.TableRepository, orderRepo *repository.OrderRepository) *TableProcessor {
func NewTableProcessor(tableRepo *repository.TableRepository, orderRepo repository.OrderRepository) *TableProcessor {
return &TableProcessor{
tableRepo: tableRepo,
orderRepo: orderRepo,
@ -136,7 +136,7 @@ func (p *TableProcessor) OccupyTable(ctx context.Context, tableID uuid.UUID, req
}
// Verify order exists
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
_, err = p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return nil, errors.New("order not found")
}

View File

@ -60,7 +60,8 @@ func NewRouter(cfg *config.Config,
paymentMethodService service.PaymentMethodService,
paymentMethodValidator validator.PaymentMethodValidator,
analyticsService *service.AnalyticsServiceImpl,
tableService *service.TableService) *Router {
tableService *service.TableServiceImpl,
tableValidator *validator.TableValidator) *Router {
return &Router{
config: cfg,
@ -78,7 +79,7 @@ func NewRouter(cfg *config.Config,
customerHandler: handler.NewCustomerHandler(customerService, customerValidator),
paymentMethodHandler: handler.NewPaymentMethodHandler(paymentMethodService, paymentMethodValidator),
analyticsHandler: handler.NewAnalyticsHandler(analyticsService, transformer.NewTransformer()),
tableHandler: handler.NewTableHandler(tableService),
tableHandler: handler.NewTableHandler(tableService, tableValidator),
authMiddleware: authMiddleware,
}
}
@ -261,19 +262,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
outlets.Use(r.authMiddleware.RequireAdminOrManager())
{
outlets.GET("/list", r.outletHandler.ListOutlets)
outlets.GET("/:id", r.outletHandler.GetOutlet)
outlets.PUT("/:id", r.outletHandler.UpdateOutlet)
outlets.GET("/detail/:id", r.outletHandler.GetOutlet)
outlets.PUT("/detail/:id", r.outletHandler.UpdateOutlet)
outlets.GET("/printer-setting/:outlet_id", r.outletSettingHandler.GetPrinterSettings)
outlets.PUT("/printer-setting/:outlet_id", r.outletSettingHandler.UpdatePrinterSettings)
outlets.GET("/:outlet_id/tables/available", r.tableHandler.GetAvailableTables)
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
}
//outletPrinterSettings := protected.Group("/outlets/:outlet_id/settings")
//outletPrinterSettings.Use(r.authMiddleware.RequireAdminOrManager())
//{
//
//}
}
}
}

View File

@ -1,115 +1,84 @@
package service
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
"context"
"strconv"
"github.com/google/uuid"
)
type TableService struct {
type TableServiceImpl struct {
tableProcessor *processor.TableProcessor
tableTransformer *transformer.TableTransformer
}
func NewTableService(tableProcessor *processor.TableProcessor, tableTransformer *transformer.TableTransformer) *TableService {
return &TableService{
func NewTableService(tableProcessor *processor.TableProcessor, tableTransformer *transformer.TableTransformer) *TableServiceImpl {
return &TableServiceImpl{
tableProcessor: tableProcessor,
tableTransformer: tableTransformer,
}
}
func (s *TableService) Create(ctx context.Context, req contract.CreateTableRequest, organizationID uuid.UUID) (*contract.TableResponse, error) {
modelReq := models.CreateTableRequest{
OutletID: req.OutletID,
TableName: req.TableName,
PositionX: req.PositionX,
PositionY: req.PositionY,
Capacity: req.Capacity,
Metadata: req.Metadata,
}
func (s *TableServiceImpl) CreateTable(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateTableRequest) *contract.Response {
modelReq := transformer.CreateTableRequestToModel(apctx, req)
response, err := s.tableProcessor.Create(ctx, modelReq, organizationID)
response, err := s.tableProcessor.Create(ctx, modelReq, apctx.OrganizationID)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return s.tableTransformer.ToContract(*response), nil
contractResponse := s.tableTransformer.ToContract(*response)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *TableService) GetByID(ctx context.Context, id uuid.UUID) (*contract.TableResponse, error) {
response, err := s.tableProcessor.GetByID(ctx, id)
if err != nil {
return nil, err
}
return s.tableTransformer.ToContract(*response), nil
}
func (s *TableService) Update(ctx context.Context, id uuid.UUID, req contract.UpdateTableRequest) (*contract.TableResponse, error) {
modelReq := models.UpdateTableRequest{
TableName: req.TableName,
PositionX: req.PositionX,
PositionY: req.PositionY,
Capacity: req.Capacity,
IsActive: req.IsActive,
Metadata: req.Metadata,
}
if req.Status != nil {
status := models.TableStatus(*req.Status)
modelReq.Status = &status
}
func (s *TableServiceImpl) UpdateTable(ctx context.Context, id uuid.UUID, req *contract.UpdateTableRequest) *contract.Response {
modelReq := transformer.UpdateTableRequestToModel(req)
response, err := s.tableProcessor.Update(ctx, id, modelReq)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return s.tableTransformer.ToContract(*response), nil
contractResponse := s.tableTransformer.ToContract(*response)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *TableService) Delete(ctx context.Context, id uuid.UUID) error {
return s.tableProcessor.Delete(ctx, id)
}
func (s *TableService) List(ctx context.Context, query contract.ListTablesQuery) (*contract.ListTablesResponse, error) {
req := models.ListTablesRequest{
Page: query.Page,
Limit: query.Limit,
Search: query.Search,
}
if query.OrganizationID != "" {
if orgID, err := uuid.Parse(query.OrganizationID); err == nil {
req.OrganizationID = &orgID
}
}
if query.OutletID != "" {
if outletID, err := uuid.Parse(query.OutletID); err == nil {
req.OutletID = &outletID
}
}
if query.Status != "" {
status := models.TableStatus(query.Status)
req.Status = &status
}
if query.IsActive != "" {
if isActive, err := strconv.ParseBool(query.IsActive); err == nil {
req.IsActive = &isActive
}
}
response, err := s.tableProcessor.List(ctx, req)
func (s *TableServiceImpl) DeleteTable(ctx context.Context, id uuid.UUID) *contract.Response {
err := s.tableProcessor.Delete(ctx, id)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(map[string]interface{}{
"message": "Table deleted successfully",
})
}
func (s *TableServiceImpl) GetTableByID(ctx context.Context, id uuid.UUID) *contract.Response {
response, err := s.tableProcessor.GetByID(ctx, id)
if err != nil {
errorResp := contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "Table not found")
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResponse := s.tableTransformer.ToContract(*response)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *TableServiceImpl) ListTables(ctx context.Context, req *contract.ListTablesQuery) *contract.Response {
modelReq := transformer.ListTablesQueryToModel(req)
response, err := s.tableProcessor.List(ctx, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractTables := make([]contract.TableResponse, len(response.Tables))
@ -117,46 +86,48 @@ func (s *TableService) List(ctx context.Context, query contract.ListTablesQuery)
contractTables[i] = *s.tableTransformer.ToContract(table)
}
return &contract.ListTablesResponse{
contractResponse := &contract.ListTablesResponse{
Tables: contractTables,
TotalCount: response.TotalCount,
Page: response.Page,
Limit: response.Limit,
TotalPages: response.TotalPages,
}, nil
}
func (s *TableService) OccupyTable(ctx context.Context, tableID uuid.UUID, req contract.OccupyTableRequest) (*contract.TableResponse, error) {
modelReq := models.OccupyTableRequest{
OrderID: req.OrderID,
StartTime: req.StartTime,
return contract.BuildSuccessResponse(contractResponse)
}
func (s *TableServiceImpl) OccupyTable(ctx context.Context, tableID uuid.UUID, req *contract.OccupyTableRequest) *contract.Response {
modelReq := transformer.OccupyTableRequestToModel(req)
response, err := s.tableProcessor.OccupyTable(ctx, tableID, modelReq)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return s.tableTransformer.ToContract(*response), nil
contractResponse := s.tableTransformer.ToContract(*response)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *TableService) ReleaseTable(ctx context.Context, tableID uuid.UUID, req contract.ReleaseTableRequest) (*contract.TableResponse, error) {
modelReq := models.ReleaseTableRequest{
PaymentAmount: req.PaymentAmount,
}
func (s *TableServiceImpl) ReleaseTable(ctx context.Context, tableID uuid.UUID, req *contract.ReleaseTableRequest) *contract.Response {
modelReq := transformer.ReleaseTableRequestToModel(req)
response, err := s.tableProcessor.ReleaseTable(ctx, tableID, modelReq)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return s.tableTransformer.ToContract(*response), nil
contractResponse := s.tableTransformer.ToContract(*response)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *TableService) GetAvailableTables(ctx context.Context, outletID uuid.UUID) ([]contract.TableResponse, error) {
func (s *TableServiceImpl) GetAvailableTables(ctx context.Context, outletID uuid.UUID) *contract.Response {
tables, err := s.tableProcessor.GetAvailableTables(ctx, outletID)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
responses := make([]contract.TableResponse, len(tables))
@ -164,13 +135,14 @@ func (s *TableService) GetAvailableTables(ctx context.Context, outletID uuid.UUI
responses[i] = *s.tableTransformer.ToContract(table)
}
return responses, nil
return contract.BuildSuccessResponse(responses)
}
func (s *TableService) GetOccupiedTables(ctx context.Context, outletID uuid.UUID) ([]contract.TableResponse, error) {
func (s *TableServiceImpl) GetOccupiedTables(ctx context.Context, outletID uuid.UUID) *contract.Response {
tables, err := s.tableProcessor.GetOccupiedTables(ctx, outletID)
if err != nil {
return nil, err
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
responses := make([]contract.TableResponse, len(tables))
@ -178,5 +150,5 @@ func (s *TableService) GetOccupiedTables(ctx context.Context, outletID uuid.UUID
responses[i] = *s.tableTransformer.ToContract(table)
}
return responses, nil
return contract.BuildSuccessResponse(responses)
}

View File

@ -1,8 +1,13 @@
package transformer
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"strconv"
"github.com/google/uuid"
)
type TableTransformer struct{}
@ -33,10 +38,8 @@ func (t *TableTransformer) ToContract(model models.TableResponse) *contract.Tabl
if model.Order != nil {
response.Order = &contract.OrderResponse{
ID: model.Order.ID,
OrganizationID: model.Order.OrganizationID,
OutletID: model.Order.OutletID,
UserID: model.Order.UserID,
CustomerID: model.Order.CustomerID,
OrderNumber: model.Order.OrderNumber,
TableNumber: model.Order.TableNumber,
OrderType: string(model.Order.OrderType),
@ -45,17 +48,6 @@ func (t *TableTransformer) ToContract(model models.TableResponse) *contract.Tabl
TaxAmount: model.Order.TaxAmount,
DiscountAmount: model.Order.DiscountAmount,
TotalAmount: model.Order.TotalAmount,
TotalCost: model.Order.TotalCost,
PaymentStatus: string(model.Order.PaymentStatus),
RefundAmount: model.Order.RefundAmount,
IsVoid: model.Order.IsVoid,
IsRefund: model.Order.IsRefund,
VoidReason: model.Order.VoidReason,
VoidedAt: model.Order.VoidedAt,
VoidedBy: model.Order.VoidedBy,
RefundReason: model.Order.RefundReason,
RefundedAt: model.Order.RefundedAt,
RefundedBy: model.Order.RefundedBy,
Metadata: model.Order.Metadata,
CreatedAt: model.Order.CreatedAt,
UpdatedAt: model.Order.UpdatedAt,
@ -65,56 +57,77 @@ func (t *TableTransformer) ToContract(model models.TableResponse) *contract.Tabl
return response
}
func (t *TableTransformer) ToModel(contract contract.TableResponse) *models.TableResponse {
response := &models.TableResponse{
ID: contract.ID,
OrganizationID: contract.OrganizationID,
OutletID: contract.OutletID,
TableName: contract.TableName,
StartTime: contract.StartTime,
Status: models.TableStatus(contract.Status),
OrderID: contract.OrderID,
PaymentAmount: contract.PaymentAmount,
PositionX: contract.PositionX,
PositionY: contract.PositionY,
Capacity: contract.Capacity,
IsActive: contract.IsActive,
Metadata: contract.Metadata,
CreatedAt: contract.CreatedAt,
UpdatedAt: contract.UpdatedAt,
}
if contract.Order != nil {
response.Order = &models.OrderResponse{
ID: contract.Order.ID,
OrganizationID: contract.Order.OrganizationID,
OutletID: contract.Order.OutletID,
UserID: contract.Order.UserID,
CustomerID: contract.Order.CustomerID,
OrderNumber: contract.Order.OrderNumber,
TableNumber: contract.Order.TableNumber,
OrderType: models.OrderType(contract.Order.OrderType),
Status: models.OrderStatus(contract.Order.Status),
Subtotal: contract.Order.Subtotal,
TaxAmount: contract.Order.TaxAmount,
DiscountAmount: contract.Order.DiscountAmount,
TotalAmount: contract.Order.TotalAmount,
TotalCost: contract.Order.TotalCost,
PaymentStatus: models.PaymentStatus(contract.Order.PaymentStatus),
RefundAmount: contract.Order.RefundAmount,
IsVoid: contract.Order.IsVoid,
IsRefund: contract.Order.IsRefund,
VoidReason: contract.Order.VoidReason,
VoidedAt: contract.Order.VoidedAt,
VoidedBy: contract.Order.VoidedBy,
RefundReason: contract.Order.RefundReason,
RefundedAt: contract.Order.RefundedAt,
RefundedBy: contract.Order.RefundedBy,
Metadata: contract.Order.Metadata,
CreatedAt: contract.Order.CreatedAt,
UpdatedAt: contract.Order.UpdatedAt,
func CreateTableRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateTableRequest) models.CreateTableRequest {
return models.CreateTableRequest{
OutletID: req.OutletID,
TableName: req.TableName,
PositionX: req.PositionX,
PositionY: req.PositionY,
Capacity: req.Capacity,
Metadata: req.Metadata,
}
}
return response
func UpdateTableRequestToModel(req *contract.UpdateTableRequest) models.UpdateTableRequest {
modelReq := models.UpdateTableRequest{
TableName: req.TableName,
PositionX: req.PositionX,
PositionY: req.PositionY,
Capacity: req.Capacity,
IsActive: req.IsActive,
Metadata: req.Metadata,
}
if req.Status != nil {
status := constants.TableStatus(*req.Status)
modelReq.Status = &status
}
return modelReq
}
func OccupyTableRequestToModel(req *contract.OccupyTableRequest) models.OccupyTableRequest {
return models.OccupyTableRequest{
OrderID: req.OrderID,
StartTime: req.StartTime,
}
}
func ReleaseTableRequestToModel(req *contract.ReleaseTableRequest) models.ReleaseTableRequest {
return models.ReleaseTableRequest{
PaymentAmount: req.PaymentAmount,
}
}
func ListTablesQueryToModel(req *contract.ListTablesQuery) models.ListTablesRequest {
modelReq := models.ListTablesRequest{
Page: req.Page,
Limit: req.Limit,
Search: req.Search,
}
if req.OrganizationID != "" {
if orgID, err := uuid.Parse(req.OrganizationID); err == nil {
modelReq.OrganizationID = &orgID
}
}
if req.OutletID != "" {
if outletID, err := uuid.Parse(req.OutletID); err == nil {
modelReq.OutletID = &outletID
}
}
if req.Status != "" {
status := constants.TableStatus(req.Status)
modelReq.Status = &status
}
if req.IsActive != "" {
if isActive, err := strconv.ParseBool(req.IsActive); err == nil {
modelReq.IsActive = &isActive
}
}
return modelReq
}

View File

@ -1,6 +1,12 @@
package validator
import "regexp"
import (
"errors"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
// Shared helper functions for validators
func isValidEmail(email string) bool {
@ -30,3 +36,27 @@ func isValidPlanType(planType string) bool {
}
return validPlanTypes[planType]
}
func formatValidationError(err error) error {
if validationErrors, ok := err.(validator.ValidationErrors); ok {
var errorMessages []string
for _, fieldError := range validationErrors {
switch fieldError.Tag() {
case "required":
errorMessages = append(errorMessages, fieldError.Field()+" is required")
case "email":
errorMessages = append(errorMessages, fieldError.Field()+" must be a valid email")
case "min":
errorMessages = append(errorMessages, fieldError.Field()+" must be at least "+fieldError.Param())
case "max":
errorMessages = append(errorMessages, fieldError.Field()+" must be at most "+fieldError.Param())
case "oneof":
errorMessages = append(errorMessages, fieldError.Field()+" must be one of: "+fieldError.Param())
default:
errorMessages = append(errorMessages, fieldError.Field()+" is invalid")
}
}
return errors.New(strings.Join(errorMessages, "; "))
}
return err
}

1703
postman.json Normal file

File diff suppressed because it is too large Load Diff

BIN
server

Binary file not shown.

View File

@ -1,37 +0,0 @@
#!/bin/bash
# Test build script for apskel-pos-backend
set -e
echo "🔨 Testing Go build..."
# Check Go version
echo "Go version:"
go version
# Clean previous builds
echo "🧹 Cleaning previous builds..."
rm -f server
rm -rf tmp/
# Test local build
echo "🏗️ Building application..."
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
if [ -f "server" ]; then
echo "✅ Build successful! Binary created: server"
ls -la server
# Test if binary can run (quick test)
echo "🧪 Testing binary..."
timeout 5s ./server || true
echo "🧹 Cleaning up..."
rm -f server
echo "✅ All tests passed! Docker build should work."
else
echo "❌ Build failed! Binary not created."
exit 1
fi

View File

@ -1,53 +0,0 @@
#!/bin/bash
# Test script for inventory movement functionality
echo "Testing Inventory Movement Integration..."
# Build the application
echo "Building application..."
go build -o server cmd/server/main.go
if [ $? -ne 0 ]; then
echo "Build failed!"
exit 1
fi
echo "Build successful!"
# Run database migrations
echo "Running database migrations..."
migrate -path migrations -database "postgres://postgres:password@localhost:5432/pos_db?sslmode=disable" up
if [ $? -ne 0 ]; then
echo "Migration failed!"
exit 1
fi
echo "Migrations completed successfully!"
echo "Inventory Movement Integration Test Complete!"
echo ""
echo "Features implemented:"
echo "1. ✅ Inventory Movement Table (migrations/000023_create_inventory_movements_table.up.sql)"
echo "2. ✅ Inventory Movement Entity (internal/entities/inventory_movement.go)"
echo "3. ✅ Inventory Movement Model (internal/models/inventory_movement.go)"
echo "4. ✅ Inventory Movement Mapper (internal/mappers/inventory_movement_mapper.go)"
echo "5. ✅ Inventory Movement Repository (internal/repository/inventory_movement_repository.go)"
echo "6. ✅ Inventory Movement Processor (internal/processor/inventory_movement_processor.go)"
echo "7. ✅ Transaction Isolation in Payment Processing"
echo "8. ✅ Inventory Movement Integration with Payment Processor"
echo "9. ✅ Inventory Movement Integration with Refund Processor"
echo ""
echo "Transaction Isolation Features:"
echo "- All payment operations use database transactions"
echo "- Inventory adjustments are atomic within payment transactions"
echo "- Inventory movements are recorded with transaction isolation"
echo "- Refund operations restore inventory with proper audit trail"
echo ""
echo "The system now tracks all inventory changes with:"
echo "- Movement type (sale, refund, void, etc.)"
echo "- Previous and new quantities"
echo "- Cost tracking"
echo "- Reference to orders and payments"
echo "- User audit trail"
echo "- Timestamps and metadata"

View File

@ -1,89 +0,0 @@
#!/bin/bash
# Test script for Product CRUD operations with image_url and printer_type
echo "Testing Product CRUD Operations with Image URL and Printer Type..."
# Build the application
echo "Building application..."
go build -o server cmd/server/main.go
if [ $? -ne 0 ]; then
echo "Build failed!"
exit 1
fi
echo "Build successful!"
# Run database migrations
echo "Running database migrations..."
migrate -path migrations -database "postgres://postgres:password@localhost:5432/pos_db?sslmode=disable" up
if [ $? -ne 0 ]; then
echo "Migration failed!"
exit 1
fi
echo "Migrations completed successfully!"
echo "Product CRUD Operations with Image URL and Printer Type Test Complete!"
echo ""
echo "✅ Features implemented:"
echo "1. ✅ Database Migration (migrations/000024_add_image_and_printer_type_to_products.up.sql)"
echo "2. ✅ Product Entity Updated (internal/entities/product.go)"
echo "3. ✅ Product Models Updated (internal/models/product.go)"
echo "4. ✅ Product Mapper Updated (internal/mappers/product_mapper.go)"
echo "5. ✅ Product Contract Updated (internal/contract/product_contract.go)"
echo "6. ✅ Product Transformer Updated (internal/transformer/product_transformer.go)"
echo "7. ✅ Product Validator Updated (internal/validator/product_validator.go)"
echo ""
echo "✅ CRUD Operations Updated:"
echo "1. ✅ CREATE Product - Supports image_url and printer_type"
echo "2. ✅ READ Product - Returns image_url and printer_type"
echo "3. ✅ UPDATE Product - Supports updating image_url and printer_type"
echo "4. ✅ DELETE Product - No changes needed"
echo "5. ✅ LIST Products - Returns image_url and printer_type"
echo ""
echo "✅ API Contract Changes:"
echo "- CreateProductRequest: Added image_url and printer_type fields"
echo "- UpdateProductRequest: Added image_url and printer_type fields"
echo "- ProductResponse: Added image_url and printer_type fields"
echo ""
echo "✅ Validation Rules:"
echo "- image_url: Optional, max 500 characters"
echo "- printer_type: Optional, max 50 characters, default 'kitchen'"
echo ""
echo "✅ Database Schema:"
echo "- image_url: VARCHAR(500), nullable"
echo "- printer_type: VARCHAR(50), default 'kitchen'"
echo "- Index on printer_type for efficient filtering"
echo ""
echo "✅ Example API Usage:"
echo ""
echo "CREATE Product:"
echo 'curl -X POST /api/products \\'
echo ' -H "Content-Type: application/json" \\'
echo ' -d "{'
echo ' \"category_id\": \"uuid\",'
echo ' \"name\": \"Pizza Margherita\",'
echo ' \"price\": 12.99,'
echo ' \"image_url\": \"https://example.com/pizza.jpg\",'
echo ' \"printer_type\": \"kitchen\"'
echo ' }"'
echo ""
echo "UPDATE Product:"
echo 'curl -X PUT /api/products/{id} \\'
echo ' -H "Content-Type: application/json" \\'
echo ' -d "{'
echo ' \"image_url\": \"https://example.com/new-pizza.jpg\",'
echo ' \"printer_type\": \"bar\"'
echo ' }"'
echo ""
echo "GET Product Response:"
echo '{'
echo ' \"id\": \"uuid\",'
echo ' \"name\": \"Pizza Margherita\",'
echo ' \"price\": 12.99,'
echo ' \"image_url\": \"https://example.com/pizza.jpg\",'
echo ' \"printer_type\": \"kitchen\",'
echo ' \"is_active\": true'
echo '}'

View File

@ -1,51 +0,0 @@
#!/bin/bash
# Test script for product image and printer_type functionality
echo "Testing Product Image and Printer Type Integration..."
# Build the application
echo "Building application..."
go build -o server cmd/server/main.go
if [ $? -ne 0 ]; then
echo "Build failed!"
exit 1
fi
echo "Build successful!"
# Run database migrations
echo "Running database migrations..."
migrate -path migrations -database "postgres://postgres:password@localhost:5432/pos_db?sslmode=disable" up
if [ $? -ne 0 ]; then
echo "Migration failed!"
exit 1
fi
echo "Migrations completed successfully!"
echo "Product Image and Printer Type Integration Test Complete!"
echo ""
echo "Features implemented:"
echo "1. ✅ Database Migration (migrations/000024_add_image_and_printer_type_to_products.up.sql)"
echo "2. ✅ Product Entity Updated (internal/entities/product.go)"
echo "3. ✅ Product Models Updated (internal/models/product.go)"
echo "4. ✅ Product Mapper Updated (internal/mappers/product_mapper.go)"
echo "5. ✅ Default Printer Type: 'kitchen'"
echo "6. ✅ Image URL Support (VARCHAR(500))"
echo "7. ✅ Printer Type Support (VARCHAR(50))"
echo ""
echo "New Product Fields:"
echo "- image_url: Optional image URL for product display"
echo "- printer_type: Printer type for order printing (default: 'kitchen')"
echo ""
echo "API Changes:"
echo "- CreateProductRequest: Added image_url and printer_type fields"
echo "- UpdateProductRequest: Added image_url and printer_type fields"
echo "- ProductResponse: Added image_url and printer_type fields"
echo ""
echo "Database Changes:"
echo "- Added image_url column (VARCHAR(500), nullable)"
echo "- Added printer_type column (VARCHAR(50), default 'kitchen')"
echo "- Added index on printer_type for efficient filtering"

View File

@ -1,77 +0,0 @@
#!/bin/bash
# Test script for Table Management API
# Make sure the server is running on localhost:8080
BASE_URL="http://localhost:8080/api/v1"
TOKEN="your_jwt_token_here" # Replace with actual JWT token
echo "Testing Table Management API"
echo "=========================="
# Test 1: Create a table
echo -e "\n1. Creating a table..."
CREATE_RESPONSE=$(curl -s -X POST "$BASE_URL/tables" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"outlet_id": "your_outlet_id_here",
"table_name": "Table 1",
"position_x": 100.0,
"position_y": 200.0,
"capacity": 4,
"metadata": {
"description": "Window table"
}
}')
echo "Create Response: $CREATE_RESPONSE"
# Extract table ID from response (you'll need to parse this)
TABLE_ID=$(echo $CREATE_RESPONSE | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
if [ -n "$TABLE_ID" ]; then
echo "Created table with ID: $TABLE_ID"
# Test 2: Get table by ID
echo -e "\n2. Getting table by ID..."
GET_RESPONSE=$(curl -s -X GET "$BASE_URL/tables/$TABLE_ID" \
-H "Authorization: Bearer $TOKEN")
echo "Get Response: $GET_RESPONSE"
# Test 3: Update table
echo -e "\n3. Updating table..."
UPDATE_RESPONSE=$(curl -s -X PUT "$BASE_URL/tables/$TABLE_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"table_name": "Table 1 Updated",
"capacity": 6,
"position_x": 150.0,
"position_y": 250.0
}')
echo "Update Response: $UPDATE_RESPONSE"
# Test 4: List tables
echo -e "\n4. Listing tables..."
LIST_RESPONSE=$(curl -s -X GET "$BASE_URL/tables?page=1&limit=10" \
-H "Authorization: Bearer $TOKEN")
echo "List Response: $LIST_RESPONSE"
# Test 5: Get available tables for outlet
echo -e "\n5. Getting available tables..."
AVAILABLE_RESPONSE=$(curl -s -X GET "$BASE_URL/outlets/your_outlet_id_here/tables/available" \
-H "Authorization: Bearer $TOKEN")
echo "Available Tables Response: $AVAILABLE_RESPONSE"
# Test 6: Delete table
echo -e "\n6. Deleting table..."
DELETE_RESPONSE=$(curl -s -X DELETE "$BASE_URL/tables/$TABLE_ID" \
-H "Authorization: Bearer $TOKEN")
echo "Delete Response: $DELETE_RESPONSE"
else
echo "Failed to create table or extract table ID"
fi
echo -e "\nAPI Testing completed!"