Corin1998 commited on
Commit
403c37c
·
verified ·
1 Parent(s): 9d01aa0

Upload 4 files

Browse files
Files changed (4) hide show
  1. API_KEY=dev +5 -0
  2. main.py +226 -0
  3. openai_integration.py +130 -0
  4. requirements +6 -0
API_KEY=dev ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ API_KEY=dev
2
+ OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx
3
+ OAI_CHAT_MODEL=gpt-4o-mini
4
+ OAI_EMB_MODEL=text-embedding-3-small
5
+ DATABASE_URL=sqlite:///./app.db
main.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+ """
3
+ Mini Invoice/Estimate (Quote) SaaS — single-file FastAPI app
4
+ Beginner-friendly: SQLite + SQLModel (SQLAlchemy) + Pydantic.
5
+
6
+ Features (MVP)
7
+ - Customers CRUD
8
+ - Products CRUD
9
+ - Quotes: create, add/remove line items, compute totals
10
+ - Invoices: create (or convert from quote), mark paid, compute totals
11
+ - Simple API-key auth via X-API-Key header (set API_KEY env var; defaults to "dev")
12
+ - Autogenerated docs at /docs
13
+
14
+ Run locally:
15
+ python -m venv .venv && source .venv/bin/activate
16
+ pip install -r requirements.txt
17
+ export API_KEY=dev
18
+ uvicorn main:app --reload
19
+ """
20
+
21
+ from __future__ import annotations
22
+ from typing import Optional, List
23
+ from datetime import datetime, date
24
+ import os
25
+
26
+ from fastapi import FastAPI, Depends, HTTPException, Header, Query
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from pydantic import BaseModel
29
+ from sqlmodel import Field as SQLField, Session, SQLModel, create_engine, select, Relationship
30
+
31
+ from openai_integration import router as ai_router
32
+ app.include_router(ai_router, prefix="/ai", tags=["ai"])
33
+
34
+ # --------------------------
35
+ # Auth
36
+ # --------------------------
37
+ API_KEY = os.getenv("API_KEY", "dev")
38
+
39
+ async def require_api_key(x_api_key: str | None = Header(default=None)):
40
+ if x_api_key != API_KEY:
41
+ raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key")
42
+
43
+ # --------------------------
44
+ # DB
45
+ # --------------------------
46
+ DB_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
47
+ engine = create_engine(DB_URL, echo=False)
48
+
49
+ def get_session():
50
+ with Session(engine) as session:
51
+ yield session
52
+
53
+ # --------------------------
54
+ # Models
55
+ # --------------------------
56
+ class Customer(SQLModel, table=True):
57
+ id: Optional[int] = SQLField(default=None, primary_key=True)
58
+ name: str = SQLField(index=True)
59
+ email: Optional[str] = None
60
+ phone: Optional[str] = None
61
+ address: Optional[str] = None
62
+ city: Optional[str] = None
63
+ country: Optional[str] = None
64
+
65
+ quotes: List["Quote"] = Relationship(back_populates="customer")
66
+ invoices: List["Invoice"] = Relationship(back_populates="customer")
67
+
68
+ class Product(SQLModel, table=True):
69
+ id: Optional[int] = SQLField(default=None, primary_key=True)
70
+ name: str
71
+ unit_price: float = SQLField(ge=0)
72
+ currency: str = SQLField(default="JPY")
73
+ sku: Optional[str] = None
74
+ description: Optional[str] = None
75
+
76
+ class Quote(SQLModel, table=True):
77
+ id: Optional[int] = SQLField(default=None, primary_key=True)
78
+ customer_id: int = SQLField(foreign_key="customer.id")
79
+ status: str = SQLField(default="draft", index=True) # draft/sent/accepted/expired
80
+ issue_date: date = SQLField(default_factory=lambda: datetime.utcnow().date())
81
+ valid_until: Optional[date] = None
82
+ notes: Optional[str] = None
83
+
84
+ customer: Optional[Customer] = Relationship(back_populates="quotes")
85
+ items: List["QuoteItem"] = Relationship(back_populates="quote")
86
+
87
+ class QuoteItem(SQLModel, table=True):
88
+ id: Optional[int] = SQLField(default=None, primary_key=True)
89
+ quote_id: int = SQLField(foreign_key="quote.id")
90
+ product_id: Optional[int] = SQLField(foreign_key="product.id", default=None)
91
+ description: str
92
+ quantity: float = SQLField(gt=0, default=1)
93
+ unit_price: float = SQLField(ge=0)
94
+ tax_rate: float = SQLField(ge=0, default=0.0)
95
+
96
+ quote: Optional[Quote] = Relationship(back_populates="items")
97
+
98
+ class Invoice(SQLModel, table=True):
99
+ id: Optional[int] = SQLField(default=None, primary_key=True)
100
+ customer_id: int = SQLField(foreign_key="customer.id")
101
+ status: str = SQLField(default="unpaid", index=True) # unpaid/paid/void
102
+ issue_date: date = SQLField(default_factory=lambda: datetime.utcnow().date())
103
+ due_date: Optional[date] = None
104
+ notes: Optional[str] = None
105
+
106
+ customer: Optional[Customer] = Relationship(back_populates="invoices")
107
+ items: List["InvoiceItem"] = Relationship(back_populates="invoice")
108
+
109
+ class InvoiceItem(SQLModel, table=True):
110
+ id: Optional[int] = SQLField(default=None, primary_key=True)
111
+ invoice_id: int = SQLField(foreign_key="invoice.id")
112
+ product_id: Optional[int] = SQLField(foreign_key="product.id", default=None)
113
+ description: str
114
+ quantity: float = SQLField(gt=0, default=1)
115
+ unit_price: float = SQLField(ge=0)
116
+ tax_rate: float = SQLField(ge=0, default=0.0)
117
+
118
+ invoice: Optional[Invoice] = Relationship(back_populates="items")
119
+
120
+ # --------------------------
121
+ # Totals
122
+ # --------------------------
123
+ class MoneyBreakdown(BaseModel):
124
+ subtotal: float
125
+ tax: float
126
+ total: float
127
+
128
+ def round2(v: float) -> float:
129
+ return float(f"{v:.2f}")
130
+
131
+ def compute_totals(items: list[dict]) -> MoneyBreakdown:
132
+ subtotal = 0.0
133
+ tax = 0.0
134
+ for it in items:
135
+ line = it["quantity"] * it["unit_price"]
136
+ subtotal += line
137
+ tax += line * it.get("tax_rate", 0.0)
138
+ return MoneyBreakdown(subtotal=round2(subtotal), tax=round2(tax), total=round2(subtotal + tax))
139
+
140
+ # --------------------------
141
+ # App
142
+ # --------------------------
143
+ app = FastAPI(title="Mini Invoice/Estimate SaaS", version="0.1.0")
144
+ app.add_middleware(
145
+ CORSMiddleware,
146
+ allow_origins=["*"], allow_credentials=True,
147
+ allow_methods=["*"], allow_headers=["*"],
148
+ )
149
+
150
+ @app.on_event("startup")
151
+ def on_startup():
152
+ SQLModel.metadata.create_all(engine)
153
+
154
+ # -------- Customers --------
155
+ @app.post("/customers", dependencies=[Depends(require_api_key)])
156
+ def create_customer(payload: Customer, session: Session = Depends(get_session)):
157
+ session.add(payload); session.commit(); session.refresh(payload)
158
+ return payload
159
+
160
+ @app.get("/customers", dependencies=[Depends(require_api_key)])
161
+ def list_customers(limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), session: Session = Depends(get_session)):
162
+ rows = session.exec(select(Customer).offset(offset).limit(limit)).all()
163
+ total = session.exec(select(Customer)).count()
164
+ return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
165
+
166
+ # -------- Products --------
167
+ @app.post("/products", dependencies=[Depends(require_api_key)])
168
+ def create_product(payload: Product, session: Session = Depends(get_session)):
169
+ session.add(payload); session.commit(); session.refresh(payload)
170
+ return payload
171
+
172
+ @app.get("/products", dependencies=[Depends(require_api_key)])
173
+ def list_products(limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), session: Session = Depends(get_session)):
174
+ rows = session.exec(select(Product).offset(offset).limit(limit)).all()
175
+ total = session.exec(select(Product)).count()
176
+ return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
177
+
178
+ # -------- Quotes --------
179
+ @app.post("/quotes", dependencies=[Depends(require_api_key)])
180
+ def create_quote(payload: Quote, session: Session = Depends(get_session)):
181
+ if not session.get(Customer, payload.customer_id):
182
+ raise HTTPException(400, "Customer not found")
183
+ session.add(payload); session.commit(); session.refresh(payload)
184
+ return payload
185
+
186
+ @app.get("/quotes/{quote_id}", dependencies=[Depends(require_api_key)])
187
+ def get_quote(quote_id: int, session: Session = Depends(get_session)):
188
+ q = session.get(Quote, quote_id)
189
+ if not q: raise HTTPException(404, "Quote not found")
190
+ items = session.exec(select(QuoteItem).where(QuoteItem.quote_id == quote_id)).all()
191
+ totals = compute_totals([it.dict() for it in items])
192
+ return {"quote": q, "items": items, "totals": totals}
193
+
194
+ @app.post("/quotes/{quote_id}/items", dependencies=[Depends(require_api_key)])
195
+ def add_quote_item(quote_id: int, payload: QuoteItem, session: Session = Depends(get_session)):
196
+ if not session.get(Quote, quote_id): raise HTTPException(404, "Quote not found")
197
+ payload.quote_id = quote_id
198
+ session.add(payload); session.commit(); session.refresh(payload)
199
+ return payload
200
+
201
+ # -------- Invoices --------
202
+ @app.post("/invoices", dependencies=[Depends(require_api_key)])
203
+ def create_invoice(payload: Invoice, session: Session = Depends(get_session)):
204
+ if not session.get(Customer, payload.customer_id):
205
+ raise HTTPException(400, "Customer not found")
206
+ session.add(payload); session.commit(); session.refresh(payload)
207
+ return payload
208
+
209
+ @app.get("/invoices/{invoice_id}", dependencies=[Depends(require_api_key)])
210
+ def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
211
+ inv = session.get(Invoice, invoice_id)
212
+ if not inv: raise HTTPException(404, "Invoice not found")
213
+ items = session.exec(select(InvoiceItem).where(InvoiceItem.invoice_id == invoice_id)).all()
214
+ totals = compute_totals([it.dict() for it in items])
215
+ return {"invoice": inv, "items": items, "totals": totals}
216
+
217
+ @app.post("/invoices/{invoice_id}/items", dependencies=[Depends(require_api_key)])
218
+ def add_invoice_item(invoice_id: int, payload: InvoiceItem, session: Session = Depends(get_session)):
219
+ if not session.get(Invoice, invoice_id): raise HTTPException(404, "Invoice not found")
220
+ payload.invoice_id = invoice_id
221
+ session.add(payload); session.commit(); session.refresh(payload)
222
+ return payload
223
+
224
+ @app.get("/")
225
+ def root():
226
+ return {"ok": True, "app": "mini-invoice-saas", "docs": "/docs"}
openai_integration.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # openai_integration.py
2
+ """
3
+ OpenAI (ChatGPT) integration for the Mini Invoice/Estimate SaaS (FastAPI)
4
+ - Uses OpenAI Python SDK v1 (chat completions + embeddings)
5
+ - Auth via env var: OPENAI_API_KEY
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import os
10
+ from typing import List, Optional
11
+
12
+ from fastapi import APIRouter, Depends, HTTPException
13
+ from pydantic import BaseModel, Field
14
+
15
+ try:
16
+ from main import require_api_key # reuse API-key header guard
17
+ except Exception:
18
+ async def require_api_key():
19
+ return None
20
+
21
+ from openai import OpenAI
22
+
23
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
24
+ if not OPENAI_API_KEY:
25
+ raise RuntimeError("Set OPENAI_API_KEY before importing openai_integration")
26
+
27
+ client = OpenAI(api_key=OPENAI_API_KEY)
28
+
29
+ OAI_CHAT_MODEL = os.getenv("OAI_CHAT_MODEL", "gpt-4o-mini") # 軽量モデル推奨
30
+ OAI_EMB_MODEL = os.getenv("OAI_EMB_MODEL", "text-embedding-3-small")
31
+
32
+ router = APIRouter()
33
+
34
+ # -------- Schemas --------
35
+ class LineItem(BaseModel):
36
+ description: str
37
+ quantity: float = 1
38
+ unit_price: float
39
+ tax_rate: float = 0.1
40
+
41
+ class GenerateEmailRequest(BaseModel):
42
+ kind: str = Field(pattern="^(quote|invoice)$")
43
+ company_name: str
44
+ customer_name: str
45
+ language: str = Field("ja", description="ja or en")
46
+ items: List[LineItem]
47
+ due_date: Optional[str] = None
48
+ notes: Optional[str] = None
49
+ tone: str = Field("polite", description="polite|friendly|concise")
50
+
51
+ class SummarizeRequest(BaseModel):
52
+ text: str
53
+ language: str = "ja"
54
+ max_points: int = 5
55
+
56
+ class EmbeddingsRequest(BaseModel):
57
+ texts: List[str]
58
+
59
+ class EmbeddingsResponse(BaseModel):
60
+ vectors: List[List[float]]
61
+
62
+ # -------- Helpers --------
63
+ EMAIL_SYS = (
64
+ "You are a helpful business assistant. Write concise, professional emails. "
65
+ "Output a subject line and a body."
66
+ )
67
+ SUM_SYS = (
68
+ "You are a world-class note taker. Produce clean bullet points and an 'Action Items' list."
69
+ )
70
+
71
+ def _chat(messages: list[dict], max_tokens: int = 600, temperature: float = 0.3) -> str:
72
+ resp = client.chat.completions.create(
73
+ model=OAI_CHAT_MODEL, messages=messages, max_tokens=max_tokens, temperature=temperature
74
+ )
75
+ return resp.choices[0].message.content.strip()
76
+
77
+ def _format_items(items: List[LineItem]) -> str:
78
+ return "\n".join(
79
+ f"- {it.description}: 数量 {it.quantity}, 単価 {it.unit_price:.2f}, 税率 {it.tax_rate*100:.0f}%"
80
+ for it in items
81
+ )
82
+
83
+ # -------- Routes --------
84
+ @router.post("/generate-email", dependencies=[Depends(require_api_key)])
85
+ async def generate_email(req: GenerateEmailRequest):
86
+ kind_ja = "御見積書" if req.kind == "quote" else "請求書"
87
+ items_block = _format_items(req.items)
88
+ user_prompt = f"""
89
+ 以下の情報を用いて、{kind_ja}送付メールの本文を{req.language}で作成してください。
90
+ 制約:
91
+ - 件名(Subject)と本文を出力
92
+ - 本文は宛名、要点の箇条書き、締め、署名の順
93
+ - 不要な装飾は避け、{req.tone}な口調
94
+
95
+ 会社名: {req.company_name}
96
+ 顧客名: {req.customer_name}
97
+ 支払期日: {req.due_date or '記載なし'}
98
+ 明細:
99
+ {items_block}
100
+ 特記事項: {req.notes or 'なし'}
101
+ """.strip()
102
+
103
+ text = _chat(
104
+ [{"role": "system", "content": EMAIL_SYS}, {"role": "user", "content": user_prompt}],
105
+ max_tokens=500,
106
+ )
107
+ return {"email": text}
108
+
109
+ @router.post("/summarize-notes", dependencies=[Depends(require_api_key)])
110
+ async def summarize_notes(req: SummarizeRequest):
111
+ user_prompt = f"""
112
+ 次のメモを{req.language}で要約してください。箇条書きで最大{req.max_points}点。最後に"Action Items:"として実行項目を列挙。
113
+ ---
114
+ {req.text}
115
+ ---
116
+ """.strip()
117
+ text = _chat(
118
+ [{"role": "system", "content": SUM_SYS}, {"role": "user", "content": user_prompt}],
119
+ max_tokens=400,
120
+ )
121
+ return {"summary": text}
122
+
123
+ @router.post("/embeddings", response_model=EmbeddingsResponse, dependencies=[Depends(require_api_key)])
124
+ async def embeddings(req: EmbeddingsRequest):
125
+ try:
126
+ resp = client.embeddings.create(model=OAI_EMB_MODEL, input=req.texts)
127
+ vectors = [d.embedding for d in resp.data]
128
+ return {"vectors": vectors}
129
+ except Exception as e:
130
+ raise HTTPException(500, f"Embeddings failed: {e}")
requirements ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.27
3
+ sqlmodel>=0.0.16
4
+ pydantic>=2.6
5
+ python-multipart>=0.0.9
6
+ openai>=1.40.0