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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ build/
|
||||
# Config with secrets
|
||||
config/settings.yaml
|
||||
config/settings.local.yaml
|
||||
|
||||
.runtime
|
||||
10
README.md
10
README.md
@@ -22,14 +22,24 @@ Edit `config/settings.yaml` to configure your environment:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
active: sql_server # sql_server or postgresql
|
||||
sql_server:
|
||||
host: your-host
|
||||
port: 1433
|
||||
database: your-database
|
||||
username: your-username
|
||||
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
|
||||
|
||||
For local development, create `config/settings.local.yaml` to override specific values without committing them.
|
||||
|
||||
@@ -20,7 +20,10 @@ class FinishedGoodsLocation(Base):
|
||||
zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
location_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
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)
|
||||
box_no: Mapped[int] = mapped_column(nullable=False)
|
||||
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)
|
||||
quantity: Mapped[float | None] = mapped_column(Numeric(18, 3), nullable=True)
|
||||
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(),
|
||||
)
|
||||
|
||||
@@ -25,11 +25,7 @@ class DuplicateBoxItemError(Exception):
|
||||
|
||||
def query_erp_info(db: Session, zongpai_no: str) -> dict:
|
||||
"""从 ERP 视图查询总排号对应的排产号和数量。"""
|
||||
sql = text(
|
||||
"SELECT TOP 1 [总排号], [排产号], [数量] "
|
||||
"FROM [ERPAuto].[vw_productionContractData] "
|
||||
"WHERE [总排号] = :zongpai_no"
|
||||
)
|
||||
sql = text(_erp_info_sql(db))
|
||||
row = db.execute(sql, {"zongpai_no": zongpai_no}).fetchone()
|
||||
if not row:
|
||||
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:
|
||||
"""获取装箱信息:排产号、数量、已有箱号明细。"""
|
||||
zongpai_no = zongpai_no.strip().upper()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Literal
|
||||
from sqlalchemy.engine import URL
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from pydantic_yaml import parse_yaml_raw_as
|
||||
|
||||
|
||||
@@ -16,9 +16,20 @@ class SqlServerConfig(BaseModel):
|
||||
trust_server_certificate: str = "yes"
|
||||
|
||||
|
||||
class PostgreSqlConfig(BaseModel):
|
||||
"""PostgreSQL 连接配置"""
|
||||
host: str
|
||||
port: int = 5432
|
||||
database: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class DatabaseConfig(BaseModel):
|
||||
"""数据库配置"""
|
||||
active: Literal["sql_server", "postgresql"] = "sql_server"
|
||||
sql_server: SqlServerConfig
|
||||
postgresql: PostgreSqlConfig | None = None
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
@@ -28,6 +39,11 @@ class Settings(BaseModel):
|
||||
@property
|
||||
def database_url(self) -> 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
|
||||
return URL.create(
|
||||
"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:
|
||||
"""加载 YAML 配置文件"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# 数据库配置
|
||||
database:
|
||||
active: sql_server # 可选: sql_server, postgresql
|
||||
sql_server:
|
||||
host: YOUR_DB_HOST
|
||||
port: 1433
|
||||
@@ -8,3 +9,9 @@ database:
|
||||
password: YOUR_PASSWORD
|
||||
driver: "{ODBC Driver 18 for SQL Server}"
|
||||
trust_server_certificate: yes
|
||||
postgresql:
|
||||
host: YOUR_POSTGRES_HOST
|
||||
port: 5432
|
||||
database: YOUR_POSTGRES_DB_NAME
|
||||
username: YOUR_POSTGRES_USERNAME
|
||||
password: YOUR_POSTGRES_PASSWORD
|
||||
|
||||
@@ -2,6 +2,7 @@ fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
sqlalchemy>=2.0.0
|
||||
pyodbc>=5.2.0
|
||||
psycopg[binary]>=3.2.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
pytest>=8.0.0
|
||||
|
||||
7
tests/fixtures/test_config.yaml
vendored
7
tests/fixtures/test_config.yaml
vendored
@@ -1,5 +1,6 @@
|
||||
# 测试配置
|
||||
database:
|
||||
active: sql_server
|
||||
sql_server:
|
||||
host: localhost
|
||||
port: 1433
|
||||
@@ -8,3 +9,9 @@ database:
|
||||
password: test_pass
|
||||
driver: "{ODBC Driver 18 for SQL Server}"
|
||||
trust_server_certificate: yes
|
||||
postgresql:
|
||||
host: localhost
|
||||
port: 5432
|
||||
database: TestDB
|
||||
username: test_user
|
||||
password: test_pass
|
||||
|
||||
@@ -5,6 +5,7 @@ from config.settings import load_settings
|
||||
def test_load_settings_success():
|
||||
"""测试成功加载配置文件"""
|
||||
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.host == "localhost"
|
||||
assert settings.database.sql_server.database == "TestDB"
|
||||
@@ -23,3 +24,14 @@ def test_database_url_property():
|
||||
assert "mssql+pyodbc" in str(url)
|
||||
assert "test_user" 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"
|
||||
|
||||
Reference in New Issue
Block a user