Corin1998 commited on
Commit
9f4d112
·
verified ·
1 Parent(s): b748d8b

Upload 7 files

Browse files
Files changed (5) hide show
  1. mailer.py +38 -0
  2. main.py +166 -24
  3. pdf_export.py +81 -0
  4. requirements.txt +3 -1
  5. static:app.html +331 -0
mailer.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # mailer.py
2
+ import os
3
+ import smtplib
4
+ from email.message import EmailMessage
5
+ from typing import Optional
6
+
7
+ def send_email_smtp(to: str, subject: str, body: str, attachment_path: Optional[str] = None):
8
+ host = os.getenv("SMTP_HOST")
9
+ port = int(os.getenv("SMTP_PORT", "587"))
10
+ user = os.getenv("SMTP_USER")
11
+ password = os.getenv("SMTP_PASS")
12
+ sender = os.getenv("SMTP_FROM", user or "no-reply@example.com")
13
+ use_tls = os.getenv("SMTP_USE_TLS", "1") == "1"
14
+
15
+ if not host or not user or not password:
16
+ # 環境未設定なら NOOP(成功扱いにして詳細メッセージを返す)
17
+ return True, "SMTP not configured; skipped sending (set SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM)"
18
+
19
+ msg = EmailMessage()
20
+ msg["From"] = sender
21
+ msg["To"] = to
22
+ msg["Subject"] = subject
23
+ msg.set_content(body)
24
+
25
+ if attachment_path:
26
+ with open(attachment_path, "rb") as f:
27
+ data = f.read()
28
+ msg.add_attachment(data, maintype="application", subtype="pdf", filename=os.path.basename(attachment_path))
29
+
30
+ try:
31
+ with smtplib.SMTP(host, port) as server:
32
+ if use_tls:
33
+ server.starttls()
34
+ server.login(user, password)
35
+ server.send_message(msg)
36
+ return True, "sent"
37
+ except Exception as e:
38
+ return False, f"send failed: {e}"
main.py CHANGED
@@ -13,6 +13,8 @@ import pathlib
13
  from fastapi import FastAPI, Depends, HTTPException, Header, Query
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.openapi.utils import get_openapi
 
 
16
  from pydantic import BaseModel
17
  from sqlmodel import Field as SQLField, Session, SQLModel, create_engine, select, Relationship
18
 
@@ -36,7 +38,6 @@ def _ensure_dir(path: str) -> bool:
36
  return False
37
 
38
  def _make_sqlite_url(db_path: str) -> str:
39
- # 絶対パス想定
40
  return f"sqlite:///{db_path}"
41
 
42
  DB_URL = os.getenv("DATABASE_URL")
@@ -64,7 +65,6 @@ engine = create_engine(
64
  connect_args={"check_same_thread": False} if DB_URL.startswith("sqlite") else {}
65
  )
66
 
67
- # セッション依存性(※ これが routes より前に必要)
68
  def get_session():
69
  with Session(engine) as session:
70
  yield session
@@ -122,6 +122,11 @@ class Invoice(SQLModel, table=True):
122
  due_date: Optional[date] = None
123
  notes: Optional[str] = None
124
 
 
 
 
 
 
125
  customer: Optional[Customer] = Relationship(back_populates="invoices")
126
  items: List["InvoiceItem"] = Relationship(back_populates="invoice")
127
 
@@ -136,6 +141,44 @@ class InvoiceItem(SQLModel, table=True):
136
 
137
  invoice: Optional[Invoice] = Relationship(back_populates="items")
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  # --------------------------
140
  # Totals(堅牢化)
141
  # --------------------------
@@ -148,7 +191,6 @@ def round2(v: float) -> float:
148
  return float(f"{v:.2f}")
149
 
150
  def _get(v: Any, key: str, default=0.0):
151
- # モデルでも dict でもOKにする
152
  if isinstance(v, dict):
153
  return v.get(key, default)
154
  return getattr(v, key, default)
@@ -175,7 +217,7 @@ app.add_middleware(
175
  allow_methods=["*"], allow_headers=["*"],
176
  )
177
 
178
- # --- Swagger UI Authorize(X-API-Key)を出すための OpenAPI セキュリティ設定 ---
179
  def custom_openapi():
180
  if app.openapi_schema:
181
  return app.openapi_schema
@@ -191,7 +233,6 @@ def custom_openapi():
191
  "name": "X-API-Key",
192
  "in": "header",
193
  }
194
- # 全エンドポイントにデフォルトで適用
195
  for path in openapi_schema.get("paths", {}).values():
196
  for method in path.values():
197
  method.setdefault("security", [{"APIKeyHeader": []}])
@@ -204,6 +245,9 @@ app.openapi = custom_openapi
204
  def on_startup():
205
  SQLModel.metadata.create_all(engine)
206
 
 
 
 
207
  # -------- Customers --------
208
  @app.post("/customers", dependencies=[Depends(require_api_key)])
209
  def create_customer(payload: Customer, session: Session = Depends(get_session)):
@@ -212,15 +256,24 @@ def create_customer(payload: Customer, session: Session = Depends(get_session)):
212
 
213
  @app.get("/customers", dependencies=[Depends(require_api_key)])
