Upload main.py
Browse files
main.py
CHANGED
|
@@ -12,10 +12,11 @@ import pathlib
|
|
| 12 |
from fastapi import FastAPI, Depends, HTTPException, Header, Query
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from fastapi.openapi.utils import get_openapi
|
|
|
|
| 15 |
from fastapi.staticfiles import StaticFiles
|
| 16 |
-
from
|
| 17 |
-
from pydantic import BaseModel
|
| 18 |
from sqlmodel import Field as SQLField, Session, SQLModel, create_engine, select, Relationship
|
|
|
|
| 19 |
|
| 20 |
# --------------------------
|
| 21 |
# Auth
|
|
@@ -121,7 +122,6 @@ class Invoice(SQLModel, table=True):
|
|
| 121 |
due_date: Optional[date] = None
|
| 122 |
notes: Optional[str] = None
|
| 123 |
|
| 124 |
-
# 入金関連(任意)
|
| 125 |
paid_at: Optional[datetime] = None
|
| 126 |
paid_amount: Optional[float] = None
|
| 127 |
payment_method: Optional[str] = None
|
|
@@ -158,7 +158,7 @@ class CreateInvoiceIn(BaseModel):
|
|
| 158 |
customer_id: int
|
| 159 |
due_date: Optional[date] = None
|
| 160 |
notes: Optional[str] = None
|
| 161 |
-
quote_id: Optional[int] = None
|
| 162 |
|
| 163 |
class InvoiceItemIn(BaseModel):
|
| 164 |
description: str
|
|
@@ -177,8 +177,42 @@ class EmailIn(BaseModel):
|
|
| 177 |
body: str
|
| 178 |
attach_pdf: bool = True
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
# --------------------------
|
| 181 |
-
# Totals
|
| 182 |
# --------------------------
|
| 183 |
class MoneyBreakdown(BaseModel):
|
| 184 |
subtotal: float
|
|
@@ -215,7 +249,7 @@ app.add_middleware(
|
|
| 215 |
allow_methods=["*"], allow_headers=["*"],
|
| 216 |
)
|
| 217 |
|
| 218 |
-
# Swagger の Authorize(X-API-Key)
|
| 219 |
def custom_openapi():
|
| 220 |
if app.openapi_schema:
|
| 221 |
return app.openapi_schema
|
|
@@ -244,20 +278,7 @@ def on_startup():
|
|
| 244 |
SQLModel.metadata.create_all(engine)
|
| 245 |
|
| 246 |
# ---- UI(/app) ----
|
| 247 |
-
|
| 248 |
-
if os.path.isdir(STATIC_DIR):
|
| 249 |
-
app.mount("/app", StaticFiles(directory=STATIC_DIR, html=True), name="app")
|
| 250 |
-
else:
|
| 251 |
-
# フォールバック(staticが無い場合でも起動できるようにする)
|
| 252 |
-
@app.get("/app", response_class=HTMLResponse)
|
| 253 |
-
def app_inline():
|
| 254 |
-
return """
|
| 255 |
-
<!doctype html><meta charset="utf-8">
|
| 256 |
-
<title>Mini Invoice/Estimate SaaS</title>
|
| 257 |
-
<h1>Mini Invoice/Estimate SaaS</h1>
|
| 258 |
-
<p><code>static/app.html</code> が見つかりません。Filesで作成してから再読み込みしてください。</p>
|
| 259 |
-
<p>APIは <a href="/docs">/docs</a> から操作できます。</p>
|
| 260 |
-
"""
|
| 261 |
|
| 262 |
# -------- Customers --------
|
| 263 |
@app.post("/customers", dependencies=[Depends(require_api_key)])
|
|
@@ -265,6 +286,7 @@ def create_customer(payload: Customer, session: Session = Depends(get_session)):
|
|
| 265 |
session.add(payload); session.commit(); session.refresh(payload)
|
| 266 |
return payload
|
| 267 |
|
|
|
|
| 268 |
@app.get("/customers", dependencies=[Depends(require_api_key)])
|
| 269 |
def list_customers(
|
| 270 |
q: Optional[str] = Query(default=None, description="free text search (name/email/phone)"),
|
|
@@ -275,13 +297,13 @@ def list_customers(
|
|
| 275 |
stmt = select(Customer)
|
| 276 |
if q:
|
| 277 |
like = f"%{q}%"
|
| 278 |
-
# SQLite は ILIKE 非対応なので LIKE で十分(大文字小文字は基本無視コラテラル)
|
| 279 |
stmt = stmt.where(
|
| 280 |
-
(Customer.name.
|
| 281 |
-
(Customer.email.
|
| 282 |
-
(Customer.phone.
|
| 283 |
)
|
| 284 |
-
|
|
|
|
| 285 |
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
| 286 |
return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
|
| 287 |
|
|
@@ -291,16 +313,17 @@ def create_product(payload: Product, session: Session = Depends(get_session)):
|
|
| 291 |
session.add(payload); session.commit(); session.refresh(payload)
|
| 292 |
return payload
|
| 293 |
|
|
|
|
| 294 |
@app.get("/products", dependencies=[Depends(require_api_key)])
|
| 295 |
def list_products(
|
| 296 |
limit: int = Query(50, ge=1, le=200),
|
| 297 |
offset: int = Query(0, ge=0),
|
| 298 |
session: Session = Depends(get_session),
|
| 299 |
):
|
| 300 |
-
|
| 301 |
-
total = session.exec(select(
|
|
|
|
| 302 |
return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
|
| 303 |
-
|
| 304 |
# -------- Quotes --------
|
| 305 |
@app.post("/quotes", dependencies=[Depends(require_api_key)])
|
| 306 |
def create_quote(payload: CreateQuoteIn, session: Session = Depends(get_session)):
|
|
@@ -342,21 +365,21 @@ def create_invoice(payload: CreateInvoiceIn, session: Session = Depends(get_sess
|
|
| 342 |
inv = Invoice(customer_id=payload.customer_id, due_date=payload.due_date, notes=payload.notes)
|
| 343 |
session.add(inv); session.commit(); session.refresh(inv)
|
| 344 |
|
| 345 |
-
# 見積→請求のコピー
|
| 346 |
if payload.quote_id:
|
| 347 |
q = session.get(Quote, payload.quote_id)
|
| 348 |
if not q:
|
| 349 |
raise HTTPException(404, "Quote not found to copy")
|
| 350 |
q_items = session.exec(select(QuoteItem).where(QuoteItem.quote_id == payload.quote_id)).all()
|
| 351 |
for it in q_items:
|
| 352 |
-
|
| 353 |
invoice_id=inv.id,
|
| 354 |
product_id=it.product_id,
|
| 355 |
description=it.description,
|
| 356 |
quantity=it.quantity,
|
| 357 |
unit_price=it.unit_price,
|
| 358 |
tax_rate=it.tax_rate,
|
| 359 |
-
)
|
|
|
|
| 360 |
session.commit()
|
| 361 |
|
| 362 |
session.refresh(inv)
|
|
@@ -438,68 +461,21 @@ def email_invoice(invoice_id: int, payload: EmailIn, session: Session = Depends(
|
|
| 438 |
)
|
| 439 |
return {"ok": ok, "detail": detail}
|
| 440 |
|
| 441 |
-
# ----
|
| 442 |
-
@app.
|
| 443 |
-
def root_redirect():
|
| 444 |
-
return RedirectResponse("/app")
|
| 445 |
-
|
| 446 |
-
# -------- ChatGPT router を最後に組み込む --------
|
| 447 |
-
from openai_integration import router as ai_router
|
| 448 |
-
app.include_router(ai_router, prefix="/ai", tags=["ai"])
|
| 449 |
-
|
| 450 |
-
# ===== ここから追記(main.py の import 群より下ならどこでもOK) =====
|
| 451 |
-
from pydantic import Field
|
| 452 |
-
|
| 453 |
-
class WizardItemIn(BaseModel):
|
| 454 |
-
description: str
|
| 455 |
-
quantity: float = Field(gt=0, default=1)
|
| 456 |
-
unit_price: float = Field(ge=0)
|
| 457 |
-
tax_rate: float = Field(ge=0, default=0.0)
|
| 458 |
-
|
| 459 |
-
class WizardCustomerIn(BaseModel):
|
| 460 |
-
# 既存顧客を使うなら id を、無ければ name 等で新規作成
|
| 461 |
-
id: int | None = None
|
| 462 |
-
name: str | None = None
|
| 463 |
-
email: str | None = None
|
| 464 |
-
phone: str | None = None
|
| 465 |
-
address: str | None = None
|
| 466 |
-
city: str | None = None
|
| 467 |
-
country: str | None = None
|
| 468 |
-
|
| 469 |
-
class WizardInvoiceIn(BaseModel):
|
| 470 |
-
customer: WizardCustomerIn
|
| 471 |
-
due_date: date | None = None
|
| 472 |
-
notes: str | None = None
|
| 473 |
-
items: list[WizardItemIn]
|
| 474 |
-
|
| 475 |
-
class WizardInvoiceOut(BaseModel):
|
| 476 |
-
customer_id: int
|
| 477 |
-
invoice_id: int
|
| 478 |
-
totals: MoneyBreakdown
|
| 479 |
-
|
| 480 |
-
@app.post("/wizard/invoice", dependencies=[Depends(require_api_key)], response_model=WizardInvoiceOut)
|
| 481 |
def wizard_create_invoice(payload: WizardInvoiceIn, session: Session = Depends(get_session)):
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
cust = session.get(Customer, cust_id)
|
| 490 |
-
if not cust:
|
| 491 |
-
raise HTTPException(404, "Customer id not found")
|
| 492 |
-
else:
|
| 493 |
-
if not payload.customer.name:
|
| 494 |
-
raise HTTPException(422, "Either customer.id or customer.name is required")
|
| 495 |
-
# 既存に同名があればそれを使い、無ければ作成(簡易重複回避)
|
| 496 |
-
existing = session.exec(
|
| 497 |
-
select(Customer).where(Customer.name == payload.customer.name)
|
| 498 |
-
).first()
|
| 499 |
-
if existing:
|
| 500 |
-
cust = existing
|
| 501 |
else:
|
| 502 |
-
|
|
|
|
|
|
|
|
|
|
| 503 |
name=payload.customer.name.strip(),
|
| 504 |
email=(payload.customer.email or None),
|
| 505 |
phone=(payload.customer.phone or None),
|
|
@@ -507,30 +483,38 @@ def wizard_create_invoice(payload: WizardInvoiceIn, session: Session = Depends(g
|
|
| 507 |
city=(payload.customer.city or None),
|
| 508 |
country=(payload.customer.country or None),
|
| 509 |
)
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
session.refresh(cust)
|
| 513 |
cust_id = cust.id
|
| 514 |
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
session.commit()
|
| 519 |
-
session.refresh(inv)
|
| 520 |
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
items = session.exec(select(InvoiceItem).where(InvoiceItem.invoice_id == inv.id)).all()
|
| 533 |
-
totals = compute_totals(items)
|
| 534 |
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from fastapi import FastAPI, Depends, HTTPException, Header, Query
|
| 13 |
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
from fastapi.openapi.utils import get_openapi
|
| 15 |
+
from fastapi.responses import FileResponse
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
+
from pydantic import BaseModel, Field, model_validator
|
|
|
|
| 18 |
from sqlmodel import Field as SQLField, Session, SQLModel, create_engine, select, Relationship
|
| 19 |
+
from sqlachemy import func
|
| 20 |
|
| 21 |
# --------------------------
|
| 22 |
# Auth
|
|
|
|
| 122 |
due_date: Optional[date] = None
|
| 123 |
notes: Optional[str] = None
|
| 124 |
|
|
|
|
| 125 |
paid_at: Optional[datetime] = None
|
| 126 |
paid_amount: Optional[float] = None
|
| 127 |
payment_method: Optional[str] = None
|
|
|
|
| 158 |
customer_id: int
|
| 159 |
due_date: Optional[date] = None
|
| 160 |
notes: Optional[str] = None
|
| 161 |
+
quote_id: Optional[int] = None
|
| 162 |
|
| 163 |
class InvoiceItemIn(BaseModel):
|
| 164 |
description: str
|
|
|
|
| 177 |
body: str
|
| 178 |
attach_pdf: bool = True
|
| 179 |
|
| 180 |
+
# ---- ウィザード(フロント一括登録用)----
|
| 181 |
+
class WizardItemIn(BaseModel):
|
| 182 |
+
description: str
|
| 183 |
+
quantity: float = Field(gt=0, default=1)
|
| 184 |
+
unit_price: float = Field(ge=0)
|
| 185 |
+
tax_rate: float = Field(ge=0, default=0.0)
|
| 186 |
+
|
| 187 |
+
class WizardCustomerIn(BaseModel):
|
| 188 |
+
id: int | None = None
|
| 189 |
+
name: str | None = None
|
| 190 |
+
email: str | None = None
|
| 191 |
+
phone: str | None = None
|
| 192 |
+
address: str | None = None
|
| 193 |
+
city: str | None = None
|
| 194 |
+
country: str | None = None
|
| 195 |
+
|
| 196 |
+
class WizardInvoiceIn(BaseModel):
|
| 197 |
+
customer: WizardCustomerIn
|
| 198 |
+
due_date: date | None = None
|
| 199 |
+
notes: str | None = None
|
| 200 |
+
items: list[WizardItemIn]
|
| 201 |
+
|
| 202 |
+
@model_validator(mode="before")
|
| 203 |
+
@classmethod
|
| 204 |
+
def coerce_dates(cls, v: dict):
|
| 205 |
+
d = v.get("due_date")
|
| 206 |
+
if isinstance(d, str) and d.strip():
|
| 207 |
+
s = d.strip().replace("/", "-")
|
| 208 |
+
try:
|
| 209 |
+
v["due_date"] = date.fromisoformat(s)
|
| 210 |
+
except Exception:
|
| 211 |
+
v["due_date"] = None
|
| 212 |
+
return v
|
| 213 |
+
|
| 214 |
# --------------------------
|
| 215 |
+
# Totals
|
| 216 |
# --------------------------
|
| 217 |
class MoneyBreakdown(BaseModel):
|
| 218 |
subtotal: float
|
|
|
|
| 249 |
allow_methods=["*"], allow_headers=["*"],
|
| 250 |
)
|
| 251 |
|
| 252 |
+
# Swagger の Authorize を出す(X-API-Key)
|
| 253 |
def custom_openapi():
|
| 254 |
if app.openapi_schema:
|
| 255 |
return app.openapi_schema
|
|
|
|
| 278 |
SQLModel.metadata.create_all(engine)
|
| 279 |
|
| 280 |
# ---- UI(/app) ----
|
| 281 |
+
app.mount("/app", StaticFiles(directory="static", html=True), name="app")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
# -------- Customers --------
|
| 284 |
@app.post("/customers", dependencies=[Depends(require_api_key)])
|
|
|
|
| 286 |
session.add(payload); session.commit(); session.refresh(payload)
|
| 287 |
return payload
|
| 288 |
|
| 289 |
+
# ---- Customers 一覧APIの置き換え ----
|
| 290 |
@app.get("/customers", dependencies=[Depends(require_api_key)])
|
| 291 |
def list_customers(
|
| 292 |
q: Optional[str] = Query(default=None, description="free text search (name/email/phone)"),
|
|
|
|
| 297 |
stmt = select(Customer)
|
| 298 |
if q:
|
| 299 |
like = f"%{q}%"
|
|
|
|
| 300 |
stmt = stmt.where(
|
| 301 |
+
(Customer.name.ilike(like)) |
|
| 302 |
+
(Customer.email.ilike(like)) |
|
| 303 |
+
(Customer.phone.ilike(like))
|
| 304 |
)
|
| 305 |
+
# ここがポイント: .count() は使わず count クエリを投げる
|
| 306 |
+
total = session.exec(select(func.count()).select_from(stmt.subquery())).one()
|
| 307 |
rows = session.exec(stmt.offset(offset).limit(limit)).all()
|
| 308 |
return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
|
| 309 |
|
|
|
|
| 313 |
session.add(payload); session.commit(); session.refresh(payload)
|
| 314 |
return payload
|
| 315 |
|
| 316 |
+
# ---- Products 一覧APIの total も同様に修正 ----
|
| 317 |
@app.get("/products", dependencies=[Depends(require_api_key)])
|
| 318 |
def list_products(
|
| 319 |
limit: int = Query(50, ge=1, le=200),
|
| 320 |
offset: int = Query(0, ge=0),
|
| 321 |
session: Session = Depends(get_session),
|
| 322 |
):
|
| 323 |
+
base = select(Product)
|
| 324 |
+
total = session.exec(select(func.count()).select_from(base.subquery())).one()
|
| 325 |
+
rows = session.exec(base.offset(offset).limit(limit)).all()
|
| 326 |
return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
|
|
|
|
| 327 |
# -------- Quotes --------
|
| 328 |
@app.post("/quotes", dependencies=[Depends(require_api_key)])
|
| 329 |
def create_quote(payload: CreateQuoteIn, session: Session = Depends(get_session)):
|
|
|
|
| 365 |
inv = Invoice(customer_id=payload.customer_id, due_date=payload.due_date, notes=payload.notes)
|
| 366 |
session.add(inv); session.commit(); session.refresh(inv)
|
| 367 |
|
|
|
|
| 368 |
if payload.quote_id:
|
| 369 |
q = session.get(Quote, payload.quote_id)
|
| 370 |
if not q:
|
| 371 |
raise HTTPException(404, "Quote not found to copy")
|
| 372 |
q_items = session.exec(select(QuoteItem).where(QuoteItem.quote_id == payload.quote_id)).all()
|
| 373 |
for it in q_items:
|
| 374 |
+
new_item = InvoiceItem(
|
| 375 |
invoice_id=inv.id,
|
| 376 |
product_id=it.product_id,
|
| 377 |
description=it.description,
|
| 378 |
quantity=it.quantity,
|
| 379 |
unit_price=it.unit_price,
|
| 380 |
tax_rate=it.tax_rate,
|
| 381 |
+
)
|
| 382 |
+
session.add(new_item)
|
| 383 |
session.commit()
|
| 384 |
|
| 385 |
session.refresh(inv)
|
|
|
|
| 461 |
)
|
| 462 |
return {"ok": ok, "detail": detail}
|
| 463 |
|
| 464 |
+
# ---- ウィザード:顧客→請求→明細を一括作成 ----
|
| 465 |
+
@app.post("/wizard/invoice", dependencies=[Depends(require_api_key)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
def wizard_create_invoice(payload: WizardInvoiceIn, session: Session = Depends(get_session)):
|
| 467 |
+
try:
|
| 468 |
+
# 顧客確定(既存 or 新規)
|
| 469 |
+
cust_id: int | None = payload.customer.id
|
| 470 |
+
if cust_id:
|
| 471 |
+
cust = session.get(Customer, cust_id)
|
| 472 |
+
if not cust:
|
| 473 |
+
raise HTTPException(404, "Customer id not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
else:
|
| 475 |
+
if not payload.customer.name:
|
| 476 |
+
raise HTTPException(422, "Either customer.id or customer.name is required")
|
| 477 |
+
existing = session.exec(select(Customer).where(Customer.name == payload.customer.name)).first()
|
| 478 |
+
cust = existing or Customer(
|
| 479 |
name=payload.customer.name.strip(),
|
| 480 |
email=(payload.customer.email or None),
|
| 481 |
phone=(payload.customer.phone or None),
|
|
|
|
| 483 |
city=(payload.customer.city or None),
|
| 484 |
country=(payload.customer.country or None),
|
| 485 |
)
|
| 486 |
+
if not existing:
|
| 487 |
+
session.add(cust); session.commit(); session.refresh(cust)
|
|
|
|
| 488 |
cust_id = cust.id
|
| 489 |
|
| 490 |
+
# 請求書
|
| 491 |
+
inv = Invoice(customer_id=cust_id, due_date=payload.due_date, notes=payload.notes)
|
| 492 |
+
session.add(inv); session.commit(); session.refresh(inv)
|
|
|
|
|
|
|
| 493 |
|
| 494 |
+
# 明細
|
| 495 |
+
for it in payload.items:
|
| 496 |
+
session.add(InvoiceItem(
|
| 497 |
+
invoice_id=inv.id,
|
| 498 |
+
description=it.description,
|
| 499 |
+
quantity=it.quantity,
|
| 500 |
+
unit_price=it.unit_price,
|
| 501 |
+
tax_rate=it.tax_rate,
|
| 502 |
+
))
|
| 503 |
+
session.commit()
|
|
|
|
|
|
|
|
|
|
| 504 |
|
| 505 |
+
items = session.exec(select(InvoiceItem).where(InvoiceItem.invoice_id == inv.id)).all()
|
| 506 |
+
totals = compute_totals(items)
|
| 507 |
+
return {"customer_id": cust_id, "invoice_id": inv.id, "totals": totals}
|
| 508 |
+
except HTTPException:
|
| 509 |
+
raise
|
| 510 |
+
except Exception as e:
|
| 511 |
+
raise HTTPException(400, f"Wizard failed: {e}")
|
| 512 |
+
|
| 513 |
+
# ---- ルート(UI) ----
|
| 514 |
+
@app.get("/", response_class=FileResponse)
|
| 515 |
+
def root_ui():
|
| 516 |
+
return FileResponse("static/app.html")
|
| 517 |
+
|
| 518 |
+
# -------- ChatGPT router(最後に組み込む) --------
|
| 519 |
+
from openai_integration import router as ai_router
|
| 520 |
+
app.include_router(ai_router, prefix="/ai", tags=["ai"])
|