Corin1998 commited on
Commit
6d83cd0
·
verified ·
1 Parent(s): 31434be

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +98 -114
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 fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
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
- STATIC_DIR = "static"
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.like(like)) |
281
- (Customer.email.like(like)) |
282
- (Customer.phone.like(like))
283
  )
284
- total = session.exec(stmt).count()
 
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
- rows = session.exec(select(Product).offset(offset).limit(limit)).all()
301
- total = session.exec(select(Product)).count()
 
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
- session.add(InvoiceItem(
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
- # ---- UIへリダイレクト(デモしやすさ優先)----
442
- @app.get("/", include_in_schema=False)
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
- 顧客IDがあればそれを使用。無ければ name などから顧客を新規作成。
484
- その後、請求書を作成し、明細を一括登録して totals を返す。
485
- """
486
- # 1) 顧客を決定
487
- cust_id: int | None = payload.customer.id
488
- if cust_id:
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
- cust = Customer(
 
 
 
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
- session.add(cust)
511
- session.commit()
512
- session.refresh(cust)
513
  cust_id = cust.id
514
 
515
- # 2) 請求書を作成
516
- inv = Invoice(customer_id=cust_id, due_date=payload.due_date, notes=payload.notes)
517
- session.add(inv)
518
- session.commit()
519
- session.refresh(inv)
520
 
521
- # 3) 明細を一括追加
522
- for it in payload.items:
523
- session.add(InvoiceItem(
524
- invoice_id=inv.id,
525
- description=it.description,
526
- quantity=it.quantity,
527
- unit_price=it.unit_price,
528
- tax_rate=it.tax_rate,
529
- ))
530
- session.commit()
531
-
532
- items = session.exec(select(InvoiceItem).where(InvoiceItem.invoice_id == inv.id)).all()
533
- totals = compute_totals(items)
534
 
535
- return WizardInvoiceOut(customer_id=cust_id, invoice_id=inv.id, totals=totals)
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"])