feat: add PostgreSQL database backend support

Add multi-database support allowing selection between SQL Server and
PostgreSQL via database.active config. Changes include dialect-aware
SQL generation, cross-database timestamp functions, PostgreSQL connection
URL builder, and psycopg dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-12 12:33:48 +08:00
parent 4b8c502a91
commit ee2b2f8f25
9 changed files with 100 additions and 11 deletions

4
.gitignore vendored
View File

@@ -10,4 +10,6 @@ build/
# Config with secrets # Config with secrets
config/settings.yaml config/settings.yaml
config/settings.local.yaml config/settings.local.yaml
.runtime

View File

@@ -22,14 +22,24 @@ Edit `config/settings.yaml` to configure your environment:
```yaml ```yaml
database: database:
active: sql_server # sql_server or postgresql
sql_server: sql_server:
host: your-host host: your-host
port: 1433 port: 1433
database: your-database database: your-database
username: your-username username: your-username
password: your-password password: your-password
postgresql:
host: your-postgres-host
port: 5432
database: your-database
username: your-username
password: your-password
``` ```
Set `database.active` to choose the database backend. Both backends expect the
same database name, schemas, and table structure.
### Local Overrides ### Local Overrides
For local development, create `config/settings.local.yaml` to override specific values without committing them. For local development, create `config/settings.local.yaml` to override specific values without committing them.

View File

@@ -20,7 +20,10 @@ class FinishedGoodsLocation(Base):
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False) zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
location_code: Mapped[str] = mapped_column(String(64), nullable=False) location_code: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate() DateTime,
nullable=False,
default=func.current_timestamp(),
server_default=func.current_timestamp(),
) )
@@ -36,7 +39,10 @@ class FinishedGoodsBox(Base):
paichan_no: Mapped[str] = mapped_column(String(64), nullable=False) paichan_no: Mapped[str] = mapped_column(String(64), nullable=False)
box_no: Mapped[int] = mapped_column(nullable=False) box_no: Mapped[int] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate() DateTime,
nullable=False,
default=func.current_timestamp(),
server_default=func.current_timestamp(),
) )
@@ -53,5 +59,8 @@ class FinishedGoodsBoxItem(Base):
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False) zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True) quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.getdate() DateTime,
nullable=False,
default=func.current_timestamp(),
server_default=func.current_timestamp(),
) )

View File

@@ -25,11 +25,7 @@ class DuplicateBoxItemError(Exception):
def query_erp_info(db: Session, zongpai_no: str) -> dict: def query_erp_info(db: Session, zongpai_no: str) -> dict:
"""从 ERP 视图查询总排号对应的排产号和数量。""" """从 ERP 视图查询总排号对应的排产号和数量。"""
sql = text( sql = text(_erp_info_sql(db))
"SELECT TOP 1 [总排号], [排产号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] = :zongpai_no"
)
row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone() row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone()
if not row: if not row:
raise ZongpaiNotFoundError() raise ZongpaiNotFoundError()
@@ -40,6 +36,22 @@ def query_erp_info(db: Session, zongpai_no: str) -> dict:
} }
def _erp_info_sql(db: Session) -> str:
dialect_name = db.bind.dialect.name if db.bind is not None else ""
if dialect_name == "postgresql":
return (
'SELECT "总排号", "排产号", "数量" '
'FROM "ERPAuto"."vw_productionContractData" '
'WHERE "总排号" = :zongpai_no '
"LIMIT 1"
)
return (
"SELECT TOP 1 [总排号], [排产号], [数量] "
"FROM [ERPAuto].[vw_productionContractData] "
"WHERE [总排号] = :zongpai_no"
)
def get_box_info(db: Session, zongpai_no: str) -> dict: def get_box_info(db: Session, zongpai_no: str) -> dict:
"""获取装箱信息:排产号、数量、已有箱号明细。""" """获取装箱信息:排产号、数量、已有箱号明细。"""
zongpai_no = zongpai_no.strip().upper() zongpai_no = zongpai_no.strip().upper()

View File

