Upload 7 files
Browse files- mailer.py +38 -0
- main.py +166 -24
- pdf_export.py +81 -0
- requirements.txt +3 -1
- 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 |
-
#
|
| 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 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 242 |
if not session.get(Customer, payload.customer_id):
|
| 243 |
raise HTTPException(400, "Customer not found")
|
| 244 |
-
|
| 245 |
-
|
|
|
|
| 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)
|
| 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:
|
| 258 |
if not session.get(Quote, quote_id):
|
| 259 |
raise HTTPException(404, "Quote not found")
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
# -------- Invoices --------
|
| 265 |
@app.post("/invoices", dependencies=[Depends(require_api_key)])
|
| 266 |
-
def create_invoice(payload:
|
|
|
|
| 267 |
if not session.get(Customer, payload.customer_id):
|
| 268 |
raise HTTPException(400, "Customer not found")
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 283 |
if not session.get(Invoice, invoice_id):
|
| 284 |
raise HTTPException(404, "Invoice not found")
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
@app.get("/")
|
| 290 |
def root():
|
| 291 |
return {"ok": True, "app": "mini-invoice-saas", "docs": "/docs"}
|
| 292 |
|
| 293 |
-
# -------- ChatGPT router
|
| 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>
|