Anmelden

Odoo anbinden

Als Markdown ansehen

Lassen Sie Odoo die PDFs seiner Eingangsrechnungen an Ocost schicken und seine Rechnungsentwürfe aus dem Gelesenen füllen. Odoo führt die Bücher. Ocost schreibt nie in Odoo: Ihr Odoo fragt Ocost und füllt seine eigenen Rechnungen.

Das Ocost-Modul

Ein Ocost-Modul für Odoo ist in Arbeit, zuerst für 19.0, dann 18.0 und 20.0, Community und Enterprise. Es wird Folgendes tun:

TeilWas es tut
Einstellungen › OcostJe Firma: der API-Schlüssel und wann geschickt wird (beim Hochladen, per Knopf oder nie).
SchickenPDFs an einem Rechnungsentwurf, an ein Einkaufsjournal gemailt oder auf Eingangsrechnungen gezogen. E-Rechnungen liest Odoo weiter selbst.
FüllenPartner, Nummer, Daten, Positionen mit Steuern, Fälligkeit und Zahlungsbedingung mit Skonto. Nur leere Felder, und nie gebucht. Die Rechnung listet Zweifel und fehlgeschlagene Prüfungen.
SucheBuchhaltung › Ocost › Suche, über den Inhalt der PDFs.

Bis dahin leistet das Modul unten den Kern, in gewöhnlichem Odoo-Code.

Was womit gefüllt wird

OdooAus ocost.document.v1
partner_idseller, gefunden über USt-IdNr. und Namen, oder neu angelegt
refnumber
invoice_dateissue_date
invoice_date_duedue_date
invoice_originorder_reference
invoice_line_idslines, jede mit quantity, price und der Vorsteuer ihres vat_rate

Ohne gelesene Positionen legt das Modul eine Position je Steuersatz an. Es füllt Entwürfe und bucht nie eine Rechnung. Es vermerkt Probleme an der Rechnung: Summen, die nicht aufgehen, Zweifel von Ocost und ein mögliches Duplikat. Außerdem vermerkt es das Skonto.

Ein kleines Modul bauen

Serveraktionen können requests nicht importieren. Die Aufrufe stehen deshalb in einem Modul, das eine Serveraktion oder ein Knopf aufruft.

Text
ocost_connect/
  __init__.py              from . import models
  __manifest__.py
  models/__init__.py       from . import account_move
  models/account_move.py
  data/cron.xml

Legen Sie den Schlüssel im Systemparameter ocost.api_key ab, unter Einstellungen › Technisch › Systemparameter. Nur Administratoren können ihn lesen. Eine Odoo-Firma ist ein Ocost-Unternehmen. Bei mehreren Firmen nutzen Sie je einen Schlüssel, etwa ocost.api_key.<Firmen-ID>.

__manifest__.py:

Python
{
    "name": "Ocost Connect",
    "version": "19.0.1.0.0",
    "depends": ["account"],
    "data": ["data/cron.xml"],
    "license": "LGPL-3",
}

models/account_move.py:

Python
import base64
import logging
from decimal import Decimal

import requests

from odoo import _, api, fields, models
from odoo.fields import Command

API = "https://api.ocost.io/public/v1"
log = logging.getLogger(__name__)