214
  def list_customers(
 
215
  limit: int = Query(50, ge=1, le=200),
216
  offset: int = Query(0, ge=0),
217
  session: Session = Depends(get_session),
218
  ):
219
- rows = session.exec(select(Customer).offset(offset).limit(limit)).all()
220
- total = session.exec(select(Customer)).count()
 
 
 
 
 
 
 
 
221
  return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
222
 
223
- # -------- Products --------
224
  @app.post("/products", dependencies=[Depends(require_api_key)])
225
  def create_product(payload: Product, session: Session = Depends(get_session)):
226
  session.add(payload); session.commit(); session.refresh(payload)
@@ -238,11 +291,12 @@ def list_products(
238
 
239
  # -------- Quotes --------
240
  @app.post("/quotes", dependencies=[Depends(require_api_key)])
241
- def create_quote(payload: Quote, session: Session = Depends(get_session)):
242
  if not session.get(Customer, payload.customer_id):
243
  raise HTTPException(400, "Customer not found")
244
- session.add(payload); session.commit(); session.refresh(payload)
245
- return payload
 
246
 
247
  @app.get("/quotes/{quote_id}", dependencies=[Depends(require_api_key)])
248
  def get_quote(quote_id: int, session: Session = Depends(get_session)):
@@ -250,24 +304,52 @@ def get_quote(quote_id: int, session: Session = Depends(get_session)):
250
  if not q:
251
  raise HTTPException(404, "Quote not found")
252
  items = session.exec(select(QuoteItem).where(QuoteItem.quote_id == quote_id)).all()
253
- totals = compute_totals(items) # dict化せず、そのまま渡す
254
  return {"quote": q, "items": items, "totals": totals}
255
 
256
  @app.post("/quotes/{quote_id}/items", dependencies=[Depends(require_api_key)])
257
- def add_quote_item(quote_id: int, payload: QuoteItem, session: Session = Depends(get_session)):
258
  if not session.get(Quote, quote_id):
259
  raise HTTPException(404, "Quote not found")
260
- payload.quote_id = quote_id
261
- session.add(payload); session.commit(); session.refresh(payload)
262
- return payload
 
 
 
 
 
 
263
 
264
  # -------- Invoices --------
265
  @app.post("/invoices", dependencies=[Depends(require_api_key)])
266
- def create_invoice(payload: Invoice, session: Session = Depends(get_session)):
 
267
  if not session.get(Customer, payload.customer_id):
268
  raise HTTPException(400, "Customer not found")
269
- session.add(payload); session.commit(); session.refresh(payload)
270
- return payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
  @app.get("/invoices/{invoice_id}", dependencies=[Depends(require_api_key)])
273
  def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
@@ -279,17 +361,77 @@ def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
279
  return {"invoice": inv, "items": items, "totals": totals}
280
 
281
  @app.post("/invoices/{invoice_id}/items", dependencies=[Depends(require_api_key)])
282
- def add_invoice_item(invoice_id: int, payload: InvoiceItem, session: Session = Depends(get_session)):
283
  if not session.get(Invoice, invoice_id):
284
  raise HTTPException(404, "Invoice not found")
285
- payload.invoice_id = invoice_id
286
- session.add(payload); session.commit(); session.refresh(payload)
287
- return payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
  @app.get("/")
290
  def root():
291
  return {"ok": True, "app": "mini-invoice-saas", "docs": "/docs"}
292
 
293
- # -------- ChatGPT router を最後に組み込む(app 定義後) --------
294
  from openai_integration import router as ai_router
295
  app.include_router(ai_router, prefix="/ai", tags=["ai"])
 
 
13
  from fastapi import FastAPI, Depends, HTTPException, Header, Query
14
  from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.openapi.utils import get_openapi
16
+ from fastapi.responses import FileResponse
17
+ from fastapi.staticfiles import StaticFiles
18
  from pydantic import BaseModel
19
  from sqlmodel import Field as SQLField, Session, SQLModel, create_engine, select, Relationship
20
 
 
38
  return False
39
 
40
  def _make_sqlite_url(db_path: str) -> str:
 
41
  return f"sqlite:///{db_path}"
42
 
43
  DB_URL = os.getenv("DATABASE_URL")
 
65
  connect_args={"check_same_thread": False} if DB_URL.startswith("sqlite") else {}
66
  )
67
 
 
68
  def get_session():
69
  with Session(engine) as session:
70
  yield session
 
122
  due_date: Optional[date] = None
123
  notes: Optional[str] = None
124
 
125
+ # 入金関連(任意)
126
+ paid_at: Optional[datetime] = None
127
+ paid_amount: Optional[float] = None
128
+ payment_method: Optional[str] = None
129
+
130
  customer: Optional[Customer] = Relationship(back_populates="invoices")
131
  items: List["InvoiceItem"] = Relationship(back_populates="invoice")
132
 
 
141
 
142
  invoice: Optional[Invoice] = Relationship(back_populates="items")
143
 