@@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Literal
from sqlalchemy.engine import URL from sqlalchemy.engine import URL
from pydantic import BaseModel, Field from pydantic import BaseModel
from pydantic_yaml import parse_yaml_raw_as from pydantic_yaml import parse_yaml_raw_as
@@ -16,9 +16,20 @@ class SqlServerConfig(BaseModel):
trust_server_certificate: str = "yes" trust_server_certificate: str = "yes"
class PostgreSqlConfig(BaseModel):
"""PostgreSQL 连接配置"""
host: str
port: int = 5432
database: str
username: str
password: str
class DatabaseConfig(BaseModel): class DatabaseConfig(BaseModel):
"""数据库配置""" """数据库配置"""
active: Literal["sql_server", "postgresql"] = "sql_server"
sql_server: SqlServerConfig sql_server: SqlServerConfig
postgresql: PostgreSqlConfig | None = None
class Settings(BaseModel): class Settings(BaseModel):
@@ -28,6 +39,11 @@ class Settings(BaseModel):
@property @property
def database_url(self) -> URL: def database_url(self) -> URL:
"""构建数据库连接 URL""" """构建数据库连接 URL"""
if self.database.active == "postgresql":
return self._postgresql_url()
return self._sql_server_url()
def _sql_server_url(self) -> URL:
conf = self.database.sql_server conf = self.database.sql_server
return URL.create( return URL.create(
"mssql+pyodbc", "mssql+pyodbc",
@@ -42,6 +58,19 @@ class Settings(BaseModel):
}, },
) )
def _postgresql_url(self) -> URL:
conf = self.database.postgresql
if conf is None:
raise ValueError("已选择 postgresql但未配置 database.postgresql")
return URL.create(
"postgresql+psycopg",
username=conf.username,
password=conf.password,
host=conf.host,
port=conf.port,
database=conf.database,
)
def load_settings(config_path: str = "config/settings.yaml") -> Settings: def load_settings(config_path: str = "config/settings.yaml") -> Settings:
"""加载 YAML 配置文件""" """加载 YAML 配置文件"""

View File

@@ -1,5 +1,6 @@
# 数据库配置 # 数据库配置
database: database:
active: sql_server # 可选: sql_server, postgresql
sql_server: sql_server:
host: YOUR_DB_HOST host: YOUR_DB_HOST
port: 1433 port: 1433
@@ -8,3 +9,9 @@ database:
password: YOUR_PASSWORD password: YOUR_PASSWORD
driver: "{ODBC Driver 18 for SQL Server}" driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes trust_server_certificate: yes
postgresql:
host: YOUR_POSTGRES_HOST
port: 5432
database: YOUR_POSTGRES_DB_NAME
username: YOUR_POSTGRES_USERNAME
password: YOUR_POSTGRES_PASSWORD

View File

@@ -2,6 +2,7 @@ fastapi>=0.115.0
uvicorn[standard]>=0.34.0 uvicorn[standard]>=0.34.0
sqlalchemy>=2.0.0 sqlalchemy>=2.0.0
pyodbc>=5.2.0 pyodbc>=5.2.0
psycopg[binary]>=3.2.0
pydantic-settings>=2.0.0 pydantic-settings>=2.0.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
pytest>=8.0.0 pytest>=8.0.0

View File

@@ -1,5 +1,6 @@
# 测试配置 # 测试配置
database: database:
active: sql_server
sql_server: sql_server:
host: localhost host: localhost
port: 1433 port: 1433
@@ -8,3 +9,9 @@ database:
password: test_pass password: test_pass
driver: "{ODBC Driver 18 for SQL Server}" driver: "{ODBC Driver 18 for SQL Server}"
trust_server_certificate: yes trust_server_certificate: yes
postgresql:
host: localhost
port: 5432
database: TestDB
username: test_user
password: test_pass

View File

@@ -5,6 +5,7 @@ from config.settings import load_settings
def test_load_settings_success(): def test_load_settings_success():
"""测试成功加载配置文件""" """测试成功加载配置文件"""
settings = load_settings("tests/fixtures/test_config.yaml") settings = load_settings("tests/fixtures/test_config.yaml")
assert settings.database.active == "sql_server"
assert settings.database.sql_server.port == 1433 assert settings.database.sql_server.port == 1433
assert settings.database.sql_server.host == "localhost" assert settings.database.sql_server.host == "localhost"
assert settings.database.sql_server.database == "TestDB" assert settings.database.sql_server.database == "TestDB"
@@ -23,3 +24,14 @@ def test_database_url_property():
assert "mssql+pyodbc" in str(url) assert "mssql+pyodbc" in str(url)
assert "test_user" in str(url) assert "test_user" in str(url)
assert "TestDB" in str(url) assert "TestDB" in str(url)
def test_postgresql_database_url_property():
"""测试 PostgreSQL database_url 属性生成"""
settings = load_settings("tests/fixtures/test_config.yaml")
settings.database.active = "postgresql"
url = settings.database_url
assert url.drivername == "postgresql+psycopg"
assert url.host == "localhost"
assert url.port == 5432
assert url.database == "TestDB"