v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件

- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件
- 移除误跟踪的 server/venv/、crm_data.db、.env 文件
- 新增 server/.env.example 模板
- 新增合同管理、利润核算、AI教练等功能模块
- 新增 Playwright e2e 测试套件
- 前后端多项功能升级和 bug 修复
This commit is contained in:
hankin
2026-05-11 07:24:19 +00:00
parent 0f4c6b7924
commit 815cbf9d8c
2526 changed files with 11875 additions and 804148 deletions
+8 -3
View File
@@ -25,7 +25,10 @@ config = context.config
db_url = os.getenv("DATABASE_URL", "")
# Alembic 需要同步驱动,将 asyncpg 替换为 psycopg2
sync_url = db_url.replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", sync_url)
# 宿主机执行 Alembic 时,host.docker.internal 不可达,替换为回环地址
sync_url = sync_url.replace("host.docker.internal", "127.0.0.1")
# configparser 把 % 当插值语法,需要转义为 %%
config.set_main_option("sqlalchemy.url", sync_url.replace("%", "%%"))
# 日志配置
if config.config_file_name is not None:
@@ -33,7 +36,7 @@ if config.config_file_name is not None:
# 导入所有模型,确保 Alembic 能检测到所有表
from app.models.base import Base
from app.models import crm, erp, order, shipping, finance, ai, sys as sys_models
from app.models import crm, erp, order, shipping, finance, ai, sys as sys_models, contract, cost
target_metadata = Base.metadata
@@ -61,8 +64,10 @@ async def run_async_migrations():
"""在线迁移(异步引擎)"""
from sqlalchemy.ext.asyncio import create_async_engine
# 宿主机执行 Alembic 时,host.docker.internal 不可达,替换为回环地址
async_url = os.getenv("DATABASE_URL", "").replace("host.docker.internal", "127.0.0.1")
connectable = create_async_engine(
os.getenv("DATABASE_URL", ""),
async_url,
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
@@ -0,0 +1,159 @@
"""multi-tenant company isolation
Revision ID: a1b2c3d4e5f6
Revises: 03d8dcc2d72a
Create Date: 2026-03-18 08:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = '03d8dcc2d72a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# 默认公司的固定 UUID
DEFAULT_COMPANY_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0001'
def upgrade() -> None:
# ═══════════════════════════════════════════════════════════════
# Step 1: 创建 sys_companies 公司主体表
# ═══════════════════════════════════════════════════════════════
op.create_table(
'sys_companies',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('name', sa.String(200), nullable=False),
sa.Column('code', sa.String(50), unique=True, nullable=False),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
)
# Step 2: 插入默认公司
op.execute(f"""
INSERT INTO sys_companies (id, name, code, is_active)
VALUES ('{DEFAULT_COMPANY_ID}', '天津硕博霖', 'SHBL-TJ', true)
""")
# ═══════════════════════════════════════════════════════════════
# Step 3: 创建 sys_user_companies 用户-公司关联表
# ═══════════════════════════════════════════════════════════════
op.create_table(
'sys_user_companies',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sys_users.id'), nullable=False),
sa.Column('company_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sys_companies.id'), nullable=False),
sa.Column('is_default', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.UniqueConstraint('user_id', 'company_id', name='uq_user_company'),
)
# Step 4: 为所有现有用户关联默认公司
op.execute(f"""
INSERT INTO sys_user_companies (id, user_id, company_id, is_default)
SELECT gen_random_uuid(), id, '{DEFAULT_COMPANY_ID}'::uuid, true
FROM sys_users
WHERE is_deleted = false
""")
# ═══════════════════════════════════════════════════════════════
# Step 5: 创建 erp_sku_inventory 分公司库存表
# ═══════════════════════════════════════════════════════════════
op.create_table(
'erp_sku_inventory',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('sku_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('erp_product_skus.id'), nullable=False),
sa.Column('company_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('sys_companies.id'), nullable=False),
sa.Column('stock_qty', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False),
sa.Column('warning_threshold', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.UniqueConstraint('sku_id', 'company_id', name='uq_sku_company'),
)
op.create_index('ix_erp_sku_inventory_company_id', 'erp_sku_inventory', ['company_id'])
# Step 6: 迁移 erp_product_skus 的库存数据到 erp_sku_inventory
op.execute(f"""
INSERT INTO erp_sku_inventory (id, sku_id, company_id, stock_qty, warning_threshold)
SELECT gen_random_uuid(), id, '{DEFAULT_COMPANY_ID}'::uuid, stock_qty, warning_threshold
FROM erp_product_skus
WHERE is_deleted = false
""")
# ═══════════════════════════════════════════════════════════════
# Step 7: 为业务表追加 company_id 列(先 nullable → 填数据 → set NOT NULL
# ═══════════════════════════════════════════════════════════════
tables_with_company_id = [
'erp_orders',
'erp_inventory_flows',
'erp_shipping_records',
'fin_invoice_pool',
'fin_expense_records',
'finance_sales_invoices',
'sales_logs',
]
for table in tables_with_company_id:
# 添加列(先允许 NULL
op.add_column(table, sa.Column('company_id', postgresql.UUID(as_uuid=True), nullable=True))
# 填入默认公司 ID
op.execute(f"""
UPDATE {table} SET company_id = '{DEFAULT_COMPANY_ID}'::uuid WHERE company_id IS NULL
""")
# 设 NOT NULL
op.alter_column(table, 'company_id', nullable=False)
# 创建外键
op.create_foreign_key(
f'fk_{table}_company_id',
table, 'sys_companies',
['company_id'], ['id'],
)
# 创建索引
op.create_index(f'ix_{table}_company_id', table, ['company_id'])
# ═══════════════════════════════════════════════════════════════
# Step 8: 从 erp_product_skus 删除已迁移的库存字段
# ═══════════════════════════════════════════════════════════════
op.drop_column('erp_product_skus', 'stock_qty')
op.drop_column('erp_product_skus', 'warning_threshold')
def downgrade() -> None:
# 恢复 erp_product_skus 的库存字段
op.add_column('erp_product_skus', sa.Column('stock_qty', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False))
op.add_column('erp_product_skus', sa.Column('warning_threshold', sa.Numeric(12, 2), server_default=sa.text('0'), nullable=False))
# 从 erp_sku_inventory 回迁默认公司的库存数据
op.execute(f"""
UPDATE erp_product_skus SET
stock_qty = inv.stock_qty,
warning_threshold = inv.warning_threshold
FROM erp_sku_inventory inv
WHERE erp_product_skus.id = inv.sku_id AND inv.company_id = '{DEFAULT_COMPANY_ID}'::uuid
""")
# 删除 company_id 列
tables_with_company_id = [
'erp_orders', 'erp_inventory_flows', 'erp_shipping_records',
'fin_invoice_pool', 'fin_expense_records', 'finance_sales_invoices', 'sales_logs',
]
for table in tables_with_company_id:
op.drop_index(f'ix_{table}_company_id', table_name=table)
op.drop_constraint(f'fk_{table}_company_id', table, type_='foreignkey')
op.drop_column(table, 'company_id')
# 删除新建的表
op.drop_index('ix_erp_sku_inventory_company_id', table_name='erp_sku_inventory')
op.drop_table('erp_sku_inventory')
op.drop_table('sys_user_companies')
op.drop_table('sys_companies')
@@ -0,0 +1,43 @@
"""add xinyu lubricant company
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-03-19
"""
from alembic import op
revision = "b2c3d4e5f6a7"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
XINYU_COMPANY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0002"
def upgrade() -> None:
# 1. 插入第二个公司:新宇润滑油
op.execute(f"""
INSERT INTO sys_companies (id, name, code, is_active)
VALUES ('{XINYU_COMPANY_ID}', '新宇润滑油', 'XY-LUB', true)
ON CONFLICT (id) DO NOTHING
""")
# 2. 将所有现有用户关联到新宇润滑油(非默认)
op.execute(f"""
INSERT INTO sys_user_companies (id, user_id, company_id, is_default)
SELECT gen_random_uuid(), id, '{XINYU_COMPANY_ID}'::uuid, false
FROM sys_users
WHERE id NOT IN (
SELECT user_id FROM sys_user_companies
WHERE company_id = '{XINYU_COMPANY_ID}'::uuid
)
""")
def downgrade() -> None:
op.execute(f"""
DELETE FROM sys_user_companies WHERE company_id = '{XINYU_COMPANY_ID}'::uuid
""")
op.execute(f"""
DELETE FROM sys_companies WHERE id = '{XINYU_COMPANY_ID}'::uuid
""")
@@ -0,0 +1,82 @@
"""sales_logs company_id to involved_company_ids
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-03-19
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, ARRAY
revision = "c3d4e5f6a7b8"
down_revision = "b2c3d4e5f6a7"
branch_labels = None
depends_on = None
DEFAULT_COMPANY_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeee0001"
def upgrade() -> None:
# 1. 添加新列 involved_company_ids (ARRAY UUID)
op.add_column(
"sales_logs",
sa.Column("involved_company_ids", ARRAY(UUID(as_uuid=True)), nullable=True)
)
# 2. 数据迁移:将现有 company_id 转为数组
op.execute("""
UPDATE sales_logs
SET involved_company_ids = ARRAY[company_id]
WHERE company_id IS NOT NULL
""")
# 3. 没有 company_id 的行(不太可能但防御性处理)
op.execute(f"""
UPDATE sales_logs
SET involved_company_ids = ARRAY['{DEFAULT_COMPANY_ID}'::uuid]
WHERE involved_company_ids IS NULL
""")
# 4. 设置 NOT NULL
op.alter_column("sales_logs", "involved_company_ids", nullable=False)
# 5. 删除旧的 company_id 列及其外键
op.drop_constraint(
"fk_sales_logs_company_id", "sales_logs", type_="foreignkey"
)
op.drop_index("ix_sales_logs_company_id", table_name="sales_logs", if_exists=True)
op.drop_column("sales_logs", "company_id")
# 6. 为 involved_company_ids 创建 GIN 索引(支持 ANY/contains 查询)
op.create_index(
"ix_sales_logs_involved_company_ids",
"sales_logs",
["involved_company_ids"],
postgresql_using="gin"
)
def downgrade() -> None:
# 回滚:重新添加 company_id,从数组取第一个元素
op.drop_index("ix_sales_logs_involved_company_ids", table_name="sales_logs")
op.add_column(
"sales_logs",
sa.Column("company_id", UUID(as_uuid=True), nullable=True)
)
op.execute("""
UPDATE sales_logs
SET company_id = involved_company_ids[1]
WHERE array_length(involved_company_ids, 1) > 0
""")
op.alter_column("sales_logs", "company_id", nullable=False)
op.create_foreign_key(
"sales_logs_company_id_fkey",
"sales_logs", "sys_companies",
["company_id"], ["id"]
)
op.create_index("ix_sales_logs_company_id", "sales_logs", ["company_id"])
op.drop_column("sales_logs", "involved_company_ids")
@@ -0,0 +1,30 @@
"""add billing_info to crm_customers
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "d4e5f6a7b8c9"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"crm_customers",
sa.Column(
"billing_info",
JSONB,
nullable=True,
comment="客户开票信息: company_name/tax_id/address/phone/bank_name/bank_account",
),
)
def downgrade() -> None:
op.drop_column("crm_customers", "billing_info")
@@ -0,0 +1,113 @@
"""Phase B: contract management + order/invoice linkage
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "e5f6a7b8c9d0"
down_revision = "d4e5f6a7b8c9"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── 1. sys_companies 新增 full_info ──
op.add_column(
"sys_companies",
sa.Column("full_info", JSONB, nullable=True,
comment="公司完整信息: full_name/address/phone/bank_name/bank_account/tax_id"),
)
# ── 2. erp_contracts 主表 ──
op.create_table(
"erp_contracts",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("contract_no", sa.String(30), unique=True, nullable=False),
sa.Column("buyer_customer_id", UUID(as_uuid=True), sa.ForeignKey("crm_customers.id"), nullable=False),
sa.Column("seller_company_id", UUID(as_uuid=True), sa.ForeignKey("sys_companies.id"), nullable=False),
sa.Column("company_id", UUID(as_uuid=True), sa.ForeignKey("sys_companies.id"), nullable=False, index=True),
sa.Column("total_amount_excl_tax", sa.Numeric(14, 2), default=0),
sa.Column("total_amount_incl_tax", sa.Numeric(14, 2), default=0),
sa.Column("total_amount_cn", sa.String(100), nullable=True),
sa.Column("payment_terms", sa.String(50), nullable=False, server_default="货到付全款"),
sa.Column("shipping_terms", sa.String(50), nullable=False, server_default="买方自提"),
sa.Column("status", sa.String(20), nullable=False, server_default="draft"),
sa.Column("is_signed", sa.Boolean, default=False, server_default="false"),
sa.Column("signed_file_url", sa.String(500), nullable=True),
sa.Column("linked_order_id", UUID(as_uuid=True), sa.ForeignKey("erp_orders.id"), nullable=True),
sa.Column("salesperson_id", UUID(as_uuid=True), sa.ForeignKey("sys_users.id"), nullable=True),
sa.Column("sign_date", sa.Date, nullable=True),
sa.Column("remark", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("is_deleted", sa.Boolean, default=False, server_default="false"),
)
# ── 3. erp_contract_items 明细行 ──
op.create_table(
"erp_contract_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("contract_id", UUID(as_uuid=True), sa.ForeignKey("erp_contracts.id"), nullable=False),
sa.Column("sku_id", UUID(as_uuid=True), sa.ForeignKey("erp_product_skus.id"), nullable=False),
sa.Column("qty", sa.Numeric(12, 2), nullable=False),
sa.Column("unit_price", sa.Numeric(12, 2), nullable=False),
sa.Column("sub_total", sa.Numeric(14, 2), nullable=False),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("is_deleted", sa.Boolean, default=False, server_default="false"),
)
# ── 4. erp_contract_attachments 附件 ──
op.create_table(
"erp_contract_attachments",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("contract_id", UUID(as_uuid=True), sa.ForeignKey("erp_contracts.id"), nullable=False),
sa.Column("file_name", sa.String(200), nullable=False),
sa.Column("file_url", sa.String(500), nullable=False),
sa.Column("file_type", sa.String(30), nullable=False, server_default="signed_copy"),
sa.Column("uploader_id", UUID(as_uuid=True), sa.ForeignKey("sys_users.id"), nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("is_deleted", sa.Boolean, default=False, server_default="false"),
)
# ── 5. erp_orders 新增 contract_id ──
op.add_column(
"erp_orders",
sa.Column("contract_id", UUID(as_uuid=True),
sa.ForeignKey("erp_contracts.id"), nullable=True,
comment="来源合同(一键推单后回填)"),
)
# ── 6. finance_sales_invoices 新增 order_id / shipping_record_id / payment_due_date ──
op.add_column(
"finance_sales_invoices",
sa.Column("order_id", UUID(as_uuid=True),
sa.ForeignKey("erp_orders.id"), nullable=True,
comment="关联订单"),
)
op.add_column(
"finance_sales_invoices",
sa.Column("shipping_record_id", UUID(as_uuid=True),
sa.ForeignKey("erp_shipping_records.id"), nullable=True,
comment="关联发货单"),
)
op.add_column(
"finance_sales_invoices",
sa.Column("payment_due_date", sa.Date, nullable=True,
comment="回款截止日(根据合同付款条件自动推算)"),
)
def downgrade() -> None:
op.drop_column("finance_sales_invoices", "payment_due_date")
op.drop_column("finance_sales_invoices", "shipping_record_id")
op.drop_column("finance_sales_invoices", "order_id")
op.drop_column("erp_orders", "contract_id")
op.drop_table("erp_contract_attachments")
op.drop_table("erp_contract_items")
op.drop_table("erp_contracts")
op.drop_column("sys_companies", "full_info")
@@ -0,0 +1,57 @@
"""Phase C: MWA inventory + profit accounting
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-03-27
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "f6a7b8c9d0e1"
down_revision = "e5f6a7b8c9d0"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── 1. erp_sku_inventory 新增 mwa_unit_cost ──
op.add_column(
"erp_sku_inventory",
sa.Column("mwa_unit_cost", sa.Numeric(12, 4), server_default="0",
comment="移动加权均价 (Moving Weighted Average)"),
)
# ── 2. erp_inventory_flows 新增 purchase_unit_price + is_special_zero_cost ──
op.add_column(
"erp_inventory_flows",
sa.Column("purchase_unit_price", sa.Numeric(12, 2), server_default="0",
comment="入库采购单价"),
)
op.add_column(
"erp_inventory_flows",
sa.Column("is_special_zero_cost", sa.Boolean, server_default="false",
comment="特殊零元入库标识,不参与 MWA 计算"),
)
# ── 3. erp_order_item_costs 新表 ──
op.create_table(
"erp_order_item_costs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("order_item_id", UUID(as_uuid=True),
sa.ForeignKey("erp_order_items.id"), nullable=False, unique=True),
sa.Column("purchase_unit_price", sa.Numeric(12, 4), nullable=False,
comment="MWA 成本快照"),
sa.Column("profit_amount", sa.Numeric(14, 2), server_default="0",
comment="利润额 = (售价-成本)*数量"),
sa.Column("profit_rate", sa.Numeric(5, 4), server_default="0",
comment="利润率"),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("erp_order_item_costs")
op.drop_column("erp_inventory_flows", "is_special_zero_cost")
op.drop_column("erp_inventory_flows", "purchase_unit_price")
op.drop_column("erp_sku_inventory", "mwa_unit_cost")
@@ -0,0 +1,49 @@
"""Phase D: AI coaching engine (JSONB fields only, pgvector table deferred)
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
Create Date: 2026-03-27
Note: kb_obsidian_vectors (pgvector) 表需要先安装 postgresql-16-pgvector 包,
安装后手动执行:
CREATE EXTENSION IF NOT EXISTS vector;
然后运行: alembic upgrade pgvector_head
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "a7b8c9d0e1f2"
down_revision = "f6a7b8c9d0e1"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── 1. sales_logs 新增 ai_coaching_feedback ──
op.add_column(
"sales_logs",
sa.Column("ai_coaching_feedback", JSONB, nullable=True,
comment="AI 教练引擎回写的指导反馈"),
)
# ── 2. crm_customers 新增 health_score / meddic_status ──
op.add_column(
"crm_customers",
sa.Column("health_score", sa.Numeric(5, 2), server_default="0",
comment="客户健康度评分 (AI 教练引擎计算)"),
)
op.add_column(
"crm_customers",
sa.Column("meddic_status", JSONB, nullable=True,
comment="MEDDIC 六维评估状态"),
)
# ── 3. kb_obsidian_vectors 表暂不在此迁移创建 ──
# 需先安装 pgvector 扩展,见单独迁移脚本
def downgrade() -> None:
op.drop_column("crm_customers", "meddic_status")
op.drop_column("crm_customers", "health_score")
op.drop_column("sales_logs", "ai_coaching_feedback")
@@ -0,0 +1,43 @@
"""Phase D addon: pgvector kb_obsidian_vectors table
Revision ID: b8c9d0e1f2a3
Revises: a7b8c9d0e1f2
Create Date: 2026-03-27
Prerequisites: sudo apt-get install postgresql-16-pgvector
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "b8c9d0e1f2a3"
down_revision = "a7b8c9d0e1f2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
"kb_obsidian_vectors",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("company_id", UUID(as_uuid=True), sa.ForeignKey("sys_companies.id"), nullable=False, index=True),
sa.Column("source_path", sa.String(500), nullable=False, comment="源文件路径"),
sa.Column("chunk_index", sa.SmallInteger, server_default="0"),
sa.Column("content", sa.Text, nullable=False),
sa.Column("metadata", JSONB, nullable=True),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("is_deleted", sa.Boolean, server_default="false"),
)
op.execute("ALTER TABLE kb_obsidian_vectors ADD COLUMN embedding vector(1536)")
op.execute("""
CREATE INDEX ix_kb_obsidian_vectors_embedding
ON kb_obsidian_vectors
USING hnsw (embedding vector_cosine_ops)
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_kb_obsidian_vectors_embedding")
op.drop_table("kb_obsidian_vectors")
op.execute("DROP EXTENSION IF EXISTS vector")