From 65c241c814f9564d4b5bc397f6685c4d5c06d474 Mon Sep 17 00:00:00 2001 From: joe Date: Sat, 31 Jan 2026 08:25:13 +0900 Subject: [PATCH] =?UTF-8?q?claude=E3=81=A7=E4=BD=9C=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E3=81=AE=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- claude/Android_ApiService.kt | 104 +++++++++ claude/Android_DAO.kt | 109 +++++++++ claude/Android_Database.kt | 71 ++++++ claude/Android_Models.kt | 170 ++++++++++++++ claude/Android_PdfGenerator.kt | 390 +++++++++++++++++++++++++++++++ claude/README.md | 414 +++++++++++++++++++++++++++++++++ claude/api_main.py | 374 +++++++++++++++++++++++++++++ claude/docker-compose.yml | 96 ++++++++ claude/odoo_sync.py | 334 ++++++++++++++++++++++++++ claude/requirements_api.txt | 8 + 10 files changed, 2070 insertions(+) create mode 100644 claude/Android_ApiService.kt create mode 100644 claude/Android_DAO.kt create mode 100644 claude/Android_Database.kt create mode 100644 claude/Android_Models.kt create mode 100644 claude/Android_PdfGenerator.kt create mode 100644 claude/README.md create mode 100644 claude/api_main.py create mode 100644 claude/docker-compose.yml create mode 100644 claude/odoo_sync.py create mode 100644 claude/requirements_api.txt diff --git a/claude/Android_ApiService.kt b/claude/Android_ApiService.kt new file mode 100644 index 0000000..ae541dd --- /dev/null +++ b/claude/Android_ApiService.kt @@ -0,0 +1,104 @@ +// app/src/main/java/com/example/mobilepos/data/api/SyncApiService.kt + +package com.example.mobilepos.data.api + +import retrofit2.Response +import retrofit2.http.* +import com.example.mobilepos.data.models.* +import java.util.UUID + +interface SyncApiService { + + @POST("/api/v1/sync") + suspend fun syncDocuments( + @Header("X-API-Key") apiKey: String, + @Body request: SyncRequestDto + ): Response + + @GET("/api/v1/customers") + suspend fun getCustomers( + @Header("X-API-Key") apiKey: String + ): Response + + @GET("/api/v1/documents/{id}") + suspend fun getDocument( + @Header("X-API-Key") apiKey: String, + @Path("id") documentId: Int + ): Response + + @POST("/api/v1/receipts/{invoiceId}") + suspend fun createReceipt( + @Header("X-API-Key") apiKey: String, + @Path("invoiceId") invoiceId: Int + ): Response + + @GET("/api/v1/health") + suspend fun healthCheck( + @Header("X-API-Key") apiKey: String + ): Response +} + +// ========== API Response Models ========== + +data class CustomerListResponse( + val status: String, + val customers: List +) + +data class DocumentDetailResponse( + val status: String, + val document: DocumentDto +) + +data class ReceiptCreateResponse( + val status: String, + val receiptId: Int, + val message: String +) + +data class HealthCheckResponse( + val status: String, + val timestamp: Long +) + +// ========== Retrofit Factory ========== + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import com.google.gson.GsonBuilder + +object ApiClient { + private var retrofit: Retrofit? = null + + fun getClient(baseUrl: String): Retrofit { + if (retrofit == null) { + val httpLoggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + val okHttpClient = OkHttpClient.Builder() + .addInterceptor(httpLoggingInterceptor) + .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS) + .build() + + val gson = GsonBuilder() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ss") + .create() + + retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + } + return retrofit!! + } + + fun getSyncApiService(baseUrl: String): SyncApiService { + return getClient(baseUrl).create(SyncApiService::class.java) + } +} diff --git a/claude/Android_DAO.kt b/claude/Android_DAO.kt new file mode 100644 index 0000000..e867bc4 --- /dev/null +++ b/claude/Android_DAO.kt @@ -0,0 +1,109 @@ +// app/src/main/java/com/example/mobilepos/data/dao/DocumentDao.kt + +package com.example.mobilepos.data.dao + +import androidx.room.* +import com.example.mobilepos.data.models.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface CustomerDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCustomer(customer: CustomerEntity) + + @Update + suspend fun updateCustomer(customer: CustomerEntity) + + @Delete + suspend fun deleteCustomer(customer: CustomerEntity) + + @Query("SELECT * FROM customers WHERE id = :id") + suspend fun getCustomerById(id: Int): CustomerEntity? + + @Query("SELECT * FROM customers ORDER BY name ASC") + fun getAllCustomers(): Flow> + + @Query("SELECT * FROM customers WHERE synced = 0") + suspend fun getUnsyncedCustomers(): List + + @Query("UPDATE customers SET synced = 1 WHERE id = :id") + suspend fun markCustomerAsSynced(id: Int) +} + +@Dao +interface DocumentDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDocument(document: DocumentEntity): Long + + @Update + suspend fun updateDocument(document: DocumentEntity) + + @Delete + suspend fun deleteDocument(document: DocumentEntity) + + @Query("SELECT * FROM documents WHERE id = :id") + suspend fun getDocumentById(id: Int): DocumentEntity? + + @Query("SELECT * FROM documents WHERE customerId = :customerId ORDER BY documentDate DESC") + fun getDocumentsByCustomer(customerId: Int): Flow> + + @Query("SELECT * FROM documents WHERE docType = :docType ORDER BY documentDate DESC") + fun getDocumentsByType(docType: String): Flow> + + @Query("SELECT * FROM documents WHERE status = :status ORDER BY documentDate DESC") + fun getDocumentsByStatus(status: String): Flow> + + @Query("SELECT * FROM documents WHERE docType = 'invoice' AND paidDate IS NULL ORDER BY paymentDueDate ASC") + fun getUnpaidInvoices(): Flow> + + @Query("SELECT * FROM documents WHERE synced = 0") + suspend fun getUnsyncedDocuments(): List + + @Query("SELECT * FROM documents ORDER BY documentDate DESC") + fun getAllDocuments(): Flow> + + @Query("UPDATE documents SET synced = 1, syncTimestamp = :timestamp WHERE id = :id") + suspend fun markDocumentAsSynced(id: Int, timestamp: Long) + + @Query("UPDATE documents SET status = :status, paidDate = :paidDate WHERE id = :id") + suspend fun updateDocumentStatus(id: Int, status: String, paidDate: Long?) + + @Query("UPDATE documents SET paymentDueDate = :dueDate WHERE id = :id") + suspend fun updatePaymentDueDate(id: Int, dueDate: Long) + + @Query("DELETE FROM documents WHERE docType = :docType") + suspend fun deleteDocumentsByType(docType: String) +} + +@Dao +interface PaymentTermsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPaymentTerms(terms: PaymentTermsEntity) + + @Query("SELECT * FROM payment_terms WHERE documentId = :documentId") + suspend fun getPaymentTermsByDocument(documentId: Int): PaymentTermsEntity? + + @Query("SELECT * FROM payment_terms ORDER BY createdAt DESC") + suspend fun getAllPaymentTerms(): List + + @Delete + suspend fun deletePaymentTerms(terms: PaymentTermsEntity) +} + +@Dao +interface SyncLogDao { + @Insert + suspend fun insertSyncLog(log: SyncLogEntity) + + @Query("SELECT * FROM sync_logs ORDER BY timestamp DESC LIMIT :limit") + suspend fun getRecentSyncLogs(limit: Int = 10): List + + @Query("SELECT * FROM sync_logs WHERE deviceId = :deviceId ORDER BY timestamp DESC LIMIT :limit") + suspend fun getSyncLogsByDevice(deviceId: String, limit: Int = 10): List + + @Query("SELECT MAX(timestamp) FROM sync_logs WHERE operation = 'sync' AND status = 'success'") + suspend fun getLastSuccessfulSyncTime(): Long? + + @Query("DELETE FROM sync_logs WHERE timestamp < :olderThanMillis") + suspend fun deleteSyncLogsOlderThan(olderThanMillis: Long) +} diff --git a/claude/Android_Database.kt b/claude/Android_Database.kt new file mode 100644 index 0000000..85a502e --- /dev/null +++ b/claude/Android_Database.kt @@ -0,0 +1,71 @@ +// app/src/main/java/com/example/mobilepos/data/AppDatabase.kt + +package com.example.mobilepos.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import com.example.mobilepos.data.dao.* +import com.example.mobilepos.data.models.* + +@Database( + entities = [ + CustomerEntity::class, + DocumentEntity::class, + PaymentTermsEntity::class, + SyncLogEntity::class + ], + version = 2, + exportSchema = true +) +abstract class AppDatabase : RoomDatabase() { + abstract fun customerDao(): CustomerDao + abstract fun documentDao(): DocumentDao + abstract fun paymentTermsDao(): PaymentTermsDao + abstract fun syncLogDao(): SyncLogDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "mobile_pos_database" + ) + .addMigrations(MIGRATION_1_2) + .build() + INSTANCE = instance + instance + } + } + + // マイグレーション: v1 -> v2 + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + // syncTimestamp カラムを追加 + database.execSQL( + "ALTER TABLE documents ADD COLUMN syncTimestamp INTEGER" + ) + // インデックスを追加(パフォーマンス向上) + database.execSQL( + "CREATE INDEX IF NOT EXISTS idx_documents_customerId ON documents(customerId)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS idx_documents_docType ON documents(docType)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS idx_documents_synced ON documents(synced)" + ) + } + } + } +} diff --git a/claude/Android_Models.kt b/claude/Android_Models.kt new file mode 100644 index 0000000..c6422ba --- /dev/null +++ b/claude/Android_Models.kt @@ -0,0 +1,170 @@ +// app/src/main/java/com/example/mobilepos/data/models/Models.kt + +package com.example.mobilepos.data.models + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import java.util.Date + +// ========== Room Database Entities ========== + +@Entity(tableName = "customers") +data class CustomerEntity( + @PrimaryKey + val id: Int, + val odooCustomerId: Int? = null, + val name: String, + val address: String? = null, + val phone: String? = null, + val email: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val synced: Boolean = false +) + +@Entity(tableName = "documents") +data class DocumentEntity( + @PrimaryKey + val id: Int = 0, + val odooId: Int? = null, + val docType: String, // quotation, delivery, invoice, receipt + val customerId: Int, + val documentDate: Long, + val items: String, // JSON string + val subtotal: Double, + val tax: Double, + val total: Double, + val status: String, // draft, sent, confirmed, paid + val billingDate: Long? = null, + val paymentDueDate: Long, + val paymentMethod: String? = null, + val paidDate: Long? = null, + val notes: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), + val synced: Boolean = false, + val syncTimestamp: Long? = null +) + +@Entity(tableName = "payment_terms") +data class PaymentTermsEntity( + @PrimaryKey + val id: Int = 0, + val documentId: Int, + val billingDate: Long? = null, + val paymentDueDate: Long, + val paymentMethod: String, + val createdAt: Long = System.currentTimeMillis() +) + +@Entity(tableName = "sync_logs") +data class SyncLogEntity( + @PrimaryKey + val id: Int = 0, + val deviceId: String, + val operation: String, // sync, upload, download + val documentCount: Int, + val status: String, // success, failure + val message: String? = null, + val timestamp: Long = System.currentTimeMillis() +) + +// ========== Data Transfer Objects (DTOs) ========== + +data class CustomerDto( + val id: Int, + val name: String, + val address: String? = null, + val phone: String? = null, + val email: String? = null +) + +data class DocumentItemDto( + val productName: String, + val quantity: Double, + val unitPrice: Double, + val subtotal: Double +) + +data class PaymentTermsDto( + val billingDate: Long? = null, + val paymentDueDate: Long, + val paymentMethod: String +) + +data class DocumentDto( + val id: Int = 0, + val docType: String, + val customerId: Int, + val documentDate: Long, + val items: List, + val subtotal: Double, + val tax: Double, + val total: Double, + val paymentTerms: PaymentTermsDto, + val status: String = "draft", + val notes: String? = null +) + +data class SyncRequestDto( + val deviceId: String, + val lastSyncTimestamp: Long? = null, + val documents: List +) + +data class SyncResponseDto( + val status: String, + val message: String, + val syncedDocuments: Int, + val newDocuments: List>? = null +) + +// ========== UI State Models ========== + +data class DocumentUIState( + val id: Int = 0, + val docType: String = "quotation", + val customer: CustomerDto? = null, + val customerId: Int = 0, + val documentDate: Long = System.currentTimeMillis(), + val items: List = emptyList(), + val subtotal: Double = 0.0, + val tax: Double = 0.0, + val total: Double = 0.0, + val billingDate: Long? = null, + val paymentDueDate: Long = System.currentTimeMillis(), + val paymentMethod: String = "bank_transfer", + val status: String = "draft", + val notes: String = "", + val isSaving: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val showPaymentDatePicker: Boolean = false +) + +data class DocumentListUIState( + val documents: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val filter: String = "all" // all, quotation, delivery, invoice, receipt +) + +data class SyncUIState( + val isSyncing: Boolean = false, + val syncProgress: Int = 0, + val lastSyncTime: Long? = null, + val syncedCount: Int = 0, + val totalCount: Int = 0, + val errorMessage: String? = null, + val status: String = "ready" +) + +// ========== Payment Term Patterns ========== +enum class PaymentPattern(val displayName: String, val days: Int? = null) { + IMMEDIATE("即支払い", 0), + END_OF_MONTH("末締め翌月末", null), + THIRTY_DAYS("30日後", 30), + SIXTY_DAYS("60日後", 60), + CUSTOM("カスタム", null) +} diff --git a/claude/Android_PdfGenerator.kt b/claude/Android_PdfGenerator.kt new file mode 100644 index 0000000..7c98b7c --- /dev/null +++ b/claude/Android_PdfGenerator.kt @@ -0,0 +1,390 @@ +// app/src/main/java/com/example/mobilepos/util/PdfGenerator.kt + +package com.example.mobilepos.util + +import android.content.Context +import android.graphics.pdf.PdfDocument +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Color +import android.graphics.Typeface +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* +import com.example.mobilepos.data.models.* +import com.google.gson.Gson + +object PdfGenerator { + + private const val PAGE_WIDTH = 595 // A4幅(ポイント) + private const val PAGE_HEIGHT = 842 // A4高さ(ポイント) + private const val MARGIN = 40 + private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2) + + fun generateQuotationPdf( + context: Context, + document: DocumentEntity, + customer: CustomerEntity, + fileName: String = "quotation_${document.id}.pdf" + ): File? { + return try { + val pdfDocument = PdfDocument() + var pageHeight = PAGE_HEIGHT + var yPosition = MARGIN + + val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, pageHeight, 1).create() + var page = pdfDocument.startPage(pageInfo) + var canvas = page.canvas + + // ヘッダー + yPosition = drawHeader(canvas, yPosition, "見積書") + yPosition += 20 + + // 会社情報(左上) + yPosition = drawCompanyInfo(canvas, yPosition) + yPosition += 20 + + // 見積情報 + canvas.drawText("見積日:${formatDate(document.documentDate)}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 20 + canvas.drawText("見積番号:QT-${document.id.toString().padStart(6, '0')}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 30 + + // 顧客情報 + yPosition = drawCustomerInfo(canvas, yPosition, customer) + yPosition += 20 + + // 区切り線 + canvas.drawLine(MARGIN.toFloat(), yPosition.toFloat(), (PAGE_WIDTH - MARGIN).toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 10 + + // 商品テーブルヘッダー + yPosition = drawTableHeader(canvas, yPosition) + yPosition += 5 + + // 商品行 + val items = parseDocumentItems(document.items) + for (item in items) { + if (yPosition > PAGE_HEIGHT - 100) { + // 新しいページ + pdfDocument.finishPage(page) + val newPageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, pageHeight, pdfDocument.pages.size + 1).create() + page = pdfDocument.startPage(newPageInfo) + canvas = page.canvas + yPosition = MARGIN + } + yPosition = drawTableRow(canvas, yPosition, item) + } + + yPosition += 10 + canvas.drawLine(MARGIN.toFloat(), yPosition.toFloat(), (PAGE_WIDTH - MARGIN).toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 15 + + // 合計 + yPosition = drawTotals(canvas, yPosition, document) + yPosition += 20 + + // 支払期限 + val dueDate = formatDate(document.paymentDueDate) + canvas.drawText("お支払い期限:$dueDate", MARGIN.toFloat(), yPosition.toFloat(), getPaint(bold = true)) + yPosition += 20 + canvas.drawText("お支払い方法:${document.paymentMethod ?: "銀行振込"}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + + // 備考 + if (!document.notes.isNullOrEmpty()) { + yPosition += 20 + canvas.drawText("備考:", MARGIN.toFloat(), yPosition.toFloat(), getPaint(bold = true)) + yPosition += 15 + val noteLines = document.notes!!.split("\n") + for (line in noteLines) { + canvas.drawText(line, MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 15 + } + } + + pdfDocument.finishPage(page) + + // ファイル保存 + val outputFile = File(context.getExternalFilesDir(null), fileName) + val fos = FileOutputStream(outputFile) + pdfDocument.writeTo(fos) + fos.close() + pdfDocument.close() + + outputFile + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun generateInvoicePdf( + context: Context, + document: DocumentEntity, + customer: CustomerEntity, + fileName: String = "invoice_${document.id}.pdf" + ): File? { + return try { + val pdfDocument = PdfDocument() + var pageHeight = PAGE_HEIGHT + var yPosition = MARGIN + + val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, pageHeight, 1).create() + var page = pdfDocument.startPage(pageInfo) + var canvas = page.canvas + + // ヘッダー + yPosition = drawHeader(canvas, yPosition, "請求書") + yPosition += 20 + + // 会社情報 + yPosition = drawCompanyInfo(canvas, yPosition) + yPosition += 20 + + // 請求情報 + canvas.drawText("請求日:${formatDate(document.documentDate)}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 20 + canvas.drawText("請求番号:INV-${document.id.toString().padStart(6, '0')}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 30 + + // 顧客情報 + yPosition = drawCustomerInfo(canvas, yPosition, customer) + yPosition += 20 + + // 区切り線 + canvas.drawLine(MARGIN.toFloat(), yPosition.toFloat(), (PAGE_WIDTH - MARGIN).toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 10 + + // 商品テーブル + yPosition = drawTableHeader(canvas, yPosition) + yPosition += 5 + + val items = parseDocumentItems(document.items) + for (item in items) { + if (yPosition > PAGE_HEIGHT - 150) { + pdfDocument.finishPage(page) + val newPageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, pageHeight, pdfDocument.pages.size + 1).create() + page = pdfDocument.startPage(newPageInfo) + canvas = page.canvas + yPosition = MARGIN + } + yPosition = drawTableRow(canvas, yPosition, item) + } + + yPosition += 10 + canvas.drawLine(MARGIN.toFloat(), yPosition.toFloat(), (PAGE_WIDTH - MARGIN).toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 15 + + // 合計 + yPosition = drawTotals(canvas, yPosition, document) + yPosition += 20 + + // 支払期限・方法 + val dueDate = formatDate(document.paymentDueDate) + canvas.drawText("お支払い期限:$dueDate", MARGIN.toFloat(), yPosition.toFloat(), getPaint(bold = true)) + yPosition += 20 + canvas.drawText("お支払い方法:${document.paymentMethod ?: "銀行振込"}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + + pdfDocument.finishPage(page) + + val outputFile = File(context.getExternalFilesDir(null), fileName) + val fos = FileOutputStream(outputFile) + pdfDocument.writeTo(fos) + fos.close() + pdfDocument.close() + + outputFile + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun generateReceiptPdf( + context: Context, + document: DocumentEntity, + customer: CustomerEntity, + fileName: String = "receipt_${document.id}.pdf" + ): File? { + return try { + val pdfDocument = PdfDocument() + var pageHeight = PAGE_HEIGHT + var yPosition = MARGIN + + val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, pageHeight, 1).create() + val page = pdfDocument.startPage(pageInfo) + val canvas = page.canvas + + // ヘッダー + yPosition = drawHeader(canvas, yPosition, "領収書") + yPosition += 20 + + // 会社情報 + yPosition = drawCompanyInfo(canvas, yPosition) + yPosition += 20 + + // 領収情報 + canvas.drawText("領収日:${formatDate(document.documentDate)}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 20 + canvas.drawText("領収番号:RCP-${document.id.toString().padStart(6, '0')}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 30 + + // 顧客情報 + yPosition = drawCustomerInfo(canvas, yPosition, customer) + yPosition += 20 + + // 金額 + canvas.drawText("お振込金額", MARGIN.toFloat(), yPosition.toFloat(), getPaint(bold = true)) + yPosition += 20 + + val totalText = "¥${String.format("%,d", document.total.toLong())}" + val totalPaint = getPaint(bold = true, size = 36f) + canvas.drawText(totalText, MARGIN.toFloat(), yPosition.toFloat(), totalPaint) + yPosition += 40 + + // 摘要 + canvas.drawText("摘要:商品・サービス提供代金", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + yPosition += 20 + + // 支払い日 + if (document.paidDate != null) { + canvas.drawText("お支払い日:${formatDate(document.paidDate!!)}", MARGIN.toFloat(), yPosition.toFloat(), getPaint()) + } + + pdfDocument.finishPage(page) + + val outputFile = File(context.getExternalFilesDir(null), fileName) + val fos = FileOutputStream(outputFile) + pdfDocument.writeTo(fos) + fos.close() + pdfDocument.close() + + outputFile + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + // ========== ヘルパー関数 ========== + + private fun drawHeader(canvas: Canvas, yPosition: Int, title: String): Int { + val paint = getPaint(bold = true, size = 28f) + canvas.drawText(title, MARGIN.toFloat(), (yPosition + 25).toFloat(), paint) + return yPosition + 40 + } + + private fun drawCompanyInfo(canvas: Canvas, yPosition: Int): Int { + val paint = getPaint(size = 10f) + var y = yPosition + canvas.drawText("株式会社 ○○○○", MARGIN.toFloat(), y.toFloat(), paint) + y += 12 + canvas.drawText("住所:〒000-0000 ○○県○○市○○町1-1", MARGIN.toFloat(), y.toFloat(), paint) + y += 12 + canvas.drawText("電話:09X-XXXX-XXXX", MARGIN.toFloat(), y.toFloat(), paint) + y += 12 + canvas.drawText("メール:info@example.com", MARGIN.toFloat(), y.toFloat(), paint) + return y + } + + private fun drawCustomerInfo(canvas: Canvas, yPosition: Int, customer: CustomerEntity): Int { + val paint = getPaint() + var y = yPosition + canvas.drawText("ご購入者様", MARGIN.toFloat(), y.toFloat(), paint) + y += 20 + canvas.drawText(customer.name, MARGIN.toFloat(), y.toFloat(), paint) + y += 15 + if (!customer.address.isNullOrEmpty()) { + canvas.drawText("住所:${customer.address}", MARGIN.toFloat(), y.toFloat(), paint) + y += 15 + } + if (!customer.phone.isNullOrEmpty()) { + canvas.drawText("電話:${customer.phone}", MARGIN.toFloat(), y.toFloat(), paint) + y += 15 + } + return y + } + + private fun drawTableHeader(canvas: Canvas, yPosition: Int): Int { + val paint = getPaint(bold = true) + val smallPaint = getPaint(size = 10f) + + var x = MARGIN + canvas.drawText("品目", x.toFloat(), yPosition.toFloat(), paint) + x += 200 + canvas.drawText("数量", x.toFloat(), yPosition.toFloat(), paint) + x += 80 + canvas.drawText("単価", x.toFloat(), yPosition.toFloat(), paint) + x += 80 + canvas.drawText("小計", x.toFloat(), yPosition.toFloat(), paint) + + return yPosition + 15 + } + + private fun drawTableRow(canvas: Canvas, yPosition: Int, item: DocumentItemDto): Int { + val paint = getPaint(size = 11f) + + val productText = item.productName + val quantityText = String.format("%.2f", item.quantity) + val unitPriceText = "¥${String.format("%,d", item.unitPrice.toLong())}" + val subtotalText = "¥${String.format("%,d", item.subtotal.toLong())}" + + var x = MARGIN + canvas.drawText(productText, x.toFloat(), yPosition.toFloat(), paint) + x += 200 + canvas.drawText(quantityText, x.toFloat(), yPosition.toFloat(), paint) + x += 80 + canvas.drawText(unitPriceText, x.toFloat(), yPosition.toFloat(), paint) + x += 80 + canvas.drawText(subtotalText, x.toFloat(), yPosition.toFloat(), paint) + + return yPosition + 15 + } + + private fun drawTotals(canvas: Canvas, yPosition: Int, document: DocumentEntity): Int { + val paint = getPaint() + val boldPaint = getPaint(bold = true) + + var y = yPosition + val rightX = (PAGE_WIDTH - MARGIN - 100).toFloat() + + // 小計 + canvas.drawText("小計:", rightX - 80, y.toFloat(), paint) + canvas.drawText("¥${String.format("%,d", document.subtotal.toLong())}", rightX.toFloat(), y.toFloat(), paint) + y += 20 + + // 税金 + canvas.drawText("税金:", rightX - 80, y.toFloat(), paint) + canvas.drawText("¥${String.format("%,d", document.tax.toLong())}", rightX.toFloat(), y.toFloat(), paint) + y += 20 + + // 合計 + canvas.drawText("合計:", rightX - 80, y.toFloat(), boldPaint) + canvas.drawText("¥${String.format("%,d", document.total.toLong())}", rightX.toFloat(), y.toFloat(), boldPaint) + + return y + 20 + } + + private fun getPaint(bold: Boolean = false, size: Float = 12f): Paint { + return Paint().apply { + this.typeface = if (bold) Typeface.create(Typeface.DEFAULT, Typeface.BOLD) else Typeface.DEFAULT + this.textSize = size + this.color = Color.BLACK + } + } + + private fun formatDate(timestamp: Long): String { + val sdf = SimpleDateFormat("yyyy年MM月dd日", Locale.JAPAN) + return sdf.format(Date(timestamp)) + } + + private fun parseDocumentItems(itemsJson: String): List { + return try { + val gson = Gson() + gson.fromJson(itemsJson, Array::class.java).toList() + } catch (e: Exception) { + emptyList() + } + } +} diff --git a/claude/README.md b/claude/README.md new file mode 100644 index 0000000..ce1ab0b --- /dev/null +++ b/claude/README.md @@ -0,0 +1,414 @@ +# モバイルPOS・見積/納品/請求/領収書システム + +## 概要 + +Proxmox CT上で動作するオフラインファーストのスマートフォンアプリケーション。 +営業現場で完全スタンドアロンで見積・納品・請求・領収書を作成・管理し、 +ネットワーク接続時にOdooと同期します。 + +## アーキテクチャ + +``` +┌─────────────────┐ +│ Android App │ ← スマホ(完全オフライン対応) +│ SQLite DB │ +│ PDF生成 │ +└────────┬────────┘ + │ (ネットワーク接続時) + │ + ┌────▼─────────────────────────┐ + │ REST API コンテナ │ + │ (Python FastAPI) │ + │ - 同期エンドポイント │ + │ - Odoo連携 │ + └────┬────────────────┬─────────┘ + │ │ + ┌────▼──────┐ ┌─────▼─────┐ + │ PostgreSQL│ │ Odoo │ + │ DB │ │ (Sales) │ + └───────────┘ └───────────┘ +``` + +## ディレクトリ構成 + +``` +project_root/ +├── docker-compose.yml # Odoo + API + DB +├── api/ +│ ├── main.py # FastAPI メイン +│ ├── requirements.txt +│ └── Dockerfile +├── addons/ # Odooカスタムモジュール +├── scheduler/ # 定期同期スクリプト +└── android/ + ├── Models.kt # データモデル + ├── DAO.kt # Room DAO + ├── Database.kt # Room DB + ├── ApiService.kt # Retrofit API + └── PdfGenerator.kt # PDF生成 +``` + +## セットアップ手順 + +### 1. Docker環境構築(Proxmox CT) + +```bash +# リポジトリクローン +git clone +cd project_root + +# 環境変数設定 +cp .env.example .env +# .envを編集(API_SECRET_KEY等) + +# コンテナ起動 +docker-compose up -d + +# ログ確認 +docker-compose logs -f api +docker-compose logs -f odoo +``` + +### 2. Odooの初期セットアップ + +```bash +# Odooにアクセス +# http://localhost:8069 + +# 以下のモジュールを有効化 +# - Sales (見積・受注管理) +# - Invoicing (請求・領収書) +# - Accounting (会計・売掛金) + +# API認証設定 +# Admin > 設定 > API キーを生成 +``` + +### 3. REST APIの初期化 + +```bash +# DB テーブル作成 +docker-compose exec api python -c "from main import Base, engine; Base.metadata.create_all(bind=engine)" + +# テスト +curl -X GET http://localhost:8000/api/v1/health \ + -H "X-API-Key: your_secret_key" +``` + +### 4. Androidアプリ開発 + +#### 依存パッケージ (build.gradle) + +```gradle +dependencies { + // Room + implementation "androidx.room:room-runtime:2.6.0" + implementation "androidx.room:room-ktx:2.6.0" + kapt "androidx.room:room-compiler:2.6.0" + + // Retrofit + implementation "com.squareup.retrofit2:retrofit:2.9.0" + implementation "com.squareup.retrofit2:converter-gson:2.9.0" + implementation "com.squareup.okhttp3:logging-interceptor:4.11.0" + + // Coroutines + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" + + // Jetpack Compose + implementation "androidx.compose.ui:ui:1.6.0" + implementation "androidx.compose.material3:material3:1.1.0" + + // PDF (iText or Apache POI) + implementation "com.itextpdf:itext-core:8.0.0" + + // Gson + implementation "com.google.code.gson:gson:2.10.1" +} +``` + +#### AndroidManifest.xml の設定 + +```xml + + + + +``` + +#### build.gradle 設定 + +```gradle +android { + compileSdk 34 + + defaultConfig { + applicationId "com.example.mobilepos" + minSdk 26 + targetSdk 34 + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.0" + } +} +``` + +## API エンドポイント + +### 同期 + +**POST** `/api/v1/sync` +```bash +curl -X POST http://localhost:8000/api/v1/sync \ + -H "X-API-Key: secret_key" \ + -H "Content-Type: application/json" \ + -d '{ + "device_id": "device_001", + "last_sync_timestamp": null, + "documents": [ + { + "doc_type": "quotation", + "customer_id": 1, + "document_date": "2026-01-31T10:00:00", + "items": [ + { + "product_name": "商品A", + "quantity": 10, + "unit_price": 1000, + "subtotal": 10000 + } + ], + "subtotal": 10000, + "tax": 1000, + "total": 11000, + "payment_terms": { + "billing_date": "2026-01-31", + "payment_due_date": "2026-02-28", + "payment_method": "bank_transfer" + } + } + ] + }' +``` + +### 顧客一覧 + +**GET** `/api/v1/customers` +```bash +curl http://localhost:8000/api/v1/customers \ + -H "X-API-Key: secret_key" +``` + +### ドキュメント取得 + +**GET** `/api/v1/documents/{id}` + +### 領収書自動生成 + +**POST** `/api/v1/receipts/{invoice_id}` +```bash +# 入金から1週間以内の請求書から領収書を生成 +curl -X POST http://localhost:8000/api/v1/receipts/1 \ + -H "X-API-Key: secret_key" +``` + +## Android実装ガイド + +### 1. データベース初期化 + +```kotlin +val db = AppDatabase.getInstance(context) +val documentDao = db.documentDao() +val customerDao = db.customerDao() +``` + +### 2. ドキュメント作成・保存 + +```kotlin +val document = DocumentEntity( + docType = "quotation", + customerId = 1, + documentDate = System.currentTimeMillis(), + items = Gson().toJson(listOf( + DocumentItemDto("商品A", 10.0, 1000.0, 10000.0) + )), + subtotal = 10000.0, + tax = 1000.0, + total = 11000.0, + paymentDueDate = calculateDueDate(PaymentPattern.END_OF_MONTH), + synced = false +) + +documentDao.insertDocument(document) +``` + +### 3. PDF生成 + +```kotlin +val file = PdfGenerator.generateQuotationPdf( + context = context, + document = document, + customer = customer, + fileName = "quotation_${document.id}.pdf" +) + +// ファイル共有 +val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) +val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra(Intent.EXTRA_STREAM, uri) +} +startActivity(Intent.createChooser(shareIntent, "PDFを共有")) +``` + +### 4. 同期処理 + +```kotlin +suspend fun syncDocuments(context: Context) { + val apiKey = "your_secret_key" + val baseUrl = "http://your_api_server:8000" + val apiService = ApiClient.getSyncApiService(baseUrl) + + val db = AppDatabase.getInstance(context) + val unsyncedDocs = db.documentDao().getUnsyncedDocuments() + + val request = SyncRequestDto( + deviceId = getDeviceId(), + documents = unsyncedDocs.map { convertToDto(it) } + ) + + try { + val response = apiService.syncDocuments(apiKey, request) + if (response.isSuccessful && response.body()?.status == "success") { + response.body()?.newDocuments?.forEach { doc -> + db.documentDao().markDocumentAsSynced(doc["local_id"] as Int, System.currentTimeMillis()) + } + } + } catch (e: Exception) { + Log.e("Sync", "Error: ${e.message}") + } +} +``` + +### 5. 支払期限の計算 + +```kotlin +fun calculatePaymentDueDate(billingDate: Long, pattern: PaymentPattern): Long { + val calendar = Calendar.getInstance().apply { + timeInMillis = billingDate + } + + return when (pattern) { + PaymentPattern.IMMEDIATE -> calendar.timeInMillis + PaymentPattern.END_OF_MONTH -> { + calendar.set(Calendar.DAY_OF_MONTH, 1) + calendar.add(Calendar.MONTH, 1) + calendar.add(Calendar.DAY_OF_MONTH, -1) + calendar.timeInMillis + } + PaymentPattern.THIRTY_DAYS -> { + calendar.add(Calendar.DAY_OF_MONTH, 30) + calendar.timeInMillis + } + PaymentPattern.SIXTY_DAYS -> { + calendar.add(Calendar.DAY_OF_MONTH, 60) + calendar.timeInMillis + } + else -> calendar.timeInMillis + } +} +``` + +## 同期フロー + +### オフライン時 +1. スマホアプリで見積/納品/請求/領収書を作成 +2. SQLiteに自動保存 +3. PDF生成・送信(メール等) + +### ネットワーク接続時 +1. 未同期ドキュメントを検出 +2. REST APIに送信 +3. API が Odoo に登録 +4. 同期完了後、ローカルの synced フラグを更新 +5. Odoo で売掛金管理・レポート生成 + +## 支払条件パターン + +| パターン | 説明 | 計算方式 | +|---------|------|--------| +| 即支払い | 当日支払い | 請求日 | +| 末締め翌月末 | 末締めで翌月末払い | 翌月末日 | +| 30日後 | 請求日から30日後 | 請求日 + 30日 | +| 60日後 | 請求日から60日後 | 請求日 + 60日 | +| カスタム | 任意設定 | ユーザーが指定 | + +## セキュリティ + +- API キーは環境変数で管理 +- HTTPS通信を推奨(本番環境) +- トークン認証で API アクセス制限 +- Odoo への認証も環境変数化 + +## トラブルシューティング + +### API接続エラー +```bash +# ヘルスチェック +curl http://localhost:8000/api/v1/health -H "X-API-Key: your_key" + +# ログ確認 +docker-compose logs api +``` + +### Odoo連携エラー +```bash +# Odooログ +docker-compose logs odoo + +# DBテーブル確認 +docker-compose exec postgres psql -U odoo -d odoo -c "\dt" +``` + +### Android PDF生成エラー +- ストレージパーミッション確認 +- 外部ストレージ空き容量確認 +- iText/Apache POI の対応バージョン確認 + +## 今後の実装予定 + +- [ ] Odoo REST API 直接連携 +- [ ] Nextcloud WebDAV バックアップ統合 +- [ ] 複数ユーザー・デバイス同期 +- [ ] オフライン時の競合解決 +- [ ] 売掛金ダッシュボード +- [ ] Web UI(PCから Odoo 管理用) +- [ ] カスタム領収書テンプレート +- [ ] 電子署名対応 + +## 開発者向け情報 + +### REST API テスト +```bash +# FastAPI ドキュメント +http://localhost:8000/docs +``` + +### DB マイグレーション +```bash +# 新しいテーブル追加時 +# Android: Migrations クラスを追加 +# API: SQLAlchemy モデルを追加 → DB再作成 +``` + +## ライセンス + +MIT License + +## サポート + +問題報告は Issues で。 diff --git a/claude/api_main.py b/claude/api_main.py new file mode 100644 index 0000000..91777c4 --- /dev/null +++ b/claude/api_main.py @@ -0,0 +1,374 @@ +""" +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) diff --git a/claude/docker-compose.yml b/claude/docker-compose.yml new file mode 100644 index 0000000..69c85e4 --- /dev/null +++ b/claude/docker-compose.yml @@ -0,0 +1,96 @@ +version: '3.8' + +services: + # PostgreSQL(Odoo用) + postgres: + image: postgres:15-alpine + container_name: odoo_db + environment: + POSTGRES_DB: odoo + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo_secure_password + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - odoo_network + restart: unless-stopped + + # Odoo + odoo: + image: odoo:17.0 + container_name: odoo_app + depends_on: + - postgres + environment: + HOST: postgres + USER: odoo + PASSWORD: odoo_secure_password + DB_NAME: odoo + volumes: + - odoo_data:/var/lib/odoo + - ./addons:/mnt/extra-addons + ports: + - "8069:8069" + networks: + - odoo_network + restart: unless-stopped + + # REST API(モバイル同期用) + api: + build: + context: ./api + dockerfile: Dockerfile + container_name: mobile_sync_api + depends_on: + - postgres + - odoo + environment: + DATABASE_URL: postgresql://odoo:odoo_secure_password@postgres:5432/odoo + ODOO_URL: http://odoo:8069 + ODOO_USER: admin + ODOO_PASSWORD: admin + API_SECRET_KEY: your_secret_key_here_change_me + ports: + - "8000:8000" + volumes: + - ./api:/app + networks: + - odoo_network + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + restart: unless-stopped + + # 同期・バックアップスクリプト(定期実行) + sync_scheduler: + build: + context: ./scheduler + dockerfile: Dockerfile + container_name: sync_scheduler + depends_on: + - postgres + - odoo + - api + environment: + DATABASE_URL: postgresql://odoo:odoo_secure_password@postgres:5432/odoo + ODOO_URL: http://odoo:8069 + ODOO_USER: admin + ODOO_PASSWORD: admin + NEXTCLOUD_URL: https://your_nextcloud_url + NEXTCLOUD_USER: your_nextcloud_user + NEXTCLOUD_PASSWORD: your_nextcloud_password + volumes: + - ./scheduler:/app + - sync_logs:/var/log/sync + networks: + - odoo_network + restart: unless-stopped + +volumes: + postgres_data: + odoo_data: + sync_logs: + +networks: + odoo_network: + driver: bridge diff --git a/claude/odoo_sync.py b/claude/odoo_sync.py new file mode 100644 index 0000000..ea74088 --- /dev/null +++ b/claude/odoo_sync.py @@ -0,0 +1,334 @@ +# api/odoo_sync.py +""" +Odoo連携モジュール +REST API が受け取ったドキュメントを Odoo に同期 +""" + +import requests +import logging +from typing import Dict, List, Optional +from datetime import datetime +import json + +logger = logging.getLogger(__name__) + +class OdooClient: + """Odoo XML-RPC クライアント""" + + def __init__(self, odoo_url: str, db: str, username: str, password: str): + self.odoo_url = odoo_url + self.db = db + self.username = username + self.password = password + self.uid = None + self.authenticate() + + def authenticate(self): + """Odoo 認証""" + try: + import xmlrpc.client + common = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/common') + self.uid = common.authenticate(self.db, self.username, self.password, {}) + logger.info(f"Odoo authenticated: uid={self.uid}") + except Exception as e: + logger.error(f"Odoo authentication failed: {str(e)}") + raise + + def create_customer(self, name: str, address: str = "", phone: str = "", email: str = "") -> int: + """顧客を Odoo に作成""" + try: + import xmlrpc.client + models = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/object') + + partner_data = { + 'name': name, + 'street': address, + 'phone': phone, + 'email': email, + 'customer_rank': 1, + } + + partner_id = models.execute_kw( + self.db, self.uid, self.password, + 'res.partner', 'create', [partner_data] + ) + + logger.info(f"Created Odoo customer: {partner_id}") + return partner_id + + except Exception as e: + logger.error(f"Error creating customer: {str(e)}") + return 0 + + def create_quotation(self, customer_id: int, items: List[Dict], + payment_due_date: str, notes: str = "") -> int: + """見積を Odoo に作成""" + try: + import xmlrpc.client + models = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/object') + + # 見積ラインの準備 + order_lines = [] + for item in items: + # 商品をOdooから検索(簡略版) + product_search = models.execute_kw( + self.db, self.uid, self.password, + 'product.product', 'search', + [[('name', '=', item['product_name'])]] + ) + + product_id = product_search[0] if product_search else 1 + + line_data = (0, 0, { + 'product_id': product_id, + 'product_qty': item['quantity'], + 'price_unit': item['unit_price'], + }) + order_lines.append(line_data) + + # 見積作成 + quotation_data = { + 'partner_id': customer_id, + 'order_line': order_lines, + 'date_order': datetime.now().isoformat(), + 'payment_term_id': self._get_payment_term_id(payment_due_date), + 'note': notes, + } + + quotation_id = models.execute_kw( + self.db, self.uid, self.password, + 'sale.order', 'create', [quotation_data] + ) + + logger.info(f"Created Odoo quotation: {quotation_id}") + return quotation_id + + except Exception as e: + logger.error(f"Error creating quotation: {str(e)}") + return 0 + + def create_invoice(self, customer_id: int, items: List[Dict], + payment_due_date: str, notes: str = "") -> int: + """請求書を Odoo に作成""" + try: + import xmlrpc.client + models = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/object') + + invoice_lines = [] + for item in items: + product_search = models.execute_kw( + self.db, self.uid, self.password, + 'product.product', 'search', + [[('name', '=', item['product_name'])]] + ) + + product_id = product_search[0] if product_search else 1 + + line_data = (0, 0, { + 'product_id': product_id, + 'quantity': item['quantity'], + 'price_unit': item['unit_price'], + }) + invoice_lines.append(line_data) + + invoice_data = { + 'partner_id': customer_id, + 'invoice_line_ids': invoice_lines, + 'invoice_date': datetime.now().date().isoformat(), + 'invoice_date_due': payment_due_date, + 'note': notes, + } + + invoice_id = models.execute_kw( + self.db, self.uid, self.password, + 'account.move', 'create', [invoice_data] + ) + + logger.info(f"Created Odoo invoice: {invoice_id}") + return invoice_id + + except Exception as e: + logger.error(f"Error creating invoice: {str(e)}") + return 0 + + def record_payment(self, invoice_id: int, amount: float, payment_date: str) -> int: + """支払いを記録""" + try: + import xmlrpc.client + models = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/object') + + payment_data = { + 'move_id': invoice_id, + 'amount': amount, + 'payment_date': payment_date, + } + + payment_id = models.execute_kw( + self.db, self.uid, self.password, + 'account.payment', 'create', [payment_data] + ) + + logger.info(f"Recorded payment: {payment_id}") + return payment_id + + except Exception as e: + logger.error(f"Error recording payment: {str(e)}") + return 0 + + def get_customer_by_email(self, email: str) -> Optional[int]: + """メールアドレスで顧客を検索""" + try: + import xmlrpc.client + models = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/object') + + result = models.execute_kw( + self.db, self.uid, self.password, + 'res.partner', 'search', + [[('email', '=', email)]] + ) + + return result[0] if result else None + + except Exception as e: + logger.error(f"Error searching customer: {str(e)}") + return None + + def _get_payment_term_id(self, due_date: str) -> int: + """支払い条件を Odoo から取得""" + try: + import xmlrpc.client + models = xmlrpc.client.ServerProxy(f'{self.odoo_url}/xmlrpc/2/object') + + # 簡略版:「30日」の支払い条件 ID を取得 + result = models.execute_kw( + self.db, self.uid, self.password, + 'account.payment.term', 'search', + [[('name', 'like', '30')]] + ) + + return result[0] if result else 1 + + except Exception as e: + logger.warning(f"Could not get payment term: {str(e)}") + return 1 + + +class SyncService: + """REST API と Odoo の同期サービス""" + + def __init__(self, odoo_client: OdooClient): + self.odoo = odoo_client + + def sync_document(self, db_session, document_entity) -> Dict: + """ドキュメントを Odoo に同期""" + + # 顧客情報を取得 + customer = db_session.query(Customer).filter( + Customer.id == document_entity.customer_id + ).first() + + if not customer: + return {"status": "error", "message": "Customer not found"} + + # Odoo 顧客 ID を確認・作成 + odoo_customer_id = customer.odoo_customer_id + if not odoo_customer_id: + odoo_customer_id = self.odoo.create_customer( + name=customer.name, + address=customer.address or "", + phone=customer.phone or "", + email=customer.email or "" + ) + customer.odoo_customer_id = odoo_customer_id + db_session.commit() + + if not odoo_customer_id: + return {"status": "error", "message": "Could not create/find Odoo customer"} + + # ドキュメントタイプ別処理 + items = json.loads(document_entity.items) + + try: + if document_entity.doc_type == "quotation": + odoo_id = self.odoo.create_quotation( + customer_id=odoo_customer_id, + items=items, + payment_due_date=self._format_date(document_entity.payment_due_date), + notes=document_entity.notes or "" + ) + + elif document_entity.doc_type == "invoice": + odoo_id = self.odoo.create_invoice( + customer_id=odoo_customer_id, + items=items, + payment_due_date=self._format_date(document_entity.payment_due_date), + notes=document_entity.notes or "" + ) + + else: + odoo_id = 0 + + if odoo_id: + document_entity.odoo_id = odoo_id + return {"status": "success", "odoo_id": odoo_id} + else: + return {"status": "error", "message": "Failed to create Odoo document"} + + except Exception as e: + logger.error(f"Sync error: {str(e)}") + return {"status": "error", "message": str(e)} + + @staticmethod + def _format_date(timestamp: int) -> str: + """タイムスタンプを ISO 形式に変換""" + from datetime import datetime + return datetime.fromtimestamp(timestamp / 1000).isoformat() + + +# FastAPI メインに統合される部分 + +from fastapi import FastAPI, Depends +from sqlalchemy.orm import Session + +app = FastAPI() + +# グローバル Odoo クライアント +odoo_client = OdooClient( + odoo_url=os.getenv("ODOO_URL", "http://localhost:8069"), + db="odoo", + username=os.getenv("ODOO_USER", "admin"), + password=os.getenv("ODOO_PASSWORD", "admin") +) + +sync_service = SyncService(odoo_client) + +@app.post("/api/v1/sync") +async def sync_documents(request: SyncRequest, db: Session = Depends(get_db)): + """ドキュメント同期エンドポイント""" + + synced_count = 0 + new_documents = [] + + for doc in request.documents: + # ドキュメント作成(DB) + db_doc = Document(...) + db.add(db_doc) + db.commit() + + # Odoo に同期 + result = sync_service.sync_document(db, db_doc) + + if result["status"] == "success": + synced_count += 1 + new_documents.append({ + "local_id": db_doc.id, + "odoo_id": result.get("odoo_id"), + "doc_type": db_doc.doc_type + }) + + return SyncResponse( + status="success", + message=f"Synced {synced_count} documents", + synced_documents=synced_count, + new_documents=new_documents + ) diff --git a/claude/requirements_api.txt b/claude/requirements_api.txt new file mode 100644 index 0000000..5c48280 --- /dev/null +++ b/claude/requirements_api.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +pydantic==2.5.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +python-dateutil==2.8.2 +requests==2.31.0 +python-multipart==0.0.6