Compare commits

...

4 Commits

Author SHA1 Message Date
cjw cf6d708566 xxx 2026-07-27 15:54:25 +08:00
cjw a8c0e1af3d docs: 更新演进路线图(P1-2 注册表 + P1-4 Alembic 进度)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 11:05:03 +08:00
cjw 00ca81287e feat(db): 引入 Alembic 取代裸 DDL 迁移
- alembic init + env.py 接 settings/models metadata(asyncpg->psycopg2 同步 URL)
- 补 3 个 CheckConstraint 到 models(原只在裸 DDL)
- 离线生成初始迁移(31 表+约束+95 索引,全 sa.* 通用类型)
- init_db 用 _run_alembic_migrations(自动基线+upgrade head)替换 create_tables+ensure_schema_updates(删 92 行裸 DDL)
- 删破坏性 migrate_db.py(drop_all)
- 既有 DB 首次启动自动 stamp 基线,无需手动

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 11:04:23 +08:00
cjw 66ba6e00f5 refactor(moldinsight): 引入可插拔注册表(MoldGenerator + FeatureDetector)
- 新增 MoldGeneratorRegistry:multi_scheme_planner 消除 if-else,按 mold_type 选生成器
- 新增 FeatureDetectorRegistry:geometry_analyzer._detect_features 消除 6 个检测器硬编码
- 新增模具类型/特征检测器只需 register 一行
- 移除 OCC ThreadPoolExecutor(注册表本身串行执行)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 11:03:16 +08:00
23 changed files with 1362 additions and 1050 deletions
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+73
View File
@@ -0,0 +1,73 @@
"""Alembic 迁移环境配置
- 从 shared.config.settings 读取 DB 配置,构造同步 URL(psycopg2)供 alembic 使用
(项目运行时用 asyncpg,但 alembic 是同步库,需 psycopg2)
- target_metadata 指向 shared.models.database.Base.metadata
- 支持 ALEMBIC_URL 环境变量覆盖(用于离线/空库生成初始迁移,如 sqlite:///empty.db)
"""
from logging.config import fileConfig
from pathlib import Path
import os
import sys
from sqlalchemy import engine_from_config, pool
from alembic import context
# 让 alembic 能 import 项目模块(src 在项目根下)
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "src"))
from shared.config.settings import settings # noqa: E402
from shared.models.database import Base # noqa: E402
import shared.models.database # noqa: E402,F401 # 导入所有模型,确保 metadata 注册
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 构造同步 URL:asyncpg -> psycopg2
_sync_url = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
# 支持 ALEMBIC_URL 覆盖(离线生成/测试用)
config.set_main_option("sqlalchemy.url", os.getenv("ALEMBIC_URL", _sync_url))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线模式:生成 SQL 脚本,不连接 DB"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""在线模式:连接 DB 执行迁移"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,38 @@
"""add product_id to stp_files
Revision ID: 006c18c51b0d
Revises: 9928d7f8c1ef
Create Date: 2026-07-23 10:37:56.516787
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '006c18c51b0d'
down_revision: Union[str, Sequence[str], None] = '9928d7f8c1ef'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema: stp_files 加 product_id 外键,关联进销存成品。"""
op.add_column("stp_files", sa.Column("product_id", sa.Integer(), nullable=True))
op.create_index("ix_stp_files_product_id", "stp_files", ["product_id"])
op.create_foreign_key(
"fk_stp_files_product_id_products",
"stp_files",
"products",
["product_id"],
["id"],
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_constraint("fk_stp_files_product_id_products", "stp_files", type_="foreignkey")
op.drop_index("ix_stp_files_product_id", table_name="stp_files")
op.drop_column("stp_files", "product_id")
@@ -0,0 +1,728 @@
"""initial schema
Revision ID: 9928d7f8c1ef
Revises:
Create Date: 2026-07-20 10:30:30.249866
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9928d7f8c1ef'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('customers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('contact_person', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('bank_name', sa.String(length=100), nullable=True),
sa.Column('bank_account', sa.String(length=50), nullable=True),
sa.Column('tax_number', sa.String(length=50), nullable=True),
sa.Column('credit_limit', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_customers_code'), 'customers', ['code'], unique=True)
op.create_index(op.f('ix_customers_id'), 'customers', ['id'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('module', sa.String(length=50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_permissions_code'), 'permissions', ['code'], unique=True)
op.create_index(op.f('ix_permissions_id'), 'permissions', ['id'], unique=False)
op.create_table('products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sku', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('unit', sa.String(length=20), nullable=True),
sa.Column('item_type', sa.String(length=20), nullable=True),
sa.Column('cost_price', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('sale_price', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('min_stock', sa.Integer(), nullable=True),
sa.Column('max_stock', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False)
op.create_index(op.f('ix_products_item_type'), 'products', ['item_type'], unique=False)
op.create_index(op.f('ix_products_sku'), 'products', ['sku'], unique=True)
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_system', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_roles_code'), 'roles', ['code'], unique=True)
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
op.create_table('suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('contact_person', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('bank_name', sa.String(length=100), nullable=True),
sa.Column('bank_account', sa.String(length=50), nullable=True),
sa.Column('tax_number', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_suppliers_code'), 'suppliers', ['code'], unique=True)
op.create_index(op.f('ix_suppliers_id'), 'suppliers', ['id'], unique=False)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('full_name', sa.String(length=100), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('warehouses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('manager', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_warehouses_code'), 'warehouses', ['code'], unique=True)
op.create_index(op.f('ix_warehouses_id'), 'warehouses', ['id'], unique=False)
op.create_table('finance_transactions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('txn_no', sa.String(length=50), nullable=False),
sa.Column('txn_type', sa.String(length=20), nullable=False),
sa.Column('partner_type', sa.String(length=20), nullable=False),
sa.Column('partner_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('txn_date', sa.DateTime(), nullable=True),
sa.Column('method', sa.String(length=30), nullable=True),
sa.Column('account_name', sa.String(length=100), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_finance_transactions_created_at'), 'finance_transactions', ['created_at'], unique=False)
op.create_index(op.f('ix_finance_transactions_id'), 'finance_transactions', ['id'], unique=False)
op.create_index(op.f('ix_finance_transactions_partner_id'), 'finance_transactions', ['partner_id'], unique=False)
op.create_index(op.f('ix_finance_transactions_partner_type'), 'finance_transactions', ['partner_type'], unique=False)
op.create_index(op.f('ix_finance_transactions_status'), 'finance_transactions', ['status'], unique=False)
op.create_index(op.f('ix_finance_transactions_txn_date'), 'finance_transactions', ['txn_date'], unique=False)
op.create_index(op.f('ix_finance_transactions_txn_no'), 'finance_transactions', ['txn_no'], unique=True)
op.create_index(op.f('ix_finance_transactions_txn_type'), 'finance_transactions', ['txn_type'], unique=False)
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('locked_quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('batch_number', sa.String(length=50), nullable=True),
sa.Column('location', sa.String(length=100), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.CheckConstraint('quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity', name='ck_inventory_qty_nonnegative'),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('product_id', 'warehouse_id', name='uq_inventory_product_warehouse')
)
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
op.create_index(op.f('ix_inventory_product_id'), 'inventory', ['product_id'], unique=False)
op.create_index(op.f('ix_inventory_warehouse_id'), 'inventory', ['warehouse_id'], unique=False)
op.create_table('material_price_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('effective_date', sa.DateTime(), nullable=True),
sa.Column('supplier_id', sa.Integer(), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_material_price_history_effective_date'), 'material_price_history', ['effective_date'], unique=False)
op.create_index(op.f('ix_material_price_history_id'), 'material_price_history', ['id'], unique=False)
op.create_index(op.f('ix_material_price_history_product_id'), 'material_price_history', ['product_id'], unique=False)
op.create_index(op.f('ix_material_price_history_supplier_id'), 'material_price_history', ['supplier_id'], unique=False)
op.create_table('material_suppliers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('supplier_id', sa.Integer(), nullable=False),
sa.Column('is_primary', sa.Boolean(), nullable=True),
sa.Column('contact_person', sa.String(length=100), nullable=True),
sa.Column('contact_phone', sa.String(length=50), nullable=True),
sa.Column('lead_time', sa.Integer(), nullable=True),
sa.Column('min_order_quantity', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_material_suppliers_id'), 'material_suppliers', ['id'], unique=False)
op.create_index(op.f('ix_material_suppliers_product_id'), 'material_suppliers', ['product_id'], unique=False)
op.create_index(op.f('ix_material_suppliers_supplier_id'), 'material_suppliers', ['supplier_id'], unique=False)
op.create_table('product_materials',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('finished_product_id', sa.Integer(), nullable=False),
sa.Column('material_product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(precision=12, scale=4), nullable=False),
sa.Column('loss_rate', sa.Numeric(precision=5, scale=4), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['finished_product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['material_product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('finished_product_id', 'material_product_id', name='uq_product_material_unique')
)
op.create_index(op.f('ix_product_materials_finished_product_id'), 'product_materials', ['finished_product_id'], unique=False)
op.create_index(op.f('ix_product_materials_id'), 'product_materials', ['id'], unique=False)
op.create_index(op.f('ix_product_materials_material_product_id'), 'product_materials', ['material_product_id'], unique=False)
op.create_table('purchase_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_no', sa.String(length=50), nullable=False),
sa.Column('supplier_id', sa.Integer(), nullable=False),
sa.Column('order_date', sa.DateTime(), nullable=True),
sa.Column('expected_date', sa.Date(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('paid_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('received_date', sa.DateTime(), nullable=True),
sa.Column('paid_date', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['supplier_id'], ['suppliers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchase_orders_id'), 'purchase_orders', ['id'], unique=False)
op.create_index(op.f('ix_purchase_orders_order_no'), 'purchase_orders', ['order_no'], unique=True)
op.create_index(op.f('ix_purchase_orders_supplier_id'), 'purchase_orders', ['supplier_id'], unique=False)
op.create_table('role_permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_role_permissions_id'), 'role_permissions', ['id'], unique=False)
op.create_index(op.f('ix_role_permissions_permission_id'), 'role_permissions', ['permission_id'], unique=False)
op.create_index(op.f('ix_role_permissions_role_id'), 'role_permissions', ['role_id'], unique=False)
op.create_table('sales_orders',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_no', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.Integer(), nullable=False),
sa.Column('order_date', sa.DateTime(), nullable=True),
sa.Column('delivery_date', sa.Date(), nullable=True),
sa.Column('manufacturing_date', sa.DateTime(), nullable=True),
sa.Column('actual_delivery_date', sa.DateTime(), nullable=True),
sa.Column('actual_payment_date', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('production_status', sa.String(length=20), nullable=True),
sa.Column('production_no', sa.String(length=50), nullable=True),
sa.Column('planned_material_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('actual_material_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('received_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sales_orders_customer_id'), 'sales_orders', ['customer_id'], unique=False)
op.create_index(op.f('ix_sales_orders_id'), 'sales_orders', ['id'], unique=False)
op.create_index(op.f('ix_sales_orders_order_no'), 'sales_orders', ['order_no'], unique=True)
op.create_index(op.f('ix_sales_orders_production_no'), 'sales_orders', ['production_no'], unique=False)
op.create_index(op.f('ix_sales_orders_production_status'), 'sales_orders', ['production_status'], unique=False)
op.create_table('stock_movements',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('warehouse_id', sa.Integer(), nullable=False),
sa.Column('movement_type', sa.String(length=20), nullable=False),
sa.Column('quantity', sa.Numeric(precision=12, scale=4), nullable=False),
sa.Column('before_quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('after_quantity', sa.Numeric(precision=12, scale=4), nullable=True),
sa.Column('reference_type', sa.String(length=50), nullable=True),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('reference_no', sa.String(length=50), nullable=True),
sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('remark', sa.Text(), nullable=True),
sa.Column('operator_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['operator_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_stock_movements_created_at'), 'stock_movements', ['created_at'], unique=False)
op.create_index(op.f('ix_stock_movements_id'), 'stock_movements', ['id'], unique=False)
op.create_index(op.f('ix_stock_movements_product_id'), 'stock_movements', ['product_id'], unique=False)
op.create_table('stp_files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('file_hash', sa.String(length=64), nullable=True),
sa.Column('mime_type', sa.String(length=50), nullable=True),
sa.Column('upload_batch', sa.String(length=36), nullable=True),
sa.Column('upload_time', sa.DateTime(), nullable=True),
sa.Column('processed_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('volume', sa.Float(), nullable=True),
sa.Column('surface_area', sa.Float(), nullable=True),
sa.Column('product_weight', sa.Float(), nullable=True),
sa.Column('file_path', sa.String(length=500), nullable=True),
sa.Column('file_content', sa.LargeBinary(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_stp_files_file_hash'), 'stp_files', ['file_hash'], unique=False)
op.create_index(op.f('ix_stp_files_id'), 'stp_files', ['id'], unique=False)
op.create_index(op.f('ix_stp_files_object_key'), 'stp_files', ['object_key'], unique=False)
op.create_index(op.f('ix_stp_files_original_filename'), 'stp_files', ['original_filename'], unique=False)
op.create_index(op.f('ix_stp_files_status'), 'stp_files', ['status'], unique=False)
op.create_index(op.f('ix_stp_files_upload_batch'), 'stp_files', ['upload_batch'], unique=False)
op.create_index(op.f('ix_stp_files_user_id'), 'stp_files', ['user_id'], unique=False)
op.create_table('system_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('level', sa.String(length=20), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('module', sa.String(length=100), nullable=True),
sa.Column('function_name', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('request_id', sa.String(length=100), nullable=True),
sa.Column('execution_time_ms', sa.Integer(), nullable=True),
sa.Column('resource_type', sa.String(length=50), nullable=True),
sa.Column('resource_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_system_logs_created_at'), 'system_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_system_logs_id'), 'system_logs', ['id'], unique=False)
op.create_index(op.f('ix_system_logs_level'), 'system_logs', ['level'], unique=False)
op.create_table('user_activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('activity_type', sa.String(length=50), nullable=False),
sa.Column('resource_type', sa.String(length=50), nullable=True),
sa.Column('resource_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('meta_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=500), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_activities_activity_type'), 'user_activities', ['activity_type'], unique=False)
op.create_index(op.f('ix_user_activities_created_at'), 'user_activities', ['created_at'], unique=False)
op.create_index(op.f('ix_user_activities_id'), 'user_activities', ['id'], unique=False)
op.create_index(op.f('ix_user_activities_user_id'), 'user_activities', ['user_id'], unique=False)
op.create_table('user_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_roles_id'), 'user_roles', ['id'], unique=False)
op.create_index(op.f('ix_user_roles_role_id'), 'user_roles', ['role_id'], unique=False)
op.create_index(op.f('ix_user_roles_user_id'), 'user_roles', ['user_id'], unique=False)
op.create_table('analysis_metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('volume_utilization', sa.Float(), nullable=True),
sa.Column('topology_complexity', sa.Float(), nullable=True),
sa.Column('wall_uniformity', sa.Float(), nullable=True),
sa.Column('analysis_summary', sa.Text(), nullable=True),
sa.Column('verification_status', sa.String(length=20), nullable=True),
sa.Column('verification_volume_diff', sa.Float(), nullable=True),
sa.Column('verification_area_diff', sa.Float(), nullable=True),
sa.Column('verification_details', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_analysis_metrics_id'), 'analysis_metrics', ['id'], unique=False)
op.create_index(op.f('ix_analysis_metrics_stp_file_id'), 'analysis_metrics', ['stp_file_id'], unique=False)
op.create_table('design_recommendations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('rec_type', sa.String(length=50), nullable=False),
sa.Column('priority', sa.String(length=20), nullable=False),
sa.Column('description', sa.String(length=500), nullable=False),
sa.Column('reason', sa.Text(), nullable=True),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('user_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_design_recommendations_id'), 'design_recommendations', ['id'], unique=False)
op.create_index(op.f('ix_design_recommendations_stp_file_id'), 'design_recommendations', ['stp_file_id'], unique=False)
op.create_table('finance_allocations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.Integer(), nullable=False),
sa.Column('order_type', sa.String(length=20), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('allocated_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['transaction_id'], ['finance_transactions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_finance_allocations_created_at'), 'finance_allocations', ['created_at'], unique=False)
op.create_index(op.f('ix_finance_allocations_id'), 'finance_allocations', ['id'], unique=False)
op.create_index(op.f('ix_finance_allocations_order_id'), 'finance_allocations', ['order_id'], unique=False)
op.create_index(op.f('ix_finance_allocations_order_type'), 'finance_allocations', ['order_type'], unique=False)
op.create_index(op.f('ix_finance_allocations_transaction_id'), 'finance_allocations', ['transaction_id'], unique=False)
op.create_table('geometry_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('analysis_method', sa.String(length=50), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('volume', sa.Float(), nullable=True),
sa.Column('surface_area', sa.Float(), nullable=True),
sa.Column('bounding_box_min', sa.JSON(), nullable=True),
sa.Column('bounding_box_max', sa.JSON(), nullable=True),
sa.Column('center_of_mass', sa.JSON(), nullable=True),
sa.Column('topology_faces', sa.Integer(), nullable=True),
sa.Column('topology_edges', sa.Integer(), nullable=True),
sa.Column('topology_vertices', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_geometry_data_id'), 'geometry_data', ['id'], unique=False)
op.create_index(op.f('ix_geometry_data_stp_file_id'), 'geometry_data', ['stp_file_id'], unique=False)
op.create_table('html_files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('generated_time', sa.DateTime(), nullable=True),
sa.Column('visualization_type', sa.String(length=50), nullable=True),
sa.Column('has_interactive_elements', sa.Boolean(), nullable=True),
sa.Column('file_path', sa.String(length=500), nullable=True),
sa.Column('html_content', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_html_files_id'), 'html_files', ['id'], unique=False)
op.create_index(op.f('ix_html_files_stp_file_id'), 'html_files', ['stp_file_id'], unique=False)
op.create_table('mesh_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('object_url', sa.String(length=1000), nullable=True),
sa.Column('quality', sa.String(length=20), nullable=True),
sa.Column('vertex_count', sa.Integer(), nullable=True),
sa.Column('face_count', sa.Integer(), nullable=True),
sa.Column('point_count', sa.Integer(), nullable=True),
sa.Column('bounding_box_min', sa.JSON(), nullable=True),
sa.Column('bounding_box_max', sa.JSON(), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mesh_data_id'), 'mesh_data', ['id'], unique=False)
op.create_index(op.f('ix_mesh_data_stp_file_id'), 'mesh_data', ['stp_file_id'], unique=False)
op.create_table('mold_cavity_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('detailed_object_key', sa.String(length=500), nullable=False),
sa.Column('storage_bucket', sa.String(length=100), nullable=False),
sa.Column('mold_material', sa.String(length=100), nullable=True),
sa.Column('mold_type', sa.String(length=50), nullable=True),
sa.Column('shrinkage_rate', sa.Float(), nullable=False),
sa.Column('draft_angle', sa.Float(), nullable=False),
sa.Column('parting_line_length', sa.Float(), nullable=True),
sa.Column('generated_time', sa.DateTime(), nullable=True),
sa.Column('cavity_key_info', sa.JSON(), nullable=True),
sa.Column('mold_size_length', sa.Float(), nullable=True),
sa.Column('mold_size_width', sa.Float(), nullable=True),
sa.Column('mold_size_height', sa.Float(), nullable=True),
sa.Column('estimated_clamping_force', sa.String(length=50), nullable=True),
sa.Column('product_weight', sa.String(length=50), nullable=True),
sa.Column('product_volume', sa.Float(), nullable=True),
sa.Column('wall_thickness_range', sa.String(length=50), nullable=True),
sa.Column('complexity_score', sa.Float(), nullable=True),
sa.Column('weld_line_risk', sa.String(length=50), nullable=True),
sa.Column('sink_mark_risk', sa.String(length=50), nullable=True),
sa.Column('warpage_risk', sa.String(length=50), nullable=True),
sa.Column('best_scheme_id', sa.String(length=64), nullable=True),
sa.Column('confidence_score', sa.Float(), nullable=True),
sa.Column('is_fallback', sa.Boolean(), nullable=True),
sa.Column('fallback_reason', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mold_cavity_data_best_scheme_id'), 'mold_cavity_data', ['best_scheme_id'], unique=False)
op.create_index(op.f('ix_mold_cavity_data_id'), 'mold_cavity_data', ['id'], unique=False)
op.create_index(op.f('ix_mold_cavity_data_is_fallback'), 'mold_cavity_data', ['is_fallback'], unique=False)
op.create_index(op.f('ix_mold_cavity_data_stp_file_id'), 'mold_cavity_data', ['stp_file_id'], unique=False)
op.create_table('processing_tasks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('task_id', sa.String(length=36), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('task_type', sa.String(length=50), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('started_time', sa.DateTime(), nullable=True),
sa.Column('completed_time', sa.DateTime(), nullable=True),
sa.Column('progress', sa.Integer(), nullable=True),
sa.Column('current_step', sa.String(length=100), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('error_stack', sa.Text(), nullable=True),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_processing_tasks_id'), 'processing_tasks', ['id'], unique=False)
op.create_index(op.f('ix_processing_tasks_stp_file_id'), 'processing_tasks', ['stp_file_id'], unique=False)
op.create_index(op.f('ix_processing_tasks_task_id'), 'processing_tasks', ['task_id'], unique=True)
op.create_table('purchase_order_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('received_quantity', sa.Integer(), nullable=True),
sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('remark', sa.Text(), nullable=True),
sa.CheckConstraint('quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity', name='ck_purchase_order_items_qty'),
sa.ForeignKeyConstraint(['order_id'], ['purchase_orders.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchase_order_items_id'), 'purchase_order_items', ['id'], unique=False)
op.create_table('sales_order_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('delivered_quantity', sa.Integer(), nullable=True),
sa.Column('unit_price', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('remark', sa.Text(), nullable=True),
sa.CheckConstraint('quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity', name='ck_sales_order_items_qty'),
sa.ForeignKeyConstraint(['order_id'], ['sales_orders.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sales_order_items_id'), 'sales_order_items', ['id'], unique=False)
op.create_table('feature_detections',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stp_file_id', sa.Integer(), nullable=False),
sa.Column('feature_type', sa.String(length=50), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('location', sa.JSON(), nullable=True),
sa.Column('dimensions', sa.JSON(), nullable=True),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('detected_at', sa.DateTime(), nullable=True),
sa.Column('geometry_data_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['geometry_data_id'], ['geometry_data.id'], ),
sa.ForeignKeyConstraint(['stp_file_id'], ['stp_files.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_feature_detections_feature_type'), 'feature_detections', ['feature_type'], unique=False)
op.create_index(op.f('ix_feature_detections_id'), 'feature_detections', ['id'], unique=False)
op.create_index(op.f('ix_feature_detections_stp_file_id'), 'feature_detections', ['stp_file_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_feature_detections_stp_file_id'), table_name='feature_detections')
op.drop_index(op.f('ix_feature_detections_id'), table_name='feature_detections')
op.drop_index(op.f('ix_feature_detections_feature_type'), table_name='feature_detections')
op.drop_table('feature_detections')
op.drop_index(op.f('ix_sales_order_items_id'), table_name='sales_order_items')
op.drop_table('sales_order_items')
op.drop_index(op.f('ix_purchase_order_items_id'), table_name='purchase_order_items')
op.drop_table('purchase_order_items')
op.drop_index(op.f('ix_processing_tasks_task_id'), table_name='processing_tasks')
op.drop_index(op.f('ix_processing_tasks_stp_file_id'), table_name='processing_tasks')
op.drop_index(op.f('ix_processing_tasks_id'), table_name='processing_tasks')
op.drop_table('processing_tasks')
op.drop_index(op.f('ix_mold_cavity_data_stp_file_id'), table_name='mold_cavity_data')
op.drop_index(op.f('ix_mold_cavity_data_is_fallback'), table_name='mold_cavity_data')
op.drop_index(op.f('ix_mold_cavity_data_id'), table_name='mold_cavity_data')
op.drop_index(op.f('ix_mold_cavity_data_best_scheme_id'), table_name='mold_cavity_data')
op.drop_table('mold_cavity_data')
op.drop_index(op.f('ix_mesh_data_stp_file_id'), table_name='mesh_data')
op.drop_index(op.f('ix_mesh_data_id'), table_name='mesh_data')
op.drop_table('mesh_data')
op.drop_index(op.f('ix_html_files_stp_file_id'), table_name='html_files')
op.drop_index(op.f('ix_html_files_id'), table_name='html_files')
op.drop_table('html_files')
op.drop_index(op.f('ix_geometry_data_stp_file_id'), table_name='geometry_data')
op.drop_index(op.f('ix_geometry_data_id'), table_name='geometry_data')
op.drop_table('geometry_data')
op.drop_index(op.f('ix_finance_allocations_transaction_id'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_order_type'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_order_id'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_id'), table_name='finance_allocations')
op.drop_index(op.f('ix_finance_allocations_created_at'), table_name='finance_allocations')
op.drop_table('finance_allocations')
op.drop_index(op.f('ix_design_recommendations_stp_file_id'), table_name='design_recommendations')
op.drop_index(op.f('ix_design_recommendations_id'), table_name='design_recommendations')
op.drop_table('design_recommendations')
op.drop_index(op.f('ix_analysis_metrics_stp_file_id'), table_name='analysis_metrics')
op.drop_index(op.f('ix_analysis_metrics_id'), table_name='analysis_metrics')
op.drop_table('analysis_metrics')
op.drop_index(op.f('ix_user_roles_user_id'), table_name='user_roles')
op.drop_index(op.f('ix_user_roles_role_id'), table_name='user_roles')
op.drop_index(op.f('ix_user_roles_id'), table_name='user_roles')
op.drop_table('user_roles')
op.drop_index(op.f('ix_user_activities_user_id'), table_name='user_activities')
op.drop_index(op.f('ix_user_activities_id'), table_name='user_activities')
op.drop_index(op.f('ix_user_activities_created_at'), table_name='user_activities')
op.drop_index(op.f('ix_user_activities_activity_type'), table_name='user_activities')
op.drop_table('user_activities')
op.drop_index(op.f('ix_system_logs_level'), table_name='system_logs')
op.drop_index(op.f('ix_system_logs_id'), table_name='system_logs')
op.drop_index(op.f('ix_system_logs_created_at'), table_name='system_logs')
op.drop_table('system_logs')
op.drop_index(op.f('ix_stp_files_user_id'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_upload_batch'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_status'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_original_filename'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_object_key'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_id'), table_name='stp_files')
op.drop_index(op.f('ix_stp_files_file_hash'), table_name='stp_files')
op.drop_table('stp_files')
op.drop_index(op.f('ix_stock_movements_product_id'), table_name='stock_movements')
op.drop_index(op.f('ix_stock_movements_id'), table_name='stock_movements')
op.drop_index(op.f('ix_stock_movements_created_at'), table_name='stock_movements')
op.drop_table('stock_movements')
op.drop_index(op.f('ix_sales_orders_production_status'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_production_no'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_order_no'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_id'), table_name='sales_orders')
op.drop_index(op.f('ix_sales_orders_customer_id'), table_name='sales_orders')
op.drop_table('sales_orders')
op.drop_index(op.f('ix_role_permissions_role_id'), table_name='role_permissions')
op.drop_index(op.f('ix_role_permissions_permission_id'), table_name='role_permissions')
op.drop_index(op.f('ix_role_permissions_id'), table_name='role_permissions')
op.drop_table('role_permissions')
op.drop_index(op.f('ix_purchase_orders_supplier_id'), table_name='purchase_orders')
op.drop_index(op.f('ix_purchase_orders_order_no'), table_name='purchase_orders')
op.drop_index(op.f('ix_purchase_orders_id'), table_name='purchase_orders')
op.drop_table('purchase_orders')
op.drop_index(op.f('ix_product_materials_material_product_id'), table_name='product_materials')
op.drop_index(op.f('ix_product_materials_id'), table_name='product_materials')
op.drop_index(op.f('ix_product_materials_finished_product_id'), table_name='product_materials')
op.drop_table('product_materials')
op.drop_index(op.f('ix_material_suppliers_supplier_id'), table_name='material_suppliers')
op.drop_index(op.f('ix_material_suppliers_product_id'), table_name='material_suppliers')
op.drop_index(op.f('ix_material_suppliers_id'), table_name='material_suppliers')
op.drop_table('material_suppliers')
op.drop_index(op.f('ix_material_price_history_supplier_id'), table_name='material_price_history')
op.drop_index(op.f('ix_material_price_history_product_id'), table_name='material_price_history')
op.drop_index(op.f('ix_material_price_history_id'), table_name='material_price_history')
op.drop_index(op.f('ix_material_price_history_effective_date'), table_name='material_price_history')
op.drop_table('material_price_history')
op.drop_index(op.f('ix_inventory_warehouse_id'), table_name='inventory')
op.drop_index(op.f('ix_inventory_product_id'), table_name='inventory')
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
op.drop_table('inventory')
op.drop_index(op.f('ix_finance_transactions_txn_type'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_txn_no'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_txn_date'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_status'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_partner_type'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_partner_id'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_id'), table_name='finance_transactions')
op.drop_index(op.f('ix_finance_transactions_created_at'), table_name='finance_transactions')
op.drop_table('finance_transactions')
op.drop_index(op.f('ix_warehouses_id'), table_name='warehouses')
op.drop_index(op.f('ix_warehouses_code'), table_name='warehouses')
op.drop_table('warehouses')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_suppliers_id'), table_name='suppliers')
op.drop_index(op.f('ix_suppliers_code'), table_name='suppliers')
op.drop_table('suppliers')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.drop_index(op.f('ix_roles_code'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_products_sku'), table_name='products')
op.drop_index(op.f('ix_products_item_type'), table_name='products')
op.drop_index(op.f('ix_products_id'), table_name='products')
op.drop_table('products')
op.drop_index(op.f('ix_permissions_id'), table_name='permissions')
op.drop_index(op.f('ix_permissions_code'), table_name='permissions')
op.drop_table('permissions')
op.drop_index(op.f('ix_customers_id'), table_name='customers')
op.drop_index(op.f('ix_customers_code'), table_name='customers')
op.drop_table('customers')
# ### end Alembic commands ###
+12 -5
View File
@@ -93,7 +93,8 @@
- **现状**:模具类型硬编码 if-else(`multi_scheme_planner.py:40`);特征检测器硬编码 6 个(`geometry_analyzer.py:76-99`);`process_file_core` 280 行。
- **目标**:`FeatureDetectorRegistry` + `MoldGeneratorRegistry`(`@register` 装饰器);`process_file_core` 拆成 Stage 链。
- **解锁**:新增模具类型、IGES/BREP、批量分析。
- **状态**:- [ ]
- **进展(2026-07-15)**:✅ `MoldGeneratorRegistry`(消除 `multi_scheme_planner` if-else,新增模具类型只需 `register`)+ ✅ `FeatureDetectorRegistry`(消除 6 个检测器硬编码,新增检测器只需 `register`)+ 顺带移除 OCC 线程池改用注册表串行执行;⏳ Stage 流水线**暂缓**--`process_file_core`(278 行/10 stage/~20 跨 stage 变量)是 STP 处理核心,本环境无 OCC 无法运行时验证,盲改风险高。建议在有 OCC 的环境按现有 `_step_*` 模式增量抽取 inline stages(parse_stp / build_plan_result / generate_visualization / analyze_design / generate_llm_report / finalize)
- **状态**:- [~](2/3:两个注册表完成,Stage 流水线暂缓)
### P1-3 前端 OpenAPI 契约生成
- **现状**:前端 40+ 处 `any`,字段全手写已大面积错配。
@@ -103,7 +104,7 @@
### P1-4 引入 Alembic,废除裸 DDL
- **现状**:无 `alembic.ini`;`init_db.py` 22 条 `ALTER TABLE ADD COLUMN IF NOT EXISTS`,无版本/无回滚;`migrate_db.py` 是 `drop_all` 破坏性脚本;两应用 startup 并发跑 DDL 争锁。
- **目标**:`alembic init`,固化版本化迁移,启动只 `upgrade head`;删 `migrate_db.py`。
- **状态**:- [ ]
- **状态**:- [x]
### P1-5 统一材料属性源
- **现状**:材料字典在 4 处重复定义且冲突(PE 收缩率 `material_service` 0.020 vs `aluminum_foam_mold.py:92` 0.025)。
@@ -121,6 +122,8 @@
- ✅ 全量 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 + 一致性核对通过
- ✅ **P1-4 引入 Alembic**:`alembic init` + 配置 env.py(接 settings+models metadata);补 3 个 CheckConstraint 到 models;离线生成初始迁移(31 表+约束+95 索引,全 sa.* 通用类型);`init_db.py` 用 `_run_alembic_migrations`(自动基线+upgrade head)替换 `create_tables`+`ensure_schema_updates`(删 92 行裸 DDL);删破坏性 `migrate_db.py`。**既有 DB 自动 stamp 基线**(无需手动);全新部署建议先 `alembic upgrade head` 再启应用
- ✅ **P1-2 可插拔注册表**(Stage 流水线暂缓):新增 `MoldGeneratorRegistry`(`multi_scheme_planner` 消除 if-else,按 mold_type 选生成器)+ `FeatureDetectorRegistry`(`geometry_analyzer._detect_features` 消除 6 个检测器硬编码,改遍历注册表);新增模具类型/特征检测器只需 `register` 一行;顺带移除 OCC 线程池改串行。Stage 流水线(process_file_core 拆分)因无 OCC 运行环境暂缓,文档留计划
---
@@ -129,7 +132,7 @@
### P2-1 打通模具分析 -> 进销存(最高产品价值)
- **现状**:`STPFile` 无 `product_id`,moldinsight 与 inventory 零数据关联。
- **目标**:`STPFile` 加 `product_id` 外键(可空),分析完成后一键创建 `Product(finished)` 并回写。
- **状态**:- [ ]
- **状态**:- [x]
### P2-2 真 AI 落地,砍掉假 AI
- **现状**:`ai_mold_assistant.py` 209 行纯 stub 从未被调用;`ai_parting_detector.py` GNN 框架完整但无权重;`llm_service` 是唯一真接 AI(且有 P0-2 bug)。
@@ -140,6 +143,10 @@
- 依赖 P1-2 完成后才有性价比。
- **状态**:- [ ]
### P2 执行结果
- ✅ **P2-1 打通模具分析 -> 进销存**:`STPFile` 加 `product_id` 外键(nullable+index+FK)+ Alembic 迁移 `006c18c51b0d`(首次真实迁移);inventory `POST /api/products/from-task/{task_id}` 端点(按 task_id 查 STPFile,幂等创建 `Product(finished)`,回写 product_id,SKU=`MI{stp_file_id}`,描述含体积/重量/表面积);前端 ResultView 导出栏加「创建为成品」按钮。py_compile + alembic heads + vue-tsc 0 错误通过。**模具分析 -> 成品 -> BOM -> 销售/采购的业务闭环接通**
---
## P3 工程治理(穿插顺手做)
@@ -159,6 +166,6 @@
| 阶段 | 项数 | 已完成 | 进行中 |
|------|------|--------|--------|
| P0 | 7 | 6 修复 + 1 排查 | - |
| P1 | 5 | 2 | P1-1 核心完成 + P1-5 完成 |
| P2 | 3 | 0 | - |
| P1 | 5 | 4 | P1-1+P1-5+P1-4 完成 + P1-2 注册表完成(Stage 暂缓) |
| P2 | 3 | 1 | P2-1 完成 |
| P3 | 7 | 0 | - |
@@ -120,6 +120,9 @@
<t-button type="default" size="small" @click="exportCAD('brep')" title="导出BRep格式(FreeCAD原生)">
导出 BRep
</t-button>
<t-button type="default" size="small" @click="createProductFromAnalysis" :loading="state.creatingProduct" title="将本次模具分析创建为进销存成品,可在进销存模块继续配置 BOM / 销售">
📋 创建为成品
</t-button>
</div>
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
@@ -603,7 +606,8 @@ const state = reactive({
surface_quality: 'standard',
controller: 'fanuc',
include_gcode: false
}
},
creatingProduct: false
})
const camSteelOptions = [
@@ -644,6 +648,19 @@ const loadTask = async () => {
}
}
const createProductFromAnalysis = async () => {
const taskId = route.params.taskId as string
try {
state.creatingProduct = true
const product = await apiRequest<any>(`/api/products/from-task/${taskId}`, { method: 'POST' })
addNotification(`已创建成品:${product.name}(SKU: ${product.sku})`, 'success')
} catch (e) {
handleApiError(e, '创建成品')
} finally {
state.creatingProduct = false
}
}
onMounted(() => {
if (!appStore.user) {
router.push('/login')
+66 -1
View File
@@ -14,10 +14,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, func, delete
from typing import Optional, List, Dict
from decimal import Decimal
from pathlib import Path
from shared.database.database import get_db_session
from shared.services.auth_service import get_current_active_user, get_current_admin_user
from shared.models.database import User, Product, ProductMaterial
from shared.models.database import User, Product, ProductMaterial, STPFile, ProcessingTask
from ..schemas import (
ProductCreate,
ProductResponse,
@@ -118,6 +119,70 @@ async def create_product(
return _build_product_response(product, 0)
@router.post("/from-task/{task_id}", response_model=ProductResponse, status_code=201)
async def create_product_from_task(
task_id: str,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user),
):
"""从模具分析任务创建进销存成品,回写 stp_files.product_id(P2-1)"""
task_result = await db_session.execute(select(ProcessingTask).where(ProcessingTask.task_id == task_id))
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="分析任务不存在")
stp_result = await db_session.execute(select(STPFile).where(STPFile.id == task.stp_file_id))
stp_file = stp_result.scalar_one_or_none()
if not stp_file:
raise HTTPException(status_code=404, detail="STP 分析记录不存在")
# 已关联成品则直接返回(幂等)
if stp_file.product_id:
existed = await db_session.execute(select(Product).where(Product.id == stp_file.product_id))
product = existed.scalar_one_or_none()
if product:
return _build_product_response(product, 0)
# 生成唯一 SKU:MI{stp_file_id},冲突则追加序号
base_sku = f"MI{stp_file_id}"
sku = base_sku
n = 1
while True:
conflict = await db_session.execute(select(Product).where(Product.sku == sku))
if not conflict.scalar_one_or_none():
break
n += 1
sku = f"{base_sku}-{n}"
name = Path(stp_file.original_filename or f"mold_{stp_file_id}").stem or f"模具分析-{stp_file_id}"
desc_parts = []
if stp_file.volume:
desc_parts.append(f"体积 {stp_file.volume:.1f} mm³")
if stp_file.product_weight:
desc_parts.append(f"重量 {stp_file.product_weight:.2f} g")
if stp_file.surface_area:
desc_parts.append(f"表面积 {stp_file.surface_area:.1f} mm²")
description = "由模具分析创建" + (":" + ";".join(desc_parts) if desc_parts else "")
product = Product(
sku=sku,
name=name,
description=description,
category="模具成品",
unit="件",
item_type="finished",
cost_price=0,
sale_price=0,
min_stock=0,
max_stock=0,
)
db_session.add(product)
await db_session.flush()
stp_file.product_id = product.id
await db_session.commit()
await db_session.refresh(product)
return _build_product_response(product, 0)
@router.put("/{product_id}", response_model=ProductResponse)
async def update_product(
product_id: int,
+28 -21
View File
@@ -279,27 +279,6 @@ async def design_complete_mold_system(
return {"status": "success", "data": result}
@router.post("/ai-parting-detect")
async def ai_parting_surface_detect(
request: Request,
current_user: User = Depends(get_current_active_user),
):
body = await request.json()
task_id = body.get("task_id")
if not task_id:
raise HTTPException(404, "缺少 task_id")
task_data = await _get_task_data(task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
geometry_data = task_data.get("geometry_data")
if not geometry_data:
raise HTTPException(400, "该任务尚未完成几何分析")
from moldinsight.core.ai_parting_detector import AIPartingSurfaceDetectorV2
detector = AIPartingSurfaceDetectorV2(use_gnn=True)
result = detector._detect_with_geometry(None, geometry_data)
return {"status": "success", "data": result}
@router.post("/detect-undercuts")
async def detect_undercuts(
request: Request,
@@ -323,6 +302,34 @@ async def detect_undercuts(
return {"status": "success", "data": result}
@router.post("/cost-estimate")
async def estimate_cost(
request: Request,
current_user: User = Depends(get_current_active_user),
):
"""LLM 模具成本估算(P2-2:真 AI 落地,需启用 LLM)"""
body = await request.json()
task_id = body.get("task_id")
if not task_id:
raise HTTPException(404, "缺少 task_id")
task_data = await _get_task_data(task_id)
if not task_data:
raise HTTPException(404, "任务不存在")
analysis_result = task_data.get("analysis_result")
if not analysis_result:
raise HTTPException(400, "该任务尚未完成分析")
detailed_context = {
"candidate_schemes": task_data.get("candidate_schemes", []),
"geometry_data": task_data.get("geometry_data", {}),
"metadata": {"selected_material": task_data.get("material")},
}
from moldinsight.services.llm_service import llm_service
result = await llm_service.estimate_cost(analysis_result, detailed_context)
if result is None:
raise HTTPException(503, "成本估算不可用(LLM 未启用或生成失败)")
return {"status": "success", "data": result}
@router.post("/design-cam")
async def design_mold_cam(
request: Request,
-209
View File
@@ -1,209 +0,0 @@
"""
AI 分模辅助模型接口示例
此文件展示了如何创建 AI 模型来辅助分模过程。
实际使用时需要替换为真实的 AI 模型。
"""
from typing import Dict, Any, Optional
import numpy as np
from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Face
class AIPartingSurfaceDetector:
"""
AI 分型面检测器(示例接口)
功能:
- 分析产品 3D 几何
- 预测最优分型面位置和方向
- 识别倒扣区域
"""
def __init__(self, model_path: Optional[str] = None):
"""
初始化 AI 分型面检测器
Args:
model_path: 训练好的模型路径
"""
self.model_path = model_path
self.model = None
# 如果提供了模型路径,加载模型
if model_path:
self._load_model(model_path)
def _load_model(self, model_path: str):
"""加载训练好的 AI 模型"""
# TODO: 实现模型加载逻辑
# 示例:
# import torch
# self.model = torch.load(model_path)
print(f"AI 模型加载:{model_path}")
def detect(self, product_shape: TopoDS_Shape, analysis: Dict) -> Optional[Dict]:
"""
检测最优分型面
Args:
product_shape: OpenCASCADE 形状对象
analysis: 几何分析结果(包含 bounding_box, volume 等)
Returns:
{
"origin": [x, y, z], # 分型面原点
"normal": [nx, ny, nz], # 分型面法向量
"confidence": 0.95, # 置信度
"parting_line": [...] # 可选的分型线
}
"""
# TODO: 使用 AI 模型进行预测
# 这里是示例返回
# 1. 将产品形状转换为 AI 模型输入
# - 体素化 (voxelization)
# - 点云 (point cloud)
# - 多视图 (multi-view images)
input_data = self._preprocess_shape(product_shape, analysis)
# 2. 使用模型预测
# prediction = self.model.predict(input_data)
# 3. 返回预测结果
return {
"origin": [0, 0, analysis["bounding_box"]["center"][2]],
"normal": [0, 0, 1], # Z 方向
"confidence": 0.85,
"undercut_regions": [] # 倒扣区域
}
def _preprocess_shape(self, shape: TopoDS_Shape, analysis: Dict) -> TopoDS_Shape:
"""
预处理产品形状为 AI 模型输入
可能的预处理方式:
1. 体素化:将 3D 模型转换为 3D 网格
2. 点云:采样表面点
3. 多视图:渲染多个角度的 2D 图像
"""
# TODO: 实现预处理逻辑
return None
class AIDraftAnalyzer:
"""
AI 拔模分析器(示例接口)
功能:
- 分析哪些面需要拔模
- 预测最优拔模角度
- 检测脱模干涉
"""
def __init__(self, model_path: Optional[str] = None):
self.model_path = model_path
self.model = None
if model_path:
self._load_model(model_path)
def _load_model(self, model_path: str):
"""加载训练好的 AI 模型"""
print(f"AI 拔模分析模型加载:{model_path}")
def analyze(self, product_shape: TopoDS_Shape, parting_surface: TopoDS_Face,
base_draft_angle: float) -> Optional[Dict]:
"""
分析拔模需求
Args:
product_shape: 产品形状
parting_surface: 分型面
base_draft_angle: 基础拔模角(度)
Returns:
{
"drafted_shape": ..., # 应用拔模后的形状
"draft_angles": {...}, # 各面的拔模角
"interference_areas": [...], # 干涉区域
"recommendations": [...] # 优化建议
}
"""
# TODO: 使用 AI 模型分析拔模
# 示例返回
return {
"drafted_shape": product_shape, # 简化:返回原始形状
"draft_angles": {"default": base_draft_angle},
"interference_areas": [],
"recommendations": ["建议增加圆角", "壁厚均匀化"]
}
class AICavityLayoutOptimizer:
"""
AI 型腔布局优化器(示例接口)
功能:
- 优化多型腔排列
- 设计流道系统
- 平衡材料流动
"""
def __init__(self, model_path: Optional[str] = None):
self.model_path = model_path
self.model = None
if model_path:
self._load_model(model_path)
def optimize(self, product_shape: TopoDS_Shape, cavity_count: int,
mold_base_size: Dict) -> Optional[Dict]:
"""
优化型腔布局
Args:
product_shape: 产品形状
cavity_count: 型腔数量
mold_base_size: 模架尺寸
Returns:
{
"cavity_positions": [...], # 各型腔位置
"runner_system": {...}, # 流道系统设计
"balance_score": 0.92, # 流动平衡评分
"material_efficiency": 0.85 # 材料利用率
}
"""
# TODO: 使用 AI 优化型腔布局
return {
"cavity_positions": [[0, 0, 0]], # 示例
"runner_system": {"type": "cold_runner"},
"balance_score": 0.85,
"material_efficiency": 0.80
}
# ==================== 使用示例 ====================
if __name__ == "__main__":
# 示例:如何使用 AI 模型接口
# 1. 创建 AI 模型实例
parting_detector = AIPartingSurfaceDetector(model_path="models/parting_surface.pth")
draft_analyzer = AIDraftAnalyzer(model_path="models/draft_analysis.pth")
# 2. 设置到 MoldCavityGenerator
from moldinsight.core.mold_generator import MoldCavityGenerator
generator = MoldCavityGenerator()
generator.set_ai_model(
parting_detector=parting_detector,
draft_analyzer=draft_analyzer
)
# 3. 使用(AI 模型会自动介入)
# result = generator.generate_mold_cavities(product_shape)
print("AI 模型接口已配置,分模时将自动使用 AI 辅助")
-547
View File
@@ -1,547 +0,0 @@
"""
AI 分型面检测模块 - 基于 GNN 的分型面预测框架
架构设计:
1. ShapeGraphBuilder - 将 OCC 形状转换为图表示(面为节点,共享边为图边)
2. PartingSurfaceGNN - 图神经网络模型定义
3. AIPartingSurfaceDetectorV2 - 增强版分型面检测器(集成 GNN)
图构建策略:
- 节点:每个 TopoDS_Face 作为一个节点
- 节点特征:法向量(3) + 面积(1) + 曲率(2) + 面类型(1) = 7维
- 边:共享 TopoDS_Edge 的面之间建立边
- 边特征:共享边长度(1) + 二面角(1) = 2维
GNN 模型:
- 3层 GraphConv + 全局池化 + MLP 分类头
- 输出:每个面的分型面归属概率 + 分型方向
依赖:
- PyTorch + PyTorch Geometric(可选,缺失时回退到几何方法)
"""
from typing import Dict, List, Any, Optional, Tuple
import numpy as np
from OCC.Core.TopoDS import TopoDS_Shape
from shared.utils.logger import get_logger
logger = get_logger(__name__)
_TORCH_AVAILABLE = False
_TORCH_GEOMETRIC_AVAILABLE = False
try:
import torch
import torch.nn as nn
import torch.nn.functional as F
_TORCH_AVAILABLE = True
try:
from torch_geometric.nn import GCNConv, global_mean_pool
from torch_geometric.data import Data
_TORCH_GEOMETRIC_AVAILABLE = True
except ImportError:
logger.info("PyTorch Geometric 未安装,GNN 模型不可用")
except ImportError:
logger.info("PyTorch 未安装,AI 分型面检测将使用几何回退方法")
class ShapeGraphBuilder:
"""将 OCC 形状转换为图表示"""
def build_graph(self, shape: TopoDS_Shape) -> Optional[Dict]:
"""
从 OCC 形状构建图数据
Returns:
{
"node_features": np.ndarray (N, 7),
"edge_index": np.ndarray (2, E),
"edge_features": np.ndarray (E, 2),
"face_map": List[TopoDS_Face],
"num_nodes": int,
"num_edges": int
}
"""
try:
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.Bnd import Bnd_Box
from OCC.Core.BRepBndLib import brepbndlib
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCC.Core.TopExp import topexp_MapShapesAndAncestors
from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Edge, topods
faces = []
face_features = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = topods.Face(explorer.Current())
features = self._extract_face_features(face)
if features is not None:
faces.append(face)
face_features.append(features)
explorer.Next()
if not faces:
logger.warning("未找到面,无法构建图")
return None
node_features = np.array(face_features, dtype=np.float32)
edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
topexp_MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, edge_map)
edge_list = []
edge_features_list = []
for i in range(1, edge_map.Extent() + 1):
edge = topods.Edge(edge_map.FindKey(i))
face_list = edge_map.FindFromIndex(i)
connected_faces = []
it = face_list.begin()
while it != face_list.end():
f = topods.Face(it.Value())
try:
idx = faces.index(f)
connected_faces.append(idx)
except ValueError:
pass
it.next_ptr()
if len(connected_faces) >= 2:
edge_feat = self._extract_edge_features(edge, connected_faces, faces)
for j in range(len(connected_faces)):
for k in range(j + 1, len(connected_faces)):
edge_list.append([connected_faces[j], connected_faces[k]])
edge_features_list.append(edge_feat)
if not edge_list:
logger.warning("未找到边连接,返回无图边的图")
edge_index = np.zeros((2, 0), dtype=np.int64)
edge_features_arr = np.zeros((0, 2), dtype=np.float32)
else:
edge_index = np.array(edge_list, dtype=np.int64).T
rev_edges = np.array([[e[1], e[0]] for e in edge_list], dtype=np.int64).T
edge_index = np.concatenate([edge_index, rev_edges], axis=1)
edge_features_arr = np.array(edge_features_list, dtype=np.float32)
edge_features_arr = np.concatenate([edge_features_arr, edge_features_arr], axis=0)
return {
"node_features": node_features,
"edge_index": edge_index,
"edge_features": edge_features_arr,
"face_map": faces,
"num_nodes": len(faces),
"num_edges": edge_index.shape[1]
}
except Exception as e:
logger.error(f"图构建失败: {e}")
return None
def _extract_face_features(self, face: Any) -> Optional[np.ndarray]:
"""
提取面特征:[nx, ny, nz, area, u_curvature, v_curvature, face_type]
"""
try:
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop
surface = BRepAdaptor_Surface(face)
u = (surface.FirstUParameter() + surface.LastUParameter()) / 2
v = (surface.FirstVParameter() + surface.LastVParameter()) / 2
if surface.GetType() == 0:
normal = surface.Plane().Position().Direction()
face_type = 0.0
u_curv = 0.0
v_curv = 0.0
elif surface.GetType() == 1:
normal = surface.Cylinder().Position().Direction()
face_type = 1.0
radius = surface.Cylinder().Radius()
u_curv = 1.0 / radius if radius > 0.001 else 0.0
v_curv = 0.0
elif surface.GetType() == 2:
normal = surface.Cone().Position().Direction()
face_type = 2.0
u_curv = 0.0
v_curv = 0.0
elif surface.GetType() == 3:
normal = surface.Sphere().Position().Direction()
face_type = 3.0
radius = surface.Sphere().Radius()
u_curv = 1.0 / radius if radius > 0.001 else 0.0
v_curv = 1.0 / radius if radius > 0.001 else 0.0
elif surface.GetType() == 4:
normal = surface.Torus().Position().Direction()
face_type = 4.0
u_curv = 0.0
v_curv = 0.0
else:
from OCC.Core.BRepLProp import BRepLProp_SLProps
props = BRepLProp_SLProps(surface, 2, 0.001)
props.SetParameters(u, v)
if props.IsNormalDefined():
normal = props.Normal()
else:
normal = gp_Dir(0, 0, 1)
face_type = 5.0
u_curv = 0.0
v_curv = 0.0
face_props = GProp_GProps()
brepgprop.SurfaceProperties(face, face_props)
area = face_props.Mass()
return np.array([
normal.X(), normal.Y(), normal.Z(),
area,
u_curv, v_curv,
face_type
], dtype=np.float32)
except Exception as e:
logger.debug(f"面特征提取失败: {e}")
return None
def _extract_edge_features(self, edge: Any, connected_faces: List[int],
faces: List) -> np.ndarray:
"""
提取边特征:[edge_length, dihedral_angle]
"""
try:
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
from OCC.Core.GProp import GProp_GProps
from OCC.Core.BRepGProp import brepgprop
curve = BRepAdaptor_Curve(edge)
first = curve.FirstParameter()
last = curve.LastParameter()
edge_len = abs(last - first)
dihedral = 0.0
if len(connected_faces) >= 2:
n1 = self._get_face_normal_fast(faces[connected_faces[0]])
n2 = self._get_face_normal_fast(faces[connected_faces[1]])
if n1 is not None and n2 is not None:
dot = np.clip(np.dot(n1, n2), -1.0, 1.0)
dihedral = np.arccos(dot)
return np.array([edge_len, dihedral], dtype=np.float32)
except Exception:
return np.array([0.0, 0.0], dtype=np.float32)
def _get_face_normal_fast(self, face: Any) -> Optional[np.ndarray]:
"""快速获取面法向量(numpy数组)"""
try:
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
surface = BRepAdaptor_Surface(face)
if surface.GetType() == 0:
n = surface.Plane().Position().Direction()
return np.array([n.X(), n.Y(), n.Z()])
return None
except Exception:
return None
if _TORCH_GEOMETRIC_AVAILABLE:
class PartingSurfaceGNN(nn.Module):
"""
分型面检测 GNN 模型
架构:
- 3层 GCNConv (hidden_dim=64)
- 全局平均池化
- 3层 MLP 分类头
- 输出:每个面的分型面归属概率 (0-1)
"""
def __init__(self, input_dim: int = 7, hidden_dim: int = 64,
num_layers: int = 3, dropout: float = 0.3):
super().__init__()
self.input_dim = input_dim
self.hidden_dim = hidden_dim
self.num_layers = num_layers
self.input_proj = nn.Linear(input_dim, hidden_dim)
self.convs = nn.ModuleList()
self.bns = nn.ModuleList()
for _ in range(num_layers):
self.convs.append(GCNConv(hidden_dim, hidden_dim))
self.bns.append(nn.BatchNorm1d(hidden_dim))
self.dropout = dropout
self.mlp = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, hidden_dim // 2),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim // 2, 1),
)
def forward(self, data: Data) -> torch.Tensor:
x, edge_index = data.x, data.edge_index
x = self.input_proj(x)
x = F.relu(x)
for conv, bn in zip(self.convs, self.bns):
x = conv(x, edge_index)
x = bn(x)
x = F.relu(x)
x = F.dropout(x, p=self.dropout, training=self.training)
out = self.mlp(x)
return torch.sigmoid(out).squeeze(-1)
class PartingDirectionHead(nn.Module):
"""
分型方向预测头
基于全局池化的面特征,预测分型方向向量
"""
def __init__(self, hidden_dim: int = 64):
super().__init__()
self.direction_mlp = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, 3),
)
def forward(self, node_embeddings: torch.Tensor,
batch: torch.Tensor) -> torch.Tensor:
pooled = global_mean_pool(node_embeddings, batch)
direction = self.direction_mlp(pooled)
direction = F.normalize(direction, p=2, dim=-1)
return direction
class AIPartingSurfaceDetectorV2:
"""
增强版 AI 分型面检测器
支持:
1. GNN 模型推理(需要 PyTorch + PyG)
2. 几何方法回退(无需任何 AI 依赖)
3. 模型训练数据收集
"""
def __init__(self, model_path: Optional[str] = None,
use_gnn: bool = True,
device: str = "cpu"):
self.model = None
self.direction_head = None
self.graph_builder = ShapeGraphBuilder()
self.device = device
self.use_gnn = use_gnn and _TORCH_GEOMETRIC_AVAILABLE
if model_path and self.use_gnn:
self._load_model(model_path)
def _load_model(self, model_path: str):
"""加载训练好的 GNN 模型"""
if not _TORCH_GEOMETRIC_AVAILABLE:
logger.warning("PyTorch Geometric 不可用,无法加载 GNN 模型")
return
try:
checkpoint = torch.load(model_path, map_location=self.device)
self.model = PartingSurfaceGNN(
input_dim=checkpoint.get("input_dim", 7),
hidden_dim=checkpoint.get("hidden_dim", 64),
)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.model.to(self.device)
self.model.eval()
if "direction_head_state_dict" in checkpoint:
self.direction_head = PartingDirectionHead(
hidden_dim=checkpoint.get("hidden_dim", 64)
)
self.direction_head.load_state_dict(checkpoint["direction_head_state_dict"])
self.direction_head.to(self.device)
self.direction_head.eval()
logger.info(f"GNN 模型加载成功: {model_path}")
except Exception as e:
logger.error(f"GNN 模型加载失败: {e}")
self.model = None
def detect(self, product_shape: TopoDS_Shape, analysis: Dict) -> Optional[Dict]:
"""
检测最优分型面
Args:
product_shape: OpenCASCADE 形状对象
analysis: 几何分析结果
Returns:
{
"origin": [x, y, z],
"normal": [nx, ny, nz],
"confidence": float,
"parting_line": [...],
"method": "gnn" | "geometric"
}
"""
if self.use_gnn and self.model is not None:
result = self._detect_with_gnn(product_shape, analysis)
if result is not None:
return result
return self._detect_with_geometry(product_shape, analysis)
def _detect_with_gnn(self, shape: TopoDS_Shape, analysis: Dict) -> Optional[Dict]:
"""使用 GNN 模型检测分型面"""
if not _TORCH_GEOMETRIC_AVAILABLE:
return None
try:
graph_data = self.graph_builder.build_graph(shape)
if graph_data is None:
return None
node_features = torch.tensor(
graph_data["node_features"], dtype=torch.float32
).to(self.device)
edge_index = torch.tensor(
graph_data["edge_index"], dtype=torch.long
).to(self.device)
data = Data(x=node_features, edge_index=edge_index)
with torch.no_grad():
face_probs = self.model(data)
if self.direction_head is not None:
batch = torch.zeros(
data.num_nodes, dtype=torch.long, device=self.device
)
direction = self.direction_head(data.x, batch)
normal = direction.cpu().numpy().tolist()
else:
normal = [0, 0, 1]
parting_face_mask = face_probs.cpu().numpy() > 0.5
confidence = float(face_probs.mean().cpu().numpy())
bbox = analysis.get("bounding_box", {})
center = bbox.get("center", [0, 0, 0])
return {
"origin": center,
"normal": normal,
"confidence": confidence,
"method": "gnn",
"face_probabilities": face_probs.cpu().numpy().tolist(),
"parting_face_count": int(parting_face_mask.sum()),
}
except Exception as e:
logger.warning(f"GNN 检测失败,回退到几何方法: {e}")
return None
def _detect_with_geometry(self, shape: TopoDS_Shape, analysis: Dict) -> Dict:
"""几何方法回退:基于法向量统计的分型面检测"""
try:
graph_data = self.graph_builder.build_graph(shape)
if graph_data is not None:
node_features = graph_data["node_features"]
normals = node_features[:, :3]
areas = node_features[:, 3]
total_area = areas.sum()
if total_area > 0:
weights = areas / total_area
weighted_normal = np.sum(normals * weights[:, np.newaxis], axis=0)
else:
weighted_normal = np.mean(normals, axis=0)
length = np.linalg.norm(weighted_normal)
if length > 0.001:
weighted_normal /= length
else:
weighted_normal = np.array([0, 0, 1])
dot_products = np.abs(np.dot(normals, weighted_normal))
confidence = float(np.mean(dot_products))
bbox = analysis.get("bounding_box", {})
center = bbox.get("center", [0, 0, 0])
return {
"origin": center,
"normal": weighted_normal.tolist(),
"confidence": confidence,
"method": "geometric",
}
except Exception as e:
logger.warning(f"几何方法检测失败: {e}")
bbox = analysis.get("bounding_box", {})
center = bbox.get("center", [0, 0, 0])
return {
"origin": center,
"normal": [0, 0, 1],
"confidence": 0.5,
"method": "fallback",
}
def collect_training_sample(self, shape: TopoDS_Shape, analysis: Dict,
ground_truth_normal: List[float],
ground_truth_origin: List[float]) -> Optional[Dict]:
"""
收集训练样本
Args:
shape: OCC 形状
analysis: 几何分析
ground_truth_normal: 人工标注的分型方向
ground_truth_origin: 人工标注的分型面原点
Returns:
可序列化的训练样本
"""
graph_data = self.graph_builder.build_graph(shape)
if graph_data is None:
return None
return {
"node_features": graph_data["node_features"].tolist(),
"edge_index": graph_data["edge_index"].tolist(),
"edge_features": graph_data["edge_features"].tolist(),
"label_normal": ground_truth_normal,
"label_origin": ground_truth_origin,
"bounding_box": analysis.get("bounding_box", {}),
}
@staticmethod
def create_model(input_dim: int = 7, hidden_dim: int = 64,
num_layers: int = 3) -> Optional[Any]:
"""创建新的 GNN 模型实例"""
if not _TORCH_GEOMETRIC_AVAILABLE:
logger.warning("PyTorch Geometric 不可用,无法创建模型")
return None
return PartingSurfaceGNN(
input_dim=input_dim,
hidden_dim=hidden_dim,
num_layers=num_layers,
)
@@ -462,37 +462,6 @@ class AluminumFoamMoldGenerator(BaseMoldGenerator):
"bounds": metadata["bounds"],
}
def _create_parting_surface_from_ai(self, ai_result: Dict, analysis: Dict,
shape: Optional[TopoDS_Shape] = None) -> Dict:
"""从 AI 结果创建分型面"""
origin = ai_result.get("origin", [0, 0, 0])
normal = ai_result.get("normal", [0, 0, 1])
parting_plane = gp_Pln(
gp_Pnt(origin[0], origin[1], origin[2]),
gp_Dir(normal[0], normal[1], normal[2])
)
try:
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
except Exception:
parting_plane = gp_Pln(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
if shape is not None:
parting_line = self._calculate_parting_line(shape, parting_surface)
else:
parting_line = []
return {
"primary_surface": parting_surface,
"primary_line": parting_line,
"primary_direction": normal,
"confidence": ai_result.get("confidence", 0.8),
"additional_surfaces": [],
"surface_count": 1
}
# ==================== 辅助方法 ====================
def _calculate_mold_size(self, analysis: Dict) -> Dict[str, float]:
@@ -33,14 +33,6 @@ class BaseMoldGenerator:
self.draft_angle = draft_angle
self.material_density = material_density
self.ai_parting_detector: Optional[Any] = None
self.ai_draft_analyzer: Optional[Any] = None
def set_ai_model(self, parting_detector: Any = None, draft_analyzer: Any = None):
self.ai_parting_detector = parting_detector
self.ai_draft_analyzer = draft_analyzer
logger.info("AI 模型接口已设置")
def _apply_shrinkage_compensation(self, shape: TopoDS_Shape) -> TopoDS_Shape:
scale_factor = 1.0 + self.shrinkage_rate
trsf = gp_Trsf()
@@ -0,0 +1,34 @@
"""特征检测器注册表
特征检测器通过 FeatureDetectorRegistry 注册,GeometryAnalyzer 遍历注册表执行,
不再硬编码检测器列表。新增检测器只需 register 一个 (name, fn, requires_shape)。
"""
from typing import Callable, List, Dict, Any, Optional, Tuple
class FeatureDetectorRegistry:
"""特征检测器注册表
每个检测器是一个 (name, fn, requires_shape) 条目:
- fn: callable(geometry_data, shape) -> List[Dict],返回检测到的特征列表
- requires_shape: True 表示仅当 shape 非 None 时才执行(如曲率/圆角检测需 OCC Shape)
串行执行(OCC 非线程安全),单个检测器失败不影响其他。
"""
def __init__(self):
self._entries: List[Tuple[str, Callable, bool]] = []
def register(self, name: str, fn: Callable, requires_shape: bool = False) -> None:
self._entries.append((name, fn, requires_shape))
def detect_all(self, geometry_data: Dict[str, Any], shape: Optional[Any]) -> List[Dict[str, Any]]:
features: List[Dict[str, Any]] = []
for _name, fn, requires_shape in self._entries:
if requires_shape and shape is None:
continue
try:
features.extend(fn(geometry_data, shape))
except Exception:
pass
return features
+14 -20
View File
@@ -1,6 +1,5 @@
from typing import Dict, List, Any, Optional
import math
from concurrent.futures import ThreadPoolExecutor, as_completed
import numpy as np
from OCC.Core.TopoDS import TopoDS_Shape
from shared.models.schemas import (
@@ -10,6 +9,7 @@ from shared.models.schemas import (
)
from shared.utils.logger import get_logger
from moldinsight.services.material_service import MaterialService
from moldinsight.core.feature_detector_registry import FeatureDetectorRegistry
logger = get_logger(__name__)
@@ -32,6 +32,15 @@ class GeometryAnalyzer:
"H13_Steel": {"thermal_conductivity": 25, "hardness": "HRC48", "cost": "high"}
}
# 特征检测器注册表 - 新增检测器只需在此 register
self._feature_detectors = FeatureDetectorRegistry()
self._feature_detectors.register("wall", lambda gd, s: self._detect_wall_features(gd, s))
self._feature_detectors.register("rib", lambda gd, s: self._detect_rib_features(gd, s))
self._feature_detectors.register("boss", lambda gd, s: self._detect_boss_features(gd, s))
self._feature_detectors.register("draft", lambda gd, s: self._analyze_draft_angles(gd, s))
self._feature_detectors.register("curvature", lambda gd, s: self._detect_curvature_features(s), requires_shape=True)
self._feature_detectors.register("fillet", lambda gd, s: self._detect_fillet_features(s), requires_shape=True)
def analyze_mold_design(self, geometry_data: Dict[str, Any],
product_material: str = "ABS",
mold_material: str = "Aluminum",
@@ -69,26 +78,11 @@ class GeometryAnalyzer:
def _detect_features(self, geometry_data: Dict[str, Any],
shape: Optional[TopoDS_Shape] = None) -> List[Dict[str, Any]]:
"""检测模具特征 — 独立检测并行执行"""
features: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="feat") as pool:
futures = {
pool.submit(self._detect_wall_features, geometry_data, shape): "wall",
pool.submit(self._detect_rib_features, geometry_data, shape): "rib",
pool.submit(self._detect_boss_features, geometry_data, shape): "boss",
pool.submit(self._analyze_draft_angles, geometry_data, shape): "draft",
}
if shape is not None:
futures[pool.submit(self._detect_curvature_features, shape)] = "curvature"
futures[pool.submit(self._detect_fillet_features, shape)] = "fillet"
for future in as_completed(futures):
try:
features.extend(future.result())
except Exception:
pass
"""检测模具特征 - 串行执行(OCC 非线程安全)
检测器通过 FeatureDetectorRegistry 注册,新增检测器只需在 __init__ 中 register。
"""
features = self._feature_detectors.detect_all(geometry_data, shape)
logger.info(f"检测到 {len(features)} 个特征")
return features
-49
View File
@@ -214,21 +214,6 @@ class MoldCavityGenerator(BaseMoldGenerator):
def _detect_primary_parting(self, shape: TopoDS_Shape, analysis: Dict) -> Dict[str, Any]:
"""检测主分型面(AI优先 → 几何法向量 → 简化回退)"""
if self.ai_parting_detector is not None:
try:
ai_result = self.ai_parting_detector.detect(shape, analysis)
if ai_result is not None:
surface, line = self._create_parting_surface_from_ai(ai_result, analysis, shape)
return {
"surface": surface,
"line": line,
"direction": ai_result.get("normal", [0, 0, 1]),
"method": ai_result.get("method", "ai"),
"confidence": ai_result.get("confidence", 0.8),
}
except Exception as e:
logger.warning(f"AI 分型面检测失败: {e}")
try:
normal_dir = self._analyze_face_normals(shape)
parting_plane = self._create_optimal_parting_plane(shape, analysis, normal_dir)
@@ -364,40 +349,6 @@ class MoldCavityGenerator(BaseMoldGenerator):
return parting_surface, parting_line
def _create_parting_surface_from_ai(self, ai_result: Dict,
analysis: Dict, shape: Optional[TopoDS_Shape] = None) -> Tuple[TopoDS_Face, List]:
"""
从 AI 模型结果创建分型面(预留接口)
Args:
ai_result: AI 模型输出,应包含:
- origin: [x, y, z] 平面原点
- normal: [nx, ny, nz] 法向量
analysis: 几何分析结果
shape: 产品形状(用于计算分型线)
Returns:
(parting_surface, parting_line)
"""
origin = ai_result.get("origin", [0, 0, 0])
normal = ai_result.get("normal", [0, 0, 1])
parting_plane = gp_Pln(
gp_Pnt(origin[0], origin[1], origin[2]),
gp_Dir(normal[0], normal[1], normal[2])
)
parting_surface = BRepBuilderAPI_MakeFace(parting_plane).Face()
if "parting_line" in ai_result:
parting_line = ai_result["parting_line"]
elif shape is not None:
parting_line = self._calculate_parting_line(shape, parting_surface)
else:
parting_line = []
logger.info(f"从 AI 结果创建分型面:原点={origin}, 法向量={normal}")
return parting_surface, parting_line
def _extract_parting_surface_geometry(self, surface: TopoDS_Face) -> Dict[str, Any]:
"""提取分型面几何数据"""
metadata = self._extract_plane_metadata(surface)
@@ -0,0 +1,42 @@
"""模具生成器注册表
按 mold_type 注册生成器实例,MultiSchemeMoldPlanner 通过 mold_type 查询,
不再硬编码 if-else 选择生成器。新增模具类型只需 `register` 一个新生成器,无需改 planner。
"""
from typing import Dict
from moldinsight.core.base_mold_generator import BaseMoldGenerator
from moldinsight.core.mold_generator import MoldCavityGenerator
from moldinsight.core.aluminum_foam_mold import AluminumFoamMoldGenerator
class MoldGeneratorRegistry:
"""模具生成器注册表"""
def __init__(self):
self._generators: Dict[str, BaseMoldGenerator] = {}
def register(self, mold_type: str, generator: BaseMoldGenerator) -> None:
"""注册一个模具生成器"""
self._generators[mold_type] = generator
def get_by_type(self, mold_type: str) -> BaseMoldGenerator:
"""按 mold_type 获取生成器,未注册则抛错"""
gen = self._generators.get(mold_type)
if gen is None:
raise ValueError(
f"未注册的模具类型: {mold_type},已注册: {list(self._generators.keys())}"
)
return gen
def list_types(self):
return list(self._generators.keys())
# 全局单例,注册默认生成器
mold_generator_registry = MoldGeneratorRegistry()
mold_generator_registry.register("injection", MoldCavityGenerator(shrinkage_rate=0.005))
mold_generator_registry.register(
"aluminum_foam",
AluminumFoamMoldGenerator(shrinkage_rate=0.015, draft_angle=3.0),
)
+2 -8
View File
@@ -8,8 +8,7 @@ from OCC.Core.TopAbs import TopAbs_FACE
from OCC.Core.TopExp import TopExp_Explorer
from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, topods
from moldinsight.core.mold_generator import MoldCavityGenerator
from moldinsight.core.aluminum_foam_mold import AluminumFoamMoldGenerator
from moldinsight.core.mold_generator_registry import mold_generator_registry
from moldinsight.core.parting_candidate_generator import PartingCandidateGenerator
from moldinsight.core.parting_scheme_scorer import PartingSchemeScorer
from shared.utils.logger import get_logger
@@ -23,11 +22,6 @@ class MultiSchemeMoldPlanner:
def __init__(self):
self.candidate_generator = PartingCandidateGenerator()
self.scheme_scorer = PartingSchemeScorer()
self.mold_generator = MoldCavityGenerator(shrinkage_rate=0.005)
self.aluminum_foam_generator = AluminumFoamMoldGenerator(
shrinkage_rate=0.015,
draft_angle=3.0,
)
def generate_plan(
self,
@@ -37,7 +31,7 @@ class MultiSchemeMoldPlanner:
max_schemes: int = 3,
process_params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
generator = self.aluminum_foam_generator if is_foam_material else self.mold_generator
generator = mold_generator_registry.get_by_type("aluminum_foam" if is_foam_material else "injection")
generator.set_material(material["name"])
self._apply_process_params(generator, material, process_params)
+82
View File
@@ -179,6 +179,33 @@ _PARTING_USER = """请评估以下候选分模方向并推荐最优方案:
请综合评估制造可行性、成本和风险,给出推荐。"""
_COST_ESTIMATE_SYSTEM = """你是一位资深模具报价工程师,擅长根据产品几何与模具设计方案估算模具造价与单件成本。
要求:
1. 使用中文,金额用人民币(¥)
2. 基于给定数据合理估算,数据不足时给出区间并标注假设
3. 综合考虑:模具材料、加工复杂度(滑块/斜顶/镶件)、型腔数、产品材料用量、成型周期
严格输出 JSON,不要输出其他内容。JSON 格式:
{
"mold_cost": {
"material": "¥XX(P20 钢,约 XX kg)",
"machining": "¥XX(含 CNC/EDM/线切割)",
"complexity_factor": "1.2(含 X 个滑块/斜顶)",
"subtotal": "¥XX"
},
"part_cost": {
"material": "¥XX(ABS,约 XX g)",
"cycle_time": "30 s",
"cost_per_part": "¥XX"
},
"total_mold_cost": "¥XX",
"cost_per_part": "¥XX",
"confidence": 0.7,
"assumptions": ["假设模具寿命 50 万模次", "假设..."]
}"""
class LLMService:
"""LLM 增强分析服务(单例)"""
@@ -308,6 +335,61 @@ class LLMService:
logger.warning("LLM 分型推荐失败(不影响主流程): %s", e)
return None
async def estimate_cost(
self,
analysis_result: Dict[str, Any],
detailed_cavity_json: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""估算模具造价与单件成本 (结构化 JSON)"""
if not self._enabled:
return None
try:
prompt = self._build_cost_estimate_prompt(analysis_result, detailed_cavity_json)
response = await self._chat(
_COST_ESTIMATE_SYSTEM,
prompt,
min(self._max_tokens, 1200),
expect_json=True,
)
if not response:
return None
result = self._parse_json_response(response)
if result:
logger.info("LLM 成本估算生成成功: total=%s", result.get("total_mold_cost"))
return result
except Exception as e:
logger.warning("LLM 成本估算失败(不影响主流程): %s", e)
return None
def _build_cost_estimate_prompt(self, analysis_result: Dict[str, Any], detailed_cavity_json: Optional[Dict[str, Any]]) -> str:
geometry_data = analysis_result.get("geometry_data", {}) or (detailed_cavity_json or {}).get("geometry_data", {})
volume = geometry_data.get("volume", 0) or 0
bbox = geometry_data.get("bounding_box", {}) or {}
dims = bbox.get("dimensions", [0, 0, 0])
meta = (detailed_cavity_json or {}).get("metadata", {}) or {}
material = meta.get("selected_material") or analysis_result.get("material") or "ABS"
schemes = (detailed_cavity_json or {}).get("candidate_schemes", []) or []
best = schemes[0] if schemes else {}
cd = best.get("cavity_data", {}) if isinstance(best, dict) else {}
mfg = cd.get("manufacturing_info", {}) if isinstance(cd, dict) else {}
features = analysis_result.get("detected_features", []) or []
complexity_hints = [
f.get("description", f.get("feature_type", ""))
for f in features
if f.get("feature_type") in ("undercut", "side_action", "insert")
]
return (
f"产品材料:{material}\n"
f"体积:{float(volume):.1f} mm³\n"
f"边界框尺寸(长×宽×高):{float(dims[0]):.1f} × {float(dims[1]):.1f} × {float(dims[2]):.1f} mm\n"
f"预估锁模力:{mfg.get('estimated_clamping_force', '未知')}\n"
f"预估模具尺寸:{json.dumps(mfg.get('estimated_mold_size', {}), ensure_ascii=False)}\n"
f"预估成型周期:{mfg.get('estimated_cycle_time', '未知')}\n"
f"型腔数:{best.get('cavity_count', 1) if isinstance(best, dict) else 1}\n"
f"模具结构:{best.get('mold_structure_type', '未知') if isinstance(best, dict) else '未知'}\n"
f"复杂度线索:{', '.join(complexity_hints) if complexity_hints else '无明显倒扣/滑块'}\n"
)
def _build_design_report_prompt(self, analysis_result, detailed_cavity_json) -> str:
features = json.dumps(analysis_result.get("detected_features", []), ensure_ascii=False, indent=2)
if len(features) > 4000:
+36 -94
View File
@@ -7,9 +7,44 @@ from shared.models.database import User, Role, Permission, UserRole, RolePermiss
from shared.services.auth_service import get_password_hash
from shared.config.settings import settings
from shared.utils.logger import get_logger
from alembic.config import Config
from alembic import command
logger = get_logger(__name__)
_ALEMBIC_INI = Path(__file__).resolve().parents[3] / "alembic.ini"
def _alembic_stamp_head() -> None:
"""将当前 DB 标记为已到最新版本(基线既有 DB,不执行 SQL)"""
cfg = Config(str(_ALEMBIC_INI))
command.stamp(cfg, "head")
def _alembic_upgrade_head() -> None:
"""执行 alembic 迁移到最新版本"""
cfg = Config(str(_ALEMBIC_INI))
command.upgrade(cfg, "head")
async def _run_alembic_migrations() -> None:
"""以 alembic 管理 schema:既有未纳入管理的 DB 自动 stamp 基线,再 upgrade head
- 全新 DB:upgrade head 执行初始迁移,创建全部表
- 既有已纳入管理:upgrade head 为 no-op
- 既有但无 alembic_version(历史 DB):先 stamp head 基线,再 upgrade(no-op)
"""
async with db_manager.engine.begin() as conn:
has_alembic = await conn.execute(text("SELECT to_regclass('public.alembic_version')")).scalar()
if not has_alembic:
table_count = await conn.execute(
text("SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name <> 'alembic_version'")
).scalar()
if table_count and table_count > 0:
logger.info("检测到既有 DB 未纳入 alembic 管理,自动 stamp head 作为基线")
await asyncio.to_thread(_alembic_stamp_head)
await asyncio.to_thread(_alembic_upgrade_head)
DEFAULT_PERMISSIONS = [
{"code": "view_dashboard", "name": "查看仪表盘", "module": "dashboard"},
{"code": "view_moldinsight", "name": "使用模具分析", "module": "moldinsight"},
@@ -115,8 +150,7 @@ async def init_database(keep_connected: bool = True):
"""初始化数据库"""
try:
await db_manager.connect()
await db_manager.create_tables()
await ensure_schema_updates()
await _run_alembic_migrations()
async with db_manager.session() as session:
perm_map = await init_permissions(session)
@@ -153,97 +187,5 @@ async def init_database(keep_connected: bool = True):
await db_manager.disconnect()
async def ensure_schema_updates():
async with db_manager.engine.begin() as conn:
await conn.execute(text("ALTER TABLE products ADD COLUMN IF NOT EXISTS item_type VARCHAR(20) DEFAULT 'finished'"))
await conn.execute(text("UPDATE products SET item_type = 'finished' WHERE item_type IS NULL"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_status VARCHAR(20) DEFAULT 'not_started'"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS received_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS product_materials (
id SERIAL PRIMARY KEY,
finished_product_id INTEGER NOT NULL REFERENCES products(id),
material_product_id INTEGER NOT NULL REFERENCES products(id),
quantity DOUBLE PRECISION NOT NULL,
loss_rate DOUBLE PRECISION DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
await conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_material_unique
ON product_materials (finished_product_id, material_product_id)
"""))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS best_scheme_id VARCHAR(64)"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS confidence_score DOUBLE PRECISION"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS is_fallback BOOLEAN"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS fallback_reason TEXT"))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_best_scheme_id
ON mold_cavity_data (best_scheme_id)
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_is_fallback
ON mold_cavity_data (is_fallback)
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_inventory_product_warehouse'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT uq_inventory_product_warehouse UNIQUE (product_id, warehouse_id);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_qty_nonnegative'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT ck_inventory_qty_nonnegative
CHECK (quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_purchase_order_items_qty'
) THEN
ALTER TABLE purchase_order_items
ADD CONSTRAINT ck_purchase_order_items_qty
CHECK (quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_sales_order_items_qty'
) THEN
ALTER TABLE sales_order_items
ADD CONSTRAINT ck_sales_order_items_qty
CHECK (quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS manufacturing_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE sales_orders ALTER COLUMN manufacturing_date TYPE TIMESTAMP WITHOUT TIME ZONE"))
if __name__ == "__main__":
asyncio.run(init_database(keep_connected=False))
-55
View File
@@ -1,55 +0,0 @@
"""数据库迁移脚本 - 删除旧表并重新创建"""
import asyncio
from shared.database.database import db_manager
from shared.models.database import Base
from shared.utils.logger import get_logger
logger = get_logger(__name__)
async def migrate_database():
"""迁移数据库:删除所有表并重新创建"""
try:
# 连接数据库
await db_manager.connect()
# 删除所有表
logger.info("正在删除所有数据库表...")
async with db_manager.engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# 重新创建所有表
logger.info("正在创建所有数据库表...")
async with db_manager.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("数据库迁移完成!")
return True
except Exception as e:
logger.error(f"数据库迁移失败: {e}")
return False
finally:
await db_manager.disconnect()
if __name__ == "__main__":
import sys
# 检查命令行参数
if len(sys.argv) > 1 and sys.argv[1] == '--force':
confirm = 'yes'
else:
print("=== 数据库迁移 ===")
print("警告:这将删除所有数据库表和数据!")
confirm = input("确认继续?(yes/no): ")
if confirm.lower() == 'yes':
asyncio.run(migrate_database())
else:
print("已取消迁移")
+11 -1
View File
@@ -1,5 +1,5 @@
# models/database.py
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint, Numeric
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint, Numeric, CheckConstraint
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
@@ -125,6 +125,8 @@ class STPFile(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
# 关联进销存成品(P2-1:分析结果可一键创建为成品并回写)
product_id = Column(Integer, ForeignKey("products.id"), nullable=True, index=True)
# 对象存储信息
object_key = Column(String(500), nullable=False, index=True) # MinIO对象键
@@ -160,6 +162,7 @@ class STPFile(Base):
# 关联关系
user = relationship("User", back_populates="stp_files")
product = relationship("Product") # P2-1: 关联的进销存成品
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
@@ -662,6 +665,7 @@ class Inventory(Base):
__tablename__ = "inventory"
__table_args__ = (
UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"),
CheckConstraint("quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity", name="ck_inventory_qty_nonnegative"),
)
id = Column(Integer, primary_key=True, index=True)
@@ -740,6 +744,9 @@ class PurchaseOrder(Base):
class PurchaseOrderItem(Base):
"""采购订单明细表"""
__tablename__ = "purchase_order_items"
__table_args__ = (
CheckConstraint("quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity", name="ck_purchase_order_items_qty"),
)
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
@@ -860,6 +867,9 @@ class AnalysisMetrics(Base):
class SalesOrderItem(Base):
"""销售订单明细表"""
__tablename__ = "sales_order_items"
__table_args__ = (
CheckConstraint("quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity", name="ck_sales_order_items_qty"),
)
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)