class AccountMove(models.Model):
    _inherit = "account.move"

    ocost_file_id = fields.Char(copy=False, readonly=True)
    ocost_state = fields.Selection(
        [("sent", "Sent"), ("read", "Read"), ("not_read", "Not read"), ("failed", "Failed")],
        copy=False,
        readonly=True,
    )

    def _ocost(self):
        key = self.env["ir.config_parameter"].sudo().get_param("ocost.api_key")
        session = requests.Session()
        session.headers["Authorization"] = f"Bearer {key}"
        return session

    def action_ocost_send(self):
        """Sends each draft vendor bill's PDF to Ocost (a button, a server
        action or the cron). The same PDF twice is the same file: safe."""
        session = self._ocost()
        bills = self.filtered(
            lambda m: m.move_type in ("in_invoice", "in_refund")
            and m.state == "draft"
            and not m.ocost_file_id
        )
        for move in bills:
            attachment = move.message_main_attachment_id or move.attachment_ids[:1]
            if not attachment:
                continue
            answer = session.post(
                f"{API}/files",
                files={
                    "file": (
                        attachment.name,
                        base64.b64decode(attachment.datas),
                        attachment.mimetype,
                    )
                },
                data={"external_ref": f"account.move,{move.id}"},
                timeout=30,
            )
            if answer.status_code == 429 or answer.status_code >= 500:
                continue  # the next run tries again
            if answer.status_code == 401:
                log.warning("Ocost refused the API key: replace it")
                return
            if answer.status_code in (413, 415):
                move.ocost_state = "failed"
                move.message_post(body=answer.json().get("detail"))
                continue
            answer.raise_for_status()
            file = answer.json()
            move.write({"ocost_file_id": file["id"], "ocost_state": "sent"})
            if file["status"] == "read":
                move._ocost_fill(session)

    @api.model
    def _cron_ocost(self):
        """Every few minutes: send what waits, then fill what Ocost read."""
        self.search(
            [("move_type", "in", ("in_invoice", "in_refund")), ("state", "=", "draft"),
             ("ocost_file_id", "=", False), ("message_main_attachment_id", "!=", False)],
            limit=20,
        ).action_ocost_send()
        session = self._ocost()
        for move in self.search([("ocost_state", "=", "sent")], limit=50):
            file = session.get(f"{API}/files/{move.ocost_file_id}", timeout=15).json()
            if file["status"] in ("received", "reading"):
                continue
            if file["status"] == "read":
                move._ocost_fill(session)
            else:
                move.ocost_state = file["status"]
                move.message_post(body=_("Ocost didn't read it: %s", file["reason"]))

    def _ocost_fill(self, session):
        """Fills the draft bill's empty fields from Ocost's reading."""
        self.ensure_one()
        doc = session.get(f"{API}/files/{self.ocost_file_id}/document", timeout=15).json()
        notes = []
        if doc["direction"] == "outgoing" or doc["kind"] not in ("invoice", "credit_note"):
            self.ocost_state = "read"
            self.message_post(body=_("Ocost read a %s, not a bill to fill.", doc["kind_detail"]))
            return
        vals = {}
        seller = doc["seller"] or {}
        contact = seller.get("contact") or {}
        vat_sure = doc["sources"].get("seller.vat_id", {}).get("confidence") != "doubt"
        if not self.partner_id and seller.get("name"):
            partner = self.env["res.partner"]._retrieve_partner(
                name=seller["name"],
                vat=seller.get("vat_id") if vat_sure else None,
                email=contact.get("email"),
                company=self.company_id,
            )
            if not partner:
                address = seller.get("address") or {}
                country = self.env["res.country"].search(
                    [("code", "=", address.get("country"))], limit=1
                )
                partner = self.env["res.partner"].create({
                    "name": seller["name"],
                    "is_company": True,
                    "vat": seller.get("vat_id") if vat_sure else False,
                    "street": address.get("street"),
                    "zip": address.get("postal_code"),
                    "city": address.get("city"),
                    "country_id": country.id or False,
                    "email": contact.get("email"),
                    "phone": contact.get("phone"),
                    "supplier_rank": 1,
                })
            vals["partner_id"] = partner.id
        if not self.ref and doc["number"]:
            vals["ref"] = doc["number"]
        if not self.invoice_date and doc["issue_date"]:
            vals["invoice_date"] = doc["issue_date"]
        if doc["due_date"]:
            vals["invoice_date_due"] = doc["due_date"]
        if doc["order_reference"] and not self.invoice_origin:
            vals["invoice_origin"] = doc["order_reference"]
        currency = self.env["res.currency"].search([("name", "=", doc["currency"])], limit=1)
        if currency:
            vals["currency_id"] = currency.id
        if doc["kind"] == "credit_note" and self.move_type == "in_invoice" and not self.invoice_line_ids:
            vals["move_type"] = "in_refund"
        if not self.invoice_line_ids:
            vals["invoice_line_ids"] = [
                Command.create(self._ocost_line(line, doc["totals"]["prices_include_vat"], notes))
                for line in doc["lines"]
            ] or [
                # No lines read: one line per VAT rate.
                Command.create(self._ocost_line(
                    {"name": doc["number"] or _("Invoice"), "quantity": "1",
                     "price": group["taxable_amount"], "vat_rate": group["rate"]},
                    False, notes,
                ))
                for group in doc["vat_breakdown"]
            ]
        self.write(vals)
        self.ocost_state = "read"
        if not all(c["passed"] for c in doc["checks"] if c["sums"]):
            notes.append(_("The sums don't add up as read: check the amounts."))
        doubts = [path for path, s in doc["sources"].items() if s["confidence"] == "doubt"]
        if doubts:
            notes.append(_("Ocost isn't sure of: %s", ", ".join(doubts)))
        if doc["possible_duplicate_of"]:
            notes.append(_("Ocost read a bill of this number from this seller before."))
        if doc["early_payment_discounts"]:
            skonto = doc["early_payment_discounts"][0]
            notes.append(_("Skonto: %s %% until %s", skonto["percent"], skonto["due_date"]))
        self.message_post(body="\n".join([_("Filled from Ocost's reading. Check before posting."), *notes]))

    def _ocost_line(self, line, included, notes):
        price = Decimal(line.get("price") or line.get("net_amount") or "0")
        per = Decimal(line.get("base_quantity") or "1")
        values = {
            "name": " ".join(filter(None, [
                line.get("seller_item_id") and f"[{line['seller_item_id']}]", line["name"],
            ])),
            "quantity": float(Decimal(line.get("quantity") or "1")),
            "price_unit": float(price / per),
            "discount": float(Decimal(line.get("discount_percent") or "0")),
        }
        if line.get("vat_rate") is not None:
            tax = self.env["account.tax"].search([
                ("company_id", "=", self.company_id.id),
                ("type_tax_use", "=", "purchase"),
                ("amount_type", "=", "percent"),
                ("amount", "=", float(line["vat_rate"])),
                ("price_include", "=", included),
            ], limit=1)
            if tax:
                values["tax_ids"] = [Command.set(tax.ids)]
            else:
                notes.append(_("No purchase tax of %s %%: set the line's tax.", line["vat_rate"]))
        return values