144
+ # --------------------------
145
+ # DTO(入力用)
146
+ # --------------------------
147
+ class CreateQuoteIn(BaseModel):
148
+ customer_id: int
149
+ valid_until: Optional[date] = None
150
+ notes: Optional[str] = None
151
+
152
+ class QuoteItemIn(BaseModel):
153
+ description: str
154
+ quantity: float = 1
155
+ unit_price: float
156
+ tax_rate: float = 0.0
157
+
158
+ class CreateInvoiceIn(BaseModel):
159
+ customer_id: int
160
+ due_date: Optional[date] = None
161
+ notes: Optional[str] = None
162
+ # 追加:見積→請求のコピー用
163
+ quote_id: Optional[int] = None
164
+
165
+ class InvoiceItemIn(BaseModel):
166
+ description: str
167
+ quantity: float = 1
168
+ unit_price: float
169
+ tax_rate: float = 0.0
170
+
171
+ class PayIn(BaseModel):
172
+ paid_amount: float
173
+ paid_at: Optional[datetime] = None
174
+ payment_method: Optional[str] = "bank_transfer"
175
+
176
+ class EmailIn(BaseModel):
177
+ to: str
178
+ subject: str
179
+ body: str
180
+ attach_pdf: bool = True
181
+
182
  # --------------------------
183
  # Totals(堅牢化)
184
  # --------------------------
 
191
  return float(f"{v:.2f}")
192
 
193
  def _get(v: Any, key: str, default=0.0):
 
194
  if isinstance(v, dict):
195
  return v.get(key, default)
196
  return getattr(v, key, default)
 
217
  allow_methods=["*"], allow_headers=["*"],
218
  )
219
 
220
+ # Swagger Authorize(X-API-Key)を出す
221
  def custom_openapi():
222
  if app.openapi_schema:
223
  return app.openapi_schema
 
233
  "name": "X-API-Key",
234
  "in": "header",
235
  }
 
236
  for path in openapi_schema.get("paths", {}).values():
237
  for method in path.values():
238
  method.setdefault("security", [{"APIKeyHeader": []}])
 
245
  def on_startup():
246
  SQLModel.metadata.create_all(engine)
247
 
248
+ # ---- UI(/app) ----
249
+ app.mount("/app", StaticFiles(directory="static", html=True), name="app")
250
+
251
  # -------- Customers --------
252
  @app.post("/customers", dependencies=[Depends(require_api_key)])
253
  def create_customer(payload: Customer, session: Session = Depends(get_session)):
 
256
 
257
  @app.get("/customers", dependencies=[Depends(require_api_key)])
258
  def list_customers(
259
+ q: Optional[str] = Query(default=None, description="free text search (name/email/phone)"),
260
  limit: int = Query(50, ge=1, le=200),
261
  offset: int = Query(0, ge=0),
262
  session: Session = Depends(get_session),
263
  ):
264
+ stmt = select(Customer)
265
+ if q:
266
+ like = f"%{q}%"
267
+ stmt = stmt.where(
268
+ (Customer.name.ilike(like)) |
269
+ (Customer.email.ilike(like)) |
270
+ (Customer.phone.ilike(like))
271
+ )
272
+ total = session.exec(stmt).count()
273
+ rows = session.exec(stmt.offset(offset).limit(limit)).all()
274
  return {"data": rows, "pagination": {"total": total, "limit": limit, "offset": offset}}
275
 
276
+ # -------- Products(必要ならUIから追加) --------
277
  @app.post("/products", dependencies=[Depends(require_api_key)])
278
  def create_product(payload: Product, session: Session = Depends(get_session)):
279
  session.add(payload); session.commit(); session.refresh(payload)
 
291
 
292
  # -------- Quotes --------
293
  @app.post("/quotes", dependencies=[Depends(require_api_key)])
294
+ def create_quote(payload: CreateQuoteIn, session: Session = Depends(get_session)):
295
  if not session.get(Customer, payload.customer_id):
296
  raise HTTPException(400, "Customer not found")
297
+ q = Quote(customer_id=payload.customer_id, valid_until=payload.valid_until, notes=payload.notes)
298
+ session.add(q); session.commit(); session.refresh(q)
299
+ return q
300
 
301
  @app.get("/quotes/{quote_id}", dependencies=[Depends(require_api_key)])
302
  def get_quote(quote_id: int, session: Session = Depends(get_session)):
 
304
  if not q:
305
  raise HTTPException(404, "Quote not found")
306
  items = session.exec(select(QuoteItem).where(QuoteItem.quote_id == quote_id)).all()
307
+ totals = compute_totals(items)
308
  return {"quote": q, "items": items, "totals": totals}
309
 
310
  @app.post("/quotes/{quote_id}/items", dependencies=[Depends(require_api_key)])
311
+ def add_quote_item(quote_id: int, payload: QuoteItemIn, session: Session = Depends(get_session)):
312
  if not session.get(Quote, quote_id):
313
  raise HTTPException(404, "Quote not found")
314
+ item = QuoteItem(
315
+ quote_id=quote_id,
316
+ description=payload.description,
317
+ quantity=payload.quantity,
318
+ unit_price=payload.unit_price,
319
+ tax_rate=payload.tax_rate,
320
+ )
321
+ session.add(item); session.commit(); session.refresh(item)
322
+ return item
323
 
324
  # -------- Invoices --------
325
  @app.post("/invoices", dependencies=[Depends(require_api_key)])
326
+ def create_invoice(payload: CreateInvoiceIn, session: Session = Depends(get_session)):
327
+ # 見積→請求のコピーにも対応(quote_id を受け付ける)
328
  if not session.get(Customer, payload.customer_id):
