diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..33ceb9c Binary files /dev/null and b/.coverage differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf6a9d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.14-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_ROOT_USER_ACTION=ignore + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml /app/ + +RUN pip install --upgrade pip \ + && pip install --no-cache-dir uv \ + && uv pip install --system . + +COPY . /app + +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + + +FROM base AS celery + +CMD ["celery", "-A", "flower", "worker", "-l", "info", "--concurrency=2"] + diff --git a/api_backend.md b/api_backend.md new file mode 100644 index 0000000..a0c84d6 --- /dev/null +++ b/api_backend.md @@ -0,0 +1,741 @@ +# API Backend Documentation + +This document provides comprehensive documentation for all API endpoints in the `/api/backend/` namespace, with special focus on the Customers API which includes employee visibility control features. + +## Table of Contents + +1. [Authentication](#authentication) +2. [Common Response Format](#common-response-format) +3. [API Endpoints](#api-endpoints) + - [Quick Inputs](#quick-inputs) + - [Products](#products) + - [Warehouses](#warehouses) + - [Product Categories](#product-categories) + - [Suppliers](#suppliers) + - [Employees](#employees) + - [Employee Types](#employee-types) + - [Customers](#customers) + - [Vehicle Types](#vehicle-types) + - [Bank Accounts](#bank-accounts) + - [Device Info](#device-info) + - [Vehicle Transport Records](#vehicle-transport-records) + - [User Profiles](#user-profiles) +4. [Error Handling](#error-handling) +5. [Pagination](#pagination) + +## Authentication + +All API endpoints in the `/api/backend/` namespace require authentication. Requests must include a valid authentication token or session. + +```http +Authorization: Bearer +``` + +Or use session cookies for web-based applications. + +## Common Response Format + +Most endpoints follow a standard response format: + +### Success Response (200 OK, 201 Created) +```json +{ + "id": 1, + "field1": "value1", + "field2": "value2", + "created_at": "2025-11-24T10:00:00Z", + "updated_at": "2025-11-24T10:00:00Z" +} +``` + +### List Response (200 OK) +```json +{ + "count": 100, + "next": "http://example.com/api/backend/endpoint/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "field1": "value1", + ... + } + ] +} +``` + +### Error Response (400, 401, 403, 404, 500) +```json +{ + "field_name": ["Error message for this field"], + "non_field_errors": ["General error message"] +} +``` + +## API Endpoints + +### Quick Inputs + +**Base URL**: `/api/backend/quick-inputs/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/quick-inputs/` | List | Retrieve all quick input items | +| GET | `/api/backend/quick-inputs/{id}/` | Retrieve | Get a specific quick input item | +| POST | `/api/backend/quick-inputs/` | Create | Create a new quick input item | +| PUT | `/api/backend/quick-inputs/{id}/` | Update | Update a quick input item | +| PATCH | `/api/backend/quick-inputs/{id}/` | Partial Update | Partially update a quick input item | +| DELETE | `/api/backend/quick-inputs/{id}/` | Delete | Delete a quick input item | + +**Query Parameters**: +- `group` (optional): Filter items by group + +**Request/Response Fields**: +- `id`: Quick input ID +- `name`: Name of the quick input +- `value`: Value of the quick input +- `group`: Group category for the quick input + +**Example Response**: +```json +{ + "id": 1, + "name": "常用尺寸", + "value": "1.5米", + "group": "尺寸" +} +``` + +### Products + +**Base URL**: `/api/backend/products/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/products/` | List | Retrieve all products | +| GET | `/api/backend/products/{id}/` | Retrieve | Get a specific product | +| POST | `/api/backend/products/` | Create | Create a new product | +| PUT | `/api/backend/products/{id}/` | Update | Update a product | +| PATCH | `/api/backend/products/{id}/` | Partial Update | Partially update a product | +| DELETE | `/api/backend/products/{id}/` | Delete | Delete a product | + +**Request/Response Fields**: +- `id`: Product ID +- `name`: Product name +- `category`: Product category (nested object) +- `price`: Product price +- `unit`: Unit of measurement +- `image`: Product image file +- `image_url`: URL of product image (generated) +- `description`: Product description + +**Example Response**: +```json +{ + "id": 1, + "name": "纯棉印花布", + "price": "25.50", + "unit": 1, + "description": "高品质纯棉印花布料", + "image": "/media/products/cotton_fabric.jpg", + "image_url": "http://example.com/media/products/cotton_fabric.jpg", + "category": { + "id": 3, + "name": "印花布" + }, + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Warehouses + +**Base URL**: `/api/backend/warehouses/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/warehouses/` | List | Retrieve all warehouses | +| GET | `/api/backend/warehouses/{id}/` | Retrieve | Get a specific warehouse | +| POST | `/api/backend/warehouses/` | Create | Create a new warehouse | +| PUT | `/api/backend/warehouses/{id}/` | Update | Update a warehouse | +| PATCH | `/api/backend/warehouses/{id}/` | Partial Update | Partially update a warehouse | +| DELETE | `/api/backend/warehouses/{id}/` | Delete | Delete a warehouse | + +**Request/Response Fields**: +- `id`: Warehouse ID +- `name`: Warehouse name +- `location`: Warehouse location +- `type`: Warehouse type (1: Whole, 2: Scattered) + +**Example Response**: +```json +{ + "id": 1, + "name": "主仓库", + "location": "园区A区1号", + "type": 1, + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Product Categories + +**Base URL**: `/api/backend/product-categories/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/product-categories/` | List | Retrieve all product categories | +| GET | `/api/backend/product-categories/{id}/` | Retrieve | Get a specific product category | +| POST | `/api/backend/product-categories/` | Create | Create a new product category | +| PUT | `/api/backend/product-categories/{id}/` | Update | Update a product category | +| PATCH | `/api/backend/product-categories/{id}/` | Partial Update | Partially update a product category | +| DELETE | `/api/backend/product-categories/{id}/` | Delete | Delete a product category | + +**Request/Response Fields**: +- `id`: Category ID +- `name`: Category name +- `description`: Category description + +**Example Response**: +```json +{ + "id": 1, + "name": "印花布", + "description": "各类印花布料", + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Suppliers + +**Base URL**: `/api/backend/suppliers/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/suppliers/` | List | Retrieve all suppliers | +| GET | `/api/backend/suppliers/{id}/` | Retrieve | Get a specific supplier | +| POST | `/api/backend/suppliers/` | Create | Create a new supplier | +| PUT | `/api/backend/suppliers/{id}/` | Update | Update a supplier | +| PATCH | `/api/backend/suppliers/{id}/` | Partial Update | Partially update a supplier | +| DELETE | `/api/backend/suppliers/{id}/` | Delete | Delete a supplier | + +**Request/Response Fields**: +- `id`: Supplier ID +- `name`: Supplier name +- `contact`: Contact person +- `mobile`: Mobile phone number +- `email`: Email address +- `address`: Physical address +- `description`: Additional description + +**Example Response**: +```json +{ + "id": 1, + "name": "华美纺织原料厂", + "contact": "张经理", + "mobile": "13800138001", + "email": "zhang@huamei.com", + "address": "广州市天河区科技园", + "description": "长期合作的优质原料供应商", + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Employees + +**Base URL**: `/api/backend/employees/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/employees/` | List | Retrieve all employees | +| GET | `/api/backend/employees/{id}/` | Retrieve | Get a specific employee | +| POST | `/api/backend/employees/` | Create | Create a new employee | +| PUT | `/api/backend/employees/{id}/` | Update | Update an employee | +| PATCH | `/api/backend/employees/{id}/` | Partial Update | Partially update an employee | +| DELETE | `/api/backend/employees/{id}/` | Delete | Delete an employee | + +**Request/Response Fields**: +- `id`: Employee ID +- `name`: Employee name +- `position`: Employee position (EmployeeType object) +- `job_type`: Job type (read-only string derived from position) +- `mobile`: Mobile phone number +- `status`: Employee status +- `sys_user`: Associated Django User ID (nullable) + +**Example Response**: +```json +{ + "id": 1, + "name": "张三", + "position": { + "id": 2, + "title": "打纸工", + "description": "负责打纸工作", + "merchant": 1 + }, + "job_type": "打纸工", + "mobile": "13800138000", + "status": "在职", + "sys_user": 5, + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Employee Types + +**Base URL**: `/api/backend/employee-types/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/employee-types/` | List | Retrieve all employee types | +| GET | `/api/backend/employee-types/{id}/` | Retrieve | Get a specific employee type | +| POST | `/api/backend/employee-types/` | Create | Create a new employee type | +| PUT | `/api/backend/employee-types/{id}/` | Update | Update an employee type | +| PATCH | `/api/backend/employee-types/{id}/` | Partial Update | Partially update an employee type | +| DELETE | `/api/backend/employee-types/{id}/` | Delete | Delete an employee type | + +**Request/Response Fields**: +- `id`: Employee type ID +- `title`: Type title +- `description`: Type description + +**Example Response**: +```json +{ + "id": 1, + "title": "打纸工", + "description": "负责打纸工作", + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Customers + +**Base URL**: `/api/backend/customers/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/customers/` | List | Retrieve customers visible to the current employee | +| GET | `/api/backend/customers/{id}/` | Retrieve | Get a specific customer (if visible to current employee) | +| POST | `/api/backend/customers/` | Create | Create a new customer | +| PUT | `/api/backend/customers/{id}/` | Update | Update a customer (if visible to current employee) | +| PATCH | `/api/backend/customers/{id}/` | Partial Update | Partially update a customer (if visible to current employee) | +| DELETE | `/api/backend/customers/{id}/` | Delete | Delete a customer (if visible to current employee) | + +#### Customer Visibility System + +The Customers API implements a visibility control system that restricts which customers are accessible to each employee. This system works as follows: + +**Visibility Rules**: +1. A customer is visible to an employee if: + - The employee created the customer, OR + - The employee is explicitly added to the customer's `visible_employees` list, OR + - The employee is a superuser or has `basic_info.view_all_customers` permission + +2. When listing customers, the API automatically filters to only show customers visible to the current employee. + +3. When accessing a specific customer (GET, PUT, PATCH, DELETE), the API checks if the customer is visible to the current employee. If not, a 404 Not Found response is returned. + +**Request/Response Fields**: +- `id`: Customer ID +- `name`: Customer name +- `mobile`: Mobile phone number +- `email`: Email address +- `contact`: Contact person +- `area`: Geographic area +- `description`: Additional description +- `visible_employees`: List of employee IDs who can view this customer +- `created_by`: ID of the employee who created this customer (auto-set on creation) + +**Creating a Customer (POST)**: +```json +{ + "name": "Acme Corporation", + "mobile": "13800138000", + "email": "contact@acme.com", + "contact": "John Doe", + "area": "Beijing", + "description": "Regular wholesale customer", + "visible_employees": [1, 2, 3] // Optional: employees who can view this customer +} +``` + +**Updating a Customer (PUT/PATCH)**: +When updating a customer, the same visibility rules apply: +- The employee must be able to see the customer to update it +- The `visible_employees` field can be updated to add or remove access for other employees + +```json +{ + "name": "Updated Customer Name", + "visible_employees": [1, 4, 5] // Updated list of employees who can view this customer +} +``` + +**Example Response**: +```json +{ + "id": 1, + "name": "北京服装批发公司", + "mobile": "13800138000", + "email": "beijing@clothing.com", + "contact": "李经理", + "area": "北京朝阳区", + "description": "长期合作的服装批发客户", + "visible_employees": [1, 3, 5], + "created_by": 1, + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +**Security Note**: Customer visibility is enforced at the API level. Attempts to access a customer that isn't visible to the current employee will result in a 404 Not Found response, regardless of whether the customer actually exists in the database. + +#### Customer Visibility System + +The Customers API implements a visibility control system that restricts which customers are accessible to each employee. This system works as follows: + +**Visibility Rules**: +1. A customer is visible to an employee if: + - The employee created the customer, OR + - The employee is explicitly added to the customer's `visible_employees` list, OR + - The employee is a superuser or has `basic_info.view_all_customers` permission + +2. When listing customers, the API automatically filters to only show customers visible to the current employee. + +3. When accessing a specific customer (GET, PUT, PATCH, DELETE), the API checks if the customer is visible to the current employee. If not, a 404 Not Found response is returned. + +**Request/Response Fields**: +- `id`: Customer ID +- `name`: Customer name +- `mobile`: Mobile phone number +- `email`: Email address +- `contact`: Contact person +- `area`: Geographic area +- `description`: Additional description +- `visible_employees`: List of employee IDs who can view this customer +- `created_by`: ID of the employee who created this customer (auto-set on creation) + +**Creating a Customer (POST)**: +```json +{ + "name": "Acme Corporation", + "mobile": "13800138000", + "email": "contact@acme.com", + "contact": "John Doe", + "area": "Beijing", + "description": "Regular wholesale customer", + "visible_employees": [1, 2, 3] // Optional: employees who can view this customer +} +``` + +**Updating a Customer (PUT/PATCH)**: +When updating a customer, the same visibility rules apply: +- The employee must be able to see the customer to update it +- The `visible_employees` field can be updated to add or remove access for other employees + +```json +{ + "name": "Updated Customer Name", + "visible_employees": [1, 4, 5] // Updated list of employees who can view this customer +} +``` + +**Security Note**: Customer visibility is enforced at the API level. Attempts to access a customer that isn't visible to the current employee will result in a 404 Not Found response, regardless of whether the customer actually exists in the database. + +### Vehicle Types + +**Base URL**: `/api/backend/vehicle-types/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/vehicle-types/` | List | Retrieve all vehicle types | +| GET | `/api/backend/vehicle-types/{id}/` | Retrieve | Get a specific vehicle type | +| POST | `/api/backend/vehicle-types/` | Create | Create a new vehicle type | +| PUT | `/api/backend/vehicle-types/{id}/` | Update | Update a vehicle type | +| PATCH | `/api/backend/vehicle-types/{id}/` | Partial Update | Partially update a vehicle type | +| DELETE | `/api/backend/vehicle-types/{id}/` | Delete | Delete a vehicle type | + +**Request/Response Fields**: +- `id`: Vehicle type ID +- `name`: Type name +- `capacity`: Vehicle capacity +- `description`: Type description + +**Example Response**: +```json +{ + "id": 1, + "name": "小型货车", + "capacity": "500公斤", + "description": "适合小批量货物运输", + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Bank Accounts + +**Base URL**: `/api/backend/bank-accounts/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/bank-accounts/` | List | Retrieve all bank accounts | +| GET | `/api/backend/bank-accounts/{id}/` | Retrieve | Get a specific bank account | +| POST | `/api/backend/bank-accounts/` | Create | Create a new bank account | +| PUT | `/api/backend/bank-accounts/{id}/` | Update | Update a bank account | +| PATCH | `/api/backend/bank-accounts/{id}/` | Partial Update | Partially update a bank account | +| DELETE | `/api/backend/bank-accounts/{id}/` | Delete | Delete a bank account | + +**Request/Response Fields**: +- `id`: Account ID +- `bank_name`: Bank name +- `account_number`: Account number +- `account_holder`: Account holder name +- `branch`: Bank branch +- `is_default`: Whether this is the default account + +**Example Response**: +```json +{ + "id": 1, + "bank_name": "中国工商银行", + "account_number": "6222021234567890", + "account_holder": "张三", + "branch": "广州天河支行", + "is_default": true, + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Device Info + +**Base URL**: `/api/backend/device-info/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/device-info/` | List | Retrieve all device information | +| GET | `/api/backend/device-info/{id}/` | Retrieve | Get specific device information | +| POST | `/api/backend/device-info/` | Create | Create new device information | +| PUT | `/api/backend/device-info/{id}/` | Update | Update device information | +| PATCH | `/api/backend/device-info/{id}/` | Partial Update | Partially update device information | +| DELETE | `/api/backend/device-info/{id}/` | Delete | Delete device information | + +**Request/Response Fields**: +- `id`: Device ID +- `name`: Device name +- `type`: Device type (ROLLING, PRINTING, etc.) +- `status`: Device status (ACTIVE, MAINTENANCE, etc.) +- `start_working_at`: Date when device started working +- `stop_working_at`: Date when device stopped working +- `is_occupied`: Whether device is currently occupied +- `description`: Additional description + +**Example Response**: +```json +{ + "id": 1, + "name": "滚筒机A", + "type": "ROLLING", + "status": "ACTIVE", + "start_working_at": "2025-01-15", + "stop_working_at": null, + "is_occupied": true, + "description": "主要生产用滚筒设备", + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### Vehicle Transport Records + +**Base URL**: `/api/backend/vehicle-transport-records/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/vehicle-transport-records/` | List | Retrieve all transport records | +| GET | `/api/backend/vehicle-transport-records/{id}/` | Retrieve | Get a specific transport record | +| POST | `/api/backend/vehicle-transport-records/` | Create | Create a new transport record | +| PUT | `/api/backend/vehicle-transport-records/{id}/` | Update | Update a transport record | +| PATCH | `/api/backend/vehicle-transport-records/{id}/` | Partial Update | Partially update a transport record | +| DELETE | `/api/backend/vehicle-transport-records/{id}/` | Delete | Delete a transport record | + +**Request/Response Fields**: +- `id`: Record ID +- `vehicle_type`: Vehicle type (nested object) +- `driver_name`: Driver name +- `driver_mobile`: Driver mobile phone +- `transport_date`: Date of transport +- `source`: Source location +- `destination`: Destination location +- `goods_description`: Description of goods being transported +- `quantity`: Quantity of goods +- `status`: Transport status +- `notes`: Additional notes +- `vehicle_type_name`: Read-only derived vehicle type name + +**Example Response**: +```json +{ + "id": 1, + "vehicle_type": { + "id": 1, + "name": "小型货车", + "capacity": "500公斤", + "description": "适合小批量货物运输" + }, + "driver_name": "王师傅", + "driver_mobile": "13900139000", + "transport_date": "2025-11-24", + "source": "广州工厂", + "destination": "深圳客户", + "goods_description": "印花布料", + "quantity": "300公斤", + "status": "COMPLETED", + "notes": "运输顺利", + "vehicle_type_name": "小型货车", + "merchant": 1, + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +### User Profiles + +**Base URL**: `/api/backend/user-profiles/` + +| Method | URL Pattern | Action | Description | +|--------|-------------|---------|-------------| +| GET | `/api/backend/user-profiles/` | List | Retrieve all user profiles for current merchant | +| GET | `/api/backend/user-profiles/{id}/` | Retrieve | Get a specific user profile | +| POST | `/api/backend/user-profiles/` | Create | Create a new user profile | +| PUT | `/api/backend/user-profiles/{id}/` | Update | Update a user profile | +| PATCH | `/api/backend/user-profiles/{id}/` | Partial Update | Partially update a user profile | +| DELETE | `/api/backend/user-profiles/{id}/` | Delete | Delete a user profile | + +**Request/Response Fields**: +- `id`: Profile ID +- `user`: User ID (for writing) +- `user_detail`: User object details (for reading) +- `merchant`: Merchant ID (auto-set to current user's merchant) +- `description`: Profile description +- `created_at`: Creation timestamp +- `updated_at`: Last update timestamp + +**Creating a User Profile (POST)**: +```json +{ + "user": 123, // User ID + "description": "User profile for system access" +} +``` + +**Example Response**: +```json +{ + "id": 1, + "user": 5, + "user_detail": { + "id": 5, + "username": "testuser", + "email": "test@example.com", + "is_active": true, + "is_superuser": false, + "last_login": "2025-11-24T09:00:00Z" + }, + "merchant": 1, + "description": "用户资料描述", + "created_at": "2025-11-24T10:30:00Z", + "updated_at": "2025-11-24T10:30:00Z" +} +``` + +## Error Handling + +All endpoints may return the following error responses: + +### 401 Unauthorized +```json +{ + "detail": "Authentication credentials were not provided." +} +``` + +### 403 Forbidden +```json +{ + "detail": "You do not have permission to perform this action." +} +``` + +### 404 Not Found +```json +{ + "detail": "Not found." +} +``` + +### 400 Bad Request +```json +{ + "field_name": ["Error message for this field"], + "non_field_errors": ["General error message"] +} +``` + +### 500 Server Error +```json +{ + "detail": "A server error occurred." +} +``` + +## Pagination + +List endpoints support pagination using the LimitOffsetPagination scheme. + +**Query Parameters**: +- `limit`: Number of results to return per page +- `offset`: Number of results to skip + +**Example**: +``` +GET /api/backend/products/?limit=20&offset=40 +``` + +This will return 20 items starting from position 40 (items 41-60). + +**Response Format**: +```json +{ + "count": 150, + "next": "http://example.com/api/backend/products/?limit=20&offset=60", + "previous": "http://example.com/api/backend/products/?limit=20&offset=20", + "results": [ + { + "id": 41, + "name": "Product 41", + ... + } + // ... up to 20 items + ] +} +``` \ No newline at end of file diff --git a/api_man/views.py b/api_man/views.py index db96570..462d656 100644 --- a/api_man/views.py +++ b/api_man/views.py @@ -1,6 +1,8 @@ from rest_framework import viewsets from rest_framework.exceptions import PermissionDenied from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import IsAuthenticated, DjangoModelPermissions +from django.contrib.auth.models import Permission from . import serializers @@ -68,17 +70,28 @@ class EmployeeTypeViewSet(BaseViewSet): class CustomerViewSet(BaseViewSet): queryset = serializers.basic_models.Customer.objects serializer_class = serializers.CustomerSerializer + permission_classes = [IsAuthenticated, DjangoModelPermissions] def perform_create(self, serializer): merchant = self.request.user.employee.merchant employee = self.request.user.employee serializer.save(merchant=merchant, created_by=employee) + def can_view_all(self) -> bool: + return self.request.user.is_superuser or self.request.user.has_perm('basic_info.view_all_customers') + def get_queryset(self): qs = super().get_queryset() # 应用可见性过滤 from basic_info.services import CustomerVisibilityService + if self.can_view_all(): + return qs.filter(merchant=self.request.user.employee.merchant) return CustomerVisibilityService.filter_customers_for_employee(qs, self.request.user) + + def has_permission(self, request, view): + if request.user.is_superuser or request.user.has_perm('basic_info.view_all_customer'): + return True + return super().has_permission(request, view) class VehicleTypeViewSet(BaseViewSet): diff --git a/api_v1/serializers.py b/api_v1/serializers.py index dbe8bae..72d855a 100644 --- a/api_v1/serializers.py +++ b/api_v1/serializers.py @@ -12,8 +12,15 @@ class ProductStockChangeSerializer(serializers.Serializer): quantity = serializers.ListField( child=serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.01')), min_length=1, + required=False, help_text="数量列表,每个数量对应一条明细记录" ) + consume_with = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + allow_empty=False, + help_text="严进严出模式(出库)指定的入库明细ID列表" + ) def validate_product(self, value: int) -> int: """验证产品是否存在""" @@ -23,6 +30,11 @@ class ProductStockChangeSerializer(serializers.Serializer): raise serializers.ValidationError(f"产品ID {value} 不存在") return value + def validate_consume_with(self, value: List[int]) -> List[int]: + if len(value) != len(set(value)): + raise serializers.ValidationError("consume_with 中存在重复的入库明细ID") + return value + class CreateStockChangeSerializer(serializers.Serializer): """创建库存变动记录序列化器""" @@ -115,12 +127,14 @@ class StockChangeDetailResponseSerializer(serializers.ModelSerializer): """库存变动明细响应序列化器""" product_name = serializers.CharField(source='product.name', read_only=True) unit_display = serializers.CharField(source='get_unit_display', read_only=True) + consume_with_id = serializers.IntegerField(read_only=True) class Meta: model = stock_models.StockChangeDetail fields = [ 'id', 'product', 'product_name', 'quantity', - 'unit', 'unit_display', 'stock_change_record' + 'unit', 'unit_display', 'stock_change_record', + 'is_consumed', 'consume_with_id', ] diff --git a/api_v1/urls.py b/api_v1/urls.py index 9ef87c4..aeaac2b 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ path('stock-changes/', stock_change_views.list_stock_changes, name='list_stock_changes'), path('stock-change/', stock_change_views.create_full_stock_change, name='create_full_stock_change'), path('stock-change/relaxed/', stock_change_views.create_relaxed_stock_change, name='create_relaxed_stock_change'), + path('stock-change/restrict/', stock_change_views.create_restrict_stock_change, name='create_restrict_stock_change'), path('stock-change//', stock_change_views.get_stock_change, name='get_stock_change'), path( 'set-merchant-auto-complete-stock-change/', diff --git a/api_v1/views/stock_change_views/README.md b/api_v1/views/stock_change_views/README.md index ac179af..c506240 100644 --- a/api_v1/views/stock_change_views/README.md +++ b/api_v1/views/stock_change_views/README.md @@ -89,6 +89,7 @@ from api_v1.views import stock_change_views urlpatterns = [ path('stock-change/', stock_change_views.create_full_stock_change), path('stock-change/relaxed/', stock_change_views.create_relaxed_stock_change), + path('stock-change/restrict/', stock_change_views.create_restrict_stock_change), path('stock-changes/', stock_change_views.list_stock_changes), path('stock-change//', stock_change_views.get_stock_change), path('set-merchant-auto-complete-stock-change/', stock_change_views.set_merchant_auto_complete_stock_change), @@ -101,6 +102,7 @@ urlpatterns = [ from api_v1.views.stock_change_views import ( CreateStockChangeView, CreateStockChangeRelaxedView, + CreateStockChangeRestrictView, ListStockChangesView, GetStockChangeView, SetMerchantAutoCompleteView, @@ -109,6 +111,7 @@ from api_v1.views.stock_change_views import ( urlpatterns = [ path('stock-change/', CreateStockChangeView.as_view()), path('stock-change/relaxed/', CreateStockChangeRelaxedView.as_view()), + path('stock-change/restrict/', CreateStockChangeRestrictView.as_view()), path('stock-changes/', ListStockChangesView.as_view()), path('stock-change//', GetStockChangeView.as_view()), path('set-merchant-auto-complete-stock-change/', SetMerchantAutoCompleteView.as_view()), diff --git a/api_v1/views/stock_change_views/__init__.py b/api_v1/views/stock_change_views/__init__.py index f8515e1..6afa8fe 100644 --- a/api_v1/views/stock_change_views/__init__.py +++ b/api_v1/views/stock_change_views/__init__.py @@ -5,7 +5,11 @@ """ from .mixins import StockChangeViewMixin -from .create import CreateStockChangeView, CreateStockChangeRelaxedView +from .create import ( + CreateStockChangeView, + CreateStockChangeRelaxedView, + CreateStockChangeRestrictView, +) from .list import ListStockChangesView from .detail import GetStockChangeView from .settings import SetMerchantAutoCompleteView @@ -13,6 +17,7 @@ from .settings import SetMerchantAutoCompleteView # 向后兼容:保持原有的函数式接口 create_full_stock_change = CreateStockChangeView.as_view() create_relaxed_stock_change = CreateStockChangeRelaxedView.as_view() +create_restrict_stock_change = CreateStockChangeRestrictView.as_view() list_stock_changes = ListStockChangesView.as_view() get_stock_change = GetStockChangeView.as_view() set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view() @@ -21,11 +26,13 @@ __all__ = [ 'StockChangeViewMixin', 'CreateStockChangeView', 'CreateStockChangeRelaxedView', + 'CreateStockChangeRestrictView', 'ListStockChangesView', 'GetStockChangeView', 'SetMerchantAutoCompleteView', 'create_full_stock_change', 'create_relaxed_stock_change', + 'create_restrict_stock_change', 'list_stock_changes', 'get_stock_change', 'set_merchant_auto_complete_stock_change', diff --git a/api_v1/views/stock_change_views/create.py b/api_v1/views/stock_change_views/create.py index 4591692..1a154a6 100644 --- a/api_v1/views/stock_change_views/create.py +++ b/api_v1/views/stock_change_views/create.py @@ -212,3 +212,88 @@ class CreateStockChangeRelaxedView(StockChangeViewMixin, views.APIView): 'error': '创建库存变动记录失败', 'message': str(e) }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class CreateStockChangeRestrictView(StockChangeViewMixin, views.APIView): + """严进严出模式接口""" + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=['创建出入库'], + request=serializers.CreateStockChangeSerializer, + responses={201: serializers.CreateStockChangeResponseSerializer}, + summary="创建库存变动记录(严进严出模式)", + description="严进严出模式会在出库时按 consume_with 消耗入库明细" + ) + def post(self, request): + if not self.check_employee_permission(request): + return self.permission_error_response('无权限访问') + + record_data = { + 'type': request.data.get('type'), + 'warehouse': request.data.get('warehouse'), + 'source_type': request.data.get('source_type'), + 'source_id': request.data.get('source_id'), + } + + if not all([record_data['type'], record_data['warehouse'], record_data['source_type']]): + return Response({ + 'error': '缺少必要参数', + 'message': '请提供 type, warehouse, source_type' + }, status=status.HTTP_400_BAD_REQUEST) + + products_data = request.data.get('products', []) + if not products_data: + return Response({ + 'error': '产品列表不能为空' + }, status=status.HTTP_400_BAD_REQUEST) + + if not self.validate_warehouse_visibility(record_data['warehouse'], request): + return Response({ + 'error': f'仓库ID {record_data["warehouse"]} 对当前用户不可见' + }, status=status.HTTP_403_FORBIDDEN) + + for p in products_data: + product_id = p.get('product') + if not self.validate_product_visibility(product_id, request): + return Response({ + 'error': f'产品ID {product_id} 对当前用户不可见' + }, status=status.HTTP_403_FORBIDDEN) + + serializer = serializers.CreateStockChangeSerializer(data={'products': products_data}) + if not serializer.is_valid(): + return Response({ + 'error': '产品数据验证失败', + 'details': serializer.errors + }, status=status.HTTP_400_BAD_REQUEST) + + try: + stock_change_record, created_details, created_count = stock_services.create_stock_change_record_with_details( + merchant=request.user.employee.merchant, + created_by=request.user, + type=record_data['type'], + warehouse_id=record_data['warehouse'], + source_type=record_data['source_type'], + source_id=record_data['source_id'], + products=products_data, + ) + response_serializer = serializers.CreateStockChangeResponseSerializer({ + 'stock_change_record': stock_change_record, + 'details': created_details, + 'message': f'成功创建库存变动记录及 {created_count} 条明细', + 'created_details_count': created_count + }) + + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + except ValueError as e: + return Response({ + 'error': str(e) + }, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + logger.error(f"创建严进严出库存变动记录失败: {str(e)}", exc_info=True) + return Response({ + 'error': '创建库存变动记录失败', + 'message': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/api_v1/views/stock_change_views/test_stock_change_api.py b/api_v1/views/stock_change_views/test_stock_change_api.py new file mode 100644 index 0000000..040438c --- /dev/null +++ b/api_v1/views/stock_change_views/test_stock_change_api.py @@ -0,0 +1,108 @@ +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from basic_info import models as basic_models +from stock import models as stock_models, services as stock_services + + +User = get_user_model() + + +class StockChangeRestrictAPITestCase(TestCase): + """测试严进严出模式的 API""" + + def setUp(self): + self.client = APIClient() + + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.category = basic_models.ProductCategory.objects.create( + merchant=self.merchant, + name='布料', + product_prefix='FAB', + ) + self.product = basic_models.Product.objects.create( + merchant=self.merchant, + category=self.category, + name='测试布料', + human_id='FAB-001', + unit=basic_models.ProductUnitEnum.METER, + ) + self.warehouse = basic_models.WareHouse.objects.create( + merchant=self.merchant, + name='严进严出仓', + mode=basic_models.WareHouseModeEnum.RESTRICT_IN_OUT, + ) + + self.user = User.objects.create_user(username='restrict_user', password='pass123') + self.employee = basic_models.Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='仓管员', + mobile='13800138000', + status=basic_models.EmployeeStatusEnum.ACTIVE, + ) + self.client.force_authenticate(user=self.user) + + # 创建一条入库明细供消耗 + _, details, _ = stock_services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=self.user, + type=stock_models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse.id, + source_type=stock_models.StockChangeSourceEnum.PURCHASE, + source_id=1, + products=[{'product': self.product.id, 'quantity': [Decimal('12.50')]}], + ) + self.inbound_detail = details[0] + + def test_restrict_outgoing_consumes_inbound_detail(self): + payload = { + 'type': stock_models.StockChangeTypeEnum.REMOVE, + 'warehouse': self.warehouse.id, + 'source_type': stock_models.StockChangeSourceEnum.SALES, + 'source_id': 2, + 'products': [ + { + 'product': self.product.id, + 'consume_with': [self.inbound_detail.id], + } + ] + } + + response = self.client.post('/api/v1/stock-change/restrict/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.inbound_detail.refresh_from_db() + self.assertTrue(self.inbound_detail.is_consumed) + + detail = response.data['details'][0] + self.assertEqual(detail['consume_with_id'], self.inbound_detail.id) + self.assertEqual( + Decimal(str(detail['quantity'])), + self.inbound_detail.quantity, + ) + + def test_restrict_outgoing_requires_consume_with(self): + payload = { + 'type': stock_models.StockChangeTypeEnum.REMOVE, + 'warehouse': self.warehouse.id, + 'source_type': stock_models.StockChangeSourceEnum.SALES, + 'source_id': 3, + 'products': [ + { + 'product': self.product.id, + 'quantity': ['10.00'], + } + ] + } + + response = self.client.post('/api/v1/stock-change/restrict/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('consume_with', str(response.data)) + diff --git a/basic_info/migrations/0013_alter_customer_options.py b/basic_info/migrations/0013_alter_customer_options.py new file mode 100644 index 0000000..a45c960 --- /dev/null +++ b/basic_info/migrations/0013_alter_customer_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.7 on 2025-11-24 09:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0012_userprofile'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customer', + options={'permissions': [('view_all_customers', '查看所有客户资料')], 'verbose_name': '客户资料', 'verbose_name_plural': '客户资料'}, + ), + ] diff --git a/basic_info/models.py b/basic_info/models.py index d82a179..068ca93 100644 --- a/basic_info/models.py +++ b/basic_info/models.py @@ -325,6 +325,9 @@ class Customer(ModelBase): class Meta: verbose_name = '客户资料' verbose_name_plural = '客户资料' + permissions = [ + ('view_all_customers', '查看所有客户资料'), + ] class Employee(ModelBase): diff --git a/business/__init__.py b/business/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/business/admin.py b/business/admin.py new file mode 100644 index 0000000..09af82b --- /dev/null +++ b/business/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.PurchaseOrder) +class PurchaseOrderAdmin(admin.ModelAdmin): + list_display = ('id', 'supplier', 'order_date', 'total_amount') + search_fields = ('supplier__name',) + list_filter = ('order_date',) + ordering = ('-order_date',) diff --git a/business/apps.py b/business/apps.py new file mode 100644 index 0000000..8e6fb15 --- /dev/null +++ b/business/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BusinessConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'business' + verbose_name = '业务模块' diff --git a/business/migrations/0001_initial.py b/business/migrations/0001_initial.py new file mode 100644 index 0000000..f5e9926 --- /dev/null +++ b/business/migrations/0001_initial.py @@ -0,0 +1,43 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('basic_info', '0013_alter_customer_options'), + ('stock', '0005_stockchangedetail_consume_fields'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunSQL( + sql="ALTER TABLE IF EXISTS stock_purchaseorder RENAME TO business_purchaseorder;", + reverse_sql="ALTER TABLE IF EXISTS business_purchaseorder RENAME TO stock_purchaseorder;", + ), + ], + state_operations=[ + migrations.CreateModel( + name='PurchaseOrder', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('order_date', models.DateField(verbose_name='订单日期')), + ('total_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='总金额')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_orders', to='basic_info.merchant', verbose_name='所属商户')), + ('supplier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='purchase_orders', to='basic_info.supplier', verbose_name='供应商')), + ], + options={ + 'verbose_name': '采购单', + 'verbose_name_plural': '采购单', + }, + ), + ], + ), + ] + diff --git a/business/migrations/__init__.py b/business/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/business/models.py b/business/models.py new file mode 100644 index 0000000..8891ebd --- /dev/null +++ b/business/models.py @@ -0,0 +1,31 @@ +from django.db import models +from flower.common import ModelBase +from basic_info import models as basic_info_models + + +class PurchaseOrder(ModelBase): + """采购单模型(业务模块)""" + + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey( + basic_info_models.Merchant, + on_delete=models.PROTECT, + related_name='purchase_orders', + verbose_name='所属商户', + ) + supplier = models.ForeignKey( + basic_info_models.Supplier, + on_delete=models.PROTECT, + related_name='purchase_orders', + verbose_name='供应商', + ) + order_date = models.DateField(verbose_name='订单日期') + total_amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='总金额') + remarks = models.TextField(blank=True, null=True, verbose_name='备注') + + def __str__(self): + return f'采购订单 {self.id} - {self.supplier.name}' + + class Meta: + verbose_name = '采购单' + verbose_name_plural = '采购单' diff --git a/business/tests.py b/business/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/business/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/business/views.py b/business/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/business/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/docker-compose.yml b/docker-compose.yml index dc1841d..1612fb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,78 @@ services: - redis_data:/data restart: unless-stopped + rabbitmq: + image: rabbitmq:3-management-alpine + container_name: rabbitmq + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + restart: unless-stopped + + web: + build: + context: . + target: base + container_name: flower_web + command: python manage.py runserver 0.0.0.0:8000 + ports: + - "8000:8000" + volumes: + - .:/app + environment: + DJANGO_SETTINGS_MODULE: flower.settings + PYTHONPATH: /app + DEBUG: "1" + ALLOWED_HOSTS: "localhost,127.0.0.1,0.0.0.0" + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: flower + DB_USER: postgres + DB_PASSWORD: postgres + depends_on: + - postgres + - redis + - rabbitmq + restart: unless-stopped + + celery_worker: + build: + context: . + target: base + container_name: celery_worker + command: + - celery + - -A + - flower + - worker + - -l + - info + - --concurrency=2 + - --pool=solo + volumes: + - .:/app + depends_on: + - postgres + - redis + - rabbitmq + environment: + CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672// + CELERY_RESULT_BACKEND: redis://redis:6379/0 + PYTHONPATH: /app + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: flower + DB_USER: postgres + DB_PASSWORD: postgres + restart: unless-stopped + volumes: db_data: redis_data: pgadmin_data: + rabbitmq_data: diff --git a/docs/20251125-stock.md b/docs/20251125-stock.md new file mode 100644 index 0000000..e1a552f --- /dev/null +++ b/docs/20251125-stock.md @@ -0,0 +1,486 @@ +# Stock Management API Documentation + +This document provides detailed documentation for the stock management module APIs, focusing on the three main stock change creation modes: Full, Relaxed, and Restricted. + +## Table of Contents + +1. [Overview](#overview) +2. [Stock Models](#stock-models) +3. [Stock Change Modes](#stock-change-modes) +4. [API Endpoints](#api-endpoints) +5. [Examples](#examples) +6. [Error Handling](#error-handling) + +## Overview + +The stock management system handles all inventory movement operations including stock-in (入库) and stock-out (出库) transactions. The system supports three different modes of operation: + +1. **Full Mode**: Standard operation with explicit quantity lists +2. **Relaxed Mode**: Automatically splits quantities based on total and unit count +3. **Restricted Mode**: Strict "restricted-in/out" mode for warehouse operations + +## Stock Models + +### StockChangeRecord +Represents a stock change record (either stock-in or stock-out). + +**Fields**: +- `id`: Record ID +- `type`: Change type (1=Stock-in, 2=Stock-out) +- `warehouse`: Associated warehouse +- `source_type`: Source of the change (1=Purchase, 6=Sales, etc.) +- `source_id`: ID of the source document (optional) +- `created_by`: User who created the record +- `is_finished`: Whether the stock change is completed +- `finished_at`: When the record was completed +- `remarks`: Additional notes + +### StockChangeDetail +Represents detailed items within a stock change record. + +**Fields**: +- `id`: Detail ID +- `product`: Associated product +- `stock_change_record`: Parent stock change record +- `quantity`: Quantity of the product +- `unit`: Unit of measurement +- `is_consumed`: Whether this detail has been consumed (for restricted mode) +- `consume_with`: Reference to the inbound detail being consumed (for restricted mode) + +### Inventory +Represents current inventory levels for a product in a warehouse. + +**Fields**: +- `id`: Inventory ID +- `product`: Associated product +- `warehouse`: Warehouse location +- `quantity`: Current quantity +- `num_of_rolls`: Number of rolls +- `spec`: Product specification +- `description`: Additional notes + +## Stock Change Modes + +### Source Types + +The system supports various source types for stock changes: + +**Incoming (入库) Sources**: +- `1`: Purchase (采购) +- `2`: Sales Return (销退) +- `3`: Transport In (调入) +- `4`: Recheck Addition (盘盈) +- `5`: Combine (合并) + +**Outgoing (出库) Sources**: +- `6`: Sales (销售) +- `7`: Purchase Return (采购退货) +- `8`: Transport Out (调出) +- `9`: Recheck Removal (盘亏) +- `10`: Explode (拆卷) + +### Warehouse Modes + +Warehouses can operate in different modes: +- `1`: Restricted In (严进) - Strict control on stock-in +- `2`: Restricted In/Out (严进严出) - Strict control on both stock-in and stock-out +- `3`: Unrestricted (宽进宽出) - Relaxed controls for both directions + +## API Endpoints + +### 1. Full Mode Stock Change + +**URL**: `POST /api/v1/stock-change/` + +**Description**: Creates a stock change record with explicit quantity lists for each product. + +**Request Parameters**: +```json +{ + "type": 1, // 1=入库, 2=出库 + "warehouse": 1, // 仓库ID + "source_type": 1, // 来源类型,见上文说明 + "source_id": 123, // 可选,来源单据ID + "products": [ + { + "product": 1, // 产品ID + "quantity": [85.5, 75.2, 90.0] // 数量列表,每个值对应一条明细 + }, + { + "product": 2, + "quantity": [120.0] + } + ] +} +``` + +**Response**: +```json +{ + "stock_change_record": { + "id": 15, + "type": 1, + "warehouse": 1, + "source_type": 1, + "source_id": 123, + "is_finished": false, + "created_at": "2025-11-25T10:30:00Z", + "updated_at": "2025-11-25T10:30:00Z" + }, + "details": [ + { + "id": 45, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 85.5, + "unit": 1, + "unit_display": "米", + "stock_change_record": 15, + "is_consumed": false, + "consume_with_id": null + }, + { + "id": 46, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 75.2, + "unit": 1, + "unit_display": "米", + "stock_change_record": 15, + "is_consumed": false, + "consume_with_id": null + } + // ... 更多明细 + ], + "message": "成功创建库存变动记录及 3 条明细", + "created_details_count": 3 +} +``` + +### 2. Relaxed Mode Stock Change + +**URL**: `POST /api/v1/stock-change/relaxed/` + +**Description**: Creates a stock change record using total quantity and unit count to automatically split into details. + +**Request Parameters**: +```json +{ + "type": 1, // 1=入库, 2=出库 + "warehouse": 1, // 仓库ID + "source_type": 1, // 来源类型 + "source_id": 123, // 可选,来源单据ID + "products": [ + { + "product": 1, // 产品ID + "quantity": { + "value": 250.8, // 总数量 + "unit_count": 2.5 // 单条数量,默认为1 + } + } + ] +} +``` + +**Response**: +```json +{ + "stock_change_record": { + "id": 16, + "type": 1, + "warehouse": 1, + "source_type": 1, + "source_id": 123, + "is_finished": false, + "created_at": "2025-11-25T11:00:00Z", + "updated_at": "2025-11-25T11:00:00Z" + }, + "details": [ + { + "id": 48, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 100.0, // 自动拆分 + "unit": 1, + "unit_display": "米", + "stock_change_record": 16, + "is_consumed": false, + "consume_with_id": null + }, + { + "id": 49, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 100.0, // 自动拆分 + "unit": 1, + "unit_display": "米", + "stock_change_record": 16, + "is_consumed": false, + "consume_with_id": null + }, + { + "id": 50, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 50.8, // 剩余数量 + "unit": 1, + "unit_display": "米", + "stock_change_record": 16, + "is_consumed": false, + "consume_with_id": null + } + ], + "message": "成功创建库存变动记录及 3 条明细", + "created_details_count": 3 +} +``` + +### 3. Restricted Mode Stock Change + +**URL**: `POST /api/v1/stock-change/restrict/` + +**Description**: Creates a stock change record in "restricted-in-out" mode where outbound details must reference existing inbound details. + +**Request Parameters**: +```json +{ + "type": 2, // 1=入库, 2=出库 + "warehouse": 1, // 仓库ID,必须为严进严出模式 + "source_type": 6, // 来源类型 + "source_id": 123, // 可选,来源单据ID + "products": [ + { + "product": 1, // 产品ID + "quantity": [150.5], // 数量列表 + "consume_with": [23, 24] // 必须指定消耗的入库明细ID列表 + } + ] +} +``` + +**Response**: +```json +{ + "stock_change_record": { + "id": 17, + "type": 2, + "warehouse": 1, + "source_type": 6, + "source_id": 123, + "is_finished": false, + "created_at": "2025-11-25T11:15:00Z", + "updated_at": "2025-11-25T11:15:00Z" + }, + "details": [ + { + "id": 51, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 75.25, + "unit": 1, + "unit_display": "米", + "stock_change_record": 17, + "is_consumed": false, + "consume_with_id": 23 // 消耗的入库明细ID + }, + { + "id": 52, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 75.25, + "unit": 1, + "unit_display": "米", + "stock_change_record": 17, + "is_consumed": false, + "consume_with_id": 24 // 消耗的入库明细ID + } + ], + "message": "成功创建库存变动记录及 2 条明细", + "created_details_count": 2 +} +``` + +### Other Related Endpoints + +#### List Stock Changes +**URL**: `GET /api/v1/stock-changes/` + +**Response**: List of stock change records with pagination +```json +{ + "count": 100, + "next": "http://example.com/api/v1/stock-changes/?page=2", + "previous": null, + "results": [ + { + "id": 15, + "type": 1, + "type_display": "入库", + "warehouse": 1, + "source_type": 1, + "source_type_display": "采购", + "is_finished": true, + "finished_at": "2025-11-25T12:00:00Z", + "created_at": "2025-11-25T10:30:00Z" + } + ] +} +``` + +#### Get Stock Change Details +**URL**: `GET /api/v1/stock-change//` + +**Response**: Detailed view of a specific stock change record with its details +```json +{ + "stock_change_record": { + "id": 15, + "type": 1, + "type_display": "入库", + "warehouse": 1, + "source_type": 1, + "source_type_display": "采购", + "is_finished": true, + "finished_at": "2025-11-25T12:00:00Z", + "created_at": "2025-11-25T10:30:00Z" + }, + "details": [ + { + "id": 45, + "product": 1, + "product_name": "纯棉印花布", + "quantity": 85.5, + "unit": 1, + "unit_display": "米", + "is_consumed": false, + "consume_with_id": null + } + ] +} +``` + +## Examples + +### Example 1: Creating a Stock-in Record (Full Mode) +```bash +curl -X POST http://example.com/api/v1/stock-change/ \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your_token" \ + -d '{ + "type": 1, + "warehouse": 1, + "source_type": 1, + "products": [ + { + "product": 1, + "quantity": [100.5, 75.3, 120.0] + } + ] + }' +``` + +### Example 2: Creating a Stock-out Record (Relaxed Mode) +```bash +curl -X POST http://example.com/api/v1/stock-change/relaxed/ \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your_token" \ + -d '{ + "type": 2, + "warehouse": 1, + "source_type": 6, + "products": [ + { + "product": 2, + "quantity": { + "value": 250.8, + "unit_count": 2.5 + } + } + ] + }' +``` + +### Example 3: Creating a Stock-out Record (Restricted Mode) +```bash +curl -X POST http://example.com/api/v1/stock-change/restrict/ \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer your_token" \ + -d '{ + "type": 2, + "warehouse": 1, + "source_type": 6, + "products": [ + { + "product": 3, + "quantity": [100.0, 50.0], + "consume_with": [45, 46] + } + ] + }' +``` + +## Error Handling + +### Common Error Responses + +#### Authentication Error +```json +{ + "error": "无权限访问" +} +``` + +#### Missing Required Parameters +```json +{ + "error": "缺少必要参数", + "message": "请提供 type, warehouse, source_type" +} +``` + +#### Validation Error +```json +{ + "error": "产品数据验证失败", + "details": { + "products": [ + "产品列表中存在重复的产品ID" + ] + } +} +``` + +#### Warehouse/Product Visibility Error +```json +{ + "error": "仓库ID 1 对当前用户不可见" +} +``` + +#### Business Logic Error +```json +{ + "error": "仓库出入库模式为【严进严出】,该模式暂未支持当前操作" +} +``` + +## Business Rules + +1. **Warehouse Mode Restrictions**: + - Full mode works with all warehouse modes + - Relaxed mode requires "unrestricted" (宽进宽出) or "restricted-in" (严进) warehouses + - Restricted mode requires "restricted-in-out" (严进严出) warehouses + +2. **Stock Change Validation**: + - Stock-in can only use incoming source types (1, 2, 3, 4, 5) + - Stock-out can only use outgoing source types (6, 7, 8, 9, 10) + +3. **Restricted Mode Specific Rules**: + - Outbound details must reference existing inbound details + - Referenced details must belong to the same warehouse + - Referenced details must not already be consumed + +4. **Permission Checks**: + - Users can only access warehouses they have permission to see + - Users can only access products they have permission to see + - All stock change operations require proper authentication \ No newline at end of file diff --git a/docs/STOCK_FLOW_SERVICE.md b/docs/STOCK_FLOW_SERVICE.md new file mode 100644 index 0000000..e9bfe06 --- /dev/null +++ b/docs/STOCK_FLOW_SERVICE.md @@ -0,0 +1,60 @@ +# StockFlowService 统一出入库服务 + +## 背景 + +随着仓库出入库模式(严谨、宽进宽出、严进严出)增多,业务层(采购、销售等)不应关心底层明细拆分及校验差异。`StockFlowService` 提供统一的 `stock_in` / `stock_out` 方法,将模式分支、字段校验与服务层逻辑封装,保证所有模式均复用已有 `create_stock_change_record_*` 逻辑。 + +## Items Payload 结构 + +每个产品条目都支持以下可选字段,由服务内部根据仓库模式挑选所需字段: + +| 字段 | 说明 | 适用模式 | +| --- | --- | --- | +| `product_id` | 产品 ID,必填 | 所有模式 | +| `quantities` | 严谨模式的数量列表 | 严谨、严进严出入库、严进严出出库(入库) | +| `value` | 总数量 | 宽进宽出 | +| `num_of_rolls` | 单条长度/匹数,默认 1 | 宽进宽出 | +| `consume_detail_ids` | 被消耗的入库明细 ID 列表 | 严进严出出库 | + +`StockFlowService` 根据 `warehouse.mode` 自动构造对应的服务入参:严谨模式使用 `quantity` 数组,宽进宽出转换为 `{value, unit_count}`,严进严出出库转换为 `consume_with`。 + +## 使用示例 + +```python +service = StockFlowService(merchant=merchant, created_by=user) + +# 严谨入库 +service.stock_in( + warehouse_id=warehouse.id, + source_type=StockChangeSourceEnum.PURCHASE, + source_id=purchase.id, + items=[{'product_id': product.id, 'quantities': ['10.5', '5']}], +) + +# 宽进宽出出库 +service.stock_out( + warehouse_id=unrestricted.id, + source_type=StockChangeSourceEnum.SALES, + source_id=sales.id, + items=[{'product_id': product.id, 'value': '30.5', 'num_of_rolls': 3}], +) + +# 严进严出出库 +service.stock_out( + warehouse_id=restrict_out.id, + source_type=StockChangeSourceEnum.SALES, + source_id=sales.id, + items=[{'product_id': product.id, 'consume_detail_ids': [detail.id]}], +) +``` + +## 方案评价 + +该构想成功实现了以下目标: + +- **隐藏模式细节**:业务层仅需关心仓库与产品输入,内部自动匹配严谨/宽进宽出/严进严出逻辑。 +- **避免重复实现**:底层仍调用现有 `create_stock_change_record_with_details`、`create_stock_change_record_relaxed` 等函数,最大化复用。 +- **可拓展性**:未来新增模式或派生参数,只需扩展 `StockFlowService` 的 `items` 解析与私有方法,无需触及业务层。 + +整体来看,该方案清晰地分离了“业务调用入口”和“模式细节实现”,有助于后续在采购、销售、生产等更高抽象的流程中快速复用库存操作。*** + diff --git a/flower/__init__.py b/flower/__init__.py index e69de29..fb989c4 100644 --- a/flower/__init__.py +++ b/flower/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/flower/celery.py b/flower/celery.py new file mode 100644 index 0000000..c6c630b --- /dev/null +++ b/flower/celery.py @@ -0,0 +1,15 @@ +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'flower.settings') + +app = Celery('flower') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() + + +@app.task(bind=True) +def debug_task(self): + print(f'Celery debug task - request: {self.request!r}') + diff --git a/flower/settings.py b/flower/settings.py index 273c26f..0ba4a72 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -103,6 +103,7 @@ INSTALLED_APPS = [ 'rest_framework', 'drf_spectacular', 'basic_info', + 'business', 'stock', 'api_v1', 'api_man', @@ -271,3 +272,17 @@ STATEFLOW_CURRENT_STATE_MODE = 'NEXT' # Printing module settings PRINTING_DEFAULT_PROCESS_ID = 1 # 默认印染流程ID PLATE_ORDER_DEFAULT_PROCESS_ID = 2 # 默认开版流程ID + +# Celery 配置 +CELERY_BROKER_URL = env( + 'CELERY_BROKER_URL', + default='amqp://guest:guest@rabbitmq:5672//', +) +CELERY_RESULT_BACKEND = env( + 'CELERY_RESULT_BACKEND', + default='redis://redis:6379/0', +) +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE diff --git a/pyproject.toml b/pyproject.toml index 9c49d94..a8faf2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.14" dependencies = [ + "celery>=5.5.3", + "coverage>=7.12.0", "django>=5.2.7", "django-cors-headers>=4.9.0", "django-environ>=0.12.0", @@ -18,10 +20,12 @@ dependencies = [ "markdown>=3.10", "pillow>=12.0.0", "uvicorn>=0.38.0", + "watchfiles>=0.22.0", + "psycopg[binary]>=3.2.12", + "redis>=5.0.0", ] [dependency-groups] dev = [ - "psycopg[binary]>=3.2.12", "pytest>=9.0.1", ] diff --git a/stock/admin.py b/stock/admin.py index 78c9ace..c0d7390 100644 --- a/stock/admin.py +++ b/stock/admin.py @@ -12,14 +12,6 @@ class StockAdminBase(admin.ModelAdmin): result = list(result) + ['created_at'] return result -@admin.register(models.PurchaseOrder) -class PurchaseOrderAdmin(admin.ModelAdmin): - list_display = ('id', 'supplier', 'order_date', 'total_amount') - search_fields = ('supplier__name',) - list_filter = ('order_date',) - ordering = ('-order_date',) - - @admin.register(models.Inventory) class InventoryAdmin(StockAdminBase): list_display = ( diff --git a/stock/migrations/0005_stockchangedetail_consume_fields.py b/stock/migrations/0005_stockchangedetail_consume_fields.py new file mode 100644 index 0000000..2978827 --- /dev/null +++ b/stock/migrations/0005_stockchangedetail_consume_fields.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0004_alter_stockchangerecord_source_type'), + ] + + operations = [ + migrations.AddField( + model_name='stockchangedetail', + name='is_consumed', + field=models.BooleanField(default=False, verbose_name='是否已消耗'), + ), + migrations.AddField( + model_name='stockchangedetail', + name='consume_with', + field=models.OneToOneField(blank=True, null=True, on_delete=models.PROTECT, related_name='consumed_by_detail', to='stock.stockchangedetail', verbose_name='所消耗的入库明细'), + ), + ] + diff --git a/stock/migrations/0006_remove_purchaseorder.py b/stock/migrations/0006_remove_purchaseorder.py new file mode 100644 index 0000000..7dc9cad --- /dev/null +++ b/stock/migrations/0006_remove_purchaseorder.py @@ -0,0 +1,21 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0005_stockchangedetail_consume_fields'), + ('business', '0001_initial'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.DeleteModel( + name='PurchaseOrder', + ), + ], + ), + ] + diff --git a/stock/models.py b/stock/models.py index d6536e0..46f05dc 100644 --- a/stock/models.py +++ b/stock/models.py @@ -62,29 +62,6 @@ class StockChangeTypeEnum(models.IntegerChoices): # ==================== 模型定义 ==================== -class PurchaseOrder(ModelBase): - """采购单模型""" - - id = models.BigAutoField(primary_key=True) - merchant = models.ForeignKey( - basic_info_models.Merchant, - on_delete=models.PROTECT, - related_name='purchase_orders', - verbose_name='所属商户' - ) - supplier = models.ForeignKey(basic_info_models.Supplier, on_delete=models.PROTECT, related_name='purchase_orders', verbose_name='供应商') - order_date = models.DateField(verbose_name='订单日期') - total_amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='总金额') - remarks = models.TextField(blank=True, null=True, verbose_name='备注') - - def __str__(self): - return f'采购订单 {self.id} - {self.supplier.name}' - - class Meta: - verbose_name = '采购单' - verbose_name_plural = '采购单' - - class StockChangeRecord(ModelBase): """库存变动记录模型""" @@ -193,9 +170,14 @@ class StockChangeDetail(ModelBase): verbose_name='库存变动记录', ) quantity = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='数量') - unit = models.IntegerField( - verbose_name='单位', - choices=basic_info_models.ProductUnitEnum.choices, + is_consumed = models.BooleanField(default=False, verbose_name='是否已消耗') + consume_with = models.OneToOneField( + 'self', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='consumed_by_detail', + verbose_name='所消耗的入库明细', ) def __str__(self): diff --git a/stock/services.py b/stock/services.py index 837194c..5e7b49d 100644 --- a/stock/services.py +++ b/stock/services.py @@ -13,13 +13,15 @@ logger = logging.getLogger(__name__) def _raise_unimplemented_mode(): - raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持创建出入库记录') + raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持当前操作') -def _ensure_strict_mode(warehouse: basic_models.WareHouse): +def _ensure_strict_mode(warehouse: basic_models.WareHouse, *, allow_restrict_in_out: bool = False): if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN: return if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT: + if allow_restrict_in_out: + return _raise_unimplemented_mode() raise ValueError('仓库出入库模式为【宽进宽出】,请使用宽松模式接口创建出入库记录') @@ -32,6 +34,105 @@ def _ensure_relaxed_mode(warehouse: basic_models.WareHouse): raise ValueError('仓库出入库模式为【严进宽出】,请使用严谨模式接口创建出入库记录') +def _create_details_from_quantities( + *, + stock_change_record: models.StockChangeRecord, + merchant: basic_models.Merchant, + products: List[Dict[str, Any]], +) -> Tuple[List[models.StockChangeDetail], int]: + created_details: List[models.StockChangeDetail] = [] + created_count = 0 + + for product_data in products: + product_id = product_data.get('product') + quantities = product_data.get('quantity') or [] + + if not product_id or not quantities: + raise ValueError('产品数据不完整,缺少 product 或 quantity') + + try: + product = basic_models.Product.objects.get(id=product_id) + except basic_models.Product.DoesNotExist: + raise ValueError(f'产品ID {product_id} 不存在') + + if product.merchant_id != merchant.id: + raise ValueError(f'产品ID {product_id} 不属于当前商户') + + for quantity in quantities: + detail = models.StockChangeDetail.objects.create( + stock_change_record=stock_change_record, + product=product, + quantity=quantity, + merchant=merchant, + unit=product.unit, + ) + created_details.append(detail) + created_count += 1 + + return created_details, created_count + + +def _create_consumption_details( + *, + stock_change_record: models.StockChangeRecord, + merchant: basic_models.Merchant, + products: List[Dict[str, Any]], +) -> List[models.StockChangeDetail]: + created_details: List[models.StockChangeDetail] = [] + + for product_data in products: + product_id = product_data.get('product') + consume_ids = product_data.get('consume_with') or [] + + if not product_id or not consume_ids: + raise ValueError('严进严出出库必须提供 consume_with 列表') + + try: + product = basic_models.Product.objects.get(id=product_id) + except basic_models.Product.DoesNotExist: + raise ValueError(f'产品ID {product_id} 不存在') + + if product.merchant_id != merchant.id: + raise ValueError(f'产品ID {product_id} 不属于当前商户') + + inbound_details = models.StockChangeDetail.objects.select_related('stock_change_record').filter( + id__in=consume_ids + ) + if inbound_details.count() != len(consume_ids): + raise ValueError('部分入库明细不存在') + + inbound_map = {detail.id: detail for detail in inbound_details} + + for detail_id in consume_ids: + inbound_detail = inbound_map.get(detail_id) + if inbound_detail is None: + raise ValueError('部分入库明细不存在') + if inbound_detail.stock_change_record.warehouse_id != stock_change_record.warehouse_id: + raise ValueError('入库明细与当前仓库不一致') + if inbound_detail.stock_change_record.merchant_id != merchant.id: + raise ValueError('入库明细与当前商户不一致') + if not inbound_detail.stock_change_record.is_incoming: + raise ValueError('只能引用入库明细进行出库') + if inbound_detail.is_consumed: + raise ValueError(f'入库明细 {inbound_detail.id} 已被消耗') + if inbound_detail.product_id != product_id: + raise ValueError('入库明细与当前产品不匹配') + + detail = models.StockChangeDetail.objects.create( + stock_change_record=stock_change_record, + product=product, + quantity=inbound_detail.quantity, + merchant=merchant, + unit=inbound_detail.unit, + consume_with=inbound_detail, + ) + inbound_detail.is_consumed = True + inbound_detail.save(update_fields=['is_consumed']) + created_details.append(detail) + + return created_details + + def create_stock_change_record_with_details( *, merchant: basic_models.Merchant, @@ -69,7 +170,17 @@ def create_stock_change_record_with_details( if warehouse.merchant_id != merchant.id: raise ValueError('仓库不属于当前商户') - _ensure_strict_mode(warehouse) + + allow_restrict_in_out = ( + warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT + and type == models.StockChangeTypeEnum.ADD + ) + is_strict_outgoing = ( + warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT + and type == models.StockChangeTypeEnum.REMOVE + ) + if not is_strict_outgoing: + _ensure_strict_mode(warehouse, allow_restrict_in_out=allow_restrict_in_out) created_details: List[models.StockChangeDetail] = [] created_count = 0 @@ -85,31 +196,19 @@ def create_stock_change_record_with_details( ) logger.info('创建新库存变动记录 ID: %s', stock_change_record.id) - for product_data in products: - product_id = product_data.get('product') - quantities = product_data.get('quantity', []) - - if not product_id or not quantities: - raise ValueError('产品数据不完整,缺少 product 或 quantity') - - try: - product = basic_models.Product.objects.get(id=product_id) - except basic_models.Product.DoesNotExist: - raise ValueError(f'产品ID {product_id} 不存在') - - if product.merchant_id != merchant.id: - raise ValueError(f'产品ID {product_id} 不属于当前商户') - - for quantity in quantities: - detail = models.StockChangeDetail.objects.create( - stock_change_record=stock_change_record, - product=product, - quantity=quantity, - merchant=merchant, - unit=product.unit, - ) - created_details.append(detail) - created_count += 1 + if is_strict_outgoing: + created_details = _create_consumption_details( + stock_change_record=stock_change_record, + merchant=merchant, + products=products, + ) + created_count = len(created_details) + else: + created_details, created_count = _create_details_from_quantities( + stock_change_record=stock_change_record, + merchant=merchant, + products=products, + ) if warehouse.merchant.auto_complete_stock_change: make_stock_change_completed(stock_change_record) @@ -228,6 +327,160 @@ def create_stock_change_record_relaxed( return stock_change_record, created_details, len(created_details) +class StockFlowService: + """ + 统一出入库服务,屏蔽仓库模式差异。 + + items 结构支持以下字段: + - product_id: 产品ID,必填 + - quantities: List[Decimal | str],用于严谨/严进严出入库 + - value: Decimal | str,总数量,用于宽进宽出 + - num_of_rolls: Decimal | int,每条数量,用于宽进宽出 + - consume_detail_ids: List[int],严进严出出库消耗的入库明细ID + """ + + def __init__(self, *, merchant: basic_models.Merchant, created_by): + if merchant is None: + raise ValueError('StockFlowService 初始化必须提供商户') + self.merchant = merchant + self.created_by = created_by + + def stock_in( + self, + *, + warehouse_id: int, + source_type: int, + source_id: int | None = None, + items: List[Dict[str, Any]], + ): + warehouse = self._get_and_validate_warehouse(warehouse_id) + products = self._build_products_payload(warehouse, is_incoming=True, items=items) + return self._create_record( + warehouse=warehouse, + record_type=models.StockChangeTypeEnum.ADD, + source_type=source_type, + source_id=source_id, + products=products, + ) + + def stock_out( + self, + *, + warehouse_id: int, + source_type: int, + source_id: int | None = None, + items: List[Dict[str, Any]], + ): + warehouse = self._get_and_validate_warehouse(warehouse_id) + products = self._build_products_payload(warehouse, is_incoming=False, items=items) + return self._create_record( + warehouse=warehouse, + record_type=models.StockChangeTypeEnum.REMOVE, + source_type=source_type, + source_id=source_id, + products=products, + ) + + def _get_and_validate_warehouse(self, warehouse_id: int) -> basic_models.WareHouse: + if not warehouse_id: + raise ValueError('必须提供仓库ID') + try: + warehouse = basic_models.WareHouse.objects.get(id=warehouse_id) + except basic_models.WareHouse.DoesNotExist: + raise ValueError(f'仓库ID {warehouse_id} 不存在') + if warehouse.merchant_id != self.merchant.id: + raise ValueError('仓库不属于当前商户') + return warehouse + + def _create_record( + self, + *, + warehouse: basic_models.WareHouse, + record_type: int, + source_type: int, + source_id: int | None, + products: List[Dict[str, Any]], + ): + common_kwargs = dict( + merchant=self.merchant, + created_by=self.created_by, + type=record_type, + warehouse_id=warehouse.id, + source_type=source_type, + source_id=source_id, + products=products, + ) + + if warehouse.mode == basic_models.WareHouseModeEnum.UNRESTRICTED: + return create_stock_change_record_relaxed(**common_kwargs) + + # 严谨或严进严出模式统一由严谨服务处理 + return create_stock_change_record_with_details(**common_kwargs) + + def _build_products_payload( + self, + warehouse: basic_models.WareHouse, + *, + is_incoming: bool, + items: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + if not items: + raise ValueError('产品明细不能为空') + + mode = warehouse.mode + if mode == basic_models.WareHouseModeEnum.UNRESTRICTED: + return self._build_relaxed_items(items) + + if mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT and not is_incoming: + return self._build_restrict_out_items(items) + + # 严谨模式 + 严进严出入库复用数量列表 + return self._build_strict_items(items) + + @staticmethod + def _build_strict_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + payload: List[Dict[str, Any]] = [] + for item in items: + product_id = item.get('product_id') + quantities = item.get('quantities') or [] + if not product_id or not quantities: + raise ValueError('严谨模式需要提供 product_id 与 quantities 列表') + payload.append({'product': product_id, 'quantity': quantities}) + return payload + + @staticmethod + def _build_relaxed_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + payload: List[Dict[str, Any]] = [] + for item in items: + product_id = item.get('product_id') + total_value = item.get('value') + unit_size = item.get('num_of_rolls', 1) + if not product_id or total_value is None: + raise ValueError('宽进宽出需要提供 product_id 与 value') + payload.append({ + 'product': product_id, + 'quantity': { + 'value': total_value, + 'unit_count': unit_size, + } + }) + return payload + + @staticmethod + def _build_restrict_out_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + payload: List[Dict[str, Any]] = [] + for item in items: + product_id = item.get('product_id') + consume_ids = item.get('consume_detail_ids') or [] + if not product_id or not consume_ids: + raise ValueError('严进严出出库需要提供 product_id 与 consume_detail_ids') + payload.append({ + 'product': product_id, + 'consume_with': consume_ids, + }) + return payload + + def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None: """根据产品ID和仓库ID查找库存记录""" diff --git a/stock/tests.py b/stock/tests.py index ed9b824..8794aa8 100644 --- a/stock/tests.py +++ b/stock/tests.py @@ -6,6 +6,7 @@ from unittest.mock import patch, MagicMock import logging from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum +from business.models import PurchaseOrder from . import models, services @@ -83,7 +84,7 @@ class StockServicesTestCase(TestCase): def setUp(self): """每个测试方法执行前的设置""" # 创建采购单 - self.purchase_order = models.PurchaseOrder.objects.create( + self.purchase_order = PurchaseOrder.objects.create( merchant=self.merchant, supplier=self.supplier, order_date=timezone.now().date(), @@ -456,23 +457,129 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase): self.assertIn('宽进宽出', str(ctx.exception)) - def test_strict_mode_not_implemented_for_restrict_in_out(self): - """严进严出模式暂不支持""" + def test_restrict_in_out_incoming_behaves_like_strict(self): + """严进严出模式的入库沿用严谨逻辑""" self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT self.warehouse_main.save() - with self.assertRaises(ValueError) as ctx: + record, details, created_count = services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.ADD, + warehouse_id=self.warehouse_main.id, + source_type=models.StockChangeSourceEnum.PURCHASE, + source_id=self.purchase_order.id, + products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('5.00'), Decimal('6.00')]}], + ) + + self.assertEqual(created_count, 2) + self.assertEqual(record.details.count(), 2) + + def test_restrict_in_out_outgoing_consumes_detail(self): + """严进严出模式的出库需要消耗入库明细""" + self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT + self.warehouse_main.save() + + products = [{ + 'product': self.product_fabric_a.id, + 'consume_with': [self.stock_detail_in.id], + }] + + record, details, created_count = services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.REMOVE, + warehouse_id=self.warehouse_main.id, + source_type=models.StockChangeSourceEnum.SALES, + source_id=123, + products=products, + ) + + self.assertEqual(created_count, 1) + detail = details[0] + self.stock_detail_in.refresh_from_db() + self.assertTrue(self.stock_detail_in.is_consumed) + self.assertEqual(detail.consume_with_id, self.stock_detail_in.id) + self.assertEqual(detail.quantity, self.stock_detail_in.quantity) + + def test_restrict_in_out_outgoing_requires_consume_ids(self): + self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT + self.warehouse_main.save() + + with self.assertRaises(ValueError): services.create_stock_change_record_with_details( merchant=self.merchant, created_by=None, - type=models.StockChangeTypeEnum.ADD, + type=models.StockChangeTypeEnum.REMOVE, warehouse_id=self.warehouse_main.id, - source_type=models.StockChangeSourceEnum.PURCHASE, - source_id=self.purchase_order.id, - products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('10.00')]}], + source_type=models.StockChangeSourceEnum.SALES, + source_id=123, + products=[{'product': self.product_fabric_a.id}], ) - self.assertIn('严进严出', str(ctx.exception)) + def test_restrict_in_out_outgoing_rejects_consumed_detail(self): + self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT + self.warehouse_main.save() + + products = [{ + 'product': self.product_fabric_a.id, + 'consume_with': [self.stock_detail_in.id], + }] + + services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.REMOVE, + warehouse_id=self.warehouse_main.id, + source_type=models.StockChangeSourceEnum.SALES, + source_id=123, + products=products, + ) + + with self.assertRaises(ValueError): + services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.REMOVE, + warehouse_id=self.warehouse_main.id, + source_type=models.StockChangeSourceEnum.SALES, + source_id=124, + products=products, + ) + + def test_restrict_in_out_outgoing_rejects_other_warehouse_detail(self): + self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT + self.warehouse_main.save() + + other_record = models.StockChangeRecord.objects.create( + merchant=self.merchant, + type=models.StockChangeTypeEnum.ADD, + source_type=models.StockChangeSourceEnum.PURCHASE, + warehouse=self.warehouse_backup, + ) + other_detail = models.StockChangeDetail.objects.create( + merchant=self.merchant, + stock_change_record=other_record, + product=self.product_fabric_a, + quantity=Decimal('8.00'), + unit=self.unit_meter, + ) + + products = [{ + 'product': self.product_fabric_a.id, + 'consume_with': [other_detail.id], + }] + + with self.assertRaises(ValueError): + services.create_stock_change_record_with_details( + merchant=self.merchant, + created_by=None, + type=models.StockChangeTypeEnum.REMOVE, + warehouse_id=self.warehouse_main.id, + source_type=models.StockChangeSourceEnum.SALES, + source_id=125, + products=products, + ) class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase): @@ -748,5 +855,82 @@ class StockServicesIntegrationTestCase(StockServicesTestCase): self.assertEqual(out_snapshot.quantity_after, 50) +class StockFlowServiceTestCase(TestCase): + """StockFlowService 统一出入库服务测试""" + + def setUp(self): + self.merchant = Merchant.objects.create( + name='统一服务商户', + type=MerchantTypeEnum.FACTORY, + ) + self.category = ProductCategory.objects.create( + merchant=self.merchant, + name='面料', + product_prefix='FAB', + ) + self.product = Product.objects.create( + merchant=self.merchant, + category=self.category, + name='测试面料', + human_id='FAB-0001', + unit=ProductUnitEnum.METER, + ) + self.strict_warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='严谨仓', + mode=WareHouseModeEnum.RESTRICT_IN, + ) + self.relaxed_warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='宽松仓', + mode=WareHouseModeEnum.UNRESTRICTED, + ) + self.restrict_in_out = WareHouse.objects.create( + merchant=self.merchant, + name='严进严出仓', + mode=WareHouseModeEnum.RESTRICT_IN_OUT, + ) + self.service = services.StockFlowService(merchant=self.merchant, created_by=None) + + def test_stock_in_strict_mode_uses_quantities(self): + record, details, count = self.service.stock_in( + warehouse_id=self.strict_warehouse.id, + source_type=models.StockChangeSourceEnum.PURCHASE, + source_id=10, + items=[{'product_id': self.product.id, 'quantities': ['5', '3']}], + ) + self.assertEqual(count, 2) + self.assertEqual(record.warehouse_id, self.strict_warehouse.id) + self.assertEqual(len(details), 2) + + def test_stock_in_relaxed_mode_splits_value(self): + _, details, count = self.service.stock_in( + warehouse_id=self.relaxed_warehouse.id, + source_type=models.StockChangeSourceEnum.PURCHASE, + source_id=11, + items=[{'product_id': self.product.id, 'value': '9', 'num_of_rolls': 4}], + ) + self.assertEqual(count, 3) # 4 + 4 + 1 + self.assertEqual(len(details), 3) + + def test_stock_out_restrict_mode_consumes_details(self): + _, inbound_details, _ = self.service.stock_in( + warehouse_id=self.restrict_in_out.id, + source_type=models.StockChangeSourceEnum.PURCHASE, + source_id=12, + items=[{'product_id': self.product.id, 'quantities': ['6']}], + ) + detail_id = inbound_details[0].id + _, outbound_details, _ = self.service.stock_out( + warehouse_id=self.restrict_in_out.id, + source_type=models.StockChangeSourceEnum.SALES, + source_id=13, + items=[{'product_id': self.product.id, 'consume_detail_ids': [detail_id]}], + ) + inbound_details[0].refresh_from_db() + self.assertTrue(inbound_details[0].is_consumed) + self.assertEqual(outbound_details[0].consume_with_id, detail_id) + + # 禁用测试期间的日志输出 logging.disable(logging.CRITICAL) diff --git a/uv.lock b/uv.lock index b54c9be..38ec459 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -29,6 +41,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "billiard" +version = "4.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/50/cc2b8b6e6433918a6b9a3566483b743dcd229da1e974be9b5f259db3aad7/billiard-4.2.3.tar.gz", hash = "sha256:96486f0885afc38219d02d5f0ccd5bec8226a414b834ab244008cbb0025b8dcb", size = 156450, upload-time = "2025-11-16T17:47:30.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/cc/38b6f87170908bd8aaf9e412b021d17e85f690abe00edf50192f1a4566b9/billiard-4.2.3-py3-none-any.whl", hash = "sha256:989e9b688e3abf153f307b68a1328dfacfb954e30a4f920005654e276c69236b", size = 87042, upload-time = "2025-11-16T17:47:29.005Z" }, +] + +[[package]] +name = "celery" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -75,6 +115,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, ] +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -84,6 +161,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" }, +] + [[package]] name = "django" version = "5.2.7" @@ -213,6 +325,8 @@ name = "flower" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "celery" }, + { name = "coverage" }, { name = "django" }, { name = "django-cors-headers" }, { name = "django-environ" }, @@ -236,6 +350,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "celery", specifier = ">=5.5.3" }, + { name = "coverage", specifier = ">=7.12.0" }, { name = "django", specifier = ">=5.2.7" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, { name = "django-environ", specifier = ">=0.12.0" }, @@ -320,6 +436,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + [[package]] name = "markdown" version = "3.10" @@ -380,6 +511,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "psycopg" version = "3.2.12" @@ -492,6 +635,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -673,3 +828,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef468 wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +]