Compare commits
4 Commits
f3d2951dc3
...
cf6d708566
| Author | SHA1 | Date | |
|---|---|---|---|
| cf6d708566 | |||
| a8c0e1af3d | |||
| 00ca81287e | |||
| 66ba6e00f5 |
+149
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 辅助")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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("已取消迁移")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user