data/cron.xml:

XML
<odoo>
  <record id="cron_ocost" model="ir.cron">
    <field name="name">Ocost: send and fill</field>
    <field name="model_id" ref="account.model_account_move"/>
    <field name="state">code</field>
    <field name="code">model._cron_ocost()</field>
    <field name="interval_number">5</field>
    <field name="interval_type">minutes</field>
  </record>
</odoo>

Legen Sie eine Serveraktion auf Eingangsrechnungen an, Aktion › An Ocost schicken, die records.action_ocost_send() ausführt. Der Cron schickt dann alle fünf Minuten, was wartet, und füllt, was Ocost gelesen hat.

Probieren Sie das Modul zuerst auf einer Kopie Ihrer Datenbank. tax_ids einer Position braucht womöglich fiscal_position_id.map_tax. Reverse Charge (AE) und innergemeinschaftliche Erwerbe (K) richten Sie nach der Tabelle Ihrer Steuerberatung ein.

Aus Odoo suchen

Fügen Sie diese Methode demselben Modell hinzu. Sie findet Rechnungen über den Inhalt ihrer PDFs.

Python
    @api.model
    def ocost_search(self, query, kind=None):
        """Bills by what their PDFs say: a list of (bill, page, text)."""
        params = {"q": query, "limit": 20}
        if kind:
            params["kind"] = kind
        found = self._ocost().get(f"{API}/search", params=params, timeout=15).json()
        out = []
        for r in found["results"]:
            bill = self.search([("ocost_file_id", "=", r["file_id"])], limit=1)
            passage = r["passages"][0] if r["passages"] else {}
            out.append((bill, passage.get("page"), passage.get("text")))
        return out

Ein Ergebnis kann eine Rechnung sein, die auf anderem Weg zu Ocost kam. Ihr PDF holen Sie auf dem Odoo-Server mit GET /public/v1/files/{file_id}/content, damit der Schlüssel nie einen Browser erreicht.