1
0
forked from erp-dev/erp

feat: running on docker fully, and added rabbitmq/worker container

This commit is contained in:
2025-11-25 15:56:13 +08:00
parent cd6a2370fc
commit d90917e764
34 changed files with 2474 additions and 74 deletions

BIN
.coverage Normal file

Binary file not shown.

29
Dockerfile Normal file
View File

@@ -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"]

741
api_backend.md Normal file
View File

@@ -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 <your_token>
```
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
]
}
```

View File

@@ -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):

View File

@@ -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',
]

View File

@@ -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/<int:record_id>/', stock_change_views.get_stock_change, name='get_stock_change'),
path(
'set-merchant-auto-complete-stock-change/',

View File

@@ -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/<int:record_id>/', 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/<int:record_id>/', GetStockChangeView.as_view()),
path('set-merchant-auto-complete-stock-change/', SetMerchantAutoCompleteView.as_view()),

View File

@@ -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',

View File

@@ -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)

View File

@@ -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))

View File

@@ -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': '客户资料'},
),
]

View File

@@ -325,6 +325,9 @@ class Customer(ModelBase):
class Meta:
verbose_name = '客户资料'
verbose_name_plural = '客户资料'
permissions = [
('view_all_customers', '查看所有客户资料'),
]
class Employee(ModelBase):

0
business/__init__.py Normal file
View File

11
business/admin.py Normal file
View File

@@ -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',)

7
business/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BusinessConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'business'
verbose_name = '业务模块'

View File

@@ -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': '采购单',
},
),
],
),
]

View File

31
business/models.py Normal file
View File

@@ -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 = '采购单'

3
business/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
business/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -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:

486
docs/20251125-stock.md Normal file
View File

@@ -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/<id>/`
**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

View File

@@ -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` 解析与私有方法,无需触及业务层。
整体来看,该方案清晰地分离了“业务调用入口”和“模式细节实现”,有助于后续在采购、销售、生产等更高抽象的流程中快速复用库存操作。***

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

15
flower/celery.py Normal file
View File

@@ -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}')

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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 = (

View File

@@ -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='所消耗的入库明细'),
),
]

View File

@@ -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',
),
],
),
]

View File

@@ -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):

View File

@@ -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查找库存记录"""

View File

@@ -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)

173
uv.lock generated
View File

@@ -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" },
]