forked from erp-dev/erp
feat: running on docker fully, and added rabbitmq/worker container
This commit is contained in:
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
741
api_backend.md
Normal 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
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
108
api_v1/views/stock_change_views/test_stock_change_api.py
Normal file
108
api_v1/views/stock_change_views/test_stock_change_api.py
Normal 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))
|
||||
|
||||
17
basic_info/migrations/0013_alter_customer_options.py
Normal file
17
basic_info/migrations/0013_alter_customer_options.py
Normal 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': '客户资料'},
|
||||
),
|
||||
]
|
||||
@@ -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
0
business/__init__.py
Normal file
11
business/admin.py
Normal file
11
business/admin.py
Normal 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
7
business/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BusinessConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'business'
|
||||
verbose_name = '业务模块'
|
||||
43
business/migrations/0001_initial.py
Normal file
43
business/migrations/0001_initial.py
Normal 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': '采购单',
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
0
business/migrations/__init__.py
Normal file
0
business/migrations/__init__.py
Normal file
31
business/models.py
Normal file
31
business/models.py
Normal 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
3
business/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
business/views.py
Normal file
3
business/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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
486
docs/20251125-stock.md
Normal 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
|
||||
60
docs/STOCK_FLOW_SERVICE.md
Normal file
60
docs/STOCK_FLOW_SERVICE.md
Normal 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` 解析与私有方法,无需触及业务层。
|
||||
|
||||
整体来看,该方案清晰地分离了“业务调用入口”和“模式细节实现”,有助于后续在采购、销售、生产等更高抽象的流程中快速复用库存操作。***
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
|
||||
15
flower/celery.py
Normal file
15
flower/celery.py
Normal 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}')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
22
stock/migrations/0005_stockchangedetail_consume_fields.py
Normal file
22
stock/migrations/0005_stockchangedetail_consume_fields.py
Normal 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='所消耗的入库明细'),
|
||||
),
|
||||
]
|
||||
|
||||
21
stock/migrations/0006_remove_purchaseorder.py
Normal file
21
stock/migrations/0006_remove_purchaseorder.py
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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查找库存记录"""
|
||||
|
||||
|
||||
202
stock/tests.py
202
stock/tests.py
@@ -6,6 +6,7 @@ from unittest.mock import patch, MagicMock
|
||||
import logging
|
||||
|
||||
from basic_info.models import Product, WareHouse, Supplier, ProductUnitEnum, ProductCategory, Merchant, MerchantTypeEnum, WareHouseModeEnum
|
||||
from business.models import PurchaseOrder
|
||||
from . import models, services
|
||||
|
||||
|
||||
@@ -83,7 +84,7 @@ class StockServicesTestCase(TestCase):
|
||||
def setUp(self):
|
||||
"""每个测试方法执行前的设置"""
|
||||
# 创建采购单
|
||||
self.purchase_order = models.PurchaseOrder.objects.create(
|
||||
self.purchase_order = PurchaseOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
supplier=self.supplier,
|
||||
order_date=timezone.now().date(),
|
||||
@@ -456,23 +457,129 @@ class CreateStockChangeRecordWithDetailsTestCase(StockServicesTestCase):
|
||||
|
||||
self.assertIn('宽进宽出', str(ctx.exception))
|
||||
|
||||
def test_strict_mode_not_implemented_for_restrict_in_out(self):
|
||||
"""严进严出模式暂不支持"""
|
||||
def test_restrict_in_out_incoming_behaves_like_strict(self):
|
||||
"""严进严出模式的入库沿用严谨逻辑"""
|
||||
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
self.warehouse_main.save()
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
record, details, created_count = services.create_stock_change_record_with_details(
|
||||
merchant=self.merchant,
|
||||
created_by=None,
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('5.00'), Decimal('6.00')]}],
|
||||
)
|
||||
|
||||
self.assertEqual(created_count, 2)
|
||||
self.assertEqual(record.details.count(), 2)
|
||||
|
||||
def test_restrict_in_out_outgoing_consumes_detail(self):
|
||||
"""严进严出模式的出库需要消耗入库明细"""
|
||||
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
self.warehouse_main.save()
|
||||
|
||||
products = [{
|
||||
'product': self.product_fabric_a.id,
|
||||
'consume_with': [self.stock_detail_in.id],
|
||||
}]
|
||||
|
||||
record, details, created_count = services.create_stock_change_record_with_details(
|
||||
merchant=self.merchant,
|
||||
created_by=None,
|
||||
type=models.StockChangeTypeEnum.REMOVE,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.SALES,
|
||||
source_id=123,
|
||||
products=products,
|
||||
)
|
||||
|
||||
self.assertEqual(created_count, 1)
|
||||
detail = details[0]
|
||||
self.stock_detail_in.refresh_from_db()
|
||||
self.assertTrue(self.stock_detail_in.is_consumed)
|
||||
self.assertEqual(detail.consume_with_id, self.stock_detail_in.id)
|
||||
self.assertEqual(detail.quantity, self.stock_detail_in.quantity)
|
||||
|
||||
def test_restrict_in_out_outgoing_requires_consume_ids(self):
|
||||
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
self.warehouse_main.save()
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_stock_change_record_with_details(
|
||||
merchant=self.merchant,
|
||||
created_by=None,
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
type=models.StockChangeTypeEnum.REMOVE,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=self.purchase_order.id,
|
||||
products=[{'product': self.product_fabric_a.id, 'quantity': [Decimal('10.00')]}],
|
||||
source_type=models.StockChangeSourceEnum.SALES,
|
||||
source_id=123,
|
||||
products=[{'product': self.product_fabric_a.id}],
|
||||
)
|
||||
|
||||
self.assertIn('严进严出', str(ctx.exception))
|
||||
def test_restrict_in_out_outgoing_rejects_consumed_detail(self):
|
||||
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
self.warehouse_main.save()
|
||||
|
||||
products = [{
|
||||
'product': self.product_fabric_a.id,
|
||||
'consume_with': [self.stock_detail_in.id],
|
||||
}]
|
||||
|
||||
services.create_stock_change_record_with_details(
|
||||
merchant=self.merchant,
|
||||
created_by=None,
|
||||
type=models.StockChangeTypeEnum.REMOVE,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.SALES,
|
||||
source_id=123,
|
||||
products=products,
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_stock_change_record_with_details(
|
||||
merchant=self.merchant,
|
||||
created_by=None,
|
||||
type=models.StockChangeTypeEnum.REMOVE,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.SALES,
|
||||
source_id=124,
|
||||
products=products,
|
||||
)
|
||||
|
||||
def test_restrict_in_out_outgoing_rejects_other_warehouse_detail(self):
|
||||
self.warehouse_main.mode = WareHouseModeEnum.RESTRICT_IN_OUT
|
||||
self.warehouse_main.save()
|
||||
|
||||
other_record = models.StockChangeRecord.objects.create(
|
||||
merchant=self.merchant,
|
||||
type=models.StockChangeTypeEnum.ADD,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
warehouse=self.warehouse_backup,
|
||||
)
|
||||
other_detail = models.StockChangeDetail.objects.create(
|
||||
merchant=self.merchant,
|
||||
stock_change_record=other_record,
|
||||
product=self.product_fabric_a,
|
||||
quantity=Decimal('8.00'),
|
||||
unit=self.unit_meter,
|
||||
)
|
||||
|
||||
products = [{
|
||||
'product': self.product_fabric_a.id,
|
||||
'consume_with': [other_detail.id],
|
||||
}]
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
services.create_stock_change_record_with_details(
|
||||
merchant=self.merchant,
|
||||
created_by=None,
|
||||
type=models.StockChangeTypeEnum.REMOVE,
|
||||
warehouse_id=self.warehouse_main.id,
|
||||
source_type=models.StockChangeSourceEnum.SALES,
|
||||
source_id=125,
|
||||
products=products,
|
||||
)
|
||||
|
||||
|
||||
class CreateStockChangeRecordRelaxedTestCase(StockServicesTestCase):
|
||||
@@ -748,5 +855,82 @@ class StockServicesIntegrationTestCase(StockServicesTestCase):
|
||||
self.assertEqual(out_snapshot.quantity_after, 50)
|
||||
|
||||
|
||||
class StockFlowServiceTestCase(TestCase):
|
||||
"""StockFlowService 统一出入库服务测试"""
|
||||
|
||||
def setUp(self):
|
||||
self.merchant = Merchant.objects.create(
|
||||
name='统一服务商户',
|
||||
type=MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.category = ProductCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='面料',
|
||||
product_prefix='FAB',
|
||||
)
|
||||
self.product = Product.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='测试面料',
|
||||
human_id='FAB-0001',
|
||||
unit=ProductUnitEnum.METER,
|
||||
)
|
||||
self.strict_warehouse = WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='严谨仓',
|
||||
mode=WareHouseModeEnum.RESTRICT_IN,
|
||||
)
|
||||
self.relaxed_warehouse = WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='宽松仓',
|
||||
mode=WareHouseModeEnum.UNRESTRICTED,
|
||||
)
|
||||
self.restrict_in_out = WareHouse.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='严进严出仓',
|
||||
mode=WareHouseModeEnum.RESTRICT_IN_OUT,
|
||||
)
|
||||
self.service = services.StockFlowService(merchant=self.merchant, created_by=None)
|
||||
|
||||
def test_stock_in_strict_mode_uses_quantities(self):
|
||||
record, details, count = self.service.stock_in(
|
||||
warehouse_id=self.strict_warehouse.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=10,
|
||||
items=[{'product_id': self.product.id, 'quantities': ['5', '3']}],
|
||||
)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(record.warehouse_id, self.strict_warehouse.id)
|
||||
self.assertEqual(len(details), 2)
|
||||
|
||||
def test_stock_in_relaxed_mode_splits_value(self):
|
||||
_, details, count = self.service.stock_in(
|
||||
warehouse_id=self.relaxed_warehouse.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=11,
|
||||
items=[{'product_id': self.product.id, 'value': '9', 'num_of_rolls': 4}],
|
||||
)
|
||||
self.assertEqual(count, 3) # 4 + 4 + 1
|
||||
self.assertEqual(len(details), 3)
|
||||
|
||||
def test_stock_out_restrict_mode_consumes_details(self):
|
||||
_, inbound_details, _ = self.service.stock_in(
|
||||
warehouse_id=self.restrict_in_out.id,
|
||||
source_type=models.StockChangeSourceEnum.PURCHASE,
|
||||
source_id=12,
|
||||
items=[{'product_id': self.product.id, 'quantities': ['6']}],
|
||||
)
|
||||
detail_id = inbound_details[0].id
|
||||
_, outbound_details, _ = self.service.stock_out(
|
||||
warehouse_id=self.restrict_in_out.id,
|
||||
source_type=models.StockChangeSourceEnum.SALES,
|
||||
source_id=13,
|
||||
items=[{'product_id': self.product.id, 'consume_detail_ids': [detail_id]}],
|
||||
)
|
||||
inbound_details[0].refresh_from_db()
|
||||
self.assertTrue(inbound_details[0].is_consumed)
|
||||
self.assertEqual(outbound_details[0].consume_with_id, detail_id)
|
||||
|
||||
|
||||
# 禁用测试期间的日志输出
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
173
uv.lock
generated
173
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user