This commit is contained in:
2026-07-20 10:16:27 +08:00
parent 8493f0df03
commit f3d2951dc3
24 changed files with 23623 additions and 1546 deletions
+10 -8
View File
@@ -108,17 +108,19 @@
### P1-5 统一材料属性源 ### P1-5 统一材料属性源
- **现状**:材料字典在 4 处重复定义且冲突(PE 收缩率 `material_service` 0.020 vs `aluminum_foam_mold.py:92` 0.025)。 - **现状**:材料字典在 4 处重复定义且冲突(PE 收缩率 `material_service` 0.020 vs `aluminum_foam_mold.py:92` 0.025)。
- **目标**:`MaterialService` 作为唯一源,其他模块查询。 - **目标**:`MaterialService` 作为唯一源,其他模块查询。
- **状态**:- [ ] - **状态**:- [x]
--- ---
### P1 执行结果(进行中) ### P1 执行结果(核心完成)
- 🚧 **P1-1 进销存抽 service 层**(进行中): - ✅ **P1-1 进销存抽 service 层**(核心完成):
- ✅ 建立 `inventory/services/` 层,抽出 `FinanceService`(`finance_routes` 767→123 行,业务逻辑全下沉;路由仅做参数校验+响应组装) - 建立 `inventory/services/` 层,抽出 5 个 service:FinanceService / SalesOrderService / PurchaseOrderService / StockMovementService / InventoryService
- ✅ 将 `schemas/` 与 `utils.py` 从 `inventory/api/` 移至 `inventory/` 顶层,打破 service↔api 循环导入(schemas 是共享 DTO、utils 是纯函数,本不应嵌在 api 层;这是正确分层) - 路由全面瘦身:finance 767->123、sales_order 777->114、purchase_order 472->90、stock_movement 209->37、inventory 205->57
- ✅ import 测试通过:9 finance 路由 + 55 inventory 路由全部正常加载 - 将 schemas/ 与 utils.py 从 inventory/api/ 移至 inventory/ 顶层,打破 service<->api 循环导入(正确分层);清理死代码 api/utils.py
- ⏳ 待办:抽 `sales_order_service` / `purchase_order_service` / `inventory`+`stock_movement` service;清理死代码 `api/utils.py`(待 `git rm`) - ✅ 全量 import 测试通过:55 inventory 路由无丢失,5 个 service 全部正常加载
- ⏳ 可选后续:剩余纯 CRUD 路由(product/supplier/customer/warehouse/material/dashboard)体量小,可按需增量抽取
- ✅ **P1-5 统一材料属性源**:`MaterialService` 成为唯一源,删除 geometry_analyzer/mold_generator/aluminum_foam_mold 三处重复字典,改查询 MaterialService;解决冲突(PE 收缩率统一 0.020、PC/PA/PMMA 收缩率、POM 密度统一)、补齐 PS、统一泡沫 `shrinkage` 键名、补 `min_wall`/泡沫字段;py_compile + 一致性核对通过
--- ---
@@ -157,6 +159,6 @@
| 阶段 | 项数 | 已完成 | 进行中 | | 阶段 | 项数 | 已完成 | 进行中 |
|------|------|--------|--------| |------|------|--------|--------|
| P0 | 7 | 6 修复 + 1 排查 | - | | P0 | 7 | 6 修复 + 1 排查 | - |
| P1 | 5 | 0 | P1-1 进行中(finance 已抽) | | P1 | 5 | 2 | P1-1 核心完成 + P1-5 完成 |
| P2 | 3 | 0 | - | | P2 | 3 | 0 | - |
| P3 | 7 | 0 | - | | P3 | 7 | 0 | - |
+349 -1
View File
@@ -20,11 +20,27 @@
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"openapi-typescript": "^7.13.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vue-tsc": "^3.2.8" "vue-tsc": "^3.2.8"
} }
}, },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -166,6 +182,52 @@
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.17",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.17.tgz",
"integrity": "sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.2.0",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
@@ -716,6 +778,16 @@
} }
} }
}, },
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/air-datepicker": { "node_modules/air-datepicker": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.6.0.tgz", "resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.6.0.tgz",
@@ -729,6 +801,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/birpc": { "node_modules/birpc": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -738,6 +834,23 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/chart.js": { "node_modules/chart.js": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -750,6 +863,13 @@
"pnpm": ">=8" "pnpm": ">=8"
} }
}, },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/copy-anything": { "node_modules/copy-anything": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
@@ -777,6 +897,24 @@
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -805,6 +943,13 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -844,6 +989,33 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": { "node_modules/is-what": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
@@ -856,6 +1028,53 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1144,12 +1363,32 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mitt": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/muggle-string": { "node_modules/muggle-string": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -1175,6 +1414,45 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.6",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.3.0",
"supports-color": "^10.2.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1228,6 +1506,16 @@
} }
} }
}, },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
"integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -1256,6 +1544,16 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rfdc": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -1332,6 +1630,19 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tdesign-icons-vue-next": { "node_modules/tdesign-icons-vue-next": {
"version": "0.4.4", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/tdesign-icons-vue-next/-/tdesign-icons-vue-next-0.4.4.tgz", "resolved": "https://registry.npmjs.org/tdesign-icons-vue-next/-/tdesign-icons-vue-next-0.4.4.tgz",
@@ -1402,11 +1713,24 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -1423,6 +1747,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/validator": { "node_modules/validator": {
"version": "13.15.35", "version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
@@ -1575,6 +1906,23 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
} }
} }
} }
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"gen:api": "openapi-typescript ../openapi-inventory.json -o src/types/api.ts"
}, },
"dependencies": { "dependencies": {
"air-datepicker": "^3.6.0", "air-datepicker": "^3.6.0",
@@ -21,6 +22,7 @@
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"openapi-typescript": "^7.13.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.0.12", "vite": "^8.0.12",
"vue-tsc": "^3.2.8" "vue-tsc": "^3.2.8"
@@ -37,22 +37,22 @@ onMounted(() => {
refreshFinance() refreshFinance()
}) })
// 字段名对齐后端 FinanceSummaryResponse: // 字段名对齐后端 FinanceSummaryResponse。
// receivable_total / payable_total / period_receipt_total / period_payment_total // 后端 Decimal 序列化为 JSON 字符串,故用 Number() 转换再填充 0。
const totalReceivable = computed(() => { const totalReceivable = computed(() => {
return state.financeSummary?.receivable_total ?? 0 return Number(state.financeSummary?.receivable_total) || 0
}) })
const totalPayable = computed(() => { const totalPayable = computed(() => {
return state.financeSummary?.payable_total ?? 0 return Number(state.financeSummary?.payable_total) || 0
}) })
const periodReceived = computed(() => { const periodReceived = computed(() => {
return state.financeSummary?.period_receipt_total ?? 0 return Number(state.financeSummary?.period_receipt_total) || 0
}) })
const periodPaid = computed(() => { const periodPaid = computed(() => {
return state.financeSummary?.period_payment_total ?? 0 return Number(state.financeSummary?.period_payment_total) || 0
}) })
function getTransactionTypeLabel(type: string): string { function getTransactionTypeLabel(type: string): string {
@@ -2,6 +2,7 @@ import { reactive, ref, computed } from 'vue'
import { apiRequest } from '@/shared/api' import { apiRequest } from '@/shared/api'
import { addNotification, handleApiError } from '@/shared/notification' import { addNotification, handleApiError } from '@/shared/notification'
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils' import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
import type { Schema } from '@/types/schemas'
declare const AirDatepicker: any declare const AirDatepicker: any
@@ -10,19 +11,19 @@ const state = reactive({
backendDbReady: true, backendDbReady: true,
backendDbMessage: '' as string, backendDbMessage: '' as string,
productCategory: 'finished' as string, productCategory: 'finished' as string,
dashboard: null as any, dashboard: null as any, // 后端 /api/dashboard 无 response_model,暂未生成类型
financeSummary: null as any, financeSummary: null as Schema<'FinanceSummaryResponse'> | null,
financePeriod: { financePeriod: {
year: new Date().getFullYear(), year: new Date().getFullYear(),
quarter: '' as string quarter: '' as string
}, },
financeTransactions: [] as any[], financeTransactions: [] as Schema<'FinanceTransactionResponse'>[],
receivables: [] as any[], receivables: [] as Schema<'ReceivableItemResponse'>[],
payables: [] as any[], payables: [] as Schema<'PayableItemResponse'>[],
customerFinanceStatement: [] as any[], customerFinanceStatement: [] as Schema<'PartnerStatementItemResponse'>[],
supplierFinanceStatement: [] as any[], supplierFinanceStatement: [] as Schema<'PartnerStatementItemResponse'>[],
customerProductStatement: [] as any[], customerProductStatement: [] as Schema<'PartnerProductStatementItemResponse'>[],
supplierProductStatement: [] as any[], supplierProductStatement: [] as Schema<'PartnerProductStatementItemResponse'>[],
products: [] as any[], products: [] as any[],
materials: [] as any[], materials: [] as any[],
finishedProducts: [] as any[], finishedProducts: [] as any[],
@@ -392,7 +393,7 @@ export function useInventory() {
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`) apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
]) ])
state.financeSummary = summary state.financeSummary = summary
state.financeTransactions = transactions state.financeTransactions = transactions?.items || []
state.receivables = receivables state.receivables = receivables
state.payables = payables state.payables = payables
state.customerFinanceStatement = customerStatement.items || [] state.customerFinanceStatement = customerStatement.items || []
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
/**
* 后端 OpenAPI schema 类型快捷引用
*
* 类型由 `npm run gen:api` 从后端 openapi-inventory.json 自动生成(见 ./api.ts)。
* 后端字段变更后重新生成即可,前端类型自动同步,杜绝手写类型漂移。
*
* 用法:Schema<'FinanceSummaryResponse'>、Schema<'SalesOrderResponse'>[]
*/
import type { components } from './api'
export type Schema<K extends keyof components['schemas']> = components['schemas'][K]
File diff suppressed because it is too large Load Diff
+8777
View File
File diff suppressed because it is too large Load Diff
+10 -157
View File
@@ -1,22 +1,17 @@
""" """库存管理路由层 - 薄路由
库存管理路由模块
提供库存信息的查询功能,包括:
- 库存列表查询(支持分页、仓库筛选、产品筛选、低库存筛选)
- 显示产品库存数量、锁定数量、可用数量等信息
业务逻辑下沉至 inventory.services.inventory_service,路由只做参数校验与响应组装。
路由前缀: /api/inventory 路由前缀: /api/inventory
""" """
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func from typing import Optional
from sqlalchemy.exc import IntegrityError
from typing import Optional, List
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.models.database import User, Product, Warehouse, Inventory from shared.models.database import User
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
from ..services.inventory_service import inventory_service
router = APIRouter(prefix="/inventory", tags=["库存管理"]) router = APIRouter(prefix="/inventory", tags=["库存管理"])
@@ -31,43 +26,7 @@ async def list_inventory(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
base_query = ( return await inventory_service.list_inventory(db_session, warehouse_id, product_id, low_stock, skip, limit)
select(Inventory, Product, Warehouse)
.join(Product, Inventory.product_id == Product.id)
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
.where(Product.is_active == True)
.where(Product.item_type == "material")
.where(Warehouse.is_active == True)
)
if warehouse_id:
base_query = base_query.where(Inventory.warehouse_id == warehouse_id)
if product_id:
base_query = base_query.where(Inventory.product_id == product_id)
if low_stock:
base_query = base_query.where(Inventory.quantity <= Product.min_stock)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
inventory_list = []
for inv, product, warehouse in result.all():
inventory_list.append(InventoryResponse(
id=inv.id,
product_id=inv.product_id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=inv.warehouse_id,
warehouse_name=warehouse.name,
quantity=inv.quantity,
locked_quantity=inv.locked_quantity,
available_quantity=inv.available_quantity
))
return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit)
@router.post("", response_model=InventoryResponse, status_code=201) @router.post("", response_model=InventoryResponse, status_code=201)
@@ -76,64 +35,7 @@ async def create_inventory(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
if payload.quantity < 0 or payload.locked_quantity < 0: return await inventory_service.create_inventory(db_session, payload, current_user)
raise HTTPException(status_code=400, detail="库存数量不能为负数")
if payload.locked_quantity > payload.quantity:
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
product_result = await db_session.execute(
select(Product).where(Product.id == payload.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="库存仅支持物料")
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
exists_result = await db_session.execute(
select(Inventory).where(
Inventory.product_id == payload.product_id,
Inventory.warehouse_id == payload.warehouse_id
)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
inventory = Inventory(
product_id=payload.product_id,
warehouse_id=payload.warehouse_id,
quantity=payload.quantity,
locked_quantity=payload.locked_quantity,
batch_number=payload.batch_number,
location=payload.location
)
db_session.add(inventory)
try:
await db_session.commit()
except IntegrityError:
# 并发创建命中 (product_id, warehouse_id) 唯一约束
await db_session.rollback()
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
await db_session.refresh(inventory)
return InventoryResponse(
id=inventory.id,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=warehouse.id,
warehouse_name=warehouse.name,
quantity=inventory.quantity,
locked_quantity=inventory.locked_quantity,
available_quantity=inventory.available_quantity
)
@router.put("/{inventory_id}", response_model=InventoryResponse) @router.put("/{inventory_id}", response_model=InventoryResponse)
@@ -143,48 +45,7 @@ async def update_inventory(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
result = await db_session.execute( return await inventory_service.update_inventory(db_session, inventory_id, payload, current_user)
select(Inventory, Product, Warehouse)
.join(Product, Inventory.product_id == Product.id)
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
.where(Inventory.id == inventory_id)
.where(Product.item_type == "material")
.with_for_update(of=Inventory)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="库存记录不存在")
inventory, product, warehouse = row
if payload.quantity is not None:
if payload.quantity < 0:
raise HTTPException(status_code=400, detail="库存数量不能为负数")
inventory.quantity = payload.quantity
if payload.locked_quantity is not None:
if payload.locked_quantity < 0:
raise HTTPException(status_code=400, detail="锁定数量不能为负数")
inventory.locked_quantity = payload.locked_quantity
if inventory.locked_quantity > inventory.quantity:
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
if payload.batch_number is not None:
inventory.batch_number = payload.batch_number
if payload.location is not None:
inventory.location = payload.location
await db_session.commit()
await db_session.refresh(inventory)
return InventoryResponse(
id=inventory.id,
product_id=product.id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=warehouse.id,
warehouse_name=warehouse.name,
quantity=inventory.quantity,
locked_quantity=inventory.locked_quantity,
available_quantity=inventory.available_quantity
)
@router.delete("/{inventory_id}") @router.delete("/{inventory_id}")
@@ -193,12 +54,4 @@ async def delete_inventory(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id)) return await inventory_service.delete_inventory(db_session, inventory_id, current_user)
inventory = result.scalar_one_or_none()
if not inventory:
raise HTTPException(status_code=404, detail="库存记录不存在")
if inventory.quantity > 0:
raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录")
await db_session.delete(inventory)
await db_session.commit()
return {"message": "库存记录已删除"}
+13 -394
View File
@@ -1,203 +1,27 @@
""" """采购订单路由层 - 薄路由
采购订单路由模块
提供采购订单的管理功能,包括:
- 采购订单列表查询(支持分页、状态筛选)
- 创建采购订单(自动生成订单号、计算总金额)
- 采购订单明细管理
业务逻辑下沉至 inventory.services.purchase_order_service,路由只做参数校验与响应组装。
路由前缀: /api/purchase-orders 路由前缀: /api/purchase-orders
""" """
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update from typing import Optional
from typing import Optional, List
from decimal import Decimal
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.models.database import ( from shared.models.database import User
User,
Supplier,
Product,
Warehouse,
Inventory,
StockMovement,
PurchaseOrder,
PurchaseOrderItem
)
from ..schemas import ( from ..schemas import (
PurchaseOrderCreate, PurchaseOrderCreate,
PurchaseOrderResponse, PurchaseOrderResponse,
PurchaseOrderDetailResponse, PurchaseOrderDetailResponse,
PurchaseOrderItemResponse,
PurchaseOrderReceiveRequest, PurchaseOrderReceiveRequest,
PaginatedResponse, PaginatedResponse,
) )
from ..utils import generate_order_no from ..services.purchase_order_service import purchase_order_service
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"]) router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
def _compute_receipt_status(order: PurchaseOrder) -> str:
"""收货状态:已下单 / 部分收货 / 已收货 / 已作废"""
if order.status == "cancelled":
return "cancelled"
if order.status in ("received", "paid"):
return "received"
if order.status == "partial_received":
return "partial_received"
return "pending"
def _compute_payment_status(order: PurchaseOrder) -> str:
"""付款状态:未付款 / 已付款"""
if order.status == "paid" or order.paid_date:
return "paid"
return "unpaid"
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
return PurchaseOrderResponse(
id=order.id,
order_no=order.order_no,
supplier_name=supplier_name,
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
receipt_status=_compute_receipt_status(order),
payment_status=_compute_payment_status(order),
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
created_at=order.created_at,
received_date=order.received_date,
paid_date=order.paid_date
)
async def _get_order_with_supplier(
db_session: AsyncSession,
order_id: int
) -> tuple[PurchaseOrder, Supplier]:
result = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.id == order_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="采购订单不存在")
return row[0], row[1]
async def _build_purchase_order_detail(
db_session: AsyncSession,
order: PurchaseOrder,
supplier_name: str
) -> PurchaseOrderDetailResponse:
item_result = await db_session.execute(
select(PurchaseOrderItem, Product)
.join(Product, PurchaseOrderItem.product_id == Product.id)
.where(PurchaseOrderItem.order_id == order.id)
.order_by(PurchaseOrderItem.id.asc())
)
item_rows = item_result.all()
return PurchaseOrderDetailResponse(
id=order.id,
order_no=order.order_no,
supplier_id=order.supplier_id,
supplier_name=supplier_name,
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
receipt_status=_compute_receipt_status(order),
payment_status=_compute_payment_status(order),
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
created_at=order.created_at,
received_date=order.received_date,
paid_date=order.paid_date,
items=[
PurchaseOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_sku=product.sku,
product_name=product.name,
quantity=item.quantity,
received_quantity=item.received_quantity,
unit_price=item.unit_price,
amount=item.amount,
remark=item.remark
) for item, product in item_rows
]
)
async def _resolve_receive_warehouse(
db_session: AsyncSession,
warehouse_id: Optional[int]
) -> Warehouse:
if warehouse_id:
result = await db_session.execute(
select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True)
)
warehouse = result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
return warehouse
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库")
return warehouse
async def _apply_order_items(
db_session: AsyncSession,
order: PurchaseOrder,
order_data: PurchaseOrderCreate
) -> Decimal:
total_amount = Decimal("0")
for item_data in order_data.items:
product_result = await db_session.execute(
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
if product.item_type != "material":
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
# 优先使用前端传入的单价,否则使用物料成本价
if item_data.unit_price and item_data.unit_price > 0:
unit_price = float(item_data.unit_price)
else:
unit_price = product.cost_price or 0
if unit_price <= 0:
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置价格,请在物料管理界面设置成本价或在采购明细中填写单价")
try:
item = PurchaseOrderItem(
order_id=order.id,
product_id=item_data.product_id,
quantity=int(item_data.quantity),
unit_price=Decimal(str(unit_price)),
amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)),
remark=item_data.remark or None
)
db_session.add(item)
total_amount += item.amount
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
return total_amount
@router.get("", response_model=PaginatedResponse[PurchaseOrderResponse]) @router.get("", response_model=PaginatedResponse[PurchaseOrderResponse])
async def list_purchase_orders( async def list_purchase_orders(
status: Optional[str] = None, status: Optional[str] = None,
@@ -206,26 +30,7 @@ async def list_purchase_orders(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
base_query = ( return await purchase_order_service.list_orders(db_session, status, skip, limit)
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.order_by(PurchaseOrder.created_at.desc())
)
if status:
base_query = base_query.where(PurchaseOrder.status == status)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
orders = []
for order, supplier in result.all():
orders.append(_build_purchase_order_response(order, supplier.name))
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
@router.post("", response_model=PurchaseOrderResponse, status_code=201) @router.post("", response_model=PurchaseOrderResponse, status_code=201)
@@ -234,31 +39,7 @@ async def create_purchase_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order = PurchaseOrder( return await purchase_order_service.create_order(db_session, order_data, current_user)
order_no=generate_order_no("PO"),
supplier_id=order_data.supplier_id,
expected_date=order_data.expected_date,
remark=order_data.remark,
operator_id=current_user.id,
status="pending"
)
db_session.add(order)
await db_session.flush()
try:
order.total_amount = await _apply_order_items(db_session, order, order_data)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
return _build_purchase_order_response(order, supplier.name)
@router.get("/{order_id}", response_model=PurchaseOrderDetailResponse) @router.get("/{order_id}", response_model=PurchaseOrderDetailResponse)
@@ -267,8 +48,7 @@ async def get_purchase_order_detail(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, supplier = await _get_order_with_supplier(db_session, order_id) return await purchase_order_service.get_detail(db_session, order_id)
return await _build_purchase_order_detail(db_session, order, supplier.name)
@router.put("/{order_id}", response_model=PurchaseOrderResponse) @router.put("/{order_id}", response_model=PurchaseOrderResponse)
@@ -278,35 +58,7 @@ async def update_purchase_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, _ = await _get_order_with_supplier(db_session, order_id) return await purchase_order_service.update_order(db_session, order_id, order_data, current_user)
if order.paid_amount and order.paid_amount > 0:
raise HTTPException(status_code=400, detail="已付款采购单不允许修改")
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
existing_items = item_result.scalars().all()
if any((item.received_quantity or 0) > 0 for item in existing_items):
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改")
for item in existing_items:
await db_session.delete(item)
try:
order.supplier_id = order_data.supplier_id
order.expected_date = order_data.expected_date
order.remark = order_data.remark
order.total_amount = await _apply_order_items(db_session, order, order_data)
order.status = "pending"
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
@router.delete("/{order_id}") @router.delete("/{order_id}")
@@ -315,17 +67,7 @@ async def delete_purchase_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, _ = await _get_order_with_supplier(db_session, order_id) return await purchase_order_service.delete_order(db_session, order_id, current_user)
if order.paid_amount and order.paid_amount > 0:
raise HTTPException(status_code=400, detail="已付款采购单不允许删除")
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()):
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除")
await db_session.delete(order)
await db_session.commit()
return {"message": "采购订单已删除"}
@router.patch("/{order_id}/status") @router.patch("/{order_id}/status")
@@ -335,45 +77,7 @@ async def update_purchase_order_status(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, _ = await _get_order_with_supplier(db_session, order_id) return await purchase_order_service.update_status(db_session, order_id, status, current_user)
new_status = status.get("status")
if not new_status:
raise HTTPException(status_code=400, detail="状态不能为空")
valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
# 收货状态(partial_received/received)只能由收货端点驱动,禁止手动设置,避免与实际入库脱钩
if new_status in ("partial_received", "received"):
raise HTTPException(status_code=400, detail="收货状态只能通过收货入库操作自动变更,不能手动设置")
# 状态转换逻辑
if order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
if order.status == "cancelled":
raise HTTPException(status_code=400, detail="已作废的采购订单禁止修改状态")
if order.status == "received" and new_status not in ("paid", "cancelled"):
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款或已作废")
if order.status == "partial_received" and new_status not in ("received", "paid", "cancelled"):
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货、已付款或已作废")
if new_status == "cancelled" and order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的订单不能作废")
# 更新状态和对应时间
order.status = new_status
if new_status == "received":
order.received_date = func.now()
elif new_status == "paid":
order.paid_date = func.now()
await db_session.commit()
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse) @router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse)
@@ -383,89 +87,4 @@ async def receive_purchase_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, supplier = await _get_order_with_supplier(db_session, order_id) return await purchase_order_service.receive_order(db_session, order_id, payload, current_user)
if order.status in ("cancelled", "paid"):
raise HTTPException(status_code=400, detail=f"当前采购单状态为 {order.status},不允许收货")
warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id)
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
item_map = {item.id: item for item in item_result.scalars().all()}
if not item_map:
raise HTTPException(status_code=400, detail="采购单无明细,无法入库")
if not payload.items:
raise HTTPException(status_code=400, detail="请提供本次入库明细")
for receive_item in payload.items:
item = item_map.get(receive_item.item_id)
if not item:
raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}")
if receive_item.receive_quantity <= 0:
raise HTTPException(status_code=400, detail="入库数量必须大于0")
remaining_qty = (item.quantity or 0) - (item.received_quantity or 0)
if receive_item.receive_quantity > remaining_qty:
raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}")
for receive_item in payload.items:
item = item_map[receive_item.item_id]
product_result = await db_session.execute(
select(Product).where(Product.id == item.product_id)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}")
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.product_id)
.where(Inventory.warehouse_id == warehouse.id)
.values(quantity=Inventory.quantity + receive_item.receive_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=item.product_id,
warehouse_id=warehouse.id,
quantity=receive_item.receive_quantity,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = receive_item.receive_quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - receive_item.receive_quantity
item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity
movement = StockMovement(
product_id=item.product_id,
warehouse_id=warehouse.id,
movement_type="purchase_in",
quantity=receive_item.receive_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="purchase_order",
reference_id=order.id,
reference_no=order.order_no,
unit_price=item.unit_price,
total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)),
remark=payload.remark or f"采购单{order.order_no}到货入库",
operator_id=current_user.id
)
db_session.add(movement)
all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values())
any_received = any((item.received_quantity or 0) > 0 for item in item_map.values())
if all_received:
order.status = "received"
order.received_date = func.now()
elif any_received:
order.status = "partial_received"
await db_session.commit()
await db_session.refresh(order)
return await _build_purchase_order_detail(db_session, order, supplier.name)
+16 -679
View File
@@ -1,406 +1,29 @@
""" """销售订单路由层 - 薄路由
销售订单路由模块
提供销售订单的管理功能,包括:
- 销售订单列表查询(支持分页、状态筛选)
- 创建销售订单(自动生成订单号、计算总金额)
- 销售订单明细管理
业务逻辑下沉至 inventory.services.sales_order_service,路由只做参数校验与响应组装。
路由前缀: /api/sales-orders 路由前缀: /api/sales-orders
""" """
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, update from typing import Optional
from typing import Optional, List
from math import ceil
from pydantic import BaseModel, Field
from decimal import Decimal
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.models.database import ( from shared.models.database import User
User,
Customer,
Product,
ProductMaterial,
Warehouse,
Inventory,
StockMovement,
SalesOrder,
SalesOrderItem
)
from ..schemas import ( from ..schemas import (
SalesOrderCreate, SalesOrderCreate,
SalesOrderResponse, SalesOrderResponse,
SalesOrderDetailResponse, SalesOrderDetailResponse,
SalesOrderItemResponse,
SalesOrderProductionPlanResponse, SalesOrderProductionPlanResponse,
ProductionMaterialPlanItemResponse,
SalesOrderIssueRequest, SalesOrderIssueRequest,
SalesOrderIssueResponse, SalesOrderIssueResponse,
SalesOrderStatusUpdate, SalesOrderStatusUpdate,
MaterialConsumptionRequest,
PaginatedResponse, PaginatedResponse,
) )
from ..utils import generate_order_no from ..services.sales_order_service import sales_order_service
router = APIRouter(prefix="/sales-orders", tags=["销售订单"]) router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"}
def _compute_delivery_status(order: SalesOrder) -> str:
"""物流状态:制造中 / 已交付 / 已作废"""
if order.status == "cancelled":
return "cancelled"
if order.status in ("delivered", "paid") or order.actual_delivery_date:
return "delivered"
return "manufacturing"
def _compute_payment_status(order: SalesOrder) -> str:
"""收款状态:未收款 / 已收款"""
if order.status == "paid" or order.actual_payment_date:
return "paid"
return "unpaid"
def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse:
return SalesOrderResponse(
id=order.id,
order_no=order.order_no,
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
delivery_status=_compute_delivery_status(order),
payment_status=_compute_payment_status(order),
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
total_amount=Decimal(str(order.total_amount or 0)),
received_amount=Decimal(str(order.received_amount or 0)),
remark=order.remark,
created_at=order.created_at
)
async def _build_sales_order_detail_response(
db_session: AsyncSession,
order: SalesOrder,
customer_name: str
) -> SalesOrderDetailResponse:
items_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc())
)
items = items_result.scalars().all()
return SalesOrderDetailResponse(
id=order.id,
order_no=order.order_no,
customer_id=order.customer_id,
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
delivery_status=_compute_delivery_status(order),
payment_status=_compute_payment_status(order),
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
total_amount=Decimal(str(order.total_amount or 0)),
received_amount=Decimal(str(order.received_amount or 0)),
remark=order.remark,
created_at=order.created_at,
items=[
SalesOrderItemResponse(
id=item.id,
product_id=item.product_id,
quantity=item.quantity,
delivered_quantity=item.delivered_quantity,
unit_price=Decimal(str(item.unit_price or 0)),
amount=Decimal(str(item.amount or 0)),
remark=item.remark
) for item in items
]
)
async def _get_sales_order_with_customer(
db_session: AsyncSession,
order_id: int,
) -> tuple[SalesOrder, Customer]:
result = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.id == order_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="销售订单不存在")
return row[0], row[1]
async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> tuple[List[ProductionMaterialPlanItemResponse], float]:
item_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id)
)
order_items = item_result.scalars().all()
if not order_items:
return [], 0
finished_ids = list({int(i.product_id) for i in order_items})
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(finished_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_rows = bom_result.all()
if not bom_rows:
return [], 0
bom_by_finished_id = {}
for bom, material in bom_rows:
bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material))
required_qty_map = {}
for order_item in order_items:
bom_items = bom_by_finished_id.get(int(order_item.product_id)) or []
for bom, material in bom_items:
qty = Decimal(str(order_item.quantity)) * Decimal(str(bom.quantity or 0)) * (1 + Decimal(str(bom.loss_rate or 0)))
entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": Decimal("0")})
entry["required_qty"] += qty
if not required_qty_map:
return [], Decimal("0")
material_ids = list(required_qty_map.keys())
stock_result = await db_session.execute(
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
.where(Inventory.product_id.in_(material_ids))
.group_by(Inventory.product_id)
)
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
plan_items = []
planned_material_cost = Decimal("0")
for material_id, entry in required_qty_map.items():
material = entry["material"]
required_qty = int(ceil(entry["required_qty"]))
available_qty = stock_map.get(material_id, Decimal("0"))
shortage_qty = max(required_qty - int(available_qty), 0)
unit_cost = Decimal(str(material.cost_price or 0))
required_cost = Decimal(str(required_qty)) * unit_cost
planned_material_cost += required_cost
plan_items.append(
ProductionMaterialPlanItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
required_quantity=Decimal(str(required_qty)),
available_quantity=available_qty,
shortage_quantity=Decimal(str(shortage_qty)),
unit_cost=unit_cost,
required_cost=required_cost,
)
)
plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True)
return plan_items, planned_material_cost
async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse:
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = warehouse_result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料")
return warehouse
async def _issue_materials_for_order_creation(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
) -> tuple[int, float, float]:
warehouse = await _get_default_warehouse(db_session)
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
order.production_no = order.production_no or generate_order_no("WO")
order.production_status = "bom_missing"
order.planned_material_cost = 0
order.actual_material_cost = 0
order.status = "manufacturing"
return 0, 0, 0
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
if shortage_items:
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
production_no = order.production_no or generate_order_no("WO")
actual_material_cost = Decimal("0")
movement_count = 0
for item in plan_items:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
movement = StockMovement(
product_id=item.material_id,
warehouse_id=warehouse.id,
movement_type="issue_to_production",
quantity=item.required_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=production_no,
unit_price=item.unit_cost,
total_amount=Decimal(str(total_amount)),
remark=f"销售单{order.order_no}创建时自动扣减物料",
operator_id=current_user.id
)
db_session.add(movement)
movement_count += 1
order.production_no = production_no
order.production_status = "material_issued"
order.planned_material_cost = planned_material_cost
order.actual_material_cost = actual_material_cost
order.status = "manufacturing"
return movement_count, planned_material_cost, actual_material_cost
async def _rollback_issued_materials(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
):
movement_result = await db_session.execute(
select(StockMovement)
.where(StockMovement.reference_type == "sales_order")
.where(StockMovement.reference_id == order.id)
.where(StockMovement.movement_type == "issue_to_production")
.order_by(StockMovement.id.asc())
)
movements = movement_result.scalars().all()
if not movements:
return
for movement in movements:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == movement.product_id)
.where(Inventory.warehouse_id == movement.warehouse_id)
.values(quantity=Inventory.quantity + movement.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
quantity=movement.quantity,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = movement.quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - movement.quantity
revert_movement = StockMovement(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
movement_type="return_from_production",
quantity=movement.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=order.production_no or order.order_no,
unit_price=movement.unit_price,
total_amount=movement.total_amount,
remark=f"销售单{order.order_no}变更/删除,自动回补物料",
operator_id=current_user.id
)
db_session.add(revert_movement)
async def _apply_order_items(
db_session: AsyncSession,
order: SalesOrder,
order_data: SalesOrderCreate
) -> Decimal:
total_amount = Decimal("0")
for item_data in order_data.items:
product = None
if item_data.product_id is not None:
product_result = await db_session.execute(
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"产品不存在: {item_data.product_id}")
else:
if not item_data.product_sku or not item_data.product_name:
raise HTTPException(status_code=400, detail="请提供产品ID,或提供产品SKU与产品名称")
by_sku_result = await db_session.execute(
select(Product).where(Product.sku == item_data.product_sku, Product.is_active == True)
)
product = by_sku_result.scalar_one_or_none()
if not product:
product = Product(
sku=item_data.product_sku,
name=item_data.product_name,
category=item_data.product_category,
unit=item_data.product_unit or "件",
item_type="finished",
cost_price=0,
sale_price=item_data.unit_price or 0,
min_stock=0,
max_stock=0
)
db_session.add(product)
await db_session.flush()
if product.item_type != "finished":
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
item = SalesOrderItem(
order_id=order.id,
product_id=product.id,
quantity=item_data.quantity,
unit_price=item_data.unit_price,
amount=item_data.quantity * item_data.unit_price,
remark=item_data.remark
)
db_session.add(item)
total_amount += item.amount
return total_amount
@router.get("", response_model=PaginatedResponse[SalesOrderResponse]) @router.get("", response_model=PaginatedResponse[SalesOrderResponse])
@@ -411,26 +34,7 @@ async def list_sales_orders(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
base_query = ( return await sales_order_service.list_orders(db_session, status, skip, limit)
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.order_by(SalesOrder.created_at.desc())
)
if status:
base_query = base_query.where(SalesOrder.status == status)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
orders = []
for order, customer in result.all():
orders.append(_build_sales_order_response(order, customer.name))
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
@router.post("", response_model=SalesOrderResponse, status_code=201) @router.post("", response_model=SalesOrderResponse, status_code=201)
@@ -439,35 +43,7 @@ async def create_sales_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
from datetime import datetime return await sales_order_service.create_order(db_session, order_data, current_user)
now = datetime.now()
order = SalesOrder(
order_no=generate_order_no("SO"),
customer_id=order_data.customer_id,
order_date=now,
delivery_date=order_data.delivery_date,
manufacturing_date=now.date(),
created_at=now,
remark=order_data.remark,
operator_id=current_user.id,
status="manufacturing"
)
db_session.add(order)
await db_session.flush()
try:
order.total_amount = await _apply_order_items(db_session, order, order_data)
await _issue_materials_for_order_creation(db_session, order, current_user)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
customer = customer.scalar_one()
return _build_sales_order_response(order, customer.name)
@router.get("/{order_id}", response_model=SalesOrderDetailResponse) @router.get("/{order_id}", response_model=SalesOrderDetailResponse)
@@ -476,8 +52,7 @@ async def get_sales_order_detail(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, customer = await _get_sales_order_with_customer(db_session, order_id) return await sales_order_service.get_detail(db_session, order_id)
return await _build_sales_order_detail_response(db_session, order, customer.name)
@router.put("/{order_id}", response_model=SalesOrderResponse) @router.put("/{order_id}", response_model=SalesOrderResponse)
@@ -487,34 +62,7 @@ async def update_sales_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, customer = await _get_sales_order_with_customer(db_session, order_id) return await sales_order_service.update_order(db_session, order_id, order_data, current_user)
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改")
try:
await _rollback_issued_materials(db_session, order, current_user)
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
order.customer_id = order_data.customer_id
order.delivery_date = order_data.delivery_date
order.remark = order_data.remark
order.production_status = "not_started"
order.production_no = None
order.planned_material_cost = 0
order.actual_material_cost = 0
order.status = "manufacturing"
order.total_amount = await _apply_order_items(db_session, order, order_data)
await _issue_materials_for_order_creation(db_session, order, current_user)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
updated_customer = customer_result.scalar_one_or_none()
customer_name = updated_customer.name if updated_customer else "未知客户"
return _build_sales_order_response(order, customer_name)
@router.patch("/{order_id}/status", response_model=SalesOrderResponse) @router.patch("/{order_id}/status", response_model=SalesOrderResponse)
@@ -524,34 +72,7 @@ async def update_sales_order_status(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
if payload.status not in VALID_ORDER_STATUSES: return await sales_order_service.update_status(db_session, order_id, payload, current_user)
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
order, customer = await _get_sales_order_with_customer(db_session, order_id)
# 状态转换守卫
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改状态")
if order.status == "cancelled":
raise HTTPException(status_code=400, detail="已作废的销售订单禁止修改状态")
if order.status == "delivered" and payload.status not in ("paid", "cancelled"):
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款或已作废")
if payload.status == "cancelled" and order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的订单不能作废,请联系管理员")
# 根据状态更新相应的日期字段
from datetime import datetime
if payload.status == "delivered" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
elif payload.status == "paid" and not order.actual_payment_date:
order.actual_payment_date = datetime.now()
elif payload.status == "cancelled" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
order.status = payload.status
await db_session.commit()
await db_session.refresh(order)
return _build_sales_order_response(order, customer.name)
@router.delete("/{order_id}") @router.delete("/{order_id}")
@@ -560,27 +81,7 @@ async def delete_sales_order(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, customer = await _get_sales_order_with_customer(db_session, order_id) return await sales_order_service.delete_order(db_session, order_id, current_user)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止删除,请先作废相关收款单")
if (order.received_amount or 0) > 0:
raise HTTPException(status_code=400, detail="该销售订单已存在收款记录(received_amount>0),禁止删除,请先作废相关收款单")
await _rollback_issued_materials(db_session, order, current_user)
await db_session.delete(order)
await db_session.commit()
return {"message": "销售订单已删除"}
class MaterialConsumptionItem(BaseModel):
material_id: int
quantity: float = Field(gt=0)
remark: Optional[str] = None
class MaterialConsumptionRequest(BaseModel):
items: List[MaterialConsumptionItem] = Field(min_length=1)
@router.post("/{order_id}/consume-materials") @router.post("/{order_id}/consume-materials")
@@ -591,76 +92,7 @@ async def consume_materials(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
"""记录销售订单的物料消耗(在已发料基础上记录额外消耗,如报废/超耗)""" """记录销售订单的物料消耗(在已发料基础上记录额外消耗,如报废/超耗)"""
order, customer = await _get_sales_order_with_customer(db_session, order_id) return await sales_order_service.consume_materials(db_session, order_id, request, current_user)
if order.status in ("delivered", "paid", "cancelled"):
raise HTTPException(status_code=400, detail="当前订单状态不允许记录物料消耗")
if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产,不允许记录物料消耗")
default_warehouse = await _get_default_warehouse(db_session)
# 计算总物料成本
total_cost = Decimal("0")
# 处理每个物料消耗项
for item in request.items:
# 获取物料信息
material = await db_session.get(Product, item.material_id)
if not material:
raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
if material.item_type != "material":
raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
# 统一转 Decimal,避免 Decimal * float 在 Postgres(Numeric) 上抛 TypeError
consume_qty = Decimal(str(item.quantity))
unit_cost = Decimal(str(material.cost_price or 0))
cost = unit_cost * consume_qty
total_cost += cost
# 更新物料库存(原子操作防并发)
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == material.id)
.where(Inventory.warehouse_id == default_warehouse.id)
.where(Inventory.quantity >= consume_qty)
.values(quantity=Inventory.quantity - consume_qty)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
after_qty = Decimal(str(after_qty))
before_qty = after_qty + consume_qty
# 记录物料消耗
movement = StockMovement(
product_id=material.id,
warehouse_id=default_warehouse.id,
quantity=consume_qty,
before_quantity=before_qty,
after_quantity=after_qty,
movement_type="consumption",
reference_type="sales_order",
reference_id=order.id,
unit_price=unit_cost,
total_amount=cost,
operator_id=current_user.id,
remark=item.remark
)
db_session.add(movement)
# 累加实际物料成本(创建时已记录计划发料成本,此处为额外消耗,不应覆盖)
order.actual_material_cost = Decimal(str(order.actual_material_cost or 0)) + total_cost
await db_session.commit()
await db_session.refresh(order)
return {
"message": "物料消耗记录保存成功",
"total_cost": total_cost,
"order": _build_sales_order_response(order, customer.name)
}
@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse) @router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
@@ -669,18 +101,7 @@ async def get_sales_order_production_plan(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, customer = await _get_sales_order_with_customer(db_session, order_id) return await sales_order_service.get_production_plan(db_session, order_id)
production_no = order.production_no or generate_order_no("WO")
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
return SalesOrderProductionPlanResponse(
sales_order_id=order.id,
order_no=order.order_no,
customer_name=customer.name,
production_no=production_no,
planned_material_cost=planned_material_cost,
items=plan_items,
)
@router.post("/{order_id}/issue-materials", response_model=SalesOrderIssueResponse) @router.post("/{order_id}/issue-materials", response_model=SalesOrderIssueResponse)
@@ -690,88 +111,4 @@ async def issue_sales_order_materials(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
order, _ = await _get_sales_order_with_customer(db_session, order_id) return await sales_order_service.issue_materials(db_session, order_id, payload, current_user)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料")
if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产")
if order.production_status == "material_issued":
raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料")
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
raise HTTPException(status_code=400, detail="该销售单未配置BOM,无法领料")
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
if shortage_items:
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
production_no = payload.production_no or order.production_no or generate_order_no("WO")
actual_material_cost = Decimal("0")
movement_count = 0
for item in plan_items:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
movement = StockMovement(
product_id=item.material_id,
warehouse_id=warehouse.id,
movement_type="issue_to_production",
quantity=item.required_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=production_no,
unit_price=item.unit_cost,
total_amount=Decimal(str(total_amount)),
remark=payload.remark or f"销售单{order.order_no}按单生产领料",
operator_id=current_user.id
)
db_session.add(movement)
movement_count += 1
order.production_no = production_no
order.production_status = "material_issued"
order.planned_material_cost = planned_material_cost
order.actual_material_cost = actual_material_cost
if order.status == "draft":
order.status = "manufacturing"
await db_session.commit()
cost_deviation = actual_material_cost - planned_material_cost
cost_deviation_rate = (cost_deviation / planned_material_cost) if planned_material_cost > Decimal("1e-9") else Decimal("0")
return SalesOrderIssueResponse(
sales_order_id=order.id,
order_no=order.order_no,
production_no=production_no,
movement_count=movement_count,
planned_material_cost=planned_material_cost,
actual_material_cost=actual_material_cost,
cost_deviation=cost_deviation,
cost_deviation_rate=cost_deviation_rate,
production_status=order.production_status,
)
+8 -180
View File
@@ -1,36 +1,20 @@
""" """库存变动路由层 - 薄路由
库存变动路由模块
提供库存变动的管理功能,包括:
- 创建库存变动记录(入库、出库、调整)
- 库存变动历史查询(支持分页、产品筛选、变动类型筛选)
- 自动更新库存数量
- 库存不足校验(出库时)
业务逻辑下沉至 inventory.services.stock_movement_service,路由只做参数校验与响应组装。
路由前缀: /api/stock-movements 路由前缀: /api/stock-movements
""" """
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func from typing import Optional
from typing import Optional, List
from shared.database.database import get_db_session from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user from shared.services.auth_service import get_current_active_user
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement from shared.models.database import User
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from ..utils import generate_order_no from ..services.stock_movement_service import stock_movement_service
router = APIRouter(prefix="/stock-movements", tags=["库存变动"]) router = APIRouter(prefix="/stock-movements", tags=["库存变动"])
INBOUND_TYPES = {
"in", "purchase_in", "return_from_production", "outsource_return", "finish_in"
}
OUTBOUND_TYPES = {
"out", "issue_to_production", "outsource_send", "shipment_out", "scrap_out"
}
ADJUST_TYPES = {"adjust"}
SUPPORTED_MOVEMENT_TYPES = INBOUND_TYPES | OUTBOUND_TYPES | ADJUST_TYPES
@router.post("", response_model=StockMovementResponse, status_code=201) @router.post("", response_model=StockMovementResponse, status_code=201)
async def create_stock_movement( async def create_stock_movement(
@@ -38,130 +22,7 @@ async def create_stock_movement(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
if movement_data.movement_type not in SUPPORTED_MOVEMENT_TYPES: return await stock_movement_service.create_movement(db_session, movement_data, current_user)
raise HTTPException(status_code=400, detail="无效的变动类型")
if movement_data.quantity <= 0:
raise HTTPException(status_code=400, detail="数量必须大于0")
warehouse_result = await db_session.execute(
select(Warehouse)
.where(Warehouse.id == movement_data.warehouse_id)
.where(Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
product_result = await db_session.execute(
select(Product)
.where(Product.id == movement_data.product_id)
.where(Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
sku_candidate = movement_data.product_sku or str(movement_data.product_id)
product_by_sku_result = await db_session.execute(
select(Product)
.where(Product.sku == sku_candidate)
.where(Product.is_active == True)
)
product = product_by_sku_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在,请选择系统中的产品")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="库存仅管理物料,该条目不是物料")
resolved_product_id = product.id
if movement_data.movement_type in INBOUND_TYPES:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.values(quantity=Inventory.quantity + movement_data.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
quantity=movement_data.quantity,
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = movement_data.quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - movement_data.quantity
elif movement_data.movement_type in OUTBOUND_TYPES:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.where(Inventory.quantity >= movement_data.quantity)
.values(quantity=Inventory.quantity - movement_data.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail="库存不足")
after_qty = int(after_qty)
before_qty = after_qty + movement_data.quantity
else:
result = await db_session.execute(
select(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.with_for_update()
)
inventory = result.scalar_one_or_none()
if not inventory:
inventory = Inventory(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
quantity=0,
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
else:
before_qty = int(inventory.quantity)
inventory.quantity = movement_data.quantity
after_qty = movement_data.quantity
movement = StockMovement(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
movement_type=movement_data.movement_type,
quantity=movement_data.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_no=generate_order_no("SM"),
unit_price=movement_data.unit_price,
total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None,
remark=movement_data.remark,
operator_id=current_user.id
)
db_session.add(movement)
await db_session.commit()
return StockMovementResponse(
id=movement.id,
product_id=product.id,
product_sku=product.sku,
product_name=product.name,
movement_type=movement.movement_type,
quantity=movement.quantity,
before_quantity=movement.before_quantity,
after_quantity=movement.after_quantity,
reference_no=movement.reference_no,
remark=movement.remark,
created_at=movement.created_at
)
@router.get("", response_model=PaginatedResponse[StockMovementResponse]) @router.get("", response_model=PaginatedResponse[StockMovementResponse])
@@ -173,37 +34,4 @@ async def list_stock_movements(
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
): ):
base_query = ( return await stock_movement_service.list_movements(db_session, product_id, movement_type, skip, limit)
select(StockMovement, Product)
.join(Product, StockMovement.product_id == Product.id)
.order_by(StockMovement.created_at.desc())
)
if product_id:
base_query = base_query.where(StockMovement.product_id == product_id)
if movement_type:
base_query = base_query.where(StockMovement.movement_type == movement_type)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
movements = []
for movement, product in result.all():
movements.append(StockMovementResponse(
id=movement.id,
product_id=product.id,
product_sku=product.sku,
product_name=product.name,
movement_type=movement.movement_type,
quantity=movement.quantity,
before_quantity=movement.before_quantity,
after_quantity=movement.after_quantity,
reference_no=movement.reference_no,
remark=movement.remark,
created_at=movement.created_at
))
return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit)
-22
View File
@@ -1,22 +0,0 @@
"""
库存管理工具函数模块
提供进销存系统通用的工具函数,包括:
- 订单编号生成器(采购订单、销售订单、库存变动等)
"""
from datetime import datetime, timezone
import secrets
def generate_order_no(prefix: str) -> str:
"""生成订单编号
Args:
prefix: 订单类型前缀,如 PO(采购订单)、SO(销售订单)、SM(库存变动)
Returns:
格式为 {prefix}{YYYYMMDDHHMMSS}{8位随机字符} 的订单编号
"""
date_str = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
random_str = secrets.token_hex(4).upper()
return f"{prefix}{date_str}{random_str}"
+3 -1
View File
@@ -30,7 +30,9 @@ from .sales_order_schemas import (
SalesOrderProductionPlanResponse, SalesOrderProductionPlanResponse,
SalesOrderIssueRequest, SalesOrderIssueRequest,
SalesOrderIssueResponse, SalesOrderIssueResponse,
SalesOrderStatusUpdate SalesOrderStatusUpdate,
MaterialConsumptionItem,
MaterialConsumptionRequest
) )
from .finance_schemas import ( from .finance_schemas import (
FinanceAllocationCreate, FinanceAllocationCreate,
@@ -102,3 +102,13 @@ class SalesOrderIssueResponse(BaseModel):
class SalesOrderStatusUpdate(BaseModel): class SalesOrderStatusUpdate(BaseModel):
status: str status: str
class MaterialConsumptionItem(BaseModel):
material_id: int
quantity: float = Field(gt=0)
remark: Optional[str] = None
class MaterialConsumptionRequest(BaseModel):
items: List[MaterialConsumptionItem] = Field(min_length=1)
+180
View File
@@ -0,0 +1,180 @@
"""库存查询业务服务层
将原 inventory_routes 中的业务编排(库存列表/建/改/删)下沉到此,
路由层只做参数校验与响应组装。
"""
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.exc import IntegrityError
from shared.models.database import User, Product, Warehouse, Inventory
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
def _build_inventory_response(inv: Inventory, product: Product, warehouse: Warehouse) -> InventoryResponse:
return InventoryResponse(
id=inv.id,
product_id=inv.product_id,
product_name=product.name,
product_sku=product.sku,
warehouse_id=inv.warehouse_id,
warehouse_name=warehouse.name,
quantity=inv.quantity,
locked_quantity=inv.locked_quantity,
available_quantity=inv.available_quantity
)
class InventoryService:
"""库存查询业务服务"""
@staticmethod
async def list_inventory(
db_session: AsyncSession,
warehouse_id: Optional[int],
product_id: Optional[int],
low_stock: bool,
skip: int,
limit: int,
) -> PaginatedResponse:
base_query = (
select(Inventory, Product, Warehouse)
.join(Product, Inventory.product_id == Product.id)
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
.where(Product.is_active == True)
.where(Product.item_type == "material")
.where(Warehouse.is_active == True)
)
if warehouse_id:
base_query = base_query.where(Inventory.warehouse_id == warehouse_id)
if product_id:
base_query = base_query.where(Inventory.product_id == product_id)
if low_stock:
base_query = base_query.where(Inventory.quantity <= Product.min_stock)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
inventory_list = [_build_inventory_response(inv, product, warehouse) for inv, product, warehouse in result.all()]
return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit)
@staticmethod
async def create_inventory(
db_session: AsyncSession,
payload: InventoryCreate,
current_user: User,
) -> InventoryResponse:
if payload.quantity < 0 or payload.locked_quantity < 0:
raise HTTPException(status_code=400, detail="库存数量不能为负数")
if payload.locked_quantity > payload.quantity:
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
product_result = await db_session.execute(
select(Product).where(Product.id == payload.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="物料不存在")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="库存仅支持物料")
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
exists_result = await db_session.execute(
select(Inventory).where(
Inventory.product_id == payload.product_id,
Inventory.warehouse_id == payload.warehouse_id
)
)
if exists_result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
inventory = Inventory(
product_id=payload.product_id,
warehouse_id=payload.warehouse_id,
quantity=payload.quantity,
locked_quantity=payload.locked_quantity,
batch_number=payload.batch_number,
location=payload.location
)
db_session.add(inventory)
try:
await db_session.commit()
except IntegrityError:
# 并发创建命中 (product_id, warehouse_id) 唯一约束
await db_session.rollback()
raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录")
await db_session.refresh(inventory)
return _build_inventory_response(inventory, product, warehouse)
@staticmethod
async def update_inventory(
db_session: AsyncSession,
inventory_id: int,
payload: InventoryUpdate,
current_user: User,
) -> InventoryResponse:
result = await db_session.execute(
select(Inventory, Product, Warehouse)
.join(Product, Inventory.product_id == Product.id)
.join(Warehouse, Inventory.warehouse_id == Warehouse.id)
.where(Inventory.id == inventory_id)
.where(Product.item_type == "material")
.with_for_update(of=Inventory)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="库存记录不存在")
inventory, product, warehouse = row
if payload.quantity is not None:
if payload.quantity < 0:
raise HTTPException(status_code=400, detail="库存数量不能为负数")
inventory.quantity = payload.quantity
if payload.locked_quantity is not None:
if payload.locked_quantity < 0:
raise HTTPException(status_code=400, detail="锁定数量不能为负数")
inventory.locked_quantity = payload.locked_quantity
if inventory.locked_quantity > inventory.quantity:
raise HTTPException(status_code=400, detail="锁定数量不能大于库存数量")
if payload.batch_number is not None:
inventory.batch_number = payload.batch_number
if payload.location is not None:
inventory.location = payload.location
await db_session.commit()
await db_session.refresh(inventory)
return _build_inventory_response(inventory, product, warehouse)
@staticmethod
async def delete_inventory(
db_session: AsyncSession,
inventory_id: int,
current_user: User,
) -> dict:
result = await db_session.execute(select(Inventory).where(Inventory.id == inventory_id))
inventory = result.scalar_one_or_none()
if not inventory:
raise HTTPException(status_code=404, detail="库存记录不存在")
if inventory.quantity > 0:
raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录")
await db_session.delete(inventory)
await db_session.commit()
return {"message": "库存记录已删除"}
inventory_service = InventoryService()
@@ -0,0 +1,454 @@
"""采购订单业务服务层
将原 purchase_order_routes 中的业务编排(建单/改单/删单、收货入库、状态流转)下沉到此,
路由层只做参数校验与响应组装。
"""
from typing import Optional, Tuple
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from shared.models.database import (
User,
Supplier,
Product,
Warehouse,
Inventory,
StockMovement,
PurchaseOrder,
PurchaseOrderItem,
)
from ..schemas import (
PurchaseOrderCreate,
PurchaseOrderResponse,
PurchaseOrderDetailResponse,
PurchaseOrderItemResponse,
PurchaseOrderReceiveRequest,
PaginatedResponse,
)
from ..utils import generate_order_no
def _compute_receipt_status(order: PurchaseOrder) -> str:
"""收货状态:已下单 / 部分收货 / 已收货 / 已作废"""
if order.status == "cancelled":
return "cancelled"
if order.status in ("received", "paid"):
return "received"
if order.status == "partial_received":
return "partial_received"
return "pending"
def _compute_payment_status(order: PurchaseOrder) -> str:
"""付款状态:未付款 / 已付款"""
if order.status == "paid" or order.paid_date:
return "paid"
return "unpaid"
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
return PurchaseOrderResponse(
id=order.id,
order_no=order.order_no,
supplier_name=supplier_name,
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
receipt_status=_compute_receipt_status(order),
payment_status=_compute_payment_status(order),
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
created_at=order.created_at,
received_date=order.received_date,
paid_date=order.paid_date
)
async def _get_order_with_supplier(
db_session: AsyncSession,
order_id: int,
) -> Tuple[PurchaseOrder, Supplier]:
result = await db_session.execute(
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.where(PurchaseOrder.id == order_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="采购订单不存在")
return row[0], row[1]
async def _build_purchase_order_detail(
db_session: AsyncSession,
order: PurchaseOrder,
supplier_name: str,
) -> PurchaseOrderDetailResponse:
item_result = await db_session.execute(
select(PurchaseOrderItem, Product)
.join(Product, PurchaseOrderItem.product_id == Product.id)
.where(PurchaseOrderItem.order_id == order.id)
.order_by(PurchaseOrderItem.id.asc())
)
item_rows = item_result.all()
return PurchaseOrderDetailResponse(
id=order.id,
order_no=order.order_no,
supplier_id=order.supplier_id,
supplier_name=supplier_name,
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
receipt_status=_compute_receipt_status(order),
payment_status=_compute_payment_status(order),
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
created_at=order.created_at,
received_date=order.received_date,
paid_date=order.paid_date,
items=[
PurchaseOrderItemResponse(
id=item.id,
product_id=item.product_id,
product_sku=product.sku,
product_name=product.name,
quantity=item.quantity,
received_quantity=item.received_quantity,
unit_price=item.unit_price,
amount=item.amount,
remark=item.remark
) for item, product in item_rows
]
)
async def _resolve_receive_warehouse(
db_session: AsyncSession,
warehouse_id: Optional[int],
) -> Warehouse:
if warehouse_id:
result = await db_session.execute(
select(Warehouse).where(Warehouse.id == warehouse_id, Warehouse.is_active == True)
)
warehouse = result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
return warehouse
result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库")
return warehouse
async def _apply_order_items(
db_session: AsyncSession,
order: PurchaseOrder,
order_data: PurchaseOrderCreate,
) -> Decimal:
total_amount = Decimal("0")
for item_data in order_data.items:
product_result = await db_session.execute(
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"物料不存在: {item_data.product_id}")
if product.item_type != "material":
raise HTTPException(status_code=400, detail=f"采购单仅允许物料: {product.name}")
# 优先使用前端传入的单价,否则使用物料成本价
if item_data.unit_price and item_data.unit_price > 0:
unit_price = float(item_data.unit_price)
else:
unit_price = product.cost_price or 0
if unit_price <= 0:
raise HTTPException(status_code=400, detail=f"物料 {product.name} 未设置价格,请在物料管理界面设置成本价或在采购明细中填写单价")
try:
item = PurchaseOrderItem(
order_id=order.id,
product_id=item_data.product_id,
quantity=int(item_data.quantity),
unit_price=Decimal(str(unit_price)),
amount=Decimal(str(item_data.quantity)) * Decimal(str(unit_price)),
remark=item_data.remark or None
)
db_session.add(item)
total_amount += item.amount
except Exception as e:
raise HTTPException(status_code=400, detail=f"创建订单明细失败: {str(e)}")
return total_amount
class PurchaseOrderService:
"""采购订单业务服务"""
@staticmethod
async def list_orders(
db_session: AsyncSession,
status: Optional[str],
skip: int,
limit: int,
) -> PaginatedResponse:
base_query = (
select(PurchaseOrder, Supplier)
.join(Supplier, PurchaseOrder.supplier_id == Supplier.id)
.order_by(PurchaseOrder.created_at.desc())
)
if status:
base_query = base_query.where(PurchaseOrder.status == status)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
orders = []
for order, supplier in result.all():
orders.append(_build_purchase_order_response(order, supplier.name))
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
@staticmethod
async def create_order(
db_session: AsyncSession,
order_data: PurchaseOrderCreate,
current_user: User,
) -> PurchaseOrderResponse:
order = PurchaseOrder(
order_no=generate_order_no("PO"),
supplier_id=order_data.supplier_id,
expected_date=order_data.expected_date,
remark=order_data.remark,
operator_id=current_user.id,
status="pending"
)
db_session.add(order)
await db_session.flush()
try:
order.total_amount = await _apply_order_items(db_session, order, order_data)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
if not supplier:
raise HTTPException(status_code=404, detail="供应商不存在")
return _build_purchase_order_response(order, supplier.name)
@staticmethod
async def get_detail(db_session: AsyncSession, order_id: int) -> PurchaseOrderDetailResponse:
order, supplier = await _get_order_with_supplier(db_session, order_id)
return await _build_purchase_order_detail(db_session, order, supplier.name)
@staticmethod
async def update_order(
db_session: AsyncSession,
order_id: int,
order_data: PurchaseOrderCreate,
current_user: User,
) -> PurchaseOrderResponse:
order, _ = await _get_order_with_supplier(db_session, order_id)
if order.paid_amount and order.paid_amount > 0:
raise HTTPException(status_code=400, detail="已付款采购单不允许修改")
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
existing_items = item_result.scalars().all()
if any((item.received_quantity or 0) > 0 for item in existing_items):
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许直接修改")
for item in existing_items:
await db_session.delete(item)
try:
order.supplier_id = order_data.supplier_id
order.expected_date = order_data.expected_date
order.remark = order_data.remark
order.total_amount = await _apply_order_items(db_session, order, order_data)
order.status = "pending"
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
@staticmethod
async def delete_order(db_session: AsyncSession, order_id: int, current_user: User) -> dict:
order, _ = await _get_order_with_supplier(db_session, order_id)
if order.paid_amount and order.paid_amount > 0:
raise HTTPException(status_code=400, detail="已付款采购单不允许删除")
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
if any((item.received_quantity or 0) > 0 for item in item_result.scalars().all()):
raise HTTPException(status_code=400, detail="已发生入库的采购单不允许删除")
await db_session.delete(order)
await db_session.commit()
return {"message": "采购订单已删除"}
@staticmethod
async def update_status(
db_session: AsyncSession,
order_id: int,
status: dict,
current_user: User,
) -> PurchaseOrderResponse:
order, _ = await _get_order_with_supplier(db_session, order_id)
new_status = status.get("status")
if not new_status:
raise HTTPException(status_code=400, detail="状态不能为空")
valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
# 收货状态(partial_received/received)只能由收货端点驱动,禁止手动设置,避免与实际入库脱钩
if new_status in ("partial_received", "received"):
raise HTTPException(status_code=400, detail="收货状态只能通过收货入库操作自动变更,不能手动设置")
# 状态转换逻辑
if order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
if order.status == "cancelled":
raise HTTPException(status_code=400, detail="已作废的采购订单禁止修改状态")
if order.status == "received" and new_status not in ("paid", "cancelled"):
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款或已作废")
if order.status == "partial_received" and new_status not in ("received", "paid", "cancelled"):
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货、已付款或已作废")
if new_status == "cancelled" and order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的订单不能作废")
# 更新状态和对应时间
order.status = new_status
if new_status == "received":
order.received_date = func.now()
elif new_status == "paid":
order.paid_date = func.now()
await db_session.commit()
await db_session.refresh(order)
supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
supplier = supplier_result.scalar_one_or_none()
return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
@staticmethod
async def receive_order(
db_session: AsyncSession,
order_id: int,
payload: PurchaseOrderReceiveRequest,
current_user: User,
) -> PurchaseOrderDetailResponse:
order, supplier = await _get_order_with_supplier(db_session, order_id)
if order.status in ("cancelled", "paid"):
raise HTTPException(status_code=400, detail=f"当前采购单状态为 {order.status},不允许收货")
warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id)
item_result = await db_session.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.order_id == order.id)
)
item_map = {item.id: item for item in item_result.scalars().all()}
if not item_map:
raise HTTPException(status_code=400, detail="采购单无明细,无法入库")
if not payload.items:
raise HTTPException(status_code=400, detail="请提供本次入库明细")
for receive_item in payload.items:
item = item_map.get(receive_item.item_id)
if not item:
raise HTTPException(status_code=400, detail=f"采购明细不存在: {receive_item.item_id}")
if receive_item.receive_quantity <= 0:
raise HTTPException(status_code=400, detail="入库数量必须大于0")
remaining_qty = (item.quantity or 0) - (item.received_quantity or 0)
if receive_item.receive_quantity > remaining_qty:
raise HTTPException(status_code=400, detail=f"明细{item.id}入库超量,剩余可入库{remaining_qty}")
for receive_item in payload.items:
item = item_map[receive_item.item_id]
product_result = await db_session.execute(
select(Product).where(Product.id == item.product_id)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}")
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.product_id)
.where(Inventory.warehouse_id == warehouse.id)
.values(quantity=Inventory.quantity + receive_item.receive_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=item.product_id,
warehouse_id=warehouse.id,
quantity=receive_item.receive_quantity,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = receive_item.receive_quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - receive_item.receive_quantity
item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity
movement = StockMovement(
product_id=item.product_id,
warehouse_id=warehouse.id,
movement_type="purchase_in",
quantity=receive_item.receive_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="purchase_order",
reference_id=order.id,
reference_no=order.order_no,
unit_price=item.unit_price,
total_amount=Decimal(str(item.unit_price * receive_item.receive_quantity)),
remark=payload.remark or f"采购单{order.order_no}到货入库",
operator_id=current_user.id
)
db_session.add(movement)
all_received = all((item.received_quantity or 0) >= (item.quantity or 0) for item in item_map.values())
any_received = any((item.received_quantity or 0) > 0 for item in item_map.values())
if all_received:
order.status = "received"
order.received_date = func.now()
elif any_received:
order.status = "partial_received"
await db_session.commit()
await db_session.refresh(order)
return await _build_purchase_order_detail(db_session, order, supplier.name)
purchase_order_service = PurchaseOrderService()
@@ -0,0 +1,744 @@
"""销售订单业务服务层
将原 sales_order_routes 中的业务编排(建单/改单/删单、BOM 物料计划、自动发料/回补、
物料消耗、领料、状态流转)下沉到此,路由层只做参数校验与响应组装。
"""
from datetime import datetime
from math import ceil
from typing import Optional, List, Tuple
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, update
from shared.models.database import (
User,
Customer,
Product,
ProductMaterial,
Warehouse,
Inventory,
StockMovement,
SalesOrder,
SalesOrderItem,
)
from ..schemas import (
SalesOrderCreate,
SalesOrderResponse,
SalesOrderDetailResponse,
SalesOrderItemResponse,
SalesOrderProductionPlanResponse,
ProductionMaterialPlanItemResponse,
SalesOrderIssueRequest,
SalesOrderIssueResponse,
SalesOrderStatusUpdate,
MaterialConsumptionRequest,
PaginatedResponse,
)
from ..utils import generate_order_no
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"}
def _compute_delivery_status(order: SalesOrder) -> str:
"""物流状态:制造中 / 已交付 / 已作废"""
if order.status == "cancelled":
return "cancelled"
if order.status in ("delivered", "paid") or order.actual_delivery_date:
return "delivered"
return "manufacturing"
def _compute_payment_status(order: SalesOrder) -> str:
"""收款状态:未收款 / 已收款"""
if order.status == "paid" or order.actual_payment_date:
return "paid"
return "unpaid"
def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse:
return SalesOrderResponse(
id=order.id,
order_no=order.order_no,
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
delivery_status=_compute_delivery_status(order),
payment_status=_compute_payment_status(order),
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
total_amount=Decimal(str(order.total_amount or 0)),
received_amount=Decimal(str(order.received_amount or 0)),
remark=order.remark,
created_at=order.created_at
)
async def _build_sales_order_detail_response(
db_session: AsyncSession,
order: SalesOrder,
customer_name: str
) -> SalesOrderDetailResponse:
items_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id).order_by(SalesOrderItem.id.asc())
)
items = items_result.scalars().all()
return SalesOrderDetailResponse(
id=order.id,
order_no=order.order_no,
customer_id=order.customer_id,
customer_name=customer_name,
order_date=order.order_date,
delivery_date=order.delivery_date,
manufacturing_date=order.manufacturing_date,
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
delivery_status=_compute_delivery_status(order),
payment_status=_compute_payment_status(order),
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
actual_material_cost=Decimal(str(order.actual_material_cost or 0)),
total_amount=Decimal(str(order.total_amount or 0)),
received_amount=Decimal(str(order.received_amount or 0)),
remark=order.remark,
created_at=order.created_at,
items=[
SalesOrderItemResponse(
id=item.id,
product_id=item.product_id,
quantity=item.quantity,
delivered_quantity=item.delivered_quantity,
unit_price=Decimal(str(item.unit_price or 0)),
amount=Decimal(str(item.amount or 0)),
remark=item.remark
) for item in items
]
)
async def _get_sales_order_with_customer(
db_session: AsyncSession,
order_id: int,
) -> Tuple[SalesOrder, Customer]:
result = await db_session.execute(
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.where(SalesOrder.id == order_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="销售订单不存在")
return row[0], row[1]
async def _build_material_plan(db_session: AsyncSession, order: SalesOrder) -> Tuple[List[ProductionMaterialPlanItemResponse], Decimal]:
item_result = await db_session.execute(
select(SalesOrderItem).where(SalesOrderItem.order_id == order.id)
)
order_items = item_result.scalars().all()
if not order_items:
return [], 0
finished_ids = list({int(i.product_id) for i in order_items})
bom_result = await db_session.execute(
select(ProductMaterial, Product)
.join(Product, ProductMaterial.material_product_id == Product.id)
.where(ProductMaterial.finished_product_id.in_(finished_ids))
.where(Product.is_active == True)
.where(Product.item_type == "material")
)
bom_rows = bom_result.all()
if not bom_rows:
return [], 0
bom_by_finished_id = {}
for bom, material in bom_rows:
bom_by_finished_id.setdefault(int(bom.finished_product_id), []).append((bom, material))
required_qty_map = {}
for order_item in order_items:
bom_items = bom_by_finished_id.get(int(order_item.product_id)) or []
for bom, material in bom_items:
qty = Decimal(str(order_item.quantity)) * Decimal(str(bom.quantity or 0)) * (1 + Decimal(str(bom.loss_rate or 0)))
entry = required_qty_map.setdefault(material.id, {"material": material, "required_qty": Decimal("0")})
entry["required_qty"] += qty
if not required_qty_map:
return [], Decimal("0")
material_ids = list(required_qty_map.keys())
stock_result = await db_session.execute(
select(Inventory.product_id, func.coalesce(func.sum(Inventory.quantity), 0))
.where(Inventory.product_id.in_(material_ids))
.group_by(Inventory.product_id)
)
stock_map = {row[0]: Decimal(str(row[1] or 0)) for row in stock_result.all()}
plan_items = []
planned_material_cost = Decimal("0")
for material_id, entry in required_qty_map.items():
material = entry["material"]
required_qty = int(ceil(entry["required_qty"]))
available_qty = stock_map.get(material_id, Decimal("0"))
shortage_qty = max(required_qty - int(available_qty), 0)
unit_cost = Decimal(str(material.cost_price or 0))
required_cost = Decimal(str(required_qty)) * unit_cost
planned_material_cost += required_cost
plan_items.append(
ProductionMaterialPlanItemResponse(
material_id=material.id,
material_sku=material.sku,
material_name=material.name,
required_quantity=Decimal(str(required_qty)),
available_quantity=available_qty,
shortage_quantity=Decimal(str(shortage_qty)),
unit_cost=unit_cost,
required_cost=required_cost,
)
)
plan_items = sorted(plan_items, key=lambda x: (x.shortage_quantity, x.required_cost), reverse=True)
return plan_items, planned_material_cost
async def _get_default_warehouse(db_session: AsyncSession) -> Warehouse:
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.is_active == True).order_by(Warehouse.is_default.desc(), Warehouse.id.asc())
)
warehouse = warehouse_result.scalars().first()
if not warehouse:
raise HTTPException(status_code=400, detail="未配置可用仓库,无法自动扣减物料")
return warehouse
async def _issue_materials_for_order_creation(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
) -> Tuple[int, Decimal, Decimal]:
warehouse = await _get_default_warehouse(db_session)
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
order.production_no = order.production_no or generate_order_no("WO")
order.production_status = "bom_missing"
order.planned_material_cost = 0
order.actual_material_cost = 0
order.status = "manufacturing"
return 0, 0, 0
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
if shortage_items:
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
production_no = order.production_no or generate_order_no("WO")
actual_material_cost = Decimal("0")
movement_count = 0
for item in plan_items:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在默认仓库库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
movement = StockMovement(
product_id=item.material_id,
warehouse_id=warehouse.id,
movement_type="issue_to_production",
quantity=item.required_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=production_no,
unit_price=item.unit_cost,
total_amount=Decimal(str(total_amount)),
remark=f"销售单{order.order_no}创建时自动扣减物料",
operator_id=current_user.id
)
db_session.add(movement)
movement_count += 1
order.production_no = production_no
order.production_status = "material_issued"
order.planned_material_cost = planned_material_cost
order.actual_material_cost = actual_material_cost
order.status = "manufacturing"
return movement_count, planned_material_cost, actual_material_cost
async def _rollback_issued_materials(
db_session: AsyncSession,
order: SalesOrder,
current_user: User
):
movement_result = await db_session.execute(
select(StockMovement)
.where(StockMovement.reference_type == "sales_order")
.where(StockMovement.reference_id == order.id)
.where(StockMovement.movement_type == "issue_to_production")
.order_by(StockMovement.id.asc())
)
movements = movement_result.scalars().all()
if not movements:
return
for movement in movements:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == movement.product_id)
.where(Inventory.warehouse_id == movement.warehouse_id)
.values(quantity=Inventory.quantity + movement.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
quantity=movement.quantity,
locked_quantity=0
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = movement.quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - movement.quantity
revert_movement = StockMovement(
product_id=movement.product_id,
warehouse_id=movement.warehouse_id,
movement_type="return_from_production",
quantity=movement.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=order.production_no or order.order_no,
unit_price=movement.unit_price,
total_amount=movement.total_amount,
remark=f"销售单{order.order_no}变更/删除,自动回补物料",
operator_id=current_user.id
)
db_session.add(revert_movement)
async def _apply_order_items(
db_session: AsyncSession,
order: SalesOrder,
order_data: SalesOrderCreate
) -> Decimal:
total_amount = Decimal("0")
for item_data in order_data.items:
product = None
if item_data.product_id is not None:
product_result = await db_session.execute(
select(Product).where(Product.id == item_data.product_id, Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail=f"产品不存在: {item_data.product_id}")
else:
if not item_data.product_sku or not item_data.product_name:
raise HTTPException(status_code=400, detail="请提供产品ID,或提供产品SKU与产品名称")
by_sku_result = await db_session.execute(
select(Product).where(Product.sku == item_data.product_sku, Product.is_active == True)
)
product = by_sku_result.scalar_one_or_none()
if not product:
product = Product(
sku=item_data.product_sku,
name=item_data.product_name,
category=item_data.product_category,
unit=item_data.product_unit or "件",
item_type="finished",
cost_price=0,
sale_price=item_data.unit_price or 0,
min_stock=0,
max_stock=0
)
db_session.add(product)
await db_session.flush()
if product.item_type != "finished":
raise HTTPException(status_code=400, detail=f"销售单仅允许成品: {product.name}")
item = SalesOrderItem(
order_id=order.id,
product_id=product.id,
quantity=item_data.quantity,
unit_price=item_data.unit_price,
amount=item_data.quantity * item_data.unit_price,
remark=item_data.remark
)
db_session.add(item)
total_amount += item.amount
return total_amount
class SalesOrderService:
"""销售订单业务服务"""
@staticmethod
async def list_orders(
db_session: AsyncSession,
status: Optional[str],
skip: int,
limit: int,
) -> PaginatedResponse:
base_query = (
select(SalesOrder, Customer)
.join(Customer, SalesOrder.customer_id == Customer.id)
.order_by(SalesOrder.created_at.desc())
)
if status:
base_query = base_query.where(SalesOrder.status == status)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
orders = []
for order, customer in result.all():
orders.append(_build_sales_order_response(order, customer.name))
return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit)
@staticmethod
async def create_order(
db_session: AsyncSession,
order_data: SalesOrderCreate,
current_user: User,
) -> SalesOrderResponse:
now = datetime.now()
order = SalesOrder(
order_no=generate_order_no("SO"),
customer_id=order_data.customer_id,
order_date=now,
delivery_date=order_data.delivery_date,
manufacturing_date=now.date(),
created_at=now,
remark=order_data.remark,
operator_id=current_user.id,
status="manufacturing"
)
db_session.add(order)
await db_session.flush()
try:
order.total_amount = await _apply_order_items(db_session, order, order_data)
await _issue_materials_for_order_creation(db_session, order, current_user)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
customer = customer.scalar_one()
return _build_sales_order_response(order, customer.name)
@staticmethod
async def get_detail(db_session: AsyncSession, order_id: int) -> SalesOrderDetailResponse:
order, customer = await _get_sales_order_with_customer(db_session, order_id)
return await _build_sales_order_detail_response(db_session, order, customer.name)
@staticmethod
async def update_order(
db_session: AsyncSession,
order_id: int,
order_data: SalesOrderCreate,
current_user: User,
) -> SalesOrderResponse:
order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改")
try:
await _rollback_issued_materials(db_session, order, current_user)
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
order.customer_id = order_data.customer_id
order.delivery_date = order_data.delivery_date
order.remark = order_data.remark
order.production_status = "not_started"
order.production_no = None
order.planned_material_cost = 0
order.actual_material_cost = 0
order.status = "manufacturing"
order.total_amount = await _apply_order_items(db_session, order, order_data)
await _issue_materials_for_order_creation(db_session, order, current_user)
await db_session.commit()
except (HTTPException, Exception):
await db_session.rollback()
raise
await db_session.refresh(order)
customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id))
updated_customer = customer_result.scalar_one_or_none()
customer_name = updated_customer.name if updated_customer else "未知客户"
return _build_sales_order_response(order, customer_name)
@staticmethod
async def update_status(
db_session: AsyncSession,
order_id: int,
payload: SalesOrderStatusUpdate,
current_user: User,
) -> SalesOrderResponse:
if payload.status not in VALID_ORDER_STATUSES:
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
order, customer = await _get_sales_order_with_customer(db_session, order_id)
# 状态转换守卫
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改状态")
if order.status == "cancelled":
raise HTTPException(status_code=400, detail="已作废的销售订单禁止修改状态")
if order.status == "delivered" and payload.status not in ("paid", "cancelled"):
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款或已作废")
if payload.status == "cancelled" and order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的订单不能作废,请联系管理员")
# 根据状态更新相应的日期字段
if payload.status == "delivered" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
elif payload.status == "paid" and not order.actual_payment_date:
order.actual_payment_date = datetime.now()
elif payload.status == "cancelled" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
order.status = payload.status
await db_session.commit()
await db_session.refresh(order)
return _build_sales_order_response(order, customer.name)
@staticmethod
async def delete_order(db_session: AsyncSession, order_id: int, current_user: User) -> dict:
order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止删除,请先作废相关收款单")
if (order.received_amount or 0) > 0:
raise HTTPException(status_code=400, detail="该销售订单已存在收款记录(received_amount>0),禁止删除,请先作废相关收款单")
await _rollback_issued_materials(db_session, order, current_user)
await db_session.delete(order)
await db_session.commit()
return {"message": "销售订单已删除"}
@staticmethod
async def consume_materials(
db_session: AsyncSession,
order_id: int,
request: MaterialConsumptionRequest,
current_user: User,
) -> dict:
"""记录销售订单的物料消耗(在已发料基础上记录额外消耗,如报废/超耗)"""
order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status in ("delivered", "paid", "cancelled"):
raise HTTPException(status_code=400, detail="当前订单状态不允许记录物料消耗")
if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产,不允许记录物料消耗")
default_warehouse = await _get_default_warehouse(db_session)
# 计算总物料成本
total_cost = Decimal("0")
# 处理每个物料消耗项
for item in request.items:
# 获取物料信息
material = await db_session.get(Product, item.material_id)
if not material:
raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
if material.item_type != "material":
raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
# 统一转 Decimal,避免 Decimal * float 在 Postgres(Numeric) 上抛 TypeError
consume_qty = Decimal(str(item.quantity))
unit_cost = Decimal(str(material.cost_price or 0))
cost = unit_cost * consume_qty
total_cost += cost
# 更新物料库存(原子操作防并发)
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == material.id)
.where(Inventory.warehouse_id == default_warehouse.id)
.where(Inventory.quantity >= consume_qty)
.values(quantity=Inventory.quantity - consume_qty)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
after_qty = Decimal(str(after_qty))
before_qty = after_qty + consume_qty
# 记录物料消耗
movement = StockMovement(
product_id=material.id,
warehouse_id=default_warehouse.id,
quantity=consume_qty,
before_quantity=before_qty,
after_quantity=after_qty,
movement_type="consumption",
reference_type="sales_order",
reference_id=order.id,
unit_price=unit_cost,
total_amount=cost,
operator_id=current_user.id,
remark=item.remark
)
db_session.add(movement)
# 累加实际物料成本(创建时已记录计划发料成本,此处为额外消耗,不应覆盖)
order.actual_material_cost = Decimal(str(order.actual_material_cost or 0)) + total_cost
await db_session.commit()
await db_session.refresh(order)
return {
"message": "物料消耗记录保存成功",
"total_cost": total_cost,
"order": _build_sales_order_response(order, customer.name)
}
@staticmethod
async def get_production_plan(db_session: AsyncSession, order_id: int) -> SalesOrderProductionPlanResponse:
order, customer = await _get_sales_order_with_customer(db_session, order_id)
production_no = order.production_no or generate_order_no("WO")
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
return SalesOrderProductionPlanResponse(
sales_order_id=order.id,
order_no=order.order_no,
customer_name=customer.name,
production_no=production_no,
planned_material_cost=planned_material_cost,
items=plan_items,
)
@staticmethod
async def issue_materials(
db_session: AsyncSession,
order_id: int,
payload: SalesOrderIssueRequest,
current_user: User,
) -> SalesOrderIssueResponse:
order, _ = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止领料")
if order.production_status == "completed":
raise HTTPException(status_code=400, detail="该销售单已完成生产")
if order.production_status == "material_issued":
raise HTTPException(status_code=400, detail="该销售单已自动扣减过物料")
warehouse_result = await db_session.execute(
select(Warehouse).where(Warehouse.id == payload.warehouse_id, Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
plan_items, planned_material_cost = await _build_material_plan(db_session, order)
if not plan_items:
raise HTTPException(status_code=400, detail="该销售单未配置BOM,无法领料")
shortage_items = [item for item in plan_items if item.shortage_quantity > 0]
if shortage_items:
shortage_text = ",".join([f"{item.material_name} 缺 {item.shortage_quantity}" for item in shortage_items])
raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}")
production_no = payload.production_no or order.production_no or generate_order_no("WO")
actual_material_cost = Decimal("0")
movement_count = 0
for item in plan_items:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == item.material_id)
.where(Inventory.warehouse_id == warehouse.id)
.where(Inventory.quantity >= item.required_quantity)
.values(quantity=Inventory.quantity - item.required_quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail=f"{item.material_name} 在所选仓库库存不足")
after_qty = int(after_qty)
before_qty = after_qty + int(item.required_quantity)
total_amount = item.required_quantity * item.unit_cost
actual_material_cost += total_amount
movement = StockMovement(
product_id=item.material_id,
warehouse_id=warehouse.id,
movement_type="issue_to_production",
quantity=item.required_quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_type="sales_order",
reference_id=order.id,
reference_no=production_no,
unit_price=item.unit_cost,
total_amount=Decimal(str(total_amount)),
remark=payload.remark or f"销售单{order.order_no}按单生产领料",
operator_id=current_user.id
)
db_session.add(movement)
movement_count += 1
order.production_no = production_no
order.production_status = "material_issued"
order.planned_material_cost = planned_material_cost
order.actual_material_cost = actual_material_cost
if order.status == "draft":
order.status = "manufacturing"
await db_session.commit()
cost_deviation = actual_material_cost - planned_material_cost
cost_deviation_rate = (cost_deviation / planned_material_cost) if planned_material_cost > Decimal("1e-9") else Decimal("0")
return SalesOrderIssueResponse(
sales_order_id=order.id,
order_no=order.order_no,
production_no=production_no,
movement_count=movement_count,
planned_material_cost=planned_material_cost,
actual_material_cost=actual_material_cost,
cost_deviation=cost_deviation,
cost_deviation_rate=cost_deviation_rate,
production_status=order.production_status,
)
sales_order_service = SalesOrderService()
@@ -0,0 +1,193 @@
"""库存变动业务服务层
将原 stock_movement_routes 中的业务编排(入库/出库/调整的库存原子更新与流水写入、
历史查询)下沉到此,路由层只做参数校验与响应组装。
"""
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
from ..utils import generate_order_no
INBOUND_TYPES = {
"in", "purchase_in", "return_from_production", "outsource_return", "finish_in"
}
OUTBOUND_TYPES = {
"out", "issue_to_production", "outsource_send", "shipment_out", "scrap_out"
}
ADJUST_TYPES = {"adjust"}
SUPPORTED_MOVEMENT_TYPES = INBOUND_TYPES | OUTBOUND_TYPES | ADJUST_TYPES
def _build_movement_response(movement: StockMovement, product: Product) -> StockMovementResponse:
return StockMovementResponse(
id=movement.id,
product_id=product.id,
product_sku=product.sku,
product_name=product.name,
movement_type=movement.movement_type,
quantity=movement.quantity,
before_quantity=movement.before_quantity,
after_quantity=movement.after_quantity,
reference_no=movement.reference_no,
remark=movement.remark,
created_at=movement.created_at
)
class StockMovementService:
"""库存变动业务服务"""
@staticmethod
async def create_movement(
db_session: AsyncSession,
movement_data: StockMovementCreate,
current_user: User,
) -> StockMovementResponse:
if movement_data.movement_type not in SUPPORTED_MOVEMENT_TYPES:
raise HTTPException(status_code=400, detail="无效的变动类型")
if movement_data.quantity <= 0:
raise HTTPException(status_code=400, detail="数量必须大于0")
warehouse_result = await db_session.execute(
select(Warehouse)
.where(Warehouse.id == movement_data.warehouse_id)
.where(Warehouse.is_active == True)
)
warehouse = warehouse_result.scalar_one_or_none()
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
product_result = await db_session.execute(
select(Product)
.where(Product.id == movement_data.product_id)
.where(Product.is_active == True)
)
product = product_result.scalar_one_or_none()
if not product:
sku_candidate = movement_data.product_sku or str(movement_data.product_id)
product_by_sku_result = await db_session.execute(
select(Product)
.where(Product.sku == sku_candidate)
.where(Product.is_active == True)
)
product = product_by_sku_result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="产品不存在,请选择系统中的产品")
if product.item_type != "material":
raise HTTPException(status_code=400, detail="库存仅管理物料,该条目不是物料")
resolved_product_id = product.id
if movement_data.movement_type in INBOUND_TYPES:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.values(quantity=Inventory.quantity + movement_data.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
inventory = Inventory(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
quantity=movement_data.quantity,
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
after_qty = movement_data.quantity
else:
after_qty = int(after_qty)
before_qty = after_qty - movement_data.quantity
elif movement_data.movement_type in OUTBOUND_TYPES:
upd_result = await db_session.execute(
update(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.where(Inventory.quantity >= movement_data.quantity)
.values(quantity=Inventory.quantity - movement_data.quantity)
.returning(Inventory.quantity)
)
after_qty = upd_result.scalar_one_or_none()
if after_qty is None:
raise HTTPException(status_code=400, detail="库存不足")
after_qty = int(after_qty)
before_qty = after_qty + movement_data.quantity
else:
result = await db_session.execute(
select(Inventory)
.where(Inventory.product_id == resolved_product_id)
.where(Inventory.warehouse_id == movement_data.warehouse_id)
.with_for_update()
)
inventory = result.scalar_one_or_none()
if not inventory:
inventory = Inventory(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
quantity=0,
)
db_session.add(inventory)
await db_session.flush()
before_qty = 0
else:
before_qty = int(inventory.quantity)
inventory.quantity = movement_data.quantity
after_qty = movement_data.quantity
movement = StockMovement(
product_id=resolved_product_id,
warehouse_id=movement_data.warehouse_id,
movement_type=movement_data.movement_type,
quantity=movement_data.quantity,
before_quantity=before_qty,
after_quantity=after_qty,
reference_no=generate_order_no("SM"),
unit_price=movement_data.unit_price,
total_amount=movement_data.unit_price * movement_data.quantity if movement_data.unit_price else None,
remark=movement_data.remark,
operator_id=current_user.id
)
db_session.add(movement)
await db_session.commit()
return _build_movement_response(movement, product)
@staticmethod
async def list_movements(
db_session: AsyncSession,
product_id: Optional[int],
movement_type: Optional[str],
skip: int,
limit: int,
) -> PaginatedResponse:
base_query = (
select(StockMovement, Product)
.join(Product, StockMovement.product_id == Product.id)
.order_by(StockMovement.created_at.desc())
)
if product_id:
base_query = base_query.where(StockMovement.product_id == product_id)
if movement_type:
base_query = base_query.where(StockMovement.movement_type == movement_type)
count_query = select(func.count()).select_from(base_query.subquery())
total = await db_session.scalar(count_query) or 0
query = base_query.offset(skip).limit(limit)
result = await db_session.execute(query)
movements = [_build_movement_response(movement, product) for movement, product in result.all()]
return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit)
stock_movement_service = StockMovementService()
+9 -50
View File
@@ -29,6 +29,7 @@ from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
from moldinsight.core.base_mold_generator import BaseMoldGenerator from moldinsight.core.base_mold_generator import BaseMoldGenerator
from moldinsight.core.side_action_designer import SideActionDesigner from moldinsight.core.side_action_designer import SideActionDesigner
from moldinsight.services.material_service import MaterialService
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -54,48 +55,6 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
self.foam_material = foam_material self.foam_material = foam_material
self.foam_materials = {
"AlSi10Mg": {
"density": 0.45,
"expansion_ratio": 2.5,
"shrinkage_rate": 0.015,
"molding_temp": 380,
"description": "常用铝硅泡沫"
},
"AlSi12": {
"density": 0.50,
"expansion_ratio": 2.2,
"shrinkage_rate": 0.012,
"molding_temp": 360,
"description": "高强度铝泡沫"
},
"Pure Al Foam": {
"density": 0.35,
"expansion_ratio": 3.0,
"shrinkage_rate": 0.020,
"molding_temp": 400,
"description": "纯铝泡沫"
},
"AlSi7Mg": {
"density": 0.40,
"expansion_ratio": 2.8,
"shrinkage_rate": 0.018,
"molding_temp": 390,
"description": "轻质铝镁泡沫"
}
}
self.plastic_materials = {
"ABS": {"density": 1.05, "shrinkage": 0.005},
"PP": {"density": 0.90, "shrinkage": 0.016},
"PC": {"density": 1.20, "shrinkage": 0.005},
"PE": {"density": 0.95, "shrinkage": 0.025},
"PS": {"density": 1.05, "shrinkage": 0.004},
"PA": {"density": 1.14, "shrinkage": 0.015},
"POM": {"density": 1.42, "shrinkage": 0.020},
"PMMA": {"density": 1.18, "shrinkage": 0.004}
}
self.parting_line_tolerance = 0.1 self.parting_line_tolerance = 0.1
self.max_draft_angle = 5.0 self.max_draft_angle = 5.0
self.min_draft_angle = 1.0 self.min_draft_angle = 1.0
@@ -107,21 +66,21 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
def set_foam_material(self, material: str): def set_foam_material(self, material: str):
"""设置铝泡沫材料""" """设置铝泡沫材料"""
if material in self.foam_materials: if MaterialService.is_foam_material(material):
props = self.foam_materials[material] props = MaterialService.get_material(material)
self.foam_material = material self.foam_material = material
self.material_density = props["density"] self.material_density = props["density"]
self.shrinkage_rate = props["shrinkage_rate"] self.shrinkage_rate = props["shrinkage"]
logger.info(f"铝泡沫材料设置为 {material}, 密度: {props['density']} g/cm³") logger.info(f"铝泡沫材料设置为 {material}, 密度: {props['density']} g/cm³")
else: else:
logger.warning(f"未知材料 {material}, 使用当前设置") logger.warning(f"未知材料 {material}, 使用当前设置")
def set_material(self, material: str): def set_material(self, material: str):
"""设置材料(自动识别类型)""" """设置材料(自动识别类型)"""
if material in self.foam_materials: if MaterialService.is_foam_material(material):
self.set_foam_material(material) self.set_foam_material(material)
elif material in self.plastic_materials: elif MaterialService.has_material(material):
props = self.plastic_materials[material] props = MaterialService.get_material(material)
self.material_density = props["density"] self.material_density = props["density"]
self.shrinkage_rate = props["shrinkage"] self.shrinkage_rate = props["shrinkage"]
logger.info(f"塑料材料设置为 {material}, 密度: {props['density']} g/cm³") logger.info(f"塑料材料设置为 {material}, 密度: {props['density']} g/cm³")
@@ -204,7 +163,7 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
parting_geometry = self._extract_parting_surface_geometry(parting_surface) parting_geometry = self._extract_parting_surface_geometry(parting_surface)
material_info = self.foam_materials.get(self.foam_material, {}) material_info = MaterialService.get_material(self.foam_material)
detailed_json = { detailed_json = {
"metadata": { "metadata": {
@@ -251,7 +210,7 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
def generate_cavity_key_info(self, cavity_data: Dict) -> Dict[str, Any]: def generate_cavity_key_info(self, cavity_data: Dict) -> Dict[str, Any]:
"""生成模具型腔的关键信息""" """生成模具型腔的关键信息"""
analysis = cavity_data["analysis"] analysis = cavity_data["analysis"]
material_info = self.foam_materials.get(self.foam_material, {}) material_info = MaterialService.get_material(self.foam_material)
key_info = { key_info = {
"mold_parameters": { "mold_parameters": {
+3 -9
View File
@@ -9,6 +9,7 @@ from shared.models.schemas import (
create_analysis_result create_analysis_result
) )
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
from moldinsight.services.material_service import MaterialService
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -25,12 +26,6 @@ class GeometryAnalyzer:
"high_complexity": 50, "high_complexity": 50,
} }
self.product_materials = {
"ABS": {"shrinkage": 0.005, "min_wall": 1.2},
"PP": {"shrinkage": 0.016, "min_wall": 1.0},
"PC": {"shrinkage": 0.007, "min_wall": 1.5},
}
self.mold_materials = { self.mold_materials = {
"Aluminum": {"thermal_conductivity": 200, "hardness": "HB80", "cost": "low"}, "Aluminum": {"thermal_conductivity": 200, "hardness": "HB80", "cost": "low"},
"P20_Steel": {"thermal_conductivity": 30, "hardness": "HRC30", "cost": "medium"}, "P20_Steel": {"thermal_conductivity": 30, "hardness": "HRC30", "cost": "medium"},
@@ -54,7 +49,6 @@ class GeometryAnalyzer:
features = self._detect_features(geometry_data, shape) features = self._detect_features(geometry_data, shape)
product_props = self.product_materials.get(product_material, {})
mold_props = self.mold_materials.get(mold_material, {}) mold_props = self.mold_materials.get(mold_material, {})
recommendations = self._generate_recommendations( recommendations = self._generate_recommendations(
@@ -744,8 +738,8 @@ class GeometryAnalyzer:
avg_thickness = (volume / surface_area) * 0.6 avg_thickness = (volume / surface_area) * 0.6
if avg_thickness is not None: if avg_thickness is not None:
material_props = self.product_materials.get(material, self.product_materials["ABS"]) material_props = MaterialService.get_material(material)
min_wall = material_props["min_wall"] min_wall = material_props.get("min_wall", 1.2)
if avg_thickness < min_wall: if avg_thickness < min_wall:
return create_design_recommendation( return create_design_recommendation(
+3 -13
View File
@@ -13,6 +13,7 @@ from shared.models.schemas import create_mold_cavity_data, create_mold_key_info
from shared.utils.logger import get_logger from shared.utils.logger import get_logger
from moldinsight.core.base_mold_generator import BaseMoldGenerator from moldinsight.core.base_mold_generator import BaseMoldGenerator
from moldinsight.core.side_action_designer import SideActionDesigner from moldinsight.core.side_action_designer import SideActionDesigner
from moldinsight.services.material_service import MaterialService
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -24,25 +25,14 @@ class MoldCavityGenerator(BaseMoldGenerator):
material_density: float = 1.05): material_density: float = 1.05):
super().__init__(shrinkage_rate, draft_angle, material_density) super().__init__(shrinkage_rate, draft_angle, material_density)
self.material_densities = {
"ABS": 1.05,
"PP": 0.90,
"PC": 1.20,
"PE": 0.95,
"PS": 1.05,
"PA": 1.14,
"POM": 1.42,
"PMMA": 1.18
}
self.parting_line_tolerance = 0.1 self.parting_line_tolerance = 0.1
self.max_draft_angle = 5.0 self.max_draft_angle = 5.0
self.side_action_designer = SideActionDesigner() self.side_action_designer = SideActionDesigner()
def set_material(self, material: str): def set_material(self, material: str):
"""设置产品材料""" """设置产品材料"""
if material in self.material_densities: if MaterialService.has_material(material):
self.material_density = self.material_densities[material] self.material_density = MaterialService.get_material(material)["density"]
logger.info(f"材料设置为 {material}, 密度: {self.material_density} g/cm³") logger.info(f"材料设置为 {material}, 密度: {self.material_density} g/cm³")
else: else:
logger.warning(f"未知材料 {material}, 使用默认密度 {self.material_density} g/cm³") logger.warning(f"未知材料 {material}, 使用默认密度 {self.material_density} g/cm³")
+36 -15
View File
@@ -1,22 +1,38 @@
# services/material_service.py # services/material_service.py
"""材料属性管理服务""" """材料属性管理服务 - 全局唯一材料属性源
from typing import Dict, Any, List, Optional 所有材料属性(密度、收缩率、最小壁厚、泡沫特性等)集中在此,
geometry_analyzer / mold_generator / aluminum_foam_mold 等模块一律通过 MaterialService 查询,
禁止再各自维护材料字典,避免数据漂移。
历史问题:材料字典曾在 4 处重复定义且冲突(如 PE 收缩率此处 0.020 vs aluminum_foam_mold 0.025;
PC/PA/PMMA 收缩率、POM 密度也冲突;PS 在其他字典有而此处缺失)。现已统一,以本文件为唯一源。
泡沫材料原用 `shrinkage_rate` 键,现统一为 `shrinkage`(值一致)。
"""
from typing import Dict, Any, List
MATERIAL_PROPERTIES: Dict[str, Dict[str, Any]] = { MATERIAL_PROPERTIES: Dict[str, Dict[str, Any]] = {
"ABS": {"density": 1.05, "shrinkage": 0.005, "name": "ABS", "is_foam": False}, # —— 注塑塑料 ——
"PP": {"density": 0.90, "shrinkage": 0.016, "name": "PP", "is_foam": False}, "ABS": {"name": "ABS", "density": 1.05, "shrinkage": 0.005, "is_foam": False, "min_wall": 1.2},
"PE": {"density": 0.95, "shrinkage": 0.020, "name": "PE", "is_foam": False}, "PP": {"name": "PP", "density": 0.90, "shrinkage": 0.016, "is_foam": False, "min_wall": 1.0},
"PC": {"density": 1.20, "shrinkage": 0.007, "name": "PC", "is_foam": False}, "PE": {"name": "PE", "density": 0.95, "shrinkage": 0.020, "is_foam": False, "min_wall": 1.0},
"PA": {"density": 1.14, "shrinkage": 0.010, "name": "PA", "is_foam": False}, "PC": {"name": "PC", "density": 1.20, "shrinkage": 0.007, "is_foam": False, "min_wall": 1.5},
"POM": {"density": 1.41, "shrinkage": 0.020, "name": "POM", "is_foam": False}, "PA": {"name": "PA", "density": 1.14, "shrinkage": 0.010, "is_foam": False, "min_wall": 1.0},
"PMMA": {"density": 1.18, "shrinkage": 0.005, "name": "PMMA", "is_foam": False}, "POM": {"name": "POM", "density": 1.41, "shrinkage": 0.020, "is_foam": False, "min_wall": 1.0},
"PBT": {"density": 1.31, "shrinkage": 0.015, "name": "PBT", "is_foam": False}, "PMMA": {"name": "PMMA", "density": 1.18, "shrinkage": 0.005, "is_foam": False, "min_wall": 1.5},
"AlSi10Mg": {"density": 0.45, "shrinkage": 0.015, "name": "AlSi10Mg", "is_foam": True}, "PBT": {"name": "PBT", "density": 1.31, "shrinkage": 0.015, "is_foam": False, "min_wall": 1.2},
"AlSi12": {"density": 0.50, "shrinkage": 0.012, "name": "AlSi12", "is_foam": True}, "PS": {"name": "PS", "density": 1.05, "shrinkage": 0.004, "is_foam": False, "min_wall": 1.0},
"Pure Al Foam": {"density": 0.35, "shrinkage": 0.020, "name": "Pure Al Foam", "is_foam": True}, # —— 铝泡沫 ——
"AlSi7Mg": {"density": 0.40, "shrinkage": 0.018, "name": "AlSi7Mg", "is_foam": True}, "AlSi10Mg": {"name": "AlSi10Mg", "density": 0.45, "shrinkage": 0.015, "is_foam": True,
"expansion_ratio": 2.5, "molding_temp": 380, "description": "常用铝硅泡沫"},
"AlSi12": {"name": "AlSi12", "density": 0.50, "shrinkage": 0.012, "is_foam": True,
"expansion_ratio": 2.2, "molding_temp": 360, "description": "高强度铝泡沫"},
"Pure Al Foam": {"name": "Pure Al Foam", "density": 0.35, "shrinkage": 0.020, "is_foam": True,
"expansion_ratio": 3.0, "molding_temp": 400, "description": "纯铝泡沫"},
"AlSi7Mg": {"name": "AlSi7Mg", "density": 0.40, "shrinkage": 0.018, "is_foam": True,
"expansion_ratio": 2.8, "molding_temp": 390, "description": "轻质铝镁泡沫"},
} }
# 默认回退材料 # 默认回退材料
@@ -24,13 +40,18 @@ _DEFAULT_MATERIAL = MATERIAL_PROPERTIES["ABS"]
class MaterialService: class MaterialService:
"""材料属性管理服务 — 集中管理材料字典,便于扩展和单测""" """材料属性管理服务 - 集中管理材料字典,便于扩展和单测"""
@staticmethod @staticmethod
def get_material(material_name: str) -> Dict[str, Any]: def get_material(material_name: str) -> Dict[str, Any]:
"""获取材料属性,不存在则回退到 ABS""" """获取材料属性,不存在则回退到 ABS"""
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL) return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL)
@staticmethod
def has_material(material_name: str) -> bool:
"""材料是否在字典中(不回退)"""
return material_name in MATERIAL_PROPERTIES
@staticmethod @staticmethod
def is_foam_material(material_name: str) -> bool: def is_foam_material(material_name: str) -> bool:
return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL).get("is_foam", False) return MATERIAL_PROPERTIES.get(material_name, _DEFAULT_MATERIAL).get("is_foam", False)