374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""
|
||
Mobile Sync API - Odoo連携
|
||
見積/納品/請求/領収書のモバイル同期エンドポイント
|
||
"""
|
||
|
||
from fastapi import FastAPI, HTTPException, Depends, Header, Request
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional, List
|
||
import sqlalchemy as sa
|
||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, JSON, Numeric, Boolean, ForeignKey
|
||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||
from sqlalchemy.ext.declarative import declarative_base
|
||
import os
|
||
import json
|
||
import requests
|
||
from dateutil.relativedelta import relativedelta
|
||
import logging
|
||
|
||
# ========== 設定 ==========
|
||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://odoo:odoo_secure_password@localhost:5432/odoo")
|
||
ODOO_URL = os.getenv("ODOO_URL", "http://localhost:8069")
|
||
ODOO_USER = os.getenv("ODOO_USER", "admin")
|
||
ODOO_PASSWORD = os.getenv("ODOO_PASSWORD", "admin")
|
||
API_SECRET_KEY = os.getenv("API_SECRET_KEY", "your_secret_key_here")
|
||
|
||
# ========== ログ設定 ==========
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ========== DB設定 ==========
|
||
engine = create_engine(DATABASE_URL, echo=False)
|
||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||
Base = declarative_base()
|
||
|
||
# ========== SQLAlchemy モデル ==========
|
||
class Customer(Base):
|
||
__tablename__ = "mobile_customers"
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
odoo_customer_id = Column(Integer, unique=True, nullable=True)
|
||
name = Column(String(255))
|
||
address = Column(String(500), nullable=True)
|
||
phone = Column(String(20), nullable=True)
|
||
email = Column(String(255), nullable=True)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
synced = Column(Boolean, default=False)
|
||
|
||
class Document(Base):
|
||
__tablename__ = "mobile_documents"
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
odoo_id = Column(Integer, unique=True, nullable=True)
|
||
doc_type = Column(String(50)) # quotation, delivery, invoice, receipt
|
||
customer_id = Column(Integer, ForeignKey("mobile_customers.id"))
|
||
document_date = Column(DateTime)
|
||
items = Column(JSON) # [{product_name, quantity, unit_price, subtotal}, ...]
|
||
subtotal = Column(Numeric(12, 2))
|
||
tax = Column(Numeric(12, 2))
|
||
total = Column(Numeric(12, 2))
|
||
status = Column(String(50)) # draft, sent, confirmed, paid, etc.
|
||
billing_date = Column(DateTime, nullable=True)
|
||
payment_due_date = Column(DateTime, nullable=True)
|
||
payment_method = Column(String(100), nullable=True)
|
||
paid_date = Column(DateTime, nullable=True)
|
||
notes = Column(String(1000), nullable=True)
|
||
created_at = Column(DateTime, default=datetime.utcnow)
|
||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||
synced = Column(Boolean, default=False)
|
||
sync_timestamp = Column(DateTime, nullable=True)
|
||
|
||
class SyncLog(Base):
|
||
__tablename__ = "sync_logs"
|
||
|
||
id = Column(Integer, primary_key=True)
|
||
device_id = Column(String(255))
|
||
operation = Column(String(50)) # sync, upload, download
|
||
document_count = Column(Integer)
|
||
status = Column(String(50)) # success, failure
|
||
message = Column(String(500), nullable=True)
|
||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||
|
||
# ========== Pydantic モデル ==========
|
||
class ItemModel(BaseModel):
|
||
product_name: str
|
||
quantity: float
|
||
unit_price: float
|
||
subtotal: float
|
||
|
||
class PaymentTermsModel(BaseModel):
|
||
billing_date: Optional[datetime] = None
|
||
payment_due_date: datetime
|
||
payment_method: str # bank_transfer, cash, etc.
|
||
|
||
class DocumentModel(BaseModel):
|
||
doc_type: str # quotation, delivery, invoice, receipt
|
||
customer_id: int
|
||
document_date: datetime
|
||
items: List[ItemModel]
|
||
subtotal: float
|
||
tax: float
|
||
total: float
|
||
payment_terms: PaymentTermsModel
|
||
status: str = "draft"
|
||
notes: Optional[str] = None
|
||
|
||
class SyncRequest(BaseModel):
|
||
device_id: str
|
||
last_sync_timestamp: Optional[datetime] = None
|
||
documents: List[DocumentModel]
|
||
|
||
class SyncResponse(BaseModel):
|
||
status: str
|
||
message: str
|
||
synced_documents: int
|
||
new_documents: Optional[List[dict]] = None
|
||
|
||
# ========== FastAPI アプリ ==========
|
||
app = FastAPI(title="Mobile Sync API", version="1.0.0")
|
||
|
||
# ========== DB セッション依存性 ==========
|
||
def get_db():
|
||
db = SessionLocal()
|
||
try:
|
||
yield db
|
||
finally:
|
||
db.close()
|
||
|
||
# ========== 認証 ==========
|
||
async def verify_api_key(x_api_key: str = Header(None)):
|
||
if x_api_key != API_SECRET_KEY:
|
||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||
return x_api_key
|
||
|
||
# ========== ヘルパー関数 ==========
|
||
def calculate_payment_due_date(billing_date: datetime, pattern: str) -> datetime:
|
||
"""
|
||
支払期限を計算
|
||
patterns:
|
||
- "immediate": 即支払い(当日)
|
||
- "end_of_month": 末締め翌月末
|
||
- "30days": 30日後
|
||
- "60days": 60日後
|
||
"""
|
||
if pattern == "immediate":
|
||
return billing_date
|
||
elif pattern == "end_of_month":
|
||
# 翌月末
|
||
next_month = billing_date + relativedelta(months=1)
|
||
return next_month.replace(day=1) - timedelta(days=1)
|
||
elif pattern == "30days":
|
||
return billing_date + timedelta(days=30)
|
||
elif pattern == "60days":
|
||
return billing_date + timedelta(days=60)
|
||
else:
|
||
# デフォルト:30日後
|
||
return billing_date + timedelta(days=30)
|
||
|
||
def sync_to_odoo(db: Session, document: Document) -> dict:
|
||
"""
|
||
ドキュメントを Odoo に同期
|
||
"""
|
||
try:
|
||
# Odoo XML-RPC または REST API を使用してデータを送信
|
||
# ここでは簡略版
|
||
logger.info(f"Syncing document {document.id} to Odoo")
|
||
|
||
# TODO: Odoo API呼び出し
|
||
# response = odoo_api.create_document(...)
|
||
|
||
document.synced = True
|
||
document.sync_timestamp = datetime.utcnow()
|
||
db.commit()
|
||
|
||
return {"status": "success", "odoo_id": document.odoo_id}
|
||
except Exception as e:
|
||
logger.error(f"Error syncing to Odoo: {str(e)}")
|
||
return {"status": "error", "message": str(e)}
|
||
|
||
# ========== エンドポイント ==========
|
||
|
||
@app.post("/api/v1/sync", response_model=SyncResponse, dependencies=[Depends(verify_api_key)])
|
||
async def sync_documents(request: SyncRequest, db: Session = Depends(get_db)):
|
||
"""
|
||
モバイルアプリからのドキュメント同期
|
||
"""
|
||
try:
|
||
synced_count = 0
|
||
new_documents = []
|
||
|
||
for doc in request.documents:
|
||
# 顧客を確認
|
||
customer = db.query(Customer).filter(Customer.id == doc.customer_id).first()
|
||
if not customer:
|
||
logger.warning(f"Customer {doc.customer_id} not found")
|
||
continue
|
||
|
||
# ドキュメント作成
|
||
db_doc = Document(
|
||
doc_type=doc.doc_type,
|
||
customer_id=doc.customer_id,
|
||
document_date=doc.document_date,
|
||
items=json.dumps([item.model_dump() for item in doc.items]),
|
||
subtotal=doc.subtotal,
|
||
tax=doc.tax,
|
||
total=doc.total,
|
||
status=doc.status,
|
||
billing_date=doc.payment_terms.billing_date,
|
||
payment_due_date=doc.payment_terms.payment_due_date,
|
||
payment_method=doc.payment_terms.payment_method,
|
||
notes=doc.notes,
|
||
synced=False
|
||
)
|
||
|
||
db.add(db_doc)
|
||
db.commit()
|
||
db.refresh(db_doc)
|
||
|
||
# Odoo に同期
|
||
sync_result = sync_to_odoo(db, db_doc)
|
||
|
||
synced_count += 1
|
||
new_documents.append({
|
||
"local_id": db_doc.id,
|
||
"odoo_id": db_doc.odoo_id,
|
||
"doc_type": db_doc.doc_type,
|
||
"status": sync_result["status"]
|
||
})
|
||
|
||
# ログ記録
|
||
sync_log = SyncLog(
|
||
device_id=request.device_id,
|
||
operation="sync",
|
||
document_count=synced_count,
|
||
status="success",
|
||
message=f"Synced {synced_count} documents"
|
||
)
|
||
db.add(sync_log)
|
||
db.commit()
|
||
|
||
return SyncResponse(
|
||
status="success",
|
||
message=f"Successfully synced {synced_count} documents",
|
||
synced_documents=synced_count,
|
||
new_documents=new_documents
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Sync error: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@app.get("/api/v1/customers", dependencies=[Depends(verify_api_key)])
|
||
async def get_customers(db: Session = Depends(get_db)):
|
||
"""
|
||
顧客一覧取得(オンライン時にマスタ更新)
|
||
"""
|
||
try:
|
||
customers = db.query(Customer).all()
|
||
return {
|
||
"status": "success",
|
||
"customers": [
|
||
{
|
||
"id": c.id,
|
||
"name": c.name,
|
||
"address": c.address,
|
||
"phone": c.phone,
|
||
"email": c.email
|
||
} for c in customers
|
||
]
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Error fetching customers: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@app.get("/api/v1/documents/{doc_id}", dependencies=[Depends(verify_api_key)])
|
||
async def get_document(doc_id: int, db: Session = Depends(get_db)):
|
||
"""
|
||
特定ドキュメント取得
|
||
"""
|
||
try:
|
||
doc = db.query(Document).filter(Document.id == doc_id).first()
|
||
if not doc:
|
||
raise HTTPException(status_code=404, detail="Document not found")
|
||
|
||
return {
|
||
"status": "success",
|
||
"document": {
|
||
"id": doc.id,
|
||
"doc_type": doc.doc_type,
|
||
"customer_id": doc.customer_id,
|
||
"document_date": doc.document_date,
|
||
"items": json.loads(doc.items) if doc.items else [],
|
||
"total": float(doc.total),
|
||
"payment_due_date": doc.payment_due_date,
|
||
"status": doc.status
|
||
}
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error fetching document: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@app.post("/api/v1/receipts/{invoice_id}", dependencies=[Depends(verify_api_key)])
|
||
async def create_receipt(invoice_id: int, db: Session = Depends(get_db)):
|
||
"""
|
||
請求書から領収書を自動生成
|
||
入金1週間以内の場合に発行可能
|
||
"""
|
||
try:
|
||
invoice = db.query(Document).filter(
|
||
Document.id == invoice_id,
|
||
Document.doc_type == "invoice"
|
||
).first()
|
||
|
||
if not invoice:
|
||
raise HTTPException(status_code=404, detail="Invoice not found")
|
||
|
||
if not invoice.paid_date:
|
||
raise HTTPException(status_code=400, detail="Invoice not yet paid")
|
||
|
||
# 入金1週間以内かチェック
|
||
days_since_payment = (datetime.utcnow() - invoice.paid_date).days
|
||
if days_since_payment > 7:
|
||
raise HTTPException(status_code=400, detail="Receipt cannot be issued (payment older than 7 days)")
|
||
|
||
# 領収書作成
|
||
receipt = Document(
|
||
doc_type="receipt",
|
||
customer_id=invoice.customer_id,
|
||
document_date=datetime.utcnow(),
|
||
items=invoice.items,
|
||
subtotal=invoice.subtotal,
|
||
tax=invoice.tax,
|
||
total=invoice.total,
|
||
status="issued",
|
||
paid_date=invoice.paid_date,
|
||
synced=False
|
||
)
|
||
|
||
db.add(receipt)
|
||
db.commit()
|
||
db.refresh(receipt)
|
||
|
||
return {
|
||
"status": "success",
|
||
"receipt_id": receipt.id,
|
||
"message": "Receipt created successfully"
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error creating receipt: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@app.get("/api/v1/health", dependencies=[Depends(verify_api_key)])
|
||
async def health_check():
|
||
"""
|
||
ヘルスチェック
|
||
"""
|
||
return {"status": "ok", "timestamp": datetime.utcnow()}
|
||
|
||
# ========== DB初期化 ==========
|
||
@app.on_event("startup")
|
||
async def startup():
|
||
Base.metadata.create_all(bind=engine)
|
||
logger.info("Database tables created")
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|