forked from erp-dev/erp
feat: log formatted
This commit is contained in:
23
api_v1/migrations/0015_alter_apiauditlog_method_and_more.py
Normal file
23
api_v1/migrations/0015_alter_apiauditlog_method_and_more.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-06-25 05:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('api_v1', '0014_alter_printingexternalsyncfailure_created_at'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='apiauditlog',
|
||||||
|
name='method',
|
||||||
|
field=models.CharField(help_text='HTTP方法,如POST、PUT、PATCH', max_length=10, verbose_name='请求方法'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='apiauditlog',
|
||||||
|
name='request_data',
|
||||||
|
field=models.JSONField(default=dict, help_text='请求的body数据(JSON格式)', verbose_name='请求体数据'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -255,9 +255,9 @@ class PrintingExternalSyncFailure(ModelBase):
|
|||||||
|
|
||||||
|
|
||||||
class ApiAuditLog(models.Model):
|
class ApiAuditLog(models.Model):
|
||||||
"""API审计日志 - 记录创建操作的历史现场
|
"""API审计日志 - 记录创建/更新操作的历史现场
|
||||||
|
|
||||||
用于保存特定模块的POST请求,记录"创建"操作的完整请求信息。
|
用于保存特定模块的POST/PUT/PATCH请求,记录"创建/更新"操作的完整请求信息。
|
||||||
通过Celery异步写入,避免影响API响应性能。
|
通过Celery异步写入,避免影响API响应性能。
|
||||||
"""
|
"""
|
||||||
url = models.CharField(
|
url = models.CharField(
|
||||||
@@ -268,12 +268,12 @@ class ApiAuditLog(models.Model):
|
|||||||
method = models.CharField(
|
method = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
verbose_name='请求方法',
|
verbose_name='请求方法',
|
||||||
help_text='HTTP方法,如POST'
|
help_text='HTTP方法,如POST、PUT、PATCH'
|
||||||
)
|
)
|
||||||
request_data = models.JSONField(
|
request_data = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
verbose_name='请求体数据',
|
verbose_name='请求体数据',
|
||||||
help_text='POST请求的body数据(JSON格式)'
|
help_text='请求的body数据(JSON格式)'
|
||||||
)
|
)
|
||||||
query_params = models.JSONField(
|
query_params = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ def _build_printing_order_before_snapshot(printing_order):
|
|||||||
{
|
{
|
||||||
'id': job.id,
|
'id': job.id,
|
||||||
'original_id': job.original_id,
|
'original_id': job.original_id,
|
||||||
|
'sub_id': job.sub_id,
|
||||||
'product_id': job.product_id,
|
'product_id': job.product_id,
|
||||||
'product_name': getattr(job.product, 'name', None),
|
'product_name': getattr(job.product, 'name', None),
|
||||||
'quantity': job.quantity,
|
'quantity': job.quantity,
|
||||||
@@ -939,6 +940,7 @@ def _upsert_external_printing_job(*, merchant, printing_order, record: dict, syn
|
|||||||
'pieces': _extract_pieces(record.get('BeiZhuC')),
|
'pieces': _extract_pieces(record.get('BeiZhuC')),
|
||||||
'description': None,
|
'description': None,
|
||||||
'original_id': record_id,
|
'original_id': record_id,
|
||||||
|
'sub_id': _extract_positive_int(record.get('SubID'), field_name='SubID'),
|
||||||
'created_by': created_by_user,
|
'created_by': created_by_user,
|
||||||
'external_product_name': None,
|
'external_product_name': None,
|
||||||
'external_raw': record,
|
'external_raw': record,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
'SeHao': '1.51',
|
'SeHao': '1.51',
|
||||||
'ShuLiang': '10.00',
|
'ShuLiang': '10.00',
|
||||||
'ShuLiangZ': '2.68',
|
'ShuLiangZ': '2.68',
|
||||||
|
'SubID': 1,
|
||||||
'YanSe': product_name,
|
'YanSe': product_name,
|
||||||
'area': '周边',
|
'area': '周边',
|
||||||
'customer': {
|
'customer': {
|
||||||
@@ -116,6 +117,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
|
|
||||||
job = printing_models.PrintingJob.objects.get(original_id=1000000)
|
job = printing_models.PrintingJob.objects.get(original_id=1000000)
|
||||||
self.assertEqual(job.product, self.existing_product)
|
self.assertEqual(job.product, self.existing_product)
|
||||||
|
self.assertEqual(job.sub_id, 1)
|
||||||
self.assertIsNone(job.external_product_name)
|
self.assertIsNone(job.external_product_name)
|
||||||
|
|
||||||
@patch('api_v1.tasks._advance_external_printing_cursor')
|
@patch('api_v1.tasks._advance_external_printing_cursor')
|
||||||
@@ -336,6 +338,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
updated_record['ShuLiang'] = '20.00'
|
updated_record['ShuLiang'] = '20.00'
|
||||||
updated_record['ShuLiangZ'] = '3.15'
|
updated_record['ShuLiangZ'] = '3.15'
|
||||||
updated_record['BeiZhuC'] = '20件'
|
updated_record['BeiZhuC'] = '20件'
|
||||||
|
updated_record['SubID'] = 2
|
||||||
mock_fetch_image.return_value = {'bytes': b'', 'content_type': 'image/jpeg', 'name': 'unused.jpg'}
|
mock_fetch_image.return_value = {'bytes': b'', 'content_type': 'image/jpeg', 'name': 'unused.jpg'}
|
||||||
mock_advance_cursor.return_value = {'updated': True}
|
mock_advance_cursor.return_value = {'updated': True}
|
||||||
|
|
||||||
@@ -354,6 +357,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase):
|
|||||||
self.assertEqual(jobs.count(), 1)
|
self.assertEqual(jobs.count(), 1)
|
||||||
job = jobs.get()
|
job = jobs.get()
|
||||||
self.assertEqual(job.original_id, 1000001)
|
self.assertEqual(job.original_id, 1000001)
|
||||||
|
self.assertEqual(job.sub_id, 2)
|
||||||
self.assertEqual(job.quantity, 20)
|
self.assertEqual(job.quantity, 20)
|
||||||
self.assertEqual(job.size, '3.15')
|
self.assertEqual(job.size, '3.15')
|
||||||
self.assertEqual(job.pieces, 20)
|
self.assertEqual(job.pieces, 20)
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name',
|
'id', 'original_id', 'sub_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name',
|
||||||
'product_image_url', 'has_started',
|
'product_image_url', 'has_started',
|
||||||
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
|
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state', 'work_state_display',
|
'work_state', 'work_state_display',
|
||||||
@@ -480,7 +480,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'original_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name', 'product_code',
|
'id', 'original_id', 'sub_id', 'merchant_id', 'printing_order', 'printing_order_id', 'external_order_id', 'product', 'product_name', 'product_code',
|
||||||
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
|
'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state', 'work_state_display',
|
'work_state', 'work_state_display',
|
||||||
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
|
'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started',
|
||||||
@@ -543,7 +543,7 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'original_id', 'printing_order', 'external_order_id', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
'id', 'original_id', 'sub_id', 'printing_order', 'external_order_id', 'product', 'quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state',
|
'work_state',
|
||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -249,6 +249,22 @@ class PrintingOrderAPITestCase(TestCase):
|
|||||||
self.assertIn('customer_name', response.data)
|
self.assertIn('customer_name', response.data)
|
||||||
self.assertEqual(response.data['customer_name'], self.customer.name)
|
self.assertEqual(response.data['customer_name'], self.customer.name)
|
||||||
|
|
||||||
|
def test_get_printing_order_by_external_order_id(self):
|
||||||
|
"""测试通过 external_order_id 获取订单详情"""
|
||||||
|
order = printing_models.PrintingOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
fabric='通过外部编号查询',
|
||||||
|
width='150cm',
|
||||||
|
external_order_id='KD20410611',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/api/v1/printing-orders/by-external-order-id/KD20410611/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['id'], order.id)
|
||||||
|
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
||||||
|
self.assertEqual(response.data['fabric'], '通过外部编号查询')
|
||||||
|
|
||||||
def test_update_printing_order(self):
|
def test_update_printing_order(self):
|
||||||
"""测试更新订单"""
|
"""测试更新订单"""
|
||||||
order = printing_models.PrintingOrder.objects.create(
|
order = printing_models.PrintingOrder.objects.create(
|
||||||
|
|||||||
@@ -114,12 +114,14 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
'unit': '米',
|
'unit': '米',
|
||||||
'size': '50*60',
|
'size': '50*60',
|
||||||
'pieces': 10,
|
'pieces': 10,
|
||||||
|
'sub_id': 7,
|
||||||
'description': '测试备注'
|
'description': '测试备注'
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
response = self.client.post('/api/v1/printing-jobs/', data, format='json')
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
||||||
|
self.assertEqual(response.data['sub_id'], 7)
|
||||||
|
|
||||||
# 新增字段:批量推进记录(稳定输出 key,默认空数组)
|
# 新增字段:批量推进记录(稳定输出 key,默认空数组)
|
||||||
self.assertIn('batch_advance_records', response.data)
|
self.assertIn('batch_advance_records', response.data)
|
||||||
@@ -135,6 +137,7 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
self.assertEqual(job.quantity, 100)
|
self.assertEqual(job.quantity, 100)
|
||||||
self.assertEqual(job.unit, '米')
|
self.assertEqual(job.unit, '米')
|
||||||
self.assertEqual(job.pieces, 10)
|
self.assertEqual(job.pieces, 10)
|
||||||
|
self.assertEqual(job.sub_id, 7)
|
||||||
self.assertFalse(job.is_production_completed)
|
self.assertFalse(job.is_production_completed)
|
||||||
|
|
||||||
def test_create_printing_job_ignores_is_production_completed(self):
|
def test_create_printing_job_ignores_is_production_completed(self):
|
||||||
@@ -162,7 +165,8 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
quantity=100,
|
quantity=100,
|
||||||
unit='米',
|
unit='米',
|
||||||
size='50*60',
|
size='50*60',
|
||||||
pieces=10
|
pieces=10,
|
||||||
|
sub_id=101
|
||||||
)
|
)
|
||||||
job2 = printing_models.PrintingJob.objects.create(
|
job2 = printing_models.PrintingJob.objects.create(
|
||||||
printing_order=self.printing_order,
|
printing_order=self.printing_order,
|
||||||
@@ -170,12 +174,16 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
quantity=200,
|
quantity=200,
|
||||||
unit='米',
|
unit='米',
|
||||||
size='60*70',
|
size='60*70',
|
||||||
pieces=20
|
pieces=20,
|
||||||
|
sub_id=202
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get('/api/v1/printing-jobs/')
|
response = self.client.get('/api/v1/printing-jobs/')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['count'], 2)
|
self.assertEqual(response.data['count'], 2)
|
||||||
|
sub_ids_by_id = {item['id']: item['sub_id'] for item in response.data['results']}
|
||||||
|
self.assertEqual(sub_ids_by_id[job1.id], 101)
|
||||||
|
self.assertEqual(sub_ids_by_id[job2.id], 202)
|
||||||
|
|
||||||
# 新增字段:批量推进记录(稳定输出 key)
|
# 新增字段:批量推进记录(稳定输出 key)
|
||||||
for item in response.data['results']:
|
for item in response.data['results']:
|
||||||
@@ -640,6 +648,7 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
'unit': '码',
|
'unit': '码',
|
||||||
'size': '60*70',
|
'size': '60*70',
|
||||||
'pieces': 20,
|
'pieces': 20,
|
||||||
|
'sub_id': 8,
|
||||||
'description': '更新后的备注'
|
'description': '更新后的备注'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,11 +659,13 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
||||||
|
self.assertEqual(response.data['sub_id'], 8)
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
self.assertEqual(job.quantity, 200)
|
self.assertEqual(job.quantity, 200)
|
||||||
self.assertEqual(job.unit, '码')
|
self.assertEqual(job.unit, '码')
|
||||||
self.assertEqual(job.pieces, 20)
|
self.assertEqual(job.pieces, 20)
|
||||||
|
self.assertEqual(job.sub_id, 8)
|
||||||
self.assertFalse(job.is_production_completed)
|
self.assertFalse(job.is_production_completed)
|
||||||
|
|
||||||
def test_update_printing_job_ignores_is_production_completed(self):
|
def test_update_printing_job_ignores_is_production_completed(self):
|
||||||
@@ -724,7 +735,8 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
|
|
||||||
patch_data = {
|
patch_data = {
|
||||||
'quantity': 150,
|
'quantity': 150,
|
||||||
'pieces': 15
|
'pieces': 15,
|
||||||
|
'sub_id': None
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.patch(
|
response = self.client.patch(
|
||||||
@@ -734,10 +746,12 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
self.assertEqual(response.data['external_order_id'], 'KD20410611')
|
||||||
|
self.assertIsNone(response.data['sub_id'])
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
self.assertEqual(job.quantity, 150)
|
self.assertEqual(job.quantity, 150)
|
||||||
self.assertEqual(job.pieces, 15)
|
self.assertEqual(job.pieces, 15)
|
||||||
|
self.assertIsNone(job.sub_id)
|
||||||
self.assertEqual(job.unit, '米') # 未修改字段保持不变
|
self.assertEqual(job.unit, '米') # 未修改字段保持不变
|
||||||
|
|
||||||
def test_delete_printing_job_forbidden(self):
|
def test_delete_printing_job_forbidden(self):
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
|||||||
):
|
):
|
||||||
queryset = queryset.filter(is_invalid=False)
|
queryset = queryset.filter(is_invalid=False)
|
||||||
|
|
||||||
if self.action in ["list", "retrieve"]:
|
if self.action in ["list", "retrieve", "by_external_order_id"]:
|
||||||
queryset = queryset.select_related("customer")
|
queryset = queryset.select_related("customer")
|
||||||
# 预取 printing_jobs 及其 business_object,用于状态汇总统计
|
# 预取 printing_jobs 及其 business_object,用于状态汇总统计
|
||||||
# 同时预取 state_logs 和 process 相关数据以减少 job.status 属性调用时的 N+1 查询
|
# 同时预取 state_logs 和 process 相关数据以减少 job.status 属性调用时的 N+1 查询
|
||||||
@@ -327,6 +327,29 @@ class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet):
|
|||||||
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
status=status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=False,
|
||||||
|
methods=["get"],
|
||||||
|
url_path=r"by-external-order-id/(?P<external_order_id>[^/.]+)",
|
||||||
|
)
|
||||||
|
def by_external_order_id(self, request, external_order_id=None):
|
||||||
|
"""通过 external_order_id 获取印染订单详情"""
|
||||||
|
normalized_external_order_id = str(external_order_id or "").strip()
|
||||||
|
printing_order = (
|
||||||
|
self.get_queryset()
|
||||||
|
.filter(external_order_id=normalized_external_order_id)
|
||||||
|
.order_by("id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not printing_order:
|
||||||
|
return Response(
|
||||||
|
{"detail": "未找到该 external_order_id 对应的印染订单"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(printing_order)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
def invalidate(self, request, pk=None):
|
def invalidate(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase):
|
|||||||
'SeHao': '1.51',
|
'SeHao': '1.51',
|
||||||
'ShuLiang': quantity,
|
'ShuLiang': quantity,
|
||||||
'ShuLiangZ': '2.68',
|
'ShuLiangZ': '2.68',
|
||||||
|
'SubID': 1,
|
||||||
'YanSe': product_name or self.product_keep.name,
|
'YanSe': product_name or self.product_keep.name,
|
||||||
'area': '周边',
|
'area': '周边',
|
||||||
'customer': {
|
'customer': {
|
||||||
@@ -220,6 +221,7 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase):
|
|||||||
self.assertEqual(order.area, '周边')
|
self.assertEqual(order.area, '周边')
|
||||||
self.assertEqual(keep_job.quantity, 25)
|
self.assertEqual(keep_job.quantity, 25)
|
||||||
self.assertEqual(keep_job.original_id, 1000008)
|
self.assertEqual(keep_job.original_id, 1000008)
|
||||||
|
self.assertEqual(keep_job.sub_id, 1)
|
||||||
self.assertFalse(printing_models.PrintingJob.objects.filter(id=stale_job.id).exists())
|
self.assertFalse(printing_models.PrintingJob.objects.filter(id=stale_job.id).exists())
|
||||||
self.assertEqual(keep_job.business_object.state_logs.filter(is_cancelled=False).count(), 0)
|
self.assertEqual(keep_job.business_object.state_logs.filter(is_cancelled=False).count(), 0)
|
||||||
|
|
||||||
@@ -229,6 +231,7 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase):
|
|||||||
self.assertTrue(audit.allow_reset_stateflow)
|
self.assertTrue(audit.allow_reset_stateflow)
|
||||||
self.assertEqual(audit.before_snapshot['printing_order']['fabric'], '旧布料')
|
self.assertEqual(audit.before_snapshot['printing_order']['fabric'], '旧布料')
|
||||||
self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2)
|
self.assertEqual(len(audit.before_snapshot['printing_jobs']), 2)
|
||||||
|
self.assertIn('sub_id', audit.before_snapshot['printing_jobs'][0])
|
||||||
|
|
||||||
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
@patch('api_v1.tasks._fetch_external_printing_order_snapshot')
|
||||||
def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot):
|
def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot):
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class PrintingJobByCustomerAPITest(TestCase):
|
|||||||
product=self.product,
|
product=self.product,
|
||||||
quantity=10,
|
quantity=10,
|
||||||
unit='米',
|
unit='米',
|
||||||
|
sub_id=11,
|
||||||
)
|
)
|
||||||
printing_models.PrintingJob.objects.filter(id=self.job_in_range.id).update(created_at=in_range)
|
printing_models.PrintingJob.objects.filter(id=self.job_in_range.id).update(created_at=in_range)
|
||||||
|
|
||||||
@@ -253,6 +254,7 @@ class PrintingJobByCustomerAPITest(TestCase):
|
|||||||
self.assertEqual(len(resp.data), 1)
|
self.assertEqual(len(resp.data), 1)
|
||||||
self.assertEqual(resp.data[0]['id'], self.job_in_range.id)
|
self.assertEqual(resp.data[0]['id'], self.job_in_range.id)
|
||||||
self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611')
|
self.assertEqual(resp.data[0]['external_order_id'], 'KD20410611')
|
||||||
|
self.assertEqual(resp.data[0]['sub_id'], 11)
|
||||||
self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
|
self.assertEqual(resp.data[0]['billed_quantity'], '20.00')
|
||||||
|
|
||||||
def test_filter_by_printing_order(self):
|
def test_filter_by_printing_order(self):
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer):
|
|||||||
fields = [
|
fields = [
|
||||||
'id',
|
'id',
|
||||||
'original_id',
|
'original_id',
|
||||||
|
'sub_id',
|
||||||
'printing_order',
|
'printing_order',
|
||||||
'external_order_id',
|
'external_order_id',
|
||||||
'product',
|
'product',
|
||||||
|
|||||||
32
apparmor/install-ai-coding-tools-apparmor.sh
Executable file
32
apparmor/install-ai-coding-tools-apparmor.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
src="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/local-ai-coding-tools"
|
||||||
|
dst="/etc/apparmor.d/local-ai-coding-tools"
|
||||||
|
|
||||||
|
if [[ ! -f "$src" ]]; then
|
||||||
|
echo "Profile not found: $src" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing AppArmor profile:"
|
||||||
|
echo " $src"
|
||||||
|
echo " -> $dst"
|
||||||
|
echo
|
||||||
|
echo "This does not restart ssh, networking, VS Code, or the machine."
|
||||||
|
echo "It only loads/reloads the single AppArmor profile file above."
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ -f "$dst" ]]; then
|
||||||
|
backup="/etc/apparmor.d/local-ai-coding-tools.$(date +%Y%m%d%H%M%S).bak"
|
||||||
|
echo "Backing up existing profile to:"
|
||||||
|
echo " $backup"
|
||||||
|
sudo cp "$dst" "$backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo install -m 0644 "$src" "$dst"
|
||||||
|
sudo apparmor_parser -r "$dst"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Loaded $dst"
|
||||||
|
echo "Restart Codex/VS Code extension hosts for already-running processes to pick up the new exec profiles."
|
||||||
34
apparmor/local-ai-coding-tools
Normal file
34
apparmor/local-ai-coding-tools
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
abi <abi/4.0>,
|
||||||
|
include <tunables/global>
|
||||||
|
|
||||||
|
# Local allow-list for AI coding tools that need unprivileged user namespaces
|
||||||
|
# for their own sandboxes on Ubuntu systems with
|
||||||
|
# kernel.apparmor_restrict_unprivileged_userns=1.
|
||||||
|
|
||||||
|
profile local-codex-cli /home/f/.codex/packages/standalone/releases/0.142.1-x86_64-unknown-linux-musl/bin/codex flags=(unconfined) {
|
||||||
|
userns,
|
||||||
|
|
||||||
|
include if exists <local/local-codex-cli>
|
||||||
|
}
|
||||||
|
|
||||||
|
profile local-openai-vscode-codex /home/f/.vscode-server/extensions/openai.chatgpt-26.616.81150-linux-x64/bin/linux-x86_64/codex flags=(unconfined) {
|
||||||
|
userns,
|
||||||
|
|
||||||
|
include if exists <local/local-openai-vscode-codex>
|
||||||
|
}
|
||||||
|
|
||||||
|
profile local-anthropic-claude-code /home/f/.vscode-server/extensions/anthropic.claude-code-2.1.191-linux-x64/resources/native-binary/claude flags=(unconfined) {
|
||||||
|
userns,
|
||||||
|
|
||||||
|
include if exists <local/local-anthropic-claude-code>
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cline runs inside the VS Code Server extension host, which is this Node
|
||||||
|
# binary in the current Remote SSH server installation. This profile is broader
|
||||||
|
# than the tool-specific profiles above because multiple VS Code extensions
|
||||||
|
# share this host process.
|
||||||
|
profile local-vscode-server-node /home/f/.vscode-server/cli/servers/Stable-7e7950df89d055b5a378379db9ee14290772148a/server/node flags=(unconfined) {
|
||||||
|
userns,
|
||||||
|
|
||||||
|
include if exists <local/local-vscode-server-node>
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ services:
|
|||||||
- "8000"
|
- "8000"
|
||||||
- --workers
|
- --workers
|
||||||
- "${UVICORN_WORKERS:-1}"
|
- "${UVICORN_WORKERS:-1}"
|
||||||
|
- --log-config
|
||||||
|
- flower/uvicorn_log_config.json
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-8100}:8000"
|
- "${WEB_PORT:-8100}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ services:
|
|||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
- --port
|
- --port
|
||||||
- "8100"
|
- "8100"
|
||||||
|
- --log-config
|
||||||
|
- flower/uvicorn_log_config.json
|
||||||
ports:
|
ports:
|
||||||
- "8100:8100"
|
- "8100:8100"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
483
docs/api_v1_printing_jobs_api_2026-06-26.md
Normal file
483
docs/api_v1_printing_jobs_api_2026-06-26.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# API v1 印染任务接口说明(printing-jobs)
|
||||||
|
|
||||||
|
本文档汇总 `/api/v1/printing-jobs/` 相关接口,按当前代码实现整理,重点覆盖列表和详情接口。
|
||||||
|
|
||||||
|
## 基础信息
|
||||||
|
|
||||||
|
- 基础路径:`/api/v1/printing-jobs/`
|
||||||
|
- 资源模型:`printing.PrintingJob`
|
||||||
|
- 认证:需要已登录用户
|
||||||
|
- 权限:
|
||||||
|
- 用户必须属于印染工厂商户
|
||||||
|
- 常规 CRUD 受 Django model permission 控制,例如 `printing.view_printingjob`
|
||||||
|
- 默认按 `printing_order.customer` 做客户可见性过滤
|
||||||
|
- 拥有 `printing.view_all_printingorders` 权限可突破客户可见性限制
|
||||||
|
- 分页:`limit/offset`
|
||||||
|
- 默认 `limit=800`
|
||||||
|
- 最大 `limit=1000`
|
||||||
|
|
||||||
|
## 接口总览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/v1/printing-jobs/` | 获取印染任务列表 |
|
||||||
|
| `GET` | `/api/v1/printing-jobs/{id}/` | 获取印染任务详情 |
|
||||||
|
| `POST` | `/api/v1/printing-jobs/` | 创建印染任务 |
|
||||||
|
| `PUT` | `/api/v1/printing-jobs/{id}/` | 全量更新印染任务 |
|
||||||
|
| `PATCH` | `/api/v1/printing-jobs/{id}/` | 部分更新印染任务 |
|
||||||
|
| `DELETE` | `/api/v1/printing-jobs/{id}/` | 已禁用,返回 405 |
|
||||||
|
| `POST` | `/api/v1/printing-jobs/mark-production-completed/` | 显式标记任务完成生产 |
|
||||||
|
| `POST` | `/api/v1/printing-jobs/{id}/advance-to-next-state/` | 推进到下一个流程节点 |
|
||||||
|
| `POST` | `/api/v1/printing-jobs/{id}/step-back-one-state/` | 回退一个流程节点 |
|
||||||
|
| `GET` | `/api/v1/printing-jobs/{id}/completed-states/` | 查询已完成流程节点 |
|
||||||
|
| `GET` | `/api/v1/printing-jobs/{id}/timeline/` | 查询流程时间线 |
|
||||||
|
|
||||||
|
## 1. 列表
|
||||||
|
|
||||||
|
`GET /api/v1/printing-jobs/`
|
||||||
|
|
||||||
|
### Schema 注意事项
|
||||||
|
|
||||||
|
- 列表响应固定为分页对象:`count`、`next`、`previous`、`results`。
|
||||||
|
- `results[]` 中除下表特别说明外,字段会稳定返回;没有值时通常返回 `null`、空字符串、空数组或 `false`。
|
||||||
|
- `is_sales_order_bound` 是条件字段:默认返回;当查询参数 `include_sales_order_bound=false`、`0` 或 `no` 时,该字段不会出现在 `results[]` 对象中。
|
||||||
|
- 列表接口不返回详情专属字段 `product_code`、`status_id`。
|
||||||
|
|
||||||
|
### 查询参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `printing_order` | integer | 否 | 按印染订单内部 ID 精确过滤 |
|
||||||
|
| `external_order_id` | string | 否 | 按所属订单 `external_order_id` 模糊过滤 |
|
||||||
|
| `product` | integer | 否 | 按产品 ID 精确过滤 |
|
||||||
|
| `product_name` | string | 否 | 按产品名称模糊过滤 |
|
||||||
|
| `unit` | string | 否 | 按单位模糊过滤 |
|
||||||
|
| `quantity_min` | number | 否 | 最小数量 |
|
||||||
|
| `quantity_max` | number | 否 | 最大数量 |
|
||||||
|
| `pieces_min` | number | 否 | 最小件数 |
|
||||||
|
| `pieces_max` | number | 否 | 最大件数 |
|
||||||
|
| `is_production_completed` | boolean | 否 | 是否已显式标记完成生产 |
|
||||||
|
| `include_sales_order_bound` | boolean/string | 否 | 是否返回 `is_sales_order_bound`;传 `false`/`0`/`no` 时不返回该字段 |
|
||||||
|
| `search` | string | 否 | 全文搜索:产品名称、单位、尺寸、备注 |
|
||||||
|
| `ordering` | string | 否 | 排序字段,支持 `id`、`created_at`、`updated_at`、`quantity`、`pieces`;前缀 `-` 表示倒序 |
|
||||||
|
| `limit` | integer | 否 | 每页条数,默认 800,最大 1000 |
|
||||||
|
| `offset` | integer | 否 | 分页偏移量 |
|
||||||
|
|
||||||
|
默认排序:`-created_at`。
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
列表使用 `PrintingJobListSerializer`。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | integer | 印染任务 ID |
|
||||||
|
| `original_id` | integer/null | 克隆来源任务 ID;外部同步场景保存外部 record `ID` |
|
||||||
|
| `sub_id` | integer/null | 外部明细子 ID;外部同步场景来自 `SubID` |
|
||||||
|
| `merchant_id` | integer/null | 所属商户 ID |
|
||||||
|
| `printing_order` | integer | 印染订单内部 ID |
|
||||||
|
| `printing_order_id` | string | 印染订单 `human_id` |
|
||||||
|
| `external_order_id` | string/null | 所属订单外部编号 |
|
||||||
|
| `product` | integer | 产品 ID |
|
||||||
|
| `product_name` | string/null | 产品名称 |
|
||||||
|
| `product_image_url` | string/null | 产品主图 URL |
|
||||||
|
| `has_started` | boolean | 是否已有未撤销流程记录 |
|
||||||
|
| `quantity` | integer | 数量 |
|
||||||
|
| `billed_quantity` | decimal string | 已关联非作废销售单明细数量合计 |
|
||||||
|
| `unit` | string | 单位 |
|
||||||
|
| `size` | string/null | 一段尺寸 |
|
||||||
|
| `pieces` | integer/null | 件数 |
|
||||||
|
| `description` | string/null | 备注 |
|
||||||
|
| `work_state` | integer | 业务进展枚举值 |
|
||||||
|
| `work_state_display` | string | 业务进展展示值 |
|
||||||
|
| `status` | string | 当前流程状态展示名 |
|
||||||
|
| `is_completed` | boolean | 流程是否完成 |
|
||||||
|
| `is_production_completed` | boolean | 是否显式标记完成生产 |
|
||||||
|
| `progress_percentage` | number | 流程进度百分比 |
|
||||||
|
| `last_completed_state` | string | 最后完成的流程节点名称 |
|
||||||
|
| `business_object_id` | integer/null | 关联流程实例 ID |
|
||||||
|
| `is_sales_order_bound` | boolean/可能不存在 | 是否已被非作废销售单明细绑定;可通过 `include_sales_order_bound=false` 关闭,关闭后字段不返回 |
|
||||||
|
| `batch_advance_records` | array | 与该任务相关的批量流程操作记录 |
|
||||||
|
| `saleitems` | array | 关联的有效销售品明细 |
|
||||||
|
| `fabric` | string | 所属印染订单面料 |
|
||||||
|
| `created_at` | datetime | 创建时间 |
|
||||||
|
| `updated_at` | datetime | 更新时间 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8100/api/v1/printing-jobs/?external_order_id=KD20410611&limit=50" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"original_id": 1190494,
|
||||||
|
"sub_id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"printing_order": 55,
|
||||||
|
"printing_order_id": "20260626000055",
|
||||||
|
"external_order_id": "KD20410611",
|
||||||
|
"product": 88,
|
||||||
|
"product_name": "Tj1712#12号色-24码",
|
||||||
|
"product_image_url": null,
|
||||||
|
"has_started": false,
|
||||||
|
"quantity": 10,
|
||||||
|
"billed_quantity": "0.00",
|
||||||
|
"unit": "段",
|
||||||
|
"size": "2.68",
|
||||||
|
"pieces": 10,
|
||||||
|
"description": null,
|
||||||
|
"work_state": 0,
|
||||||
|
"work_state_display": "生产中",
|
||||||
|
"status": "待印染",
|
||||||
|
"is_completed": false,
|
||||||
|
"is_production_completed": false,
|
||||||
|
"progress_percentage": 0.0,
|
||||||
|
"last_completed_state": "",
|
||||||
|
"business_object_id": 2001,
|
||||||
|
"is_sales_order_bound": false,
|
||||||
|
"batch_advance_records": [],
|
||||||
|
"saleitems": [],
|
||||||
|
"fabric": "120克本白四面弹单定",
|
||||||
|
"created_at": "2026-06-26T15:35:00+08:00",
|
||||||
|
"updated_at": "2026-06-26T15:35:00+08:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 详情
|
||||||
|
|
||||||
|
`GET /api/v1/printing-jobs/{id}/`
|
||||||
|
|
||||||
|
详情使用 `PrintingJobDetailSerializer`,比列表多返回 `product_code` 和 `status_id`。
|
||||||
|
|
||||||
|
### Schema 注意事项
|
||||||
|
|
||||||
|
- 详情响应是单个对象,不包在 `results` 中。
|
||||||
|
- 除下表特别说明外,字段会稳定返回;没有值时通常返回 `null`、空字符串、空数组或 `false`。
|
||||||
|
- `is_sales_order_bound` 是条件字段:默认返回;当查询参数 `include_sales_order_bound=false`、`0` 或 `no` 时,该字段不会出现在响应对象中。
|
||||||
|
- 详情接口不返回列表专属字段 `product_image_url`。
|
||||||
|
|
||||||
|
### 路径参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | integer | 是 | 印染任务 ID |
|
||||||
|
|
||||||
|
### 查询参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `include_sales_order_bound` | boolean/string | 否 | 是否返回 `is_sales_order_bound`;传 `false`/`0`/`no` 时不返回该字段 |
|
||||||
|
|
||||||
|
### 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | integer | 印染任务 ID |
|
||||||
|
| `original_id` | integer/null | 克隆来源任务 ID;外部同步场景保存外部 record `ID` |
|
||||||
|
| `sub_id` | integer/null | 外部明细子 ID;外部同步场景来自 `SubID` |
|
||||||
|
| `merchant_id` | integer/null | 所属商户 ID |
|
||||||
|
| `printing_order` | integer | 印染订单内部 ID |
|
||||||
|
| `printing_order_id` | string | 印染订单 `human_id` |
|
||||||
|
| `external_order_id` | string/null | 所属订单外部编号 |
|
||||||
|
| `product` | integer | 产品 ID |
|
||||||
|
| `product_name` | string/null | 产品名称 |
|
||||||
|
| `product_code` | string/null | 产品 `human_id` |
|
||||||
|
| `quantity` | integer | 数量 |
|
||||||
|
| `billed_quantity` | decimal string | 已关联非作废销售单明细数量合计 |
|
||||||
|
| `unit` | string | 单位 |
|
||||||
|
| `size` | string/null | 一段尺寸 |
|
||||||
|
| `pieces` | integer/null | 件数 |
|
||||||
|
| `description` | string/null | 备注 |
|
||||||
|
| `work_state` | integer | 业务进展枚举值 |
|
||||||
|
| `work_state_display` | string | 业务进展展示值 |
|
||||||
|
| `status` | string | 当前流程状态展示名 |
|
||||||
|
| `status_id` | integer/null | 当前待执行流程节点 ID;已完成时为空 |
|
||||||
|
| `is_completed` | boolean | 流程是否完成 |
|
||||||
|
| `is_production_completed` | boolean | 是否显式标记完成生产 |
|
||||||
|
| `has_started` | boolean | 是否已有未撤销流程记录 |
|
||||||
|
| `progress_percentage` | number | 流程进度百分比 |
|
||||||
|
| `last_completed_state` | string | 最后完成的流程节点名称 |
|
||||||
|
| `business_object_id` | integer/null | 关联流程实例 ID |
|
||||||
|
| `is_sales_order_bound` | boolean/可能不存在 | 是否已被非作废销售单明细绑定;可通过 `include_sales_order_bound=false` 关闭,关闭后字段不返回 |
|
||||||
|
| `batch_advance_records` | array | 与该任务相关的批量流程操作记录 |
|
||||||
|
| `saleitems` | array | 关联的有效销售品明细 |
|
||||||
|
| `fabric` | string | 所属印染订单面料 |
|
||||||
|
| `created_at` | datetime | 创建时间 |
|
||||||
|
| `updated_at` | datetime | 更新时间 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:8100/api/v1/printing-jobs/101/" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"original_id": 1190494,
|
||||||
|
"sub_id": 1,
|
||||||
|
"merchant_id": 1,
|
||||||
|
"printing_order": 55,
|
||||||
|
"printing_order_id": "20260626000055",
|
||||||
|
"external_order_id": "KD20410611",
|
||||||
|
"product": 88,
|
||||||
|
"product_name": "Tj1712#12号色-24码",
|
||||||
|
"product_code": "TP00088",
|
||||||
|
"quantity": 10,
|
||||||
|
"billed_quantity": "0.00",
|
||||||
|
"unit": "段",
|
||||||
|
"size": "2.68",
|
||||||
|
"pieces": 10,
|
||||||
|
"description": null,
|
||||||
|
"work_state": 0,
|
||||||
|
"work_state_display": "生产中",
|
||||||
|
"status": "待印染",
|
||||||
|
"status_id": 1,
|
||||||
|
"is_completed": false,
|
||||||
|
"is_production_completed": false,
|
||||||
|
"has_started": false,
|
||||||
|
"progress_percentage": 0.0,
|
||||||
|
"last_completed_state": "",
|
||||||
|
"business_object_id": 2001,
|
||||||
|
"is_sales_order_bound": false,
|
||||||
|
"batch_advance_records": [],
|
||||||
|
"saleitems": [],
|
||||||
|
"fabric": "120克本白四面弹单定",
|
||||||
|
"created_at": "2026-06-26T15:35:00+08:00",
|
||||||
|
"updated_at": "2026-06-26T15:35:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 创建
|
||||||
|
|
||||||
|
`POST /api/v1/printing-jobs/`
|
||||||
|
|
||||||
|
### Schema 注意事项
|
||||||
|
|
||||||
|
创建成功响应使用 `PrintingJobCreateUpdateSerializer`,不是列表/详情 serializer。因此响应只包含创建/更新字段集:`id`、`original_id`、`sub_id`、`printing_order`、`external_order_id`、`product`、`quantity`、`unit`、`size`、`pieces`、`description`、`work_state`、`batch_advance_records`。
|
||||||
|
|
||||||
|
创建成功响应不会返回这些列表/详情字段:`merchant_id`、`printing_order_id`、`product_name`、`product_code`、`product_image_url`、`status`、`status_id`、`has_started`、`is_completed`、`is_production_completed`、`progress_percentage`、`last_completed_state`、`business_object_id`、`is_sales_order_bound`、`saleitems`、`fabric`、`created_at`、`updated_at`。
|
||||||
|
|
||||||
|
### 请求体
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `printing_order` | integer | 是 | 印染订单 ID |
|
||||||
|
| `product` | integer | 是 | 产品 ID |
|
||||||
|
| `quantity` | integer | 是 | 数量,必须大于 0 |
|
||||||
|
| `unit` | string | 是 | 单位 |
|
||||||
|
| `original_id` | integer/null | 否 | 克隆来源任务 ID;外部同步通常由系统写入 |
|
||||||
|
| `sub_id` | integer/null | 否 | 外部明细子 ID |
|
||||||
|
| `size` | string/null | 否 | 一段尺寸 |
|
||||||
|
| `pieces` | integer/null | 否 | 件数 |
|
||||||
|
| `description` | string/null | 否 | 备注 |
|
||||||
|
| `work_state` | integer | 否 | 业务进展,默认 `0` |
|
||||||
|
|
||||||
|
创建接口会通过 service 自动绑定当前用户和商户,并在所属订单配置了流程时创建 `BusinessObject`。
|
||||||
|
|
||||||
|
## 4. 更新
|
||||||
|
|
||||||
|
`PUT /api/v1/printing-jobs/{id}/`
|
||||||
|
|
||||||
|
`PATCH /api/v1/printing-jobs/{id}/`
|
||||||
|
|
||||||
|
可更新字段同创建接口。更新时不允许修改 `printing_order` 绑定关系;若传入不同订单,会返回 `400 Bad Request`。
|
||||||
|
|
||||||
|
### Schema 注意事项
|
||||||
|
|
||||||
|
更新成功响应同样使用 `PrintingJobCreateUpdateSerializer`。字段出现规则与创建接口一致,不等同于列表/详情响应。
|
||||||
|
|
||||||
|
## 5. 删除
|
||||||
|
|
||||||
|
`DELETE /api/v1/printing-jobs/{id}/`
|
||||||
|
|
||||||
|
删除被禁用。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "印染款式明细不支持删除操作"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
状态码:`405 Method Not Allowed`
|
||||||
|
|
||||||
|
## 6. 标记完成生产
|
||||||
|
|
||||||
|
`POST /api/v1/printing-jobs/mark-production-completed/`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"printing_job_id": 101
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "OK"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 该接口只设置 `is_production_completed=true`
|
||||||
|
- 不等同于流程完成状态
|
||||||
|
- 不能跨商户操作
|
||||||
|
|
||||||
|
## 7. 推进流程
|
||||||
|
|
||||||
|
`POST /api/v1/printing-jobs/{id}/advance-to-next-state/`
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"temperature": "25.5",
|
||||||
|
"operator": "张三"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`parameters` 可为空;若下一流程节点定义了必填参数,则必须提供。
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "推进成功",
|
||||||
|
"data": {
|
||||||
|
"id": 101
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data` 使用 `PrintingJobDetailSerializer`。
|
||||||
|
|
||||||
|
### Schema 注意事项
|
||||||
|
|
||||||
|
`data` 是详情对象,但该接口内部直接序列化,没有传入请求上下文;因此 `is_sales_order_bound` 当前会返回,不受 `include_sales_order_bound=false` 控制。
|
||||||
|
|
||||||
|
## 8. 回退流程
|
||||||
|
|
||||||
|
`POST /api/v1/printing-jobs/{id}/step-back-one-state/`
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "回退成功",
|
||||||
|
"data": {
|
||||||
|
"id": 101
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data` 使用 `PrintingJobDetailSerializer`。
|
||||||
|
|
||||||
|
### Schema 注意事项
|
||||||
|
|
||||||
|
`data` 是详情对象,但该接口内部直接序列化,没有传入请求上下文;因此 `is_sales_order_bound` 当前会返回,不受 `include_sales_order_bound=false` 控制。
|
||||||
|
|
||||||
|
## 9. 已完成流程节点
|
||||||
|
|
||||||
|
`GET /api/v1/printing-jobs/{id}/completed-states/`
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `include_cancelled` | boolean/string | 否 | 是否包含已撤销流程记录,默认 `false` |
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 3001,
|
||||||
|
"state_id": 1,
|
||||||
|
"state_name": "待印染",
|
||||||
|
"completed_at": "2026-06-26T15:35:00+08:00",
|
||||||
|
"completed_by": "testuser",
|
||||||
|
"completed_by_name": "测试员工",
|
||||||
|
"is_cancelled": false,
|
||||||
|
"cancelled_at": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 流程时间线
|
||||||
|
|
||||||
|
`GET /api/v1/printing-jobs/{id}/timeline/`
|
||||||
|
|
||||||
|
返回该任务的完整流程时间线,不包含已撤销记录。
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 2,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"state_id": 1,
|
||||||
|
"state_name": "待印染",
|
||||||
|
"state_description": "",
|
||||||
|
"order": 1,
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-06-26T15:35:00+08:00",
|
||||||
|
"completed_by": "testuser",
|
||||||
|
"completed_by_name": "测试员工"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"state_id": 2,
|
||||||
|
"state_name": "印染中",
|
||||||
|
"state_description": "",
|
||||||
|
"order": 2,
|
||||||
|
"status": "in_progress",
|
||||||
|
"completed_at": null,
|
||||||
|
"completed_by": null,
|
||||||
|
"completed_by_name": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 字段补充
|
||||||
|
|
||||||
|
### `work_state`
|
||||||
|
|
||||||
|
当前枚举来自 `PrintingJobWorkStateEnum`:
|
||||||
|
|
||||||
|
| 值 | 展示 |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | 生产中 |
|
||||||
|
| `1` | 待送货 |
|
||||||
|
| `2` | 待开单 |
|
||||||
|
| `3` | 已完结 |
|
||||||
|
|
||||||
|
### `sub_id`
|
||||||
|
|
||||||
|
`sub_id` 是可空整数。外部印染 records 同步时从外部字段 `SubID` 写入,用于保留外部明细行的子编号。
|
||||||
364
docs/api_v1_printing_order_api_2026-06-26.md
Normal file
364
docs/api_v1_printing_order_api_2026-06-26.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# API v1 印染订单接口说明(printing-orders)
|
||||||
|
|
||||||
|
版本日期:2026-06-26
|
||||||
|
|
||||||
|
本文档仅汇总 `/api/v1/printing-orders/` 相关接口,按当前代码实现整理,用于前端核对和更新字段。
|
||||||
|
|
||||||
|
## 基本信息
|
||||||
|
|
||||||
|
- 基础路径:`/api/v1/printing-orders/`
|
||||||
|
- 认证:需要登录认证。
|
||||||
|
- 权限:使用 `DjangoModelPermissions`,且用户必须关联印染工厂类型商户。
|
||||||
|
- 数据范围:默认按客户可见性过滤;拥有 `printing.view_all_printingorders` 权限可查看全部客户订单。
|
||||||
|
- 分页:列表接口使用 `limit` / `offset`,响应为 DRF LimitOffset 格式:`count`、`next`、`previous`、`results`。
|
||||||
|
- 列表缓存:列表接口有 20 秒缓存。
|
||||||
|
- 删除:不支持物理删除,请使用作废接口。
|
||||||
|
|
||||||
|
## 本版重点字段变更
|
||||||
|
|
||||||
|
以下字段是当前接口已返回/支持、前端需要重点核对的字段:
|
||||||
|
|
||||||
|
- `merchant_id`:列表和详情返回,当前订单所属商户 ID,可能为 `null`。
|
||||||
|
- `external_order_id`:外部订单编号;列表、详情返回;创建/更新也允许传入;支持过滤。
|
||||||
|
- `external_customer_id`:外部客户 ID;列表、详情返回。
|
||||||
|
- `external_customer_name`:外部客户名称;列表、详情返回。
|
||||||
|
- `external_employee_name`:外部员工名称;列表、详情返回。
|
||||||
|
- `created_by`:列表返回,创建人用户 ID。
|
||||||
|
- `created_by_name`:列表、详情返回,创建人关联员工姓名,可能为 `null`。
|
||||||
|
- `order_number`:新增查询参数,支持同时按 `external_order_id` 和内部订单号逻辑检索。
|
||||||
|
- `jobs_status_summary`:仅列表返回,订单下任务当前状态汇总。
|
||||||
|
- `jobs_last_status_summary`:仅列表返回,订单下任务最后完成状态汇总。
|
||||||
|
|
||||||
|
## 接口清单
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/v1/printing-orders/` | 印染订单列表 |
|
||||||
|
| `POST` | `/api/v1/printing-orders/` | 创建印染订单 |
|
||||||
|
| `GET` | `/api/v1/printing-orders/{id}/` | 印染订单详情 |
|
||||||
|
| `PUT` | `/api/v1/printing-orders/{id}/` | 全量更新印染订单 |
|
||||||
|
| `PATCH` | `/api/v1/printing-orders/{id}/` | 部分更新印染订单 |
|
||||||
|
| `DELETE` | `/api/v1/printing-orders/{id}/` | 已禁用,返回 405 |
|
||||||
|
| `POST` | `/api/v1/printing-orders/{id}/invalidate/` | 作废订单 |
|
||||||
|
| `POST` | `/api/v1/printing-orders/{id}/activate/` | 恢复订单 |
|
||||||
|
| `POST` | `/api/v1/printing-orders/{id}/mark_fabric_received/` | 标记布料已收 |
|
||||||
|
|
||||||
|
## 列表接口
|
||||||
|
|
||||||
|
`GET /api/v1/printing-orders/`
|
||||||
|
|
||||||
|
### 查询参数
|
||||||
|
|
||||||
|
列表查询参数全部可选;不传任何过滤条件时,默认返回当前权限范围内的未作废订单。
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | number | 否 | 每页条数,默认 800,最大 1000 |
|
||||||
|
| `offset` | number | 否 | 偏移量 |
|
||||||
|
| `customer` | number | 否 | 客户 ID |
|
||||||
|
| `external_order_id` | string | 否 | 外部订单编号,模糊匹配 |
|
||||||
|
| `order_number` | string | 否 | 订单号检索:模糊匹配 `external_order_id`,并尝试匹配内部 `id` / `human_id` 后 6 位 |
|
||||||
|
| `customer_name` | string | 否 | 客户名称,模糊匹配 |
|
||||||
|
| `customer_phone` | string | 否 | 客户电话,模糊匹配 |
|
||||||
|
| `fabric` | string | 否 | 面料,模糊匹配 |
|
||||||
|
| `is_urgent` | boolean | 否 | 是否紧急 |
|
||||||
|
| `is_fabric_received` | boolean | 否 | 布料是否已收 |
|
||||||
|
| `is_invalid` | boolean | 否 | 是否作废;未传时默认只返回未作废订单 |
|
||||||
|
| `area` | string | 否 | 地区,模糊匹配 |
|
||||||
|
| `outgoing_date_from` | date/datetime | 否 | 出货日期起始;支持 `YYYY-MM-DD` 或日期时间 |
|
||||||
|
| `outgoing_date_to` | date/datetime | 否 | 出货日期结束;传 `YYYY-MM-DD` 时包含整天 |
|
||||||
|
| `created_date_from` | date | 否 | 创建日期起始 |
|
||||||
|
| `created_date_to` | date | 否 | 创建日期结束;包含整天 |
|
||||||
|
| `search` | string | 否 | 搜索客户名称、面料、地区、工艺、描述 |
|
||||||
|
| `ordering` | string | 否 | 排序字段,支持 `id`、`created_at`、`updated_at`、`outgoing_date`、`is_urgent`、`is_fabric_received`、`is_invalid`,默认 `-created_at` |
|
||||||
|
|
||||||
|
### 列表响应字段
|
||||||
|
|
||||||
|
`results[]` 每项字段:
|
||||||
|
|
||||||
|
列表响应字段均会返回;“可为空”表示字段值可能为 `null`、空字符串或空数组。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 是否返回 | 可为空 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | number | 是 | 否 | 内部订单 ID |
|
||||||
|
| `human_id` | string | 是 | 否 | 人类可读订单号,格式为 `YYYYMMDD + 6 位 ID` |
|
||||||
|
| `merchant_id` | number/null | 是 | 是 | 所属商户 ID |
|
||||||
|
| `customer` | number | 是 | 否 | 客户 ID |
|
||||||
|
| `customer_name` | string | 是 | 否 | 客户名称 |
|
||||||
|
| `customer_phone` | string/null | 是 | 是 | 客户手机号 |
|
||||||
|
| `fabric` | string | 是 | 否 | 面料 |
|
||||||
|
| `width` | string | 是 | 否 | 幅宽 |
|
||||||
|
| `is_urgent` | boolean | 是 | 否 | 是否紧急 |
|
||||||
|
| `area` | string/null | 是 | 是 | 地区 |
|
||||||
|
| `address` | string/null | 是 | 是 | 地址 |
|
||||||
|
| `curve` | string/null | 是 | 是 | 曲线 |
|
||||||
|
| `is_fabric_received` | boolean | 是 | 否 | 布料是否已收 |
|
||||||
|
| `outgoing_date` | datetime/null | 是 | 是 | 出货日期时间 |
|
||||||
|
| `is_invalid` | boolean | 是 | 否 | 是否作废 |
|
||||||
|
| `new_curve` | string/null | 是 | 是 | 新加曲线 |
|
||||||
|
| `process` | number/null | 是 | 是 | 印染流程 ID |
|
||||||
|
| `process_name` | string/null | 是 | 是 | 印染流程名称 |
|
||||||
|
| `progress` | number | 是 | 否 | 订单进度百分比;无任务时为 0 |
|
||||||
|
| `position` | string/null | 是 | 是 | 位置 |
|
||||||
|
| `print_count` | number | 是 | 否 | 打印次数 |
|
||||||
|
| `jobs_status_summary` | array | 是 | 是 | 订单下任务按当前状态汇总;无任务时为空数组 |
|
||||||
|
| `jobs_last_status_summary` | array | 是 | 是 | 订单下任务按最后完成状态汇总;无已完成状态时为空数组 |
|
||||||
|
| `external_order_id` | string/null | 是 | 是 | 外部订单编号 |
|
||||||
|
| `external_customer_id` | string/null | 是 | 是 | 外部客户 ID |
|
||||||
|
| `external_customer_name` | string/null | 是 | 是 | 外部客户名称 |
|
||||||
|
| `external_employee_name` | string/null | 是 | 是 | 外部员工名称 |
|
||||||
|
| `created_by` | number/null | 是 | 是 | 创建人用户 ID |
|
||||||
|
| `created_by_name` | string/null | 是 | 是 | 创建人关联员工姓名 |
|
||||||
|
| `created_at` | datetime | 是 | 否 | 创建时间 |
|
||||||
|
| `updated_at` | datetime | 是 | 否 | 更新时间 |
|
||||||
|
|
||||||
|
`jobs_status_summary` 格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"state_name": "待印染",
|
||||||
|
"state_id": 1,
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`jobs_last_status_summary` 格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"state_name": "印染中",
|
||||||
|
"count": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详情接口
|
||||||
|
|
||||||
|
`GET /api/v1/printing-orders/{id}/`
|
||||||
|
|
||||||
|
### 详情响应字段
|
||||||
|
|
||||||
|
详情响应字段均会返回;“可为空”表示字段值可能为 `null` 或空字符串。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 是否返回 | 可为空 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `id` | number | 是 | 否 | 内部订单 ID |
|
||||||
|
| `human_id` | string | 是 | 否 | 人类可读订单号 |
|
||||||
|
| `merchant_id` | number/null | 是 | 是 | 所属商户 ID |
|
||||||
|
| `customer` | number | 是 | 否 | 客户 ID |
|
||||||
|
| `customer_name` | string | 是 | 否 | 客户名称 |
|
||||||
|
| `customer_phone` | string/null | 是 | 是 | 客户手机号 |
|
||||||
|
| `customer_area` | string/null | 是 | 是 | 客户区域 |
|
||||||
|
| `fabric` | string | 是 | 否 | 面料 |
|
||||||
|
| `width` | string | 是 | 否 | 幅宽 |
|
||||||
|
| `is_urgent` | boolean | 是 | 否 | 是否紧急 |
|
||||||
|
| `area` | string/null | 是 | 是 | 地区 |
|
||||||
|
| `address` | string/null | 是 | 是 | 地址 |
|
||||||
|
| `fabric_source` | string/null | 是 | 是 | 布料来源 |
|
||||||
|
| `is_fabric_received` | boolean | 是 | 否 | 布料是否已收 |
|
||||||
|
| `craft` | string/null | 是 | 是 | 工艺 |
|
||||||
|
| `description` | string/null | 是 | 是 | 订单描述 |
|
||||||
|
| `outgoing_date` | datetime/null | 是 | 是 | 出货日期时间 |
|
||||||
|
| `curve` | string/null | 是 | 是 | 曲线 |
|
||||||
|
| `new_curve` | string/null | 是 | 是 | 新加曲线 |
|
||||||
|
| `position` | string/null | 是 | 是 | 位置 |
|
||||||
|
| `created_by_name` | string/null | 是 | 是 | 创建人关联员工姓名 |
|
||||||
|
| `printing_warn` | string/null | 是 | 是 | 打印注意事项 |
|
||||||
|
| `rolling_warn` | string/null | 是 | 是 | 滚筒注意事项 |
|
||||||
|
| `production_warn` | string/null | 是 | 是 | 生产注意事项 |
|
||||||
|
| `external_order_id` | string/null | 是 | 是 | 外部订单编号 |
|
||||||
|
| `external_customer_id` | string/null | 是 | 是 | 外部客户 ID |
|
||||||
|
| `external_customer_name` | string/null | 是 | 是 | 外部客户名称 |
|
||||||
|
| `external_employee_name` | string/null | 是 | 是 | 外部员工名称 |
|
||||||
|
| `is_invalid` | boolean | 是 | 否 | 是否作废 |
|
||||||
|
| `process` | number/null | 是 | 是 | 印染流程 ID |
|
||||||
|
| `process_name` | string/null | 是 | 是 | 印染流程名称 |
|
||||||
|
| `progress` | number | 是 | 否 | 订单进度百分比 |
|
||||||
|
| `print_count` | number | 是 | 否 | 打印次数 |
|
||||||
|
| `created_at` | datetime | 是 | 否 | 创建时间 |
|
||||||
|
| `updated_at` | datetime | 是 | 否 | 更新时间 |
|
||||||
|
|
||||||
|
注意:详情接口不返回 `jobs_status_summary` 和 `jobs_last_status_summary`,这两个字段是列表专用字段。
|
||||||
|
|
||||||
|
## 创建接口
|
||||||
|
|
||||||
|
`POST /api/v1/printing-orders/`
|
||||||
|
|
||||||
|
### 请求字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 可传空 | 默认/说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `customer` | number | 是 | 否 | 客户 ID |
|
||||||
|
| `fabric` | string | 是 | 否 | 面料 |
|
||||||
|
| `width` | string | 是 | 否 | 幅宽 |
|
||||||
|
| `is_urgent` | boolean | 否 | 否 | 默认 `false`,是否紧急 |
|
||||||
|
| `area` | string/null | 否 | 是 | 地区 |
|
||||||
|
| `address` | string/null | 否 | 是 | 地址 |
|
||||||
|
| `fabric_source` | string/null | 否 | 是 | 布料来源 |
|
||||||
|
| `is_fabric_received` | boolean | 否 | 否 | 默认 `false`,布料是否已收 |
|
||||||
|
| `craft` | string/null | 否 | 是 | 工艺 |
|
||||||
|
| `description` | string/null | 否 | 是 | 订单描述 |
|
||||||
|
| `outgoing_date` | date/datetime/null | 否 | 是 | 出货日期时间;支持 `YYYY-MM-DD`、`YYYY-MM-DD HH:mm`、`YYYY-MM-DD HH:mm:ss`、ISO8601;仅传日期时按当天 `00:00:00` 处理 |
|
||||||
|
| `curve` | string/null | 否 | 是 | 曲线 |
|
||||||
|
| `new_curve` | string/null | 否 | 是 | 新加曲线 |
|
||||||
|
| `position` | string/null | 否 | 是 | 位置 |
|
||||||
|
| `printing_warn` | string/null | 否 | 是 | 打印注意事项 |
|
||||||
|
| `rolling_warn` | string/null | 否 | 是 | 滚筒注意事项 |
|
||||||
|
| `production_warn` | string/null | 否 | 是 | 生产注意事项 |
|
||||||
|
| `is_invalid` | boolean | 否 | 否 | 默认 `false`,是否作废 |
|
||||||
|
| `process` | number/null | 否 | 创建可空,更新不可改为空 | 印染流程 ID;创建不传或传空时使用系统默认流程;更新时不能改为空 |
|
||||||
|
| `external_order_id` | string/null | 否 | 是 | 外部订单编号 |
|
||||||
|
|
||||||
|
创建时后端会自动写入:
|
||||||
|
|
||||||
|
- `merchant`:从当前用户关联员工的商户取得。
|
||||||
|
- `created_by`:当前登录用户。
|
||||||
|
- `process`:未传时尝试使用系统默认印染流程。
|
||||||
|
|
||||||
|
### 创建请求示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer": 12,
|
||||||
|
"fabric": "纯棉布料",
|
||||||
|
"width": "150cm",
|
||||||
|
"is_urgent": true,
|
||||||
|
"area": "白云区",
|
||||||
|
"address": "白云区xxx",
|
||||||
|
"fabric_source": "客户提供",
|
||||||
|
"is_fabric_received": false,
|
||||||
|
"craft": "活性印花",
|
||||||
|
"description": "测试订单描述",
|
||||||
|
"outgoing_date": "2026-06-26",
|
||||||
|
"printing_warn": "注意颜色",
|
||||||
|
"rolling_warn": "注意温度",
|
||||||
|
"production_warn": "质量检查",
|
||||||
|
"external_order_id": "KD20410611"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应使用创建/更新序列化器字段,通常包含请求字段及 `id`。
|
||||||
|
|
||||||
|
## 更新接口
|
||||||
|
|
||||||
|
`PUT /api/v1/printing-orders/{id}/`
|
||||||
|
|
||||||
|
`PATCH /api/v1/printing-orders/{id}/`
|
||||||
|
|
||||||
|
更新请求字段与创建接口一致。
|
||||||
|
|
||||||
|
必填规则:
|
||||||
|
|
||||||
|
- `PUT` 是全量更新,建议至少传 `customer`、`fabric`、`width`;未传的可写字段可能按 DRF 全量更新规则校验。
|
||||||
|
- `PATCH` 是部分更新,只需要传要修改的字段。
|
||||||
|
- `id`、`human_id`、`merchant_id`、`created_by`、`created_by_name`、`external_customer_id`、`external_customer_name`、`external_employee_name`、`created_at`、`updated_at` 等不作为请求字段提交。
|
||||||
|
|
||||||
|
### 流程修改限制
|
||||||
|
|
||||||
|
- 当订单下没有任务,或所有任务都未开始时,可以修改 `process`。
|
||||||
|
- 修改 `process` 时,后端会重建/重新绑定订单下未开始任务的流程实例。
|
||||||
|
- 如果存在任何已开始的 `PrintingJob`,修改 `process` 会返回 `400`。
|
||||||
|
- `process` 不能更新为空,否则返回错误。
|
||||||
|
|
||||||
|
错误示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"process": ["存在已开始的印染任务,无法修改流程"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"存在已开始的印染任务,无法修改流程"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 作废订单
|
||||||
|
|
||||||
|
`POST /api/v1/printing-orders/{id}/invalidate/`
|
||||||
|
|
||||||
|
权限:需要 `printing.can_invalidate_printingorder`。
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "订单已作废",
|
||||||
|
"data": {
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败情况:
|
||||||
|
|
||||||
|
- 无权限:`403`,`{"detail": "您没有权限作废订单"}`
|
||||||
|
- 已作废:`400`,`{"detail": "该订单已经作废"}`
|
||||||
|
|
||||||
|
## 恢复订单
|
||||||
|
|
||||||
|
`POST /api/v1/printing-orders/{id}/activate/`
|
||||||
|
|
||||||
|
权限:需要 `printing.can_activate_printingorder`。
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "订单已恢复",
|
||||||
|
"data": {
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败情况:
|
||||||
|
|
||||||
|
- 无权限:`403`,`{"detail": "您没有权限恢复订单"}`
|
||||||
|
- 未作废:`400`,`{"detail": "该订单未作废,无需恢复"}`
|
||||||
|
|
||||||
|
## 标记布料已收
|
||||||
|
|
||||||
|
`POST /api/v1/printing-orders/{id}/mark_fabric_received/`
|
||||||
|
|
||||||
|
成功后将 `is_fabric_received` 置为 `true`。
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "已标记布料已收",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"is_fabric_received": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败情况:
|
||||||
|
|
||||||
|
- 已经标记为已收:`400`,`{"detail": "布料已经标记为已收"}`
|
||||||
|
|
||||||
|
## 删除接口
|
||||||
|
|
||||||
|
`DELETE /api/v1/printing-orders/{id}/`
|
||||||
|
|
||||||
|
该接口已禁用,固定返回 `405`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "印染订单不支持删除操作,请使用作废功能"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端核对建议
|
||||||
|
|
||||||
|
- 列表页应优先使用 `human_id` 展示内部订单号,外部来源订单可同时展示 `external_order_id`。
|
||||||
|
- 默认列表不包含作废订单;作废订单列表需要显式传 `is_invalid=true`。
|
||||||
|
- 若前端有“订单号搜索”输入框,建议接入 `order_number`,不用同时拼多个参数。
|
||||||
|
- `outgoing_date_to=YYYY-MM-DD` 会包含当天整天,前端无需手动补 `23:59:59`。
|
||||||
|
- 外部字段 `external_customer_id`、`external_customer_name`、`external_employee_name` 当前只读返回,创建/更新接口只暴露 `external_order_id`。
|
||||||
66
docs/api_v1_printing_order_detail_by_external_order_id.md
Normal file
66
docs/api_v1_printing_order_detail_by_external_order_id.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# API v1: 按 external_order_id 获取印染订单详情
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
`GET /api/v1/printing-orders/by-external-order-id/{external_order_id}/`
|
||||||
|
|
||||||
|
用于通过外部订单编号精确查询本地印染订单详情,响应结构与 `GET /api/v1/printing-orders/{id}/` 使用同一个 `PrintingOrderDetailSerializer`。
|
||||||
|
|
||||||
|
## Path 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `external_order_id` | string | 是 | 外部订单编号,对应 `PrintingOrder.external_order_id` |
|
||||||
|
|
||||||
|
## 权限
|
||||||
|
|
||||||
|
与 `printing-orders` ViewSet 保持一致:
|
||||||
|
|
||||||
|
- 用户必须已登录
|
||||||
|
- 用户必须属于印染工厂商户
|
||||||
|
- 用户需要具备 `printing.view_printingorder` 权限
|
||||||
|
- 仍受客户可见性过滤影响;拥有 `printing.view_all_printingorders` 可查看全部客户订单
|
||||||
|
|
||||||
|
## 成功响应
|
||||||
|
|
||||||
|
状态码:`200 OK`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"human_id": "20260626000123",
|
||||||
|
"merchant_id": 1,
|
||||||
|
"customer": 10,
|
||||||
|
"customer_name": "测试客户",
|
||||||
|
"customer_phone": "13900139000",
|
||||||
|
"customer_area": "测试地区",
|
||||||
|
"fabric": "通过外部编号查询",
|
||||||
|
"width": "150cm",
|
||||||
|
"external_order_id": "KD20410611",
|
||||||
|
"external_customer_id": null,
|
||||||
|
"external_customer_name": null,
|
||||||
|
"external_employee_name": null,
|
||||||
|
"is_invalid": false,
|
||||||
|
"created_at": "2026-06-26T15:35:00+08:00",
|
||||||
|
"updated_at": "2026-06-26T15:35:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实际返回字段以 `PrintingOrderDetailSerializer` 为准。
|
||||||
|
|
||||||
|
## 未找到
|
||||||
|
|
||||||
|
状态码:`404 Not Found`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "未找到该 external_order_id 对应的印染订单"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8100/api/v1/printing-orders/by-external-order-id/KD20410611/" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
149
flower/logging_formatters.py
Normal file
149
flower/logging_formatters.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
_STANDARD_RECORD_ATTRS = {
|
||||||
|
"args",
|
||||||
|
"asctime",
|
||||||
|
"created",
|
||||||
|
"exc_info",
|
||||||
|
"exc_text",
|
||||||
|
"filename",
|
||||||
|
"funcName",
|
||||||
|
"levelname",
|
||||||
|
"levelno",
|
||||||
|
"lineno",
|
||||||
|
"message",
|
||||||
|
"module",
|
||||||
|
"msecs",
|
||||||
|
"msg",
|
||||||
|
"name",
|
||||||
|
"pathname",
|
||||||
|
"process",
|
||||||
|
"processName",
|
||||||
|
"relativeCreated",
|
||||||
|
"stack_info",
|
||||||
|
"taskName",
|
||||||
|
"thread",
|
||||||
|
"threadName",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_default(value: Any) -> str:
|
||||||
|
return repr(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_timestamp(created: float) -> str:
|
||||||
|
return datetime.fromtimestamp(created, tz=timezone.utc).isoformat(timespec="milliseconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _split_client_addr(client_addr: Any) -> tuple[str | None, int | None]:
|
||||||
|
if not isinstance(client_addr, str):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
host, sep, port = client_addr.rpartition(":")
|
||||||
|
if not sep:
|
||||||
|
return client_addr, None
|
||||||
|
try:
|
||||||
|
return host, int(port)
|
||||||
|
except ValueError:
|
||||||
|
return client_addr, None
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
"""
|
||||||
|
Emit one JSON object per log line for container log collectors.
|
||||||
|
|
||||||
|
Uvicorn access logs carry structured fields in record.args; keep using
|
||||||
|
uvicorn's own access logger and only unpack those fields at format time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, service_name: str | None = None, environment: str | None = None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.service_name = service_name or os.getenv("SERVICE_NAME", "flower")
|
||||||
|
self.environment = environment or os.getenv("ENVIRONMENT") or os.getenv("DJANGO_ENV") or ""
|
||||||
|
self.hostname = socket.gethostname()
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
event = {
|
||||||
|
"timestamp": _utc_timestamp(record.created),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"service": self.service_name,
|
||||||
|
"environment": self.environment,
|
||||||
|
"hostname": self.hostname,
|
||||||
|
"module": record.module,
|
||||||
|
"function": record.funcName,
|
||||||
|
"line": record.lineno,
|
||||||
|
"process": record.process,
|
||||||
|
"process_name": record.processName,
|
||||||
|
"thread": record.threadName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.name == "uvicorn.access":
|
||||||
|
event.update(self._format_uvicorn_access(record))
|
||||||
|
|
||||||
|
if record.exc_info:
|
||||||
|
exc_type, exc_value, _tb = record.exc_info
|
||||||
|
event["exception"] = {
|
||||||
|
"type": exc_type.__name__ if exc_type else None,
|
||||||
|
"message": str(exc_value) if exc_value else None,
|
||||||
|
"stacktrace": "".join(traceback.format_exception(*record.exc_info)),
|
||||||
|
}
|
||||||
|
elif record.exc_text:
|
||||||
|
event["exception"] = {"stacktrace": record.exc_text}
|
||||||
|
|
||||||
|
if record.stack_info:
|
||||||
|
event["stack_info"] = record.stack_info
|
||||||
|
|
||||||
|
extra = self._collect_extra(record)
|
||||||
|
if extra:
|
||||||
|
event["extra"] = extra
|
||||||
|
|
||||||
|
return json.dumps(event, ensure_ascii=False, default=_json_default, separators=(",", ":"))
|
||||||
|
|
||||||
|
def _format_uvicorn_access(self, record: logging.LogRecord) -> dict[str, Any]:
|
||||||
|
if not isinstance(record.args, tuple) or len(record.args) != 5:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
client_addr, method, full_path, http_version, status_code = record.args
|
||||||
|
split_result = urlsplit(str(full_path))
|
||||||
|
client_host, client_port = _split_client_addr(client_addr)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"client_addr": client_addr,
|
||||||
|
"client_host": client_host,
|
||||||
|
"client_port": client_port,
|
||||||
|
"method": method,
|
||||||
|
"full_path": full_path,
|
||||||
|
"path": split_result.path or str(full_path),
|
||||||
|
"query_string": split_result.query,
|
||||||
|
"http_version": http_version,
|
||||||
|
"status_code": int(status_code),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _collect_extra(self, record: logging.LogRecord) -> dict[str, Any]:
|
||||||
|
extra = {}
|
||||||
|
for key, value in record.__dict__.items():
|
||||||
|
if key.startswith("_") or key in _STANDARD_RECORD_ATTRS:
|
||||||
|
continue
|
||||||
|
extra[key] = value
|
||||||
|
return extra
|
||||||
|
|
||||||
|
|
||||||
|
class MaxLevelFilter(logging.Filter):
|
||||||
|
"""Allow records below the configured level."""
|
||||||
|
|
||||||
|
def __init__(self, max_level: str | int):
|
||||||
|
super().__init__()
|
||||||
|
self.max_level = logging._checkLevel(max_level)
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
return record.levelno < self.max_level
|
||||||
@@ -18,10 +18,10 @@ class ApiAuditLogMiddleware:
|
|||||||
"""
|
"""
|
||||||
API审计日志中间件
|
API审计日志中间件
|
||||||
|
|
||||||
用于记录特定URL前缀的POST请求,保存"创建"操作的历史现场。
|
用于记录特定URL前缀的POST/PUT/PATCH请求,保存"创建/更新"操作的历史现场。
|
||||||
|
|
||||||
特性:
|
特性:
|
||||||
- 只记录POST请求
|
- 只记录POST、PUT、PATCH请求
|
||||||
- 通过URL前缀白名单过滤
|
- 通过URL前缀白名单过滤
|
||||||
- 支持multipart/form-data请求,文件字段只记录元信息
|
- 支持multipart/form-data请求,文件字段只记录元信息
|
||||||
- 通过Celery异步写入,不阻塞API响应
|
- 通过Celery异步写入,不阻塞API响应
|
||||||
@@ -42,8 +42,8 @@ class ApiAuditLogMiddleware:
|
|||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
# 只处理POST请求
|
# 只处理POST、PUT、PATCH请求
|
||||||
if request.method != 'POST':
|
if request.method not in ('POST', 'PUT', 'PATCH'):
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
# 检查URL是否在白名单中
|
# 检查URL是否在白名单中
|
||||||
@@ -51,7 +51,7 @@ class ApiAuditLogMiddleware:
|
|||||||
logger.debug('ApiAuditLog: URL不匹配白名单, path=%s, prefixes=%s', request.path, self.url_prefixes)
|
logger.debug('ApiAuditLog: URL不匹配白名单, path=%s, prefixes=%s', request.path, self.url_prefixes)
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
logger.info('ApiAuditLog: 捕获到POST请求, path=%s', request.path)
|
logger.info('ApiAuditLog: 捕获到%s请求, path=%s', request.method, request.path)
|
||||||
|
|
||||||
# 在请求进入视图前,缓存请求体数据
|
# 在请求进入视图前,缓存请求体数据
|
||||||
# 注意:request.body只能读取一次,需要在这里缓存
|
# 注意:request.body只能读取一次,需要在这里缓存
|
||||||
@@ -86,6 +86,29 @@ class ApiAuditLogMiddleware:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _get_post_and_files(self, request):
|
||||||
|
"""
|
||||||
|
获取表单字段和文件,兼容PUT/PATCH请求
|
||||||
|
|
||||||
|
Django的request.POST/request.FILES只在method为POST时才会被解析,
|
||||||
|
PUT/PATCH请求即使是multipart/form-data或urlencoded也不会自动填充,
|
||||||
|
因此这里针对非POST方法手动解析。
|
||||||
|
"""
|
||||||
|
if request.method == 'POST':
|
||||||
|
return request.POST, request.FILES
|
||||||
|
|
||||||
|
content_type = request.content_type or ''
|
||||||
|
|
||||||
|
if 'multipart/form-data' in content_type:
|
||||||
|
try:
|
||||||
|
return request.parse_file_upload(request.META, io.BytesIO(request.body))
|
||||||
|
except MultiPartParserError:
|
||||||
|
return QueryDict(), MultiValueDict()
|
||||||
|
elif 'application/x-www-form-urlencoded' in content_type:
|
||||||
|
return QueryDict(request.body, encoding=request.encoding or settings.DEFAULT_CHARSET), MultiValueDict()
|
||||||
|
|
||||||
|
return QueryDict(), MultiValueDict()
|
||||||
|
|
||||||
def _extract_request_data(self, request) -> dict:
|
def _extract_request_data(self, request) -> dict:
|
||||||
"""
|
"""
|
||||||
提取请求数据
|
提取请求数据
|
||||||
@@ -98,18 +121,19 @@ class ApiAuditLogMiddleware:
|
|||||||
if 'multipart/form-data' in content_type:
|
if 'multipart/form-data' in content_type:
|
||||||
# multipart请求:分别处理表单字段和文件
|
# multipart请求:分别处理表单字段和文件
|
||||||
data = {}
|
data = {}
|
||||||
|
post, files = self._get_post_and_files(request)
|
||||||
|
|
||||||
# 处理普通表单字段
|
# 处理普通表单字段
|
||||||
for key, values in request.POST.lists():
|
for key, values in post.lists():
|
||||||
if len(values) == 1:
|
if len(values) == 1:
|
||||||
data[key] = values[0]
|
data[key] = values[0]
|
||||||
else:
|
else:
|
||||||
data[key] = values
|
data[key] = values
|
||||||
|
|
||||||
# 处理文件字段:只记录元信息
|
# 处理文件字段:只记录元信息
|
||||||
for key, files in request.FILES.lists():
|
for key, file_list in files.lists():
|
||||||
file_infos = []
|
file_infos = []
|
||||||
for f in files:
|
for f in file_list:
|
||||||
file_infos.append({
|
file_infos.append({
|
||||||
'_type': 'file',
|
'_type': 'file',
|
||||||
'name': f.name,
|
'name': f.name,
|
||||||
@@ -133,7 +157,8 @@ class ApiAuditLogMiddleware:
|
|||||||
elif 'application/x-www-form-urlencoded' in content_type:
|
elif 'application/x-www-form-urlencoded' in content_type:
|
||||||
# 表单请求
|
# 表单请求
|
||||||
data = {}
|
data = {}
|
||||||
for key, values in request.POST.lists():
|
post, _ = self._get_post_and_files(request)
|
||||||
|
for key, values in post.lists():
|
||||||
if len(values) == 1:
|
if len(values) == 1:
|
||||||
data[key] = values[0]
|
data[key] = values[0]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
七牛云 SDK 兼容性补丁
|
七牛云 SDK 兼容性补丁
|
||||||
修复 qiniu SDK 中 FormUploader 对空数据的判断问题
|
修复 qiniu SDK 中 FormUploader 对空数据的判断问题
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def patch_qiniu_form_uploader():
|
def patch_qiniu_form_uploader():
|
||||||
@@ -112,14 +116,10 @@ def patch_qiniu_form_uploader():
|
|||||||
|
|
||||||
# 替换方法
|
# 替换方法
|
||||||
FormUploader.upload = upload_wrapper
|
FormUploader.upload = upload_wrapper
|
||||||
|
|
||||||
print("✓ 七牛云 FormUploader 补丁已应用")
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠ 应用七牛云补丁时出错: {e}")
|
logger.exception("应用七牛云补丁时出错: %s", e)
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ PRE_SALES_ORDER_CREATED_FOLLOWUP_URL_TEMPLATE = env(
|
|||||||
# Logging configuration
|
# Logging configuration
|
||||||
# 目标:
|
# 目标:
|
||||||
# - 生产环境出现 500 时,能在 stdout 日志里看到完整 traceback
|
# - 生产环境出现 500 时,能在 stdout 日志里看到完整 traceback
|
||||||
# - 所有日志都带时间戳(含毫秒),便于快速定位与检索
|
# - 所有日志以单行 JSON 输出,便于 Loki / Filebeat 检索与聚合
|
||||||
LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
|
LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
|
||||||
DJANGO_LOG_LEVEL = env('DJANGO_LOG_LEVEL', default=LOG_LEVEL).upper()
|
DJANGO_LOG_LEVEL = env('DJANGO_LOG_LEVEL', default=LOG_LEVEL).upper()
|
||||||
UVICORN_LOG_LEVEL = env('UVICORN_LOG_LEVEL', default=LOG_LEVEL).upper()
|
UVICORN_LOG_LEVEL = env('UVICORN_LOG_LEVEL', default=LOG_LEVEL).upper()
|
||||||
@@ -370,49 +370,60 @@ LOGGING = {
|
|||||||
'version': 1,
|
'version': 1,
|
||||||
'disable_existing_loggers': False,
|
'disable_existing_loggers': False,
|
||||||
'formatters': {
|
'formatters': {
|
||||||
'default': {
|
'json': {
|
||||||
# 注意:Python logging 的 datefmt 基于 time.strftime,不支持 %f;
|
'()': 'flower.logging_formatters.JsonFormatter',
|
||||||
# 这里用 %(msecs)03d 输出毫秒。
|
},
|
||||||
'format': '%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s',
|
},
|
||||||
'datefmt': '%Y-%m-%d %H:%M:%S',
|
'filters': {
|
||||||
|
'below_warning': {
|
||||||
|
'()': 'flower.logging_formatters.MaxLevelFilter',
|
||||||
|
'max_level': 'WARNING',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
'handlers': {
|
||||||
'console': {
|
'stdout': {
|
||||||
'class': 'logging.StreamHandler',
|
'class': 'logging.StreamHandler',
|
||||||
'formatter': 'default',
|
'formatter': 'json',
|
||||||
|
'stream': 'ext://sys.stdout',
|
||||||
|
'filters': ['below_warning'],
|
||||||
|
},
|
||||||
|
'stderr': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'json',
|
||||||
|
'stream': 'ext://sys.stderr',
|
||||||
|
'level': 'WARNING',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'root': {
|
'root': {
|
||||||
'handlers': ['console'],
|
'handlers': ['stdout', 'stderr'],
|
||||||
'level': LOG_LEVEL,
|
'level': LOG_LEVEL,
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
# Django 在发生未捕获异常(500)时,会通过 django.request 输出错误日志,
|
# Django 在发生未捕获异常(500)时,会通过 django.request 输出错误日志,
|
||||||
# record.exc_info 会携带 traceback,Formatter 会自动把 traceback 打到日志中。
|
# record.exc_info 会携带 traceback,Formatter 会自动把 traceback 打到日志中。
|
||||||
'django.request': {
|
'django.request': {
|
||||||
'handlers': ['console'],
|
'handlers': ['stdout', 'stderr'],
|
||||||
'level': 'ERROR',
|
'level': 'ERROR',
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
'django': {
|
'django': {
|
||||||
'handlers': ['console'],
|
'handlers': ['stdout', 'stderr'],
|
||||||
'level': DJANGO_LOG_LEVEL,
|
'level': DJANGO_LOG_LEVEL,
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
# Uvicorn 日志(access/error)统一走我们的 console formatter,确保有时间戳
|
# Uvicorn 日志(access/error)统一走我们的 console formatter,确保有时间戳
|
||||||
'uvicorn': {
|
'uvicorn': {
|
||||||
'handlers': ['console'],
|
'handlers': ['stdout', 'stderr'],
|
||||||
'level': UVICORN_LOG_LEVEL,
|
'level': UVICORN_LOG_LEVEL,
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
'uvicorn.error': {
|
'uvicorn.error': {
|
||||||
'handlers': ['console'],
|
'handlers': ['stdout', 'stderr'],
|
||||||
'level': UVICORN_LOG_LEVEL,
|
'level': UVICORN_LOG_LEVEL,
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
'uvicorn.access': {
|
'uvicorn.access': {
|
||||||
'handlers': ['console'],
|
'handlers': ['stdout', 'stderr'],
|
||||||
'level': UVICORN_ACCESS_LOG_LEVEL,
|
'level': UVICORN_ACCESS_LOG_LEVEL,
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
|
|||||||
81
flower/uvicorn_log_config.json
Normal file
81
flower/uvicorn_log_config.json
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": false,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"()": "flower.logging_formatters.JsonFormatter"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"()": "flower.logging_formatters.JsonFormatter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"below_warning": {
|
||||||
|
"()": "flower.logging_formatters.MaxLevelFilter",
|
||||||
|
"max_level": "WARNING"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"stdout": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
"filters": [
|
||||||
|
"below_warning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stderr": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
"stream": "ext://sys.stderr",
|
||||||
|
"level": "WARNING"
|
||||||
|
},
|
||||||
|
"access_stdout": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "access",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
"filters": [
|
||||||
|
"below_warning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"access_stderr": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "access",
|
||||||
|
"stream": "ext://sys.stderr",
|
||||||
|
"level": "WARNING"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"handlers": [
|
||||||
|
"stdout",
|
||||||
|
"stderr"
|
||||||
|
],
|
||||||
|
"level": "INFO"
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"uvicorn": {
|
||||||
|
"handlers": [
|
||||||
|
"stdout",
|
||||||
|
"stderr"
|
||||||
|
],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": false
|
||||||
|
},
|
||||||
|
"uvicorn.error": {
|
||||||
|
"handlers": [
|
||||||
|
"stdout",
|
||||||
|
"stderr"
|
||||||
|
],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": false
|
||||||
|
},
|
||||||
|
"uvicorn.access": {
|
||||||
|
"handlers": [
|
||||||
|
"access_stdout",
|
||||||
|
"access_stderr"
|
||||||
|
],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -389,6 +389,7 @@ class PrintingJobAdmin(admin.ModelAdmin):
|
|||||||
'unit': obj.unit,
|
'unit': obj.unit,
|
||||||
'size': obj.size,
|
'size': obj.size,
|
||||||
'pieces': obj.pieces,
|
'pieces': obj.pieces,
|
||||||
|
'sub_id': obj.sub_id,
|
||||||
'description': obj.description,
|
'description': obj.description,
|
||||||
}
|
}
|
||||||
created_obj = PrintingJobService.create_printing_job(data, request.user)
|
created_obj = PrintingJobService.create_printing_job(data, request.user)
|
||||||
@@ -404,6 +405,7 @@ class PrintingJobAdmin(admin.ModelAdmin):
|
|||||||
'unit': obj.unit,
|
'unit': obj.unit,
|
||||||
'size': obj.size,
|
'size': obj.size,
|
||||||
'pieces': obj.pieces,
|
'pieces': obj.pieces,
|
||||||
|
'sub_id': obj.sub_id,
|
||||||
'description': obj.description,
|
'description': obj.description,
|
||||||
}
|
}
|
||||||
success, message, updated_obj = PrintingJobService.update_printing_job(
|
success, message, updated_obj = PrintingJobService.update_printing_job(
|
||||||
|
|||||||
16
printing/migrations/0039_printingjob_sub_id.py
Normal file
16
printing/migrations/0039_printingjob_sub_id.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('printing', '0038_printingorderexternalsnapshotsyncaudit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='printingjob',
|
||||||
|
name='sub_id',
|
||||||
|
field=models.IntegerField(blank=True, null=True, verbose_name='子编号'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -482,6 +482,11 @@ class PrintingJob(ModelBase):
|
|||||||
verbose_name='克隆来源任务ID',
|
verbose_name='克隆来源任务ID',
|
||||||
help_text='前端克隆概念:记录本任务由哪个任务复制而来(无外键约束)',
|
help_text='前端克隆概念:记录本任务由哪个任务复制而来(无外键约束)',
|
||||||
)
|
)
|
||||||
|
sub_id = models.IntegerField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='子编号',
|
||||||
|
)
|
||||||
business_object = models.OneToOneField(
|
business_object = models.OneToOneField(
|
||||||
stateflow_models.BusinessObject,
|
stateflow_models.BusinessObject,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user