329
  raise HTTPException(400, "Customer not found")
330
+
331
+ inv = Invoice(customer_id=payload.customer_id, due_date=payload.due_date, notes=payload.notes)
332
+ session.add(inv); session.commit(); session.refresh(inv)
333
+
334
+ if payload.quote_id:
335
+ q = session.get(Quote, payload.quote_id)
336
+ if not q:
337
+ raise HTTPException(404, "Quote not found to copy")
338
+ q_items = session.exec(select(QuoteItem).where(QuoteItem.quote_id == payload.quote_id)).all()
339
+ for it in q_items:
340
+ new_item = InvoiceItem(
341
+ invoice_id=inv.id,
342
+ product_id=it.product_id,
343
+ description=it.description,
344
+ quantity=it.quantity,
345
+ unit_price=it.unit_price,
346
+ tax_rate=it.tax_rate,
347
+ )
348
+ session.add(new_item)
349
+ session.commit()
350
+
351
+ session.refresh(inv)
352
+ return inv
353
 
354
  @app.get("/invoices/{invoice_id}", dependencies=[Depends(require_api_key)])
355
  def get_invoice(invoice_id: int, session: Session = Depends(get_session)):
 
361
  return {"invoice": inv, "items": items, "totals": totals}
362
 
363
  @app.post("/invoices/{invoice_id}/items", dependencies=[Depends(require_api_key)])
364
+ def add_invoice_item(invoice_id: int, payload: InvoiceItemIn, session: Session = Depends(get_session)):
365
  if not session.get(Invoice, invoice_id):
366
  raise HTTPException(404, "Invoice not found")
367
+ item = InvoiceItem(
368
+ invoice_id=invoice_id,
369
+ description=payload.description,
370
+ quantity=payload.quantity,
371
+ unit_price=payload.unit_price,
372
+ tax_rate=payload.tax_rate,
373
+ )
374
+ session.add(item); session.commit(); session.refresh(item)
375
+ return item
376
+
377
+ # ---- 支払い登録 ----
378
+ @app.post("/invoices/{invoice_id}/pay", dependencies=[Depends(require_api_key)])
379
+ def pay_invoice(invoice_id: int, payload: PayIn, session: Session = Depends(get_session)):
380
+ inv = session.get(Invoice, invoice_id)
381
+ if not inv:
382
+ raise HTTPException(404, "Invoice not found")
383
+ inv.paid_amount = payload.paid_amount
384
+ inv.paid_at = payload.paid_at or datetime.utcnow()
385
+ inv.payment_method = payload.payment_method
386
+ inv.status = "paid"
387
+ session.add(inv); session.commit(); session.refresh(inv)
388
+ return {"ok": True, "invoice": inv}
389
+
390
+ # ---- PDF生成&ダウンロード ----
391
+ @app.get("/invoices/{invoice_id}/pdf", dependencies=[Depends(require_api_key)])
392
+ def invoice_pdf(invoice_id: int, session: Session = Depends(get_session)):
393
+ inv = session.get(Invoice, invoice_id)
394
+ if not inv:
395
+ raise HTTPException(404, "Invoice not found")
396
+ cust = session.get(Customer, inv.customer_id)
397
+ items = session.exec(select(InvoiceItem).where(InvoiceItem.invoice_id == invoice_id)).all()
398
+
399
+ from pdf_export import write_invoice_pdf # 遅延import
400
+ out_path = f"/tmp/invoice_{invoice_id}.pdf"
401
+ totals = compute_totals(items)
402
+ write_invoice_pdf(out_path, inv, items, cust, totals)
403
+ return FileResponse(out_path, media_type="application/pdf", filename=f"invoice_{invoice_id}.pdf")
404
+
405
+ # ---- メール送信(PDF添付可)----
406
+ @app.post("/invoices/{invoice_id}/email", dependencies=[Depends(require_api_key)])
407
+ def email_invoice(invoice_id: int, payload: EmailIn, session: Session = Depends(get_session)):
408
+ inv = session.get(Invoice, invoice_id)
409
+ if not inv:
410
+ raise HTTPException(404, "Invoice not found")
411
+ cust = session.get(Customer, inv.customer_id)
412
+ items = session.exec(select(InvoiceItem).where(InvoiceItem.invoice_id == invoice_id)).all()
413
+ totals = compute_totals(items)
414
+
415
+ attachment_path = None
416
+ if payload.attach_pdf:
417
+ from pdf_export import write_invoice_pdf
418
+ attachment_path = f"/tmp/invoice_{invoice_id}.pdf"
419
+ write_invoice_pdf(attachment_path, inv, items, cust, totals)
420
+
421
+ from mailer import send_email_smtp
422
+ ok, detail = send_email_smtp(
423
+ to=payload.to,
424
+ subject=payload.subject,
425
+ body=payload.body,
426
+ attachment_path=attachment_path
427
+ )
428
+ return {"ok": ok, "detail": detail}
429
 
430
  @app.get("/")
431
  def root():
432
  return {"ok": True, "app": "mini-invoice-saas", "docs": "/docs"}
433
 
434
+ # -------- ChatGPT router を最後に組み込む --------
435
  from openai_integration import router as ai_router
436
  app.include_router(ai_router, prefix="/ai", tags=["ai"])
437
+
pdf_export.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pdf_export.py
2
+ from reportlab.lib.pagesizes import A4
3
+ from reportlab.lib.units import mm
4
+ from reportlab.lib import colors
5
+ from reportlab.pdfgen import canvas
6
+ from reportlab.platypus import Table, TableStyle, SimpleDocTemplate, Paragraph, Spacer
7
+ from reportlab.lib.styles import getSampleStyleSheet
8
+
9
+ def write_invoice_pdf(path, invoice, items, customer, totals):
10
+ doc = SimpleDocTemplate(path, pagesize=A4,
11
+ rightMargin=20*mm, leftMargin=20*mm,
12
+ topMargin=20*mm, bottomMargin=20*mm)
13
+ styles = getSampleStyleSheet()
14
+ story = []
15
+
16
+ title = "請求書" if invoice.status != "draft" else "請求書(下書き)"
17
+ story.append(Paragraph(title, styles["Title"]))
18
+ story.append(Spacer(1, 6))
19
+
20
+ # ヘッダ情報
21
+ header = [
22
+ ["請求書番号", str(invoice.id)],
23
+ ["発行日", str(invoice.issue_date)],
24
+ ["支払期日", str(invoice.due_date or "記載なし")],
25
+ ["ステータス", invoice.status],
26
+ ]
27
+ t = Table(header, colWidths=[30*mm, 120*mm])
28
+ t.setStyle(TableStyle([("BOX", (0,0), (-1,-1), 0.25, colors.black),
29
+ ("BACKGROUND", (0,0), (0,-1), colors.whitesmoke),
30
+ ("VALIGN",(0,0),(-1,-1),"MIDDLE")]))
31
+ story.append(t)
32
+ story.append(Spacer(1, 8))
33
+
34
+ # 請求先
35
+ to_table = [
36
+ ["請求先", customer.name if customer else f"Customer #{invoice.customer_id}"],
37
+ ["住所", (customer.address or "") if customer else ""],
38
+ ["メール", (customer.email or "") if customer else ""],
39
+ ]
40
+ tt = Table(to_table, colWidths=[30*mm, 120*mm])
41
+ tt.setStyle(TableStyle([("BOX", (0,0), (-1,-1), 0.25, colors.black),
42
+ ("BACKGROUND", (0,0), (0,-1), colors.whitesmoke)]))
43
+ story.append(tt)
44
+ story.append(Spacer(1, 8))
45
+
46
+ # 明細
47
+ data = [["内容", "数量", "単価", "税率", "金額"]]
48
+ for it in items:
49
+ line = it.quantity * it.unit_price
50
+ data.append([it.description, f"{it.quantity}", f"{it.unit_price:.2f}", f"{it.tax_rate*100:.0f}%", f"{line:.2f}"])
51
+
52
+ tbl = Table(data, colWidths=[70*mm, 20*mm, 25*mm, 20*mm, 35*mm])
53
+ tbl.setStyle(TableStyle([
54
+ ("GRID", (0,0), (-1,-1), 0.25, colors.black),
55
+ ("BACKGROUND", (0,0), (-1,0), colors.lightgrey),
56
+ ("ALIGN", (1,1), (-1,-1), "RIGHT"),
57
+ ("ALIGN", (0,0), (0,-1), "LEFT"),
58
+ ]))
59
+ story.append(tbl)
60
+ story.append(Spacer(1, 8))
61
+
62
+ # 合計
63
+ sum_table = [
64
+ ["小計", f"{totals.subtotal:.2f}"],
65
+ ["税額", f"{totals.tax:.2f}"],
66
+ ["合計", f"{totals.total:.2f}"],
67
+ ]
68
+ st = Table(sum_table, colWidths=[40*mm, 35*mm])
69
+ st.setStyle(TableStyle([
70
+ ("GRID", (0,0), (-1,-1), 0.25, colors.black),
71
+ ("BACKGROUND", (0,2), (-1,2), colors.yellow),
72
+ ("ALIGN", (1,0), (1,-1), "RIGHT"),
73
+ ]))
74
+ story.append(st)
75
+
76
+ # 備考
77
+ if invoice.notes:
78
+ story.append(Spacer(1, 8))
79
+ story.append(Paragraph(f"備考:{invoice.notes}", styles["Normal"]))
80
+
81
+ doc.build(story)
requirements.txt CHANGED
@@ -3,4 +3,6 @@ uvicorn[standard]>=0.27
3
  sqlmodel>=0.0.16
4
  pydantic>=2.6
5
  python-multipart>=0.0.9
6
- openai>=1.40.0
 
 
 
3
  sqlmodel>=0.0.16
4
  pydantic>=2.6
5
  python-multipart>=0.0.9
6
+ openai>=1.40.0
7
+ jinja2
8
+ reportlab
static:app.html ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Mini Invoice/Estimate SaaS</title>
7
+ <script>
8
+ const BASE = location.origin;
9
+ let API_KEY = localStorage.getItem("X_API_Key") || "dev";
10
+
11
+ async function api(path, method="GET", body=null) {
12
+ const res = await fetch(`${BASE}${path}`, {
13
+ method,
14
+ headers: {
15
+ "X-API-Key": API_KEY,
16
+ "Content-Type": "application/json"
17
+ },
18
+ body: body ? JSON.stringify(body) : null
19
+ });
20
+ if (!res.ok) {
21
+ const txt = await res.text();
22
+ throw new Error(`${res.status} ${txt}`);
23
+ }
24
+ const ct = res.headers.get("content-type") || "";
25
+ return ct.includes("application/json") ? res.json() : res.text();
26
+ }
27
+
28
+ function saveKey() {
29
+ API_KEY = document.querySelector("#api_key").value || "dev";
30
+ localStorage.setItem("X_API_Key", API_KEY);
31
+ log("X-API-Key を設定: " + API_KEY);
32
+ }
33
+
34
+ // ---- Customers ----
35
+ async function createCustomer() {
36
+ const name = document.querySelector("#c_name").value;
37
+ const email = document.querySelector("#c_email").value;
38
+ const phone = document.querySelector("#c_phone").value;
39
+ const address = document.querySelector("#c_addr").value;
40
+ const city = document.querySelector("#c_city").value;
41
+ const country = document.querySelector("#c_country").value;
42
+ const r = await api("/customers", "POST", {name, email, phone, address, city, country});
43
+ document.querySelector("#customer_id").value = r.id;
44
+ log("顧客作成 OK: id=" + r.id);
45
+ await searchCustomers(); // 反映
46
+ }
47
+
48
+ async function searchCustomers() {
49
+ const q = document.querySelector("#c_query").value;
50
+ const url = q ? `/customers?q=${encodeURIComponent(q)}` : "/customers";
51
+ const r = await api(url, "GET");
52
+ const tbody = document.querySelector("#customers_tbody");
53
+ tbody.innerHTML = "";
54
+ for (const c of r.data || []) {
55
+ const tr = document.createElement("tr");
56
+ tr.innerHTML = `
57
+ <td>${c.id}</td>
58
+ <td>${c.name || ""}</td>
59
+ <td>${c.email || ""}</td>
60
+ <td>${c.phone || ""}</td>
61
+ <td><button data-id="${c.id}">選択</button></td>`;
62
+ tr.querySelector("button").onclick = () => {
63
+ document.querySelector("#customer_id").value = c.id;
64
+ log("顧客を選択: id=" + c.id);
65
+ };
66
+ tbody.appendChild(tr);
67
+ }
68
+ }
69
+
70
+ // ---- Quotes ----
71
+ async function createQuote() {
72
+ const customer_id = +document.querySelector("#customer_id").value;
73
+ const valid_until = document.querySelector("#q_valid").value || null;
74
+ const notes = document.querySelector("#q_notes").value || null;
75
+ const q = await api("/quotes", "POST", {customer_id, valid_until, notes});
76
+ document.querySelector("#quote_id").value = q.id;
77
+ log("見積作成 OK: id=" + q.id);
78
+ }
79
+
80
+ async function addQuoteItem() {
81
+ const id = +document.querySelector("#quote_id").value;
82
+ const description = document.querySelector("#qit_desc").value;
83
+ const quantity = parseFloat(document.querySelector("#qit_qty").value || "1");
84
+ const unit_price = parseFloat(document.querySelector("#qit_price").value || "0");
85
+ const tax_rate = parseFloat(document.querySelector("#qit_tax").value || "0");
86
+ await api(`/quotes/${id}/items`, "POST", {description, quantity, unit_price, tax_rate});
87
+ log("見積 明細追加 OK");
88
+ }
89
+
90
+ // ---- Invoices ----
91
+ async function createInvoice() {
92
+ const customer_id = +document.querySelector("#customer_id").value;
93
+ const due_date = document.querySelector("#i_due").value || null;
94
+ const notes = document.querySelector("#i_notes").value || null;
95
+ const quote_id = document.querySelector("#quote_to_copy").value ? +document.querySelector("#quote_to_copy").value : null;
96
+ const payload = {customer_id, due_date, notes};
97
+ if (quote_id) payload.quote_id = quote_id; // 見積→請求 変換
98
+ const r = await api("/invoices", "POST", payload);
99
+ document.querySelector("#invoice_id").value = r.id;
100
+ log("請求書作成 OK: id=" + r.id + (quote_id ? "(見積コピーあり)" : ""));
101
+ }
102
+
103
+ async function addInvoiceItem() {
104
+ const id = +document.querySelector("#invoice_id").value;
105
+ const description = document.querySelector("#it_desc").value;
106
+ const quantity = parseFloat(document.querySelector("#it_qty").value || "1");
107
+ const unit_price = parseFloat(document.querySelector("#it_price").value || "0");
108
+ const tax_rate = parseFloat(document.querySelector("#it_tax").value || "0");
109
+ await api(`/invoices/${id}/items`, "POST", {description, quantity, unit_price, tax_rate});
110
+ log("請求 明細追加 OK");
111
+ }
112
+
113
+ async function viewInvoice() {
114
+ const id = +document.querySelector("#invoice_id").value;
115
+ const r = await api(`/invoices/${id}`, "GET");
116
+ document.querySelector("#totals").textContent =
117
+ `小計: ${r.totals.subtotal} / 税額: ${r.totals.tax} / 合計: ${r.totals.total}`;
118
+ log("請求書取得 OK");
119
+ return r;
120
+ }
121
+
122
+ async function markPaid() {
123
+ const id = +document.querySelector("#invoice_id").value;
124
+ const paid_amount = parseFloat(document.querySelector("#paid_amount").value || "0");
125
+ const payment_method = document.querySelector("#paid_method").value || "bank_transfer";
126
+ const r = await api(`/invoices/${id}/pay`, "POST", {paid_amount, payment_method});
127
+ log("入金登録 OK: " + JSON.stringify(r.invoice));
128
+ }
129
+
130
+ function downloadPDF() {
131
+ const id = +document.querySelector("#invoice_id").value;
132
+ const url = `${BASE}/invoices/${id}/pdf`;
133
+ fetch(url, { headers: { "X-API-Key": API_KEY }})
134
+ .then(res => {
135
+ if (!res.ok) throw new Error("PDF生成に失敗");
136
+ return res.blob();
137
+ })
138
+ .then(blob => {
139
+ const a = document.createElement("a");
140
+ a.href = URL.createObjectURL(blob);
141
+ a.download = `invoice_${id}.pdf`;
142
+ a.click();
143
+ }).catch(e => log("PDF失敗: " + e.message));
144
+ }
145
+
146
+ async function sendEmail() {
147
+ const id = +document.querySelector("#invoice_id").value;
148
+ const to = document.querySelector("#m_to").value;
149
+ const subject = document.querySelector("#m_subject").value;
150
+ const body = document.querySelector("#m_body").value;
151
+ const r = await api(`/invoices/${id}/email`, "POST", {to, subject, body, attach_pdf: true});
152
+ log("メール送信: " + (r.ok ? "OK" : "NG") + " / " + r.detail);
153
+ }
154
+
155
+ // ---- AI 文面生成(/ai/generate-email)----
156
+ async function generateAIEmail() {
157
+ const id = +document.querySelector("#invoice_id").value;
158
+ if (!id) {
159
+ log("先に請求書を作成し、invoice_id を入力してください");
160
+ return;
161
+ }
162
+ const detail = await viewInvoice(); // invoice, items, totals を取得
163
+ const invoice = detail.invoice;
164
+ const items = (detail.items || []).map(it => ({
165
+ description: it.description,
166
+ quantity: it.quantity,
167
+ unit_price: it.unit_price,
168
+ tax_rate: it.tax_rate
169
+ }));
170
+
171
+ // 顧客名を取得(請求先の見栄え強化)
172
+ let customerName = `Customer #${invoice.customer_id}`;
173
+ try {
174
+ const custList = await api(`/customers?q=${invoice.customer_id}`, "GET");
175
+ const hit = (custList.data || []).find(c => c.id === invoice.customer_id);
176
+ if (hit && hit.name) customerName = hit.name + " 御中";
177
+ } catch {}
178
+
179
+ const payload = {
180
+ kind: "invoice",
181
+ company_name: "HitC Inc.",
182
+ customer_name: customerName,
183
+ language: "ja",
184
+ tone: "polite",
185
+ due_date: invoice.due_date || null,
186
+ notes: invoice.notes || null,
187
+ items
188
+ };
189
+
190
+ try {
191
+ const res = await api("/ai/generate-email", "POST", payload);
192
+ // モデルは「件名と本文」を返すよう実装済み
193
+ // ヒューリスティックで件名/本文に分割
194
+ const txt = res.email || "";
195
+ const m = txt.match(/^(?:件名|Subject)[::]\s*(.+)\n([\s\S]*)$/m);
196
+ if (m) {
197
+ document.querySelector("#m_subject").value = m[1].trim();
198
+ document.querySelector("#m_body").value = m[2].trim();
199
+ } else {
200
+ document.querySelector("#m_subject").value = "ご請求書送付の件";
201
+ document.querySelector("#m_body").value = txt;
202
+ }
203
+ log("AI 文面生成 OK");
204
+ } catch (e) {
205
+ log("AI 文面生成に失敗: " + e.message);
206
+ }
207
+ }
208
+
209
+ function log(msg) {
210
+ const el = document.querySelector("#log");
211
+ el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}\n` + el.textContent;
212
+ }
213
+
214
+ window.addEventListener("load", () => {
215
+ document.querySelector("#api_key").value = API_KEY;
216
+ searchCustomers().catch(()=>{});
217
+ });
218
+ </script>
219
+ <style>
220
+ body { font-family: system-ui, sans-serif; margin: 16px; }
221
+ fieldset { border: 1px solid #ddd; padding: 12px; margin-bottom: 12px; }
222
+ legend { padding: 0 6px; }
223
+ label { display:block; margin-top:6px; }
224
+ input, textarea { width: 100%; padding: 6px; }
225
+ button { margin-top: 8px; padding: 6px 10px; }
226
+ table { width:100%; border-collapse: collapse; margin-top: 6px; }
227
+ th, td { border:1px solid #eee; padding:6px; }
228
+ th { background:#fafafa; }
229
+ #log { white-space: pre-wrap; border:1px solid #eee; padding:8px; min-height:80px; }
230
+ .row { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
231
+ @media (max-width: 800px){ .row { grid-template-columns: 1fr; } }
232
+ </style>
233
+ </head>
234
+ <body>
235
+ <h1>Mini Invoice/Estimate SaaS</h1>
236
+
237
+ <fieldset>
238
+ <legend>設定</legend>
239
+ <label> X-API-Key <input id="api_key" placeholder="dev"/></label>
240
+ <button onclick="saveKey()">保存</button>
241
+ <div>UI: <code>/app</code> / API例: <code>/invoices</code> / AI: <code>/ai/generate-email</code></div>
242
+ </fieldset>
243
+
244
+ <div class="row">
245
+ <fieldset>
246
+ <legend>顧客(作成)</legend>
247
+ <label>会社名 <input id="c_name" /></label>
248
+ <label>メール <input id="c_email" /></label>
249
+ <label>電話 <input id="c_phone" /></label>
250
+ <label>住所 <input id="c_addr" /></label>
251
+ <label>市区町村 <input id="c_city" /></label>
252
+ <label>国 <input id="c_country" value="JP" /></label>
253
+ <button onclick="createCustomer()">顧客を作成</button>
254
+ <label>customer_id <input id="customer_id" placeholder="自動セット"/></label>
255
+ </fieldset>
256
+
257
+ <fieldset>
258
+ <legend>顧客(一覧/検索)</legend>
259
+ <label>キーワード <input id="c_query" placeholder="社名/メール/電話"/></label>
260
+ <button onclick="searchCustomers()">検索</button>
261
+ <table>
262
+ <thead><tr><th>ID</th><th>名称</th><th>メール</th><th>電話</th><th>操作</th></tr></thead>
263
+ <tbody id="customers_tbody"></tbody>
264
+ </table>
265
+ </fieldset>
266
+ </div>
267
+
268
+ <div class="row">
269
+ <fieldset>
270
+ <legend>見積</legend>
271
+ <label>有効期限(yyyy-mm-dd) <input id="q_valid" /></label>
272
+ <label>備考 <input id="q_notes" /></label>
273
+ <button onclick="createQuote()">見積を作成</button>
274
+ <label>quote_id <input id="quote_id" placeholder="自動セット"/></label>
275
+
276
+ <h4>見積 明細</h4>
277
+ <label>内容 <input id="qit_desc" /></label>
278
+ <label>数量 <input id="qit_qty" value="1" /></label>
279
+ <label>単価 <input id="qit_price" value="0" /></label>
280
+ <label>税率(例 0.1) <input id="qit_tax" value="0.1" /></label>
281
+ <button onclick="addQuoteItem()">見積 明細を追加</button>
282
+ </fieldset>
283
+
284
+ <fieldset>
285
+ <legend>請求書</legend>
286
+ <label>支払期日(yyyy-mm-dd) <input id="i_due" /></label>
287
+ <label>備考 <input id="i_notes" /></label>
288
+ <label>見積IDからコピー(任意) <input id="quote_to_copy" placeholder="例: 1"/></label>
289
+ <button onclick="createInvoice()">請求書を作成</button>
290
+ <label>invoice_id <input id="invoice_id" placeholder="自動セット"/></label>
291
+
292
+ <h4>請求 明細</h4>
293
+ <label>内容 <input id="it_desc" /></label>
294
+ <label>数量 <input id="it_qty" value="1" /></label>
295
+ <label>単価 <input id="it_price" value="0" /></label>
296
+ <label>税率(例 0.1) <input id="it_tax" value="0.1" /></label>
297
+ <button onclick="addInvoiceItem()">請求 明細を追加</button>
298
+ <button onclick="viewInvoice()">合計を表示</button>
299
+ <div id="totals" style="margin-top:6px;color:#0a0"></div>
300
+ </fieldset>
301
+ </div>
302
+
303
+ <div class="row">
304
+ <fieldset>
305
+ <legend>入金登録</legend>
306
+ <label>受取金額 <input id="paid_amount" value="0" /></label>
307
+ <label>方法 <input id="paid_method" value="bank_transfer" /></label>
308
+ <button onclick="markPaid()">入金反映</button>
309
+ </fieldset>
310
+
311
+ <fieldset>
312
+ <legend>PDF / メール</legend>
313
+ <button onclick="downloadPDF()">PDFダウンロード</button>
314
+ <hr/>
315
+ <label>宛先(To) <input id="m_to" /></label>
316
+ <label>件名 <input id="m_subject" value="ご請求書送付の件" /></label>
317
+ <label>本文 <textarea id="m_body" rows="8">いつもお世話になっております。ご請求書をお送りします。ご確認をお願いいたします。</textarea></label>
318
+ <div style="display:flex; gap:8px; flex-wrap:wrap;">
319
+ <button onclick="generateAIEmail()">AIで文面作成</button>
320
+ <button onclick="sendEmail()">メール送信(PDF添付)</button>
321
+ </div>
322
+ <div style="color:#888; margin-top:6px;">※ SMTP が未設定の場合は送信スキップ(詳細はレスポンスに表示)</div>
323
+ </fieldset>
324
+ </div>
325
+
326
+ <fieldset>
327
+ <legend>ログ</legend>
328
+ <div id="log"></div>
329
+ </fieldset>
330
+ </body>
331
+ </html>