From 00ca81287e6ace907f152ae0049c596de6c820fb Mon Sep 17 00:00:00 2001 From: chenjw28 <792430652@qq.com> Date: Mon, 20 Jul 2026 11:04:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=20=E5=BC=95=E5=85=A5=20Alembic=20?= =?UTF-8?q?=E5=8F=96=E4=BB=A3=E8=A3=B8=20DDL=20=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - alembic init + env.py 接 settings/models metadata(asyncpg->psycopg2 同步 URL) - 补 3 个 CheckConstraint 到 models(原只在裸 DDL) - 离线生成初始迁移(31 表+约束+95 索引,全 sa.* 通用类型) - init_db 用 _run_alembic_migrations(自动基线+upgrade head)替换 create_tables+ensure_schema_updates(删 92 行裸 DDL) - 删破坏性 migrate_db.py(drop_all) - 既有 DB 首次启动自动 stamp 基线,无需手动 Co-Authored-By: Claude --- alembic.ini | 149 ++++ alembic/README | 1 + alembic/env.py | 73 ++ alembic/script.py.mako | 28 + .../versions/9928d7f8c1ef_initial_schema.py | 728 ++++++++++++++++++ src/shared/database/init_db.py | 130 +--- src/shared/database/migrate_db.py | 55 -- src/shared/models/database.py | 9 +- 8 files changed, 1023 insertions(+), 150 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/9928d7f8c1ef_initial_schema.py delete mode 100644 src/shared/database/migrate_db.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..80f91a1 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/9928d7f8c1ef_initial_schema.py b/alembic/versions/9928d7f8c1ef_initial_schema.py new file mode 100644 index 0000000..50f7f51 --- /dev/null +++ b/alembic/versions/9928d7f8c1ef_initial_schema.py @@ -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 ### diff --git a/src/shared/database/init_db.py b/src/shared/database/init_db.py index b25e6e3..c24e65b 100644 --- a/src/shared/database/init_db.py +++ b/src/shared/database/init_db.py @@ -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)) diff --git a/src/shared/database/migrate_db.py b/src/shared/database/migrate_db.py deleted file mode 100644 index e916a4c..0000000 --- a/src/shared/database/migrate_db.py +++ /dev/null @@ -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("已取消迁移") - - - - diff --git a/src/shared/models/database.py b/src/shared/models/database.py index b5bc880..c7120d5 100644 --- a/src/shared/models/database.py +++ b/src/shared/models/database.py @@ -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 @@ -662,6 +662,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 +741,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 +864,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)