From cb6589c88424941e78a18adb8d3e81577bba0c40 Mon Sep 17 00:00:00 2001 From: colaftc Date: Fri, 26 Jun 2026 17:03:54 +0800 Subject: [PATCH] feat: log formatted --- .../0015_alter_apiauditlog_method_and_more.py | 23 + api_v1/models.py | 10 +- api_v1/tasks.py | 2 + api_v1/test_external_printing_records_sync.py | 4 + api_v1/views/printing/serializers.py | 6 +- api_v1/views/printing/test_api.py | 16 + .../views/printing/test_printing_job_api.py | 20 +- api_v1/views/printing/views.py | 25 +- ...ternal_printing_order_snapshot_sync_api.py | 5 +- api_v2/tests.py | 2 + api_v2/views/printing.py | 1 + apparmor/install-ai-coding-tools-apparmor.sh | 32 ++ apparmor/local-ai-coding-tools | 34 ++ docker-compose.prod.yml | 2 + docker-compose.yml | 2 + docs/api_v1_printing_jobs_api_2026-06-26.md | 483 ++++++++++++++++++ docs/api_v1_printing_order_api_2026-06-26.md | 364 +++++++++++++ ...nting_order_detail_by_external_order_id.md | 66 +++ flower/logging_formatters.py | 149 ++++++ flower/middleware.py | 61 ++- flower/qiniu_patch.py | 10 +- flower/settings.py | 39 +- flower/uvicorn_log_config.json | 81 +++ printing/admin.py | 2 + .../migrations/0039_printingjob_sub_id.py | 16 + printing/models.py | 5 + 26 files changed, 1410 insertions(+), 50 deletions(-) create mode 100644 api_v1/migrations/0015_alter_apiauditlog_method_and_more.py create mode 100755 apparmor/install-ai-coding-tools-apparmor.sh create mode 100644 apparmor/local-ai-coding-tools create mode 100644 docs/api_v1_printing_jobs_api_2026-06-26.md create mode 100644 docs/api_v1_printing_order_api_2026-06-26.md create mode 100644 docs/api_v1_printing_order_detail_by_external_order_id.md create mode 100644 flower/logging_formatters.py create mode 100644 flower/uvicorn_log_config.json create mode 100644 printing/migrations/0039_printingjob_sub_id.py diff --git a/api_v1/migrations/0015_alter_apiauditlog_method_and_more.py b/api_v1/migrations/0015_alter_apiauditlog_method_and_more.py new file mode 100644 index 0000000..c1ba153 --- /dev/null +++ b/api_v1/migrations/0015_alter_apiauditlog_method_and_more.py @@ -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='请求体数据'), + ), + ] diff --git a/api_v1/models.py b/api_v1/models.py index c185531..4fd187a 100644 --- a/api_v1/models.py +++ b/api_v1/models.py @@ -255,9 +255,9 @@ class PrintingExternalSyncFailure(ModelBase): class ApiAuditLog(models.Model): - """API审计日志 - 记录创建操作的历史现场 - - 用于保存特定模块的POST请求,记录"创建"操作的完整请求信息。 + """API审计日志 - 记录创建/更新操作的历史现场 + + 用于保存特定模块的POST/PUT/PATCH请求,记录"创建/更新"操作的完整请求信息。 通过Celery异步写入,避免影响API响应性能。 """ url = models.CharField( @@ -268,12 +268,12 @@ class ApiAuditLog(models.Model): method = models.CharField( max_length=10, verbose_name='请求方法', - help_text='HTTP方法,如POST' + help_text='HTTP方法,如POST、PUT、PATCH' ) request_data = models.JSONField( default=dict, verbose_name='请求体数据', - help_text='POST请求的body数据(JSON格式)' + help_text='请求的body数据(JSON格式)' ) query_params = models.JSONField( default=dict, diff --git a/api_v1/tasks.py b/api_v1/tasks.py index afd5c51..ff38634 100644 --- a/api_v1/tasks.py +++ b/api_v1/tasks.py @@ -448,6 +448,7 @@ def _build_printing_order_before_snapshot(printing_order): { 'id': job.id, 'original_id': job.original_id, + 'sub_id': job.sub_id, 'product_id': job.product_id, 'product_name': getattr(job.product, 'name', None), 'quantity': job.quantity, @@ -939,6 +940,7 @@ def _upsert_external_printing_job(*, merchant, printing_order, record: dict, syn 'pieces': _extract_pieces(record.get('BeiZhuC')), 'description': None, 'original_id': record_id, + 'sub_id': _extract_positive_int(record.get('SubID'), field_name='SubID'), 'created_by': created_by_user, 'external_product_name': None, 'external_raw': record, diff --git a/api_v1/test_external_printing_records_sync.py b/api_v1/test_external_printing_records_sync.py index 9eba9da..a3d9098 100644 --- a/api_v1/test_external_printing_records_sync.py +++ b/api_v1/test_external_printing_records_sync.py @@ -85,6 +85,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): 'SeHao': '1.51', 'ShuLiang': '10.00', 'ShuLiangZ': '2.68', + 'SubID': 1, 'YanSe': product_name, 'area': '周边', 'customer': { @@ -116,6 +117,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): job = printing_models.PrintingJob.objects.get(original_id=1000000) self.assertEqual(job.product, self.existing_product) + self.assertEqual(job.sub_id, 1) self.assertIsNone(job.external_product_name) @patch('api_v1.tasks._advance_external_printing_cursor') @@ -336,6 +338,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): updated_record['ShuLiang'] = '20.00' updated_record['ShuLiangZ'] = '3.15' updated_record['BeiZhuC'] = '20件' + updated_record['SubID'] = 2 mock_fetch_image.return_value = {'bytes': b'', 'content_type': 'image/jpeg', 'name': 'unused.jpg'} mock_advance_cursor.return_value = {'updated': True} @@ -354,6 +357,7 @@ class ExternalPrintingRecordsSyncTaskTest(TestCase): self.assertEqual(jobs.count(), 1) job = jobs.get() self.assertEqual(job.original_id, 1000001) + self.assertEqual(job.sub_id, 2) self.assertEqual(job.quantity, 20) self.assertEqual(job.size, '3.15') self.assertEqual(job.pieces, 20) diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 13d7ba8..c5bb40a 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -369,7 +369,7 @@ class PrintingJobListSerializer(serializers.ModelSerializer): class Meta: model = models.PrintingJob 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', 'quantity', 'billed_quantity', 'unit', 'size', 'pieces', 'description', 'work_state', 'work_state_display', @@ -480,7 +480,7 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): class Meta: model = models.PrintingJob 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', 'work_state', 'work_state_display', 'status', 'status_id', 'is_completed', 'is_production_completed', 'has_started', @@ -543,7 +543,7 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer): class Meta: model = models.PrintingJob 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', 'batch_advance_records', ] diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index 40354ba..ce31832 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -248,6 +248,22 @@ class PrintingOrderAPITestCase(TestCase): self.assertEqual(response.data['craft'], '活性印花') self.assertIn('customer_name', response.data) 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): """测试更新订单""" diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index 64baae9..e06f2a5 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -114,12 +114,14 @@ class PrintingJobAPITestCase(TestCase): 'unit': '米', 'size': '50*60', 'pieces': 10, + 'sub_id': 7, 'description': '测试备注' } response = self.client.post('/api/v1/printing-jobs/', data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data['external_order_id'], 'KD20410611') + self.assertEqual(response.data['sub_id'], 7) # 新增字段:批量推进记录(稳定输出 key,默认空数组) self.assertIn('batch_advance_records', response.data) @@ -135,6 +137,7 @@ class PrintingJobAPITestCase(TestCase): self.assertEqual(job.quantity, 100) self.assertEqual(job.unit, '米') self.assertEqual(job.pieces, 10) + self.assertEqual(job.sub_id, 7) self.assertFalse(job.is_production_completed) def test_create_printing_job_ignores_is_production_completed(self): @@ -162,7 +165,8 @@ class PrintingJobAPITestCase(TestCase): quantity=100, unit='米', size='50*60', - pieces=10 + pieces=10, + sub_id=101 ) job2 = printing_models.PrintingJob.objects.create( printing_order=self.printing_order, @@ -170,12 +174,16 @@ class PrintingJobAPITestCase(TestCase): quantity=200, unit='米', size='60*70', - pieces=20 + pieces=20, + sub_id=202 ) response = self.client.get('/api/v1/printing-jobs/') self.assertEqual(response.status_code, status.HTTP_200_OK) 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) for item in response.data['results']: @@ -640,6 +648,7 @@ class PrintingJobAPITestCase(TestCase): 'unit': '码', 'size': '60*70', 'pieces': 20, + 'sub_id': 8, 'description': '更新后的备注' } @@ -650,11 +659,13 @@ class PrintingJobAPITestCase(TestCase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['external_order_id'], 'KD20410611') + self.assertEqual(response.data['sub_id'], 8) job.refresh_from_db() self.assertEqual(job.quantity, 200) self.assertEqual(job.unit, '码') self.assertEqual(job.pieces, 20) + self.assertEqual(job.sub_id, 8) self.assertFalse(job.is_production_completed) def test_update_printing_job_ignores_is_production_completed(self): @@ -724,7 +735,8 @@ class PrintingJobAPITestCase(TestCase): patch_data = { 'quantity': 150, - 'pieces': 15 + 'pieces': 15, + 'sub_id': None } response = self.client.patch( @@ -734,10 +746,12 @@ class PrintingJobAPITestCase(TestCase): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['external_order_id'], 'KD20410611') + self.assertIsNone(response.data['sub_id']) job.refresh_from_db() self.assertEqual(job.quantity, 150) self.assertEqual(job.pieces, 15) + self.assertIsNone(job.sub_id) self.assertEqual(job.unit, '米') # 未修改字段保持不变 def test_delete_printing_job_forbidden(self): diff --git a/api_v1/views/printing/views.py b/api_v1/views/printing/views.py index a60cf46..dd99075 100644 --- a/api_v1/views/printing/views.py +++ b/api_v1/views/printing/views.py @@ -301,7 +301,7 @@ class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): ): 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") # 预取 printing_jobs 及其 business_object,用于状态汇总统计 # 同时预取 state_logs 和 process 相关数据以减少 job.status 属性调用时的 N+1 查询 @@ -327,6 +327,29 @@ class PrintingOrderViewSet(CustomerVisibilityFilterMixin, LimitedModelViewSet): status=status.HTTP_405_METHOD_NOT_ALLOWED, ) + @action( + detail=False, + methods=["get"], + url_path=r"by-external-order-id/(?P[^/.]+)", + ) + 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"]) def invalidate(self, request, pk=None): """ diff --git a/api_v2/test_external_printing_order_snapshot_sync_api.py b/api_v2/test_external_printing_order_snapshot_sync_api.py index 5e587c0..0f4b923 100644 --- a/api_v2/test_external_printing_order_snapshot_sync_api.py +++ b/api_v2/test_external_printing_order_snapshot_sync_api.py @@ -118,6 +118,7 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): 'SeHao': '1.51', 'ShuLiang': quantity, 'ShuLiangZ': '2.68', + 'SubID': 1, 'YanSe': product_name or self.product_keep.name, 'area': '周边', 'customer': { @@ -220,6 +221,7 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): self.assertEqual(order.area, '周边') self.assertEqual(keep_job.quantity, 25) 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.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.assertEqual(audit.before_snapshot['printing_order']['fabric'], '旧布料') 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') def test_sync_records_audit_when_external_snapshot_not_found(self, mock_fetch_snapshot): @@ -246,4 +249,4 @@ class PrintingOrderExternalSnapshotSyncAPITest(TestCase): audit = printing_models.PrintingOrderExternalSnapshotSyncAudit.objects.get(id=resp.data['audit_id']) self.assertFalse(audit.is_success) self.assertEqual(audit.external_order_id, 'KD-NOT-FOUND') - self.assertEqual(audit.before_snapshot['exists'], False) \ No newline at end of file + self.assertEqual(audit.before_snapshot['exists'], False) diff --git a/api_v2/tests.py b/api_v2/tests.py index 4bbb0e5..4dcdd7f 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -188,6 +188,7 @@ class PrintingJobByCustomerAPITest(TestCase): product=self.product, quantity=10, unit='米', + sub_id=11, ) 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(resp.data[0]['id'], self.job_in_range.id) 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') def test_filter_by_printing_order(self): diff --git a/api_v2/views/printing.py b/api_v2/views/printing.py index 96f5b4f..8236834 100644 --- a/api_v2/views/printing.py +++ b/api_v2/views/printing.py @@ -62,6 +62,7 @@ class PrintingJobV2Serializer(serializers.ModelSerializer): fields = [ 'id', 'original_id', + 'sub_id', 'printing_order', 'external_order_id', 'product', diff --git a/apparmor/install-ai-coding-tools-apparmor.sh b/apparmor/install-ai-coding-tools-apparmor.sh new file mode 100755 index 0000000..336b5ee --- /dev/null +++ b/apparmor/install-ai-coding-tools-apparmor.sh @@ -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." diff --git a/apparmor/local-ai-coding-tools b/apparmor/local-ai-coding-tools new file mode 100644 index 0000000..7c8e26c --- /dev/null +++ b/apparmor/local-ai-coding-tools @@ -0,0 +1,34 @@ +abi , +include + +# 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 +} + +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 +} + +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 +} + +# 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 +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 900eeb5..c3fb122 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,6 +54,8 @@ services: - "8000" - --workers - "${UVICORN_WORKERS:-1}" + - --log-config + - flower/uvicorn_log_config.json ports: - "${WEB_PORT:-8100}:8000" depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 126867f..c29acd7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -77,6 +77,8 @@ services: - 0.0.0.0 - --port - "8100" + - --log-config + - flower/uvicorn_log_config.json ports: - "8100:8100" volumes: diff --git a/docs/api_v1_printing_jobs_api_2026-06-26.md b/docs/api_v1_printing_jobs_api_2026-06-26.md new file mode 100644 index 0000000..28289bc --- /dev/null +++ b/docs/api_v1_printing_jobs_api_2026-06-26.md @@ -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 " +``` + +响应示例: + +```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 " +``` + +响应示例: + +```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` 写入,用于保留外部明细行的子编号。 diff --git a/docs/api_v1_printing_order_api_2026-06-26.md b/docs/api_v1_printing_order_api_2026-06-26.md new file mode 100644 index 0000000..6b3a171 --- /dev/null +++ b/docs/api_v1_printing_order_api_2026-06-26.md @@ -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`。 diff --git a/docs/api_v1_printing_order_detail_by_external_order_id.md b/docs/api_v1_printing_order_detail_by_external_order_id.md new file mode 100644 index 0000000..450d753 --- /dev/null +++ b/docs/api_v1_printing_order_detail_by_external_order_id.md @@ -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 " +``` diff --git a/flower/logging_formatters.py b/flower/logging_formatters.py new file mode 100644 index 0000000..f842512 --- /dev/null +++ b/flower/logging_formatters.py @@ -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 diff --git a/flower/middleware.py b/flower/middleware.py index 9de5c1d..0ec29e2 100644 --- a/flower/middleware.py +++ b/flower/middleware.py @@ -18,10 +18,10 @@ class ApiAuditLogMiddleware: """ API审计日志中间件 - 用于记录特定URL前缀的POST请求,保存"创建"操作的历史现场。 - + 用于记录特定URL前缀的POST/PUT/PATCH请求,保存"创建/更新"操作的历史现场。 + 特性: - - 只记录POST请求 + - 只记录POST、PUT、PATCH请求 - 通过URL前缀白名单过滤 - 支持multipart/form-data请求,文件字段只记录元信息 - 通过Celery异步写入,不阻塞API响应 @@ -42,8 +42,8 @@ class ApiAuditLogMiddleware: if not self.enabled: return self.get_response(request) - # 只处理POST请求 - if request.method != 'POST': + # 只处理POST、PUT、PATCH请求 + if request.method not in ('POST', 'PUT', 'PATCH'): return self.get_response(request) # 检查URL是否在白名单中 @@ -51,7 +51,7 @@ class ApiAuditLogMiddleware: logger.debug('ApiAuditLog: URL不匹配白名单, path=%s, prefixes=%s', request.path, self.url_prefixes) 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只能读取一次,需要在这里缓存 @@ -86,30 +86,54 @@ class ApiAuditLogMiddleware: return True 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: """ 提取请求数据 - + 对于multipart/form-data请求,分别处理表单字段和文件字段。 文件字段只记录元信息(文件名、大小、类型),不记录二进制内容。 """ content_type = request.content_type or '' - + if 'multipart/form-data' in content_type: # multipart请求:分别处理表单字段和文件 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: data[key] = values[0] else: data[key] = values - + # 处理文件字段:只记录元信息 - for key, files in request.FILES.lists(): + for key, file_list in files.lists(): file_infos = [] - for f in files: + for f in file_list: file_infos.append({ '_type': 'file', 'name': f.name, @@ -120,26 +144,27 @@ class ApiAuditLogMiddleware: data[key] = file_infos[0] else: data[key] = file_infos - + return data - + elif 'application/json' in content_type: # JSON请求:解析body try: return json.loads(request.body.decode('utf-8')) except (json.JSONDecodeError, UnicodeDecodeError): return {'_raw': request.body.decode('utf-8', errors='replace')} - + elif 'application/x-www-form-urlencoded' in content_type: # 表单请求 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: data[key] = values[0] else: data[key] = values return data - + else: # 其他类型:尝试作为文本记录 try: diff --git a/flower/qiniu_patch.py b/flower/qiniu_patch.py index f1649b2..e5a4ce8 100644 --- a/flower/qiniu_patch.py +++ b/flower/qiniu_patch.py @@ -2,6 +2,10 @@ 七牛云 SDK 兼容性补丁 修复 qiniu SDK 中 FormUploader 对空数据的判断问题 """ +import logging + + +logger = logging.getLogger(__name__) def patch_qiniu_form_uploader(): @@ -112,14 +116,10 @@ def patch_qiniu_form_uploader(): # 替换方法 FormUploader.upload = upload_wrapper - - print("✓ 七牛云 FormUploader 补丁已应用") return True except Exception as e: - print(f"⚠ 应用七牛云补丁时出错: {e}") - import traceback - traceback.print_exc() + logger.exception("应用七牛云补丁时出错: %s", e) return False diff --git a/flower/settings.py b/flower/settings.py index 162f230..5ba8672 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -360,7 +360,7 @@ PRE_SALES_ORDER_CREATED_FOLLOWUP_URL_TEMPLATE = env( # Logging configuration # 目标: # - 生产环境出现 500 时,能在 stdout 日志里看到完整 traceback -# - 所有日志都带时间戳(含毫秒),便于快速定位与检索 +# - 所有日志以单行 JSON 输出,便于 Loki / Filebeat 检索与聚合 LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper() DJANGO_LOG_LEVEL = env('DJANGO_LOG_LEVEL', default=LOG_LEVEL).upper() UVICORN_LOG_LEVEL = env('UVICORN_LOG_LEVEL', default=LOG_LEVEL).upper() @@ -370,49 +370,60 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { - 'default': { - # 注意:Python logging 的 datefmt 基于 time.strftime,不支持 %f; - # 这里用 %(msecs)03d 输出毫秒。 - 'format': '%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s', - 'datefmt': '%Y-%m-%d %H:%M:%S', + 'json': { + '()': 'flower.logging_formatters.JsonFormatter', + }, + }, + 'filters': { + 'below_warning': { + '()': 'flower.logging_formatters.MaxLevelFilter', + 'max_level': 'WARNING', }, }, 'handlers': { - 'console': { + 'stdout': { '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': { - 'handlers': ['console'], + 'handlers': ['stdout', 'stderr'], 'level': LOG_LEVEL, }, 'loggers': { # Django 在发生未捕获异常(500)时,会通过 django.request 输出错误日志, # record.exc_info 会携带 traceback,Formatter 会自动把 traceback 打到日志中。 'django.request': { - 'handlers': ['console'], + 'handlers': ['stdout', 'stderr'], 'level': 'ERROR', 'propagate': False, }, 'django': { - 'handlers': ['console'], + 'handlers': ['stdout', 'stderr'], 'level': DJANGO_LOG_LEVEL, 'propagate': False, }, # Uvicorn 日志(access/error)统一走我们的 console formatter,确保有时间戳 'uvicorn': { - 'handlers': ['console'], + 'handlers': ['stdout', 'stderr'], 'level': UVICORN_LOG_LEVEL, 'propagate': False, }, 'uvicorn.error': { - 'handlers': ['console'], + 'handlers': ['stdout', 'stderr'], 'level': UVICORN_LOG_LEVEL, 'propagate': False, }, 'uvicorn.access': { - 'handlers': ['console'], + 'handlers': ['stdout', 'stderr'], 'level': UVICORN_ACCESS_LOG_LEVEL, 'propagate': False, }, diff --git a/flower/uvicorn_log_config.json b/flower/uvicorn_log_config.json new file mode 100644 index 0000000..c307255 --- /dev/null +++ b/flower/uvicorn_log_config.json @@ -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 + } + } +} diff --git a/printing/admin.py b/printing/admin.py index 7da5d0f..6098715 100644 --- a/printing/admin.py +++ b/printing/admin.py @@ -389,6 +389,7 @@ class PrintingJobAdmin(admin.ModelAdmin): 'unit': obj.unit, 'size': obj.size, 'pieces': obj.pieces, + 'sub_id': obj.sub_id, 'description': obj.description, } created_obj = PrintingJobService.create_printing_job(data, request.user) @@ -404,6 +405,7 @@ class PrintingJobAdmin(admin.ModelAdmin): 'unit': obj.unit, 'size': obj.size, 'pieces': obj.pieces, + 'sub_id': obj.sub_id, 'description': obj.description, } success, message, updated_obj = PrintingJobService.update_printing_job( diff --git a/printing/migrations/0039_printingjob_sub_id.py b/printing/migrations/0039_printingjob_sub_id.py new file mode 100644 index 0000000..9dba10e --- /dev/null +++ b/printing/migrations/0039_printingjob_sub_id.py @@ -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='子编号'), + ), + ] diff --git a/printing/models.py b/printing/models.py index a37bea8..eafbcce 100644 --- a/printing/models.py +++ b/printing/models.py @@ -482,6 +482,11 @@ class PrintingJob(ModelBase): verbose_name='克隆来源任务ID', help_text='前端克隆概念:记录本任务由哪个任务复制而来(无外键约束)', ) + sub_id = models.IntegerField( + null=True, + blank=True, + verbose_name='子编号', + ) business_object = models.OneToOneField( stateflow_models.BusinessObject, on_delete=models.SET_NULL,