Plank help · updated 2026-09-17
Working with 1C over OData
The working rules for reading and writing a 1C database over its standard OData interface — what the integration user can and cannot do, how to recognise a document that already exists, how to create one safely, and the server limits that shape every script you write.
Agents: fetch the raw markdown of this page at /en/help/1c-odata-working-rules.md
Working with 1C over OData
This page is the mechanics of talking to a 1C database over its standard OData interface. It holds for any 1C database and any configuration.
- To get OData switched on and the credentials stored, read Connecting 1C first.
- For the Kazakhstan bank-statement and payroll workflow built on top of these rules, read Importing a bank statement into 1C.
- For sales documents and ЭАВР, read Sales realizations from customer invoices; for the monthly payroll chain, Monthly payroll accrual.
The scripts already exist. Every mechanic on this page ships as working, adaptable Python in the 1C starter kit — the access check, the catalog matching, the duplicate signature, the create-unposted-then-re-read discipline. Check for
scripts/1c/before you write any of it, and install the kit if it isn't there. Each section below names the file that implements it.Adapt the kit; do not write your own writer. A one-off script named after this month's client is the tempting move and it is the wrong one: it starts without the dry-run gate, without the duplicate guard, and without the checks that make a re-run safe, and it is used against a live accounting base within the hour. Every bank-statement duplication incident so far has come from a hand-written writer, never from the kit. If the kit is wrong for your database, change
mapping.json, then change the kit's payload builders — and if you genuinely must write something new, use_guard.py— it is three lines and it is the whole difference between a safe script and the ones that have caused every duplication incident so far:
from _guard import Guard
guard = Guard(client, catalogs, run_key=make_run_key(account, period, declared_total))
guard.create("outgoing_payment_order", payload,
number=op.number, date=op.operation_date, amount=op.amount)
Guard.create stamps the anchor and verifies it survived, returns the existing document instead of creating a second one when that operation is already there, refuses if the index could not be read completely, and never posts. run_key must come from the source file's own values — account, period, declared totals, operation count — never from the clock, or every run looks unique and the guard protects nothing.
It also does two things you get without asking:
- A write log. Every document it creates, and every duplicate it prevents, is appended as one JSON line to
scripts/1c/write-log.jsonl. It never fails your import if it cannot be written, and it is the only record of what this workspace wrote to 1C that does not require reading the chat back. It is write-only: nothing consults it to decide whether to create, because a document the accountant deleted in 1C is still in the log and refusing to recreate it would be wrong. - A report.
guard.report(source="kaspi-06-2026.csv")returns the end-of-run markdown — counts, the run key, the created documents with theirRef_Keys, and the duplicates it prevented — derived from what the guard actually did. Pair it withwrite_reportto save it undersynced_data/1c/. Use this instead of writing your own summary: a narrated total is the thing that has been wrong, and this one cannot disagree with what was written.
The single most important thing on this page: find out what your integration user is actually allowed to do, before you design anything around it. A restricted user — read and create, but no edit, no delete, no posting — is what providers hand out by default, and it changes the whole shape of the work. If that's what you've got, say so early and get it widened (§3). If it can't be widened, assemble each document completely, verify it locally, and only then POST: you will not get a second attempt.
1. Addresses
The web-client address and the OData address are different addresses.
web client https://<host>/<database>/ru_RU/
OData https://<host>/<database>/odata/standard.odata
metadata https://<host>/<database>/odata/standard.odata/$metadata
A healthy endpoint returns XML or JSON, or challenges for a password. An HTML page means OData is not published — see the troubleshooting section of Connecting 1C, which covers the two independent switches and the "friendly error page" trap. That holds for the first answer only: if the same base already returned data earlier in the run and then answers with a page, the session was dropped or the host is under maintenance — the kit opens a fresh session and retries a read once, and otherwise reports odata-session-lost («хостинг ответил страницей вместо данных … повторите позже»). Do not go and check the publication in that case, and never resend a write: re-read to see whether it landed. And if the page comes back for one request while the same base answers everything else (the kit checks this with a one-row control read), the error is odata-request-page and names that request: read the document narrowly ($select) and find the field with python3 scripts/1c/odata/probe-document.py <entity-set> <Ref_Key> --mapping … --credentials … (read-only).
Credentials live in scripts/1c/credentials.json and nowhere else. Never copy the password into documentation, logs, reports, or source.
2. Check access before you do anything else
Implemented by scripts/1c/odata/check-access.py (starter kit).
Run the access check before reading a single business record:
python3 scripts/1c/odata/check-access.py
A healthy result is HTTP 200, application/xml for $metadata, and hundreds of entity sets — a typical accounting configuration publishes several hundred. A handful of sets means the OData composition (Администрирование → Состав стандартного интерфейса OData) exposes almost nothing and needs widening before you go further.
Zero sets with a 200 is a failure, and the check says so. A base can answer $metadata with a well-formed, empty container: OData is published and the composition inside 1C lists nothing. That is fixed in 1C by an administrator, not on the web server.
The check also tells you whether this base filters on the server — «Server-side filtering: works», «REFUSED» or «UNRELIABLE» — after three read-only requests against the counterparty catalog. On a base that refuses, the kit reads catalogs whole once per run and matches locally, so runs are slower. Tell the accountant that is a property of her base, and do not retry $filter queries hoping for a different answer (§4).
2.5 Establish which company before anything else
An accounting practice does not have "a 1C base". It has one per client — one surveyed practice has 17 — and a single credentials.json plus a single mapping.json describe exactly one of them.
Nothing about a wrong pairing looks wrong. The connection succeeds. The catalogs read. The arithmetic balances. The reconciliation comes back clean. And the documents are in another company's books.
Three things have to agree before any write, and none of them implies the others:
- the base the credentials reach;
- the organisation
mapping.jsonpins; - the source document — this statement, this ЭСФ, this request.
_identity.py checks 1 against 2 (verify_target) and gives you assert_source_account for 3. Both entrypoints call them before planning anything, and both failures are errors, not warnings.
A base reached through a hosting portal
Some databases have no address of their own: the host publishes them behind a portal that logs you in and then redirects to a per-tenant OData root. Configure that as a portal block in credentials.json — instead of base_url, never alongside it. Two written-down answers to «which database» means only one of them gets checked.
publication is what the check compares the landing address against, and it is the whole safety of the arrangement: a portal that logs you in successfully and drops you in a different tenant answers every subsequent request with HTTP 200.
Do not write a bespoke connector for such a base. On 2026-08-28 one lived in a single workspace as a private script, and a search for six documents across "all OData connections" silently skipped that base, reported «they are nowhere», and came one guard away from marking six posted documents in an unrelated company's base for deletion because the numbers matched. A transport nothing can enumerate turns a negative result into a lie.
One base can hold two organisations with almost the same name
A base can hold two organisations whose names differ only by punctuation or word order — the same trading name registered twice, or a migration that left both. Picking by eye is a coin flip, so the pinned ref_key decides and the name is the assertion: if the reference resolves to a different name than mapping.json claims, one of the two is stale and the run stops. Do not assume the reference wins because it is more precise — a stale reference is exactly how a run lands in last quarter's client.
When a base holds more than one organisation, say so in the report even on success. The accountant's journal may be scoped to the other one, and "I don't see anything" is then about their filter, not your write.
The source document has to be this company's
verify_arithmetic proves a statement file is internally consistent. It proves that just as well when the file belongs to somebody else — so it is not a company check. The statement's own РасчСчет must equal mapping.organization_account.iban or the run refuses.
The same rule generalises: for an incoming ЭСФ, the organisation named in the ЭСФ picks the base — never the address that happens to be in a shared credentials file. See Importing incoming ЭСФ into 1C.
Working across several companies
Give each company its own workspace, with its own scripts/1c/credentials.json and scripts/1c/mapping.json. If one workspace must reach several bases, keep a register of them — company, БИН/ИИН, base URL, organisation Ref_Key, settlement accounts — and resolve the target from the source document against that register. What you must never do is carry a Ref_Key from one base into another (§11), or reuse a mapping "because the companies are similar".
Each base gets its own mapping file (mapping.json, client-b-mapping.json, …) and every run names one with --mapping. When the workspace's 1C credentials live on the platform rather than in credentials.json, that file is also what says which connection to use: add "connection_label": "<name>" at the top level, matching the label the connection is registered under. Omit it and the run uses the connection named default — right for a workspace with one base. While credentials.json still exists it may carry "label" too, and if the two disagree the run refuses and names both files: guessing which one is current is exactly how documents land in the wrong company's books.
Write down which connection, and which credentials, each base belongs with — a run now refuses without. Both keys go in the mapping file, because credentials.json is deleted when a workspace moves its credentials to the platform, and a pairing recorded there would vanish exactly when it starts to matter.
| In the mapping | What it decides | What happens without it |
|---|---|---|
"connection_label": "<name>" | Which registered connection the platform resolves — on a migrated workspace this is the only thing that picks the base | On a workspace with several bases, the run refuses. Without it every mapping resolves to the one default connection, so --mapping chooses what to write and nothing about where |
"credentials_file": "<name>.json" | Which credentials file this base is reached with, checked before anything connects | With several credentials files present, a run with no --credentials refuses rather than taking credentials.json because of what it is called |
Two mappings naming the same connection_label are refused too: they would share one connection and one duplicate index, and no run could tell which base was meant.
"Several bases" counts credentials files as well as mapping files. A practice keeping one mapping.json beside six credentials files is a multi-base workspace, and it used to read as a single-base one — which is how a run could take credentials.json by its name and write one company's documents into another company's base with every check passing. Two byte-identical credentials files are one credential and are not a choice.
Never repoint credentials.json at a different base to switch clients. Hand-written scripts in a workspace read that path directly and usually carry one client's identifiers, so a single edit moves all of them at once, silently. Keep one credentials file per base, and name it — in the mapping, or with --credentials.
python3 scripts/1c/odata/bases.py # every base, its connection, its credentials, its profile. Connects to nothing.
That listing marks ОТКАЗ on exactly the lines a run will stop on — never on a condition this workspace does not meet. A workspace with one base and one credentials file meets none of them and is unchanged.
3. Establish what the integration user is allowed to do
Do this at setup time, not when a write fails. Rights vary per user, and the restricted shape below is what a provider grants unless someone asks for more:
| Operation | Restricted user | Full role |
|---|---|---|
Read (GET) | Yes | Yes |
Create documents (POST) | Yes | Yes |
Modify a document (PATCH / PUT) | No | Yes |
Delete / mark for deletion (DELETE) | No — answers Нарушение прав доступа | Yes |
Post a document (the Post() action) | No | Yes |
| Create tabular-section rows via their own entity set | No | No — a configuration limit, not a rights one |
Ask for the full role. When a user is connecting 1C, tell them to request read, create, edit, delete / mark for deletion, and posting (проведение) from whoever administers the database — in the same message as the OData publication request, since it's the same person and the same wait. A read-and-create user looks adequate right up to the first wrong draft, at which point nothing can be corrected, removed, or finished without a human opening 1C. Widening the role later means going back to the provider; narrowing it is trivial. The exact wording to send is in Connecting 1C.
A refused write does not look like a refusal
This is the trap, and it has cost a real import three hours. When the user cannot modify an object, 1C does not answer 403. It answers:
HTTP 500
Не удалось записать "Платежное поручение (исходящее) 00000000672 от 22.06.2026"!
That reads like a complaint about your payload, and it is routinely read that way — as "1C's OData cannot modify documents", reported to the user as a platform limitation. It is not one. The identical PATCH succeeds unchanged under a user who has modify rights.
So when a write is refused: the payload and the rights matrix are equally likely, and you cannot tell them apart from the response. Check rights first, because it is the cheaper test — ask who administers the base, or try the same request as a user who has «Изменение». Never conclude anything about 1C or about OData in general from one 500.
And whatever the cause: do not respond by creating a replacement document and leaving the original in place. See §10.
The kit checks this itself on the first document of each type. Right after creating it, create_and_verify writes a few of that document's own values back unchanged. If 1C refuses with a rights error, that one document stays (it is reported as written), no further document of that type is created in the run, and the refusal carries the sentence to send the accountant: «В этой базе я могу создавать документы, но не могу их исправлять…». Only after she agrees, re-run with PLANK_1C_ACCEPT_CREATE_ONLY=1. A write-back refused without the word «прав» does not stop the run — on some document types every flag refuses a rewrite on a document that edits fine — it only prints a warning.
If the role can't be widened, the restricted column is your working reality, and two things follow:
- Preflight is everything. A wrong document cannot be repaired by the script that made it. Build the complete payload, validate it locally, then write once. Never create a nearly-right document intending to patch it.
- Correction becomes a human protocol — see §10.
Having posting rights is not permission to post. Even with a full role, create documents unposted and let a person post them. Posting moves accounting registers, so it stays an explicit user decision — that's policy, and it doesn't change with the rights matrix. What the full role buys you is the ability to fix your own mistakes and to post when the user actually asks.
When you do post on an explicit instruction, use the kit's entrypoint rather than calling the action yourself — it posts and then proves the posting, which is the part that keeps going wrong:
python3 scripts/1c/odata/post-documents.py Document_<Name> --number 58 --number 59
python3 scripts/1c/odata/post-documents.py Document_<Name> --number 58 --apply
It reads each document, posts it, looks for a register movement recorded against it, and calls it posted only if it finds one. The batch stops at the first document that cannot be verified — if posting is not working, that leaves one document to sort out rather than sixty, all of them in the state that is hardest to spot because they look done. A document that posted nothing gets its flag cleared, so it stops claiming an accounting effect it never had.
On a base that refuses $filter on its registers, add --sweep. There, verifying one document means reading every register whole — measured at over ten minutes each, which for 256 documents is a control nobody can run, and the thing people reach for instead is a hand-written Post() loop with no verification at all. With --sweep the first document is still posted and verified on its own, and nothing else is attempted until it passes; the rest are posted and then every register's Recorder column is read once. Same question asked about every document, two passes instead of two hundred. What it gives up is stopping at the second failure — by then the remaining documents are already posted — so it names each one that moved nothing and clears its flag. An incomplete sweep answers nothing and says so: a register that refused leaves GUIDs out, and a missing GUID cannot be told from «never posted».
The underlying action is Post() on the document, and note that no PostingModeOperational is passed:
POST <base>/Document_<Name>(guid'<Ref_Key>')/Post()
Writing Posted=true as a field is not posting. It does not run the posting algorithm and produces no register movements, so the document ends up looking posted while the accounting behind it never moved. Unpost() is the matching reversal.
And neither the HTTP status nor the Posted flag proves that Post() worked. Post() answers HTTP 200 with an empty body whether or not it posted anything, and Post?PostingModeOperational=true has been measured answering 200 while leaving Posted false — a no-op reported as success. Worse, a document can come back Posted=true and still have produced no register movements at all: in one production base three receipts sat like that for nine days while a fourth, posted by the accountant in the 1C client, had four movements — same base, same user, same document type, identical field-fill profiles.
When a document posts nothing, it is almost always the document, not the channel. OData Post() on a valid document produces exactly the same register movements as posting in the 1C client — measured A/B on one document: UI post → 4 movements, OData Unpost() → 0, OData Post() → the same 4. What OData will not tell you is why it refused. 1C validates on posting and the client prints the reason in full («Не совпадают сумма документа и ее расшифровка», «The "Договор" is required on line 1 of "Расшифровка платежа"», «Укажите основной банковский счет в реквизитах организации»); over OData that entire refusal arrives as HTTP 200 with an empty body and Posted left true.
So when the check below reports not-posted, open that document in the 1C client and press «Провести». The messages it prints are the fix list. Common causes: a tabular row that exists but is empty (СуммаПлатежа = 0, an all-zero reference GUID), a header total that does not equal the sum of its rows, and organisation-level settings the document depends on.
So the only evidence that a document posted is a register movement recorded against it. After posting, check for one:
python3 scripts/1c/odata/verify-posting.py Document_<Name> <Ref_Key>
python3 scripts/1c/odata/verify-posting.py Document_<Name> --number 58
On a workspace with more than one base, add --mapping <file> --credentials <file> — the same pair import-*.py and post-documents.py take; every script that reaches a base accepts both, and without them the kit refuses to guess which base you meant.
It reads every published register carrying Recorder and reports posted, not-posted or inconclusive — the last when a register refused the question, which is not the same as answering zero. To ask the same question about every document at once — which is what you want the first time, because the flag was the success criterion for a long time and nobody knows how many documents are affected:
python3 scripts/1c/odata/audit-posting.py Document_<Name> --since 2026-07-01
That reads each register once rather than once per document, and names every Posted=true document that moved nothing. If any register could not be read completely it names nobody and says so: a document missing from a partial index is indistinguishable from one that never posted, and sending an accountant to repost documents that are fine is worse than saying nothing. A document that is Posted=true with no movements cannot be fixed over OData: it needs a repost in the 1C client, one document at a time, with this check re-run after each.
4. Reading data
Implemented by scripts/1c/odata/_odata.py — iter_all() is the portable pattern below.
$filter on date fields is unreliable. Some servers reject a filter on Date with Операция не разрешена в предложении ГДЕ. Don't build a workflow that depends on it. A refusal is not an empty result — reading it as "no matching rows" makes every existing document look absent, which is how a duplicate gets created (§6). The kit's client recognises the refusal and answers with the portable path instead of a bare HTTP 500, so you do not have to interpret one.
$orderby may be silently ignored. Never assume returned order.
The portable pattern is: page with $top / $skip, then filter locally.
GET <base>/Document_<Name>?$top=1000&$skip=0&$format=json
Filtering on string and reference fields (codes, Ref_Key, identification numbers) is generally fine — it's dates specifically that bite.
An object that isn't in $metadata may simply not be published
1C exposes objects to its OData interface one checkbox at a time, in «Публикация OData». An object nobody published is absent from $metadata entirely: no error, no empty collection, nothing that distinguishes it from an object this configuration does not have.
So "the base does not use X" is a conclusion you cannot reach from $metadata alone. On 2026-08-04 a base reported 687 entity sets and no ЭАВР object, which read as "this configuration does not do ЭАВР". Someone ticked the box; the count became 692 and 1,106 ЭАВР became readable that had been there all along — after most of a morning spent building an explanation for their absence.
When a document type you have concrete evidence for — a screenshot, an accountant's description, a field referencing it — is missing from $metadata:
- Count the entity sets and record the number.
- Say plainly that the object is not published, rather than that it does not exist.
- Ask for it to be published, then re-read
$metadataand compare the count.
Not published and not used need different answers, and only one of them is yours to give.
5. Matching records in catalogs
Implemented by scripts/1c/odata/_catalogs.py, which returns a resolution rather than a record — so "nothing found" and "two found" stay distinguishable instead of collapsing into a guess.
Match on an identifier, never on a name that merely looks similar.
| Looking for | Match on |
|---|---|
| Counterparty | Catalog_Контрагенты.ИдентификационныйКодЛичности — the BIN/IIN |
| Bank account | Catalog_БанковскиеСчета.НомерСчета — the exact IBAN |
| Individual | Catalog_ФизическиеЛица.ИдентификационныйКодЛичности — the IIN |
Names are for confirming a match, not making one.
Two rules that prevent the most common wrong-record bugs:
- Check the owner. Having found a bank account by IBAN, assert
БанковскийСчет.Owner == Контрагент.Ref_Key. An IBAN that belongs to a different counterparty is a red flag, not a match. - A stored "main account" does not outrank the account named in the source document. If the counterparty card says one IBAN and the document you're importing says another, the document wins. Silently substituting the main account produces a document that looks right and is wrong.
When an identifier resolves to two records — the same IIN on two individuals, say — do not pick one at random. Use the reference that earlier, verified documents already used.
When an identifier resolves to nothing, stop and ask. Do not auto-create a counterparty, a bank account, or a contract to make a script complete: catalogs are shared, permanent, and expensive to clean up.
A group is not a record
1C catalogs are trees, and a folder carries the same Description field as the elements inside it. «Физические лица» is routinely both — a group and an element — in the same Catalog_Контрагенты. Tell them apart by IsFolder.
A folder in a Контрагент field writes cleanly through OData and then will not post, because a group is not a counterparty. Printing both rows and noticing the difference is not enough — the exclusion has to be in the lookup, or the folder gets used anyway.
_catalogs.py now excludes IsFolder and DeletionMark from every match, and says which it excluded: "matched a group, not a record" and "not found" need different fixes, and the misleading one wastes the user's time.
6. Recognising a document that already exists
Implemented by scripts/1c/odata/_signature.py.
Getting this wrong creates duplicates in live accounting, so it earns its own section.
A document number alone is not an identity. External numbers repeat across dates and years — the same bank document number will legitimately appear in several periods. Matching on the number alone will make you skip an operation that was never imported.
The internal 1C Number is not an identity either. It can repeat across periods and numbering sequences.
Use a composite signature:
1C document type + external document number + operation date + amount
and corroborate with direction, counterparty identifier, IBAN, purpose text, and payment code where available. Ref_Key is the technical identity once you have the record; the signature is how you decide whether to create one at all.
Idempotency is the requirement. Running an import twice must not create a second document. Every run should be able to report existing versus to create before it writes anything.
The signature alone is not enough
The composite signature reads fields the configuration owns. A document type that does not carry НомерВходящегоДокумента, or a database that stores it empty, produces documents the index cannot see — and an index that silently skips what it cannot identify is worse than no index, because it reports a large signature count and inspires confidence it has not earned.
This is not hypothetical. A guard built this way has already produced a full set of duplicate documents in a live accounting base: it reported thousands of loaded signatures while recognising none of the documents it had just written, so every re-run saw every operation as absent and created it again.
So anchor identity in a field you write yourself. The kit puts [plank:doc=…;run=…] in every document's Комментарий — doc keyed on the signature, run keyed on the source file's account, period, declared totals and operation count. Комментарий is set on every document type this kit writes and re-read after every write, which is exactly what makes it usable as an identity when the numbering fields are not.
And prove the guard, don't assume it:
- Count what the index could not sign, not just what it could. A non-zero count inside the period being imported is a blind spot, and the only honest response is to refuse to write. (Outside the period it does not matter — a 2019 document cannot duplicate a June 2026 line, and failing closed over it would make the control useless.)
- A failure to read the index is an error, never a warning. Routine per-line questions train an operator to accept warnings wholesale; a warning meaning "the guard is blind" then goes through with them.
- Refuse to write a document type the index does not cover. Extending a classifier without extending the index silently removes protection for the new type.
- Scope all of that to the document types the run actually creates. A gap in a type the statement never mentions cannot produce a duplicate, and plenty of databases publish no acquiring documents at all — refusing an ordinary statement over one of those is a lockout, not a control. Report the gap, then continue. This matters more than it sounds: a guard that blocks legitimate work is a guard people route around by hand-writing a script with no guard at all.
- After the first document is written, re-read it and test the guard against it. Nothing you can read beforehand tells you whether the guard recognises your own output. If it does not, stop with one document created rather than discovering it on the next run.
Signable is not matchable
Counting what the index could not sign catches the document that has no identity. It does not catch the more common case: a document that has a perfectly good identity under a key nothing you are importing will ever produce.
A row a person typed into 1C by hand carries neither the bank's document number nor your anchor. НомерВходящегоДокумента is empty, or holds the internal 1C number, or holds whatever the accountant considered the "number" that day. The index signs it happily and counts it as covered — and then no statement line can ever collide with it, because the statement's key is built from the bank's number. The guard reports full coverage over the exact documents it is blind to, and the import creates a second copy of each.
This has happened in a live base: a run reported "16 884 indexed, 0 unsignable inside the statement period" and still duplicated a hand-entered transfer, because the hand-entered one had no bank number on it. "0 unsignable" was true and meant nothing.
So report matchable coverage, not indexed count:
- Over the account and period being imported, list every document that carries no anchor of yours and whose signature matches no row in the source. Those are the candidate twins, and they are few enough to print: number, date, amount, counterparty.
- Cross-check that list against the source by
(account, date, amount, direction)— the fields both sides always have, whatever the numbering does. A hit there is a probable existing document, and it is a question for the accountant, not a decision: the alternative to asking is creating the duplicate. - Say the number out loud in the dry run. "N documents on this account in this period that this import cannot recognise" is the honest headline; "0 unsignable" is the one that got trusted.
Never iterate on a write script against a live base. Editing a script and re-running --apply to see if the fix worked is how one mistake becomes a duplicate of every line. Dry-run between every edit.
7. Creating a document
Implemented by scripts/1c/odata/_documents.py (the payload discipline) and _runner.py (the dry-run gate).
Always dry-run first, then apply:
python3 scripts/1c/odata/<import-script>.py # dry run — writes nothing
python3 scripts/1c/odata/<import-script>.py --apply # writes
If you built the payload by reading an existing document as a template, strip the server-owned fields before POST:
odata.metadataRef_KeyDataVersionNumber- any navigation URLs
1C assigns a fresh Ref_Key and its own internal number. Leaving these in is a common source of confusing failures.
Create documents unposted:
Posted = false
DeletionMark = false
and leave a comment saying the document was created by an import and needs review. A reviewer should never have to guess where a draft came from.
Enum values must come from $metadata, not from intuition. A configuration exposes a fixed set of EnumType values and a plausible-sounding name that isn't in the list fails the write. Read the enum from $metadata and use exactly what's there.
Two things are refused at create, because 1C only refuses them at post
A document 1C accepts and then cannot post is worse than a rejected one: the create looks like success, the damage surfaces later, and the document sits in a live base looking like work that got done. Both of these are now refused before anything is written, whether you go through Guard.create or create_and_verify directly:
- «Расшифровка платежа» missing or empty against a non-zero header amount. On a payment order that document can never post. A zero header needs no breakdown and is not refused.
- A breakdown row carrying no «Статья движения денежных средств». This one does post, and is then wrong in the cash-flow report — which no arithmetic check downstream catches. An all-zeroes GUID counts as absent, because that is what 1C returns for an unset reference rather than null.
If this base renames the cash-flow section, pass the names the writer uses — otherwise the check reads a field nobody wrote:
from _documents import cash_flow_config
guard = Guard(client, catalogs, run_key=..., organisation_ref=...,
cash_flow=cash_flow_config(mapping))
Those three names live in mapping.json under cash_flow_articles and differ per 1C configuration. Omit the argument and the defaults apply; on a base that renames them the refusal says so explicitly and lists the sections it did find.
The message names the field and what to resolve. Do not work around either one by dropping the breakdown, zeroing the header, or inventing an article Ref_Key: resolve the article against Catalog_СтатьиДвиженияДенежныхСредств, reading what this base's own earlier documents of the same kind used, and do not create a new article — the catalog is shared. If you genuinely cannot resolve it, that is a stop-and-ask, exactly like an unresolved counterparty (§5).
Never write an amount in words yourself
scripts/1c/odata/_words.py does it, and it is unit-tested:
from _words import amount_in_words, format_amount
amount_in_words("2341502.75") # 'Два миллиона триста сорок одна тысяча пятьсот два тенге 75 тиын'
format_amount("2341502.75") # '2 341 502,75'
Russian numeral declension has exactly one correct rendering and it is the most reliable thing on a payment order to get wrong. «тысяча» is feminine, so it is одна тысяча and две тысячи, never «один тысяча». «миллион» is masculine and takes the genitive singular after 2-4: два миллиона, never «два миллион». And 11-14 take the plural however the last digit reads: 111 000 is «сто одиннадцать тысяч», because 111 % 100 == 11. Both currency words are invariant — «тенге» and «тиын» never decline.
If a function produces the exact answer, do not produce it in a sentence instead.
8. Tabular sections
You cannot create tabular-section rows through a separate entity set — 1C answers Создание строк табличной части напрямую не поддерживается.
Rows must be included inside the parent document in the same POST. So a document with rows has to be fully assembled — every reference resolved, every amount final — before you write anything. If the rows reference other documents, those have to exist first, which forces the ordering: create the referenced documents, verify them, then create the parent with the row references filled in.
9. After writing, re-read
Implemented by create_and_verify() in scripts/1c/odata/_documents.py, and by scripts/1c/bank-statement/reconcile.py for the independent index.
Never treat a 2xx as proof of a correct document. Re-read what 1C actually stored and reconcile it against your source:
- every source line found, exactly once;
- zero missing lines, zero unexpected duplicates;
- totals agree;
- every new document unposted and not marked for deletion;
- references (accounts, counterparties, rows) are the ones you intended.
Build the verification index independently from the import — re-reading from 1C and comparing against the original file catches errors that re-using the import's own in-memory state cannot.
10. Fixing a wrong document
Fix it in place. Never fork it.
A correction that cannot be made in place is not automatically a new document. This is the single most expensive rule on this page. An assistant that cannot PATCH (§3) and answers each correction by creating a corrected copy — leaving the wrong one alive — turns one document needing a fix into two needing a decision, and does it once per correction. The user is then asked to clean up by hand, and that cleanup is its own hazard (§12).
Run scripts/1c/odata/correct-document.py — dry-run by default, one --set Field=value per change:
# 1. Dry run. It prints the anchor the document carries.
python3 scripts/1c/odata/correct-document.py --entity-set Document_ПлатежноеПоручениеИсходящее \
--ref <guid> --set НазначениеПлатежа="Оплата по счету 42"
# 2. Apply, naming that anchor back.
python3 scripts/1c/odata/correct-document.py --entity-set Document_ПлатежноеПоручениеИсходящее \
--ref <guid> --set НазначениеПлатежа="Оплата по счету 42" \
--anchor doc=<12hex>,run=<12hex> --apply
--apply requires --anchor, and that is the point. Taking the anchor out of the document you are about to change makes the ownership test compare a value to itself, which reduces it to "carries some anchor" — and a «Копировать» copy of one of our drafts carries one. Naming it is how you state which document you believe you are editing.
It calls _documents.correct(), which refuses before sending anything when:
- there is no import anchor — this kit did not write it, so it is the accountant's record or a hand-written script's. Show the user what needs changing; do not edit somebody else's accounting document.
- the anchor is not the one you named. An anchor is not proof of ownership:
parse_anchoraccepts any well-formed token anywhere in a free-text comment, and 1C's «Копировать» duplicates «Комментарий» verbatim — so an accountant's own copy of an imported document carries a valid one. The caller has to say which document it believes it is editing. - the document belongs to another organisation. This is the one write path that does not go through
verify_target(), so the boundary lives here. - it is posted — registers have moved. Unposting is decided in 1C, by a person.
- it is marked for deletion — nothing to correct.
For those two the refusal ends with the sentence to send the accountant, naming the document. Do not write a one-off script that sets Posted or DeletionMark directly to get past it: that script skips every check above, and the decision was never yours to make. Stop, send the sentence, and continue after she has done it in 1C.
- a tabular section would lose rows. A section replaces the stored one, so changing one row means sending every row you want kept; sending one row deletes the rest. Refused unless you pass
--allow-row-removal. - the document changed between the check and the write (
DataVersionmoved). Somebody is editing it in 1C right now and their change wins.
Posted, DeletionMark, Комментарий, Организация_Key, Ref_Key, Number and DataVersion cannot be named at all. And the entrypoint additionally refuses Date, СуммаДокумента and the incoming-document number — the anchor is derived from those, so correcting one leaves a document whose identity no longer describes it, and the next run duplicates it. Route those to a person.
After the PATCH it re-reads and checks that the fields it named actually changed — 1C accepts a PATCH naming a field its own logic then overwrites, and reporting that as corrected is how «статья ДДС заполнена» gets said about an empty field. If some fields landed and others did not, it says so: "nothing was corrected" about a half-changed document sends you looking in the wrong place.
ODataClient.update() is the raw PATCH underneath and is not guarded — no anchor check, no organisation check, no posted check. Never call it directly.
When you genuinely cannot edit
Preferred. The user deletes or marks the wrong draft in the 1C interface → you confirm it no longer appears in the reconciliation → you create the correct document → you reconcile again.
If the user explicitly accepts a temporary duplicate, and only then — this is a decision they make per document, not a mode you switch into: create the replacement with a comment marking it as a corrected replacement, leave the old document unposted, report both internal numbers, and state plainly that the reconciliation will show an expected duplicate until the user removes the old one. Only the replacement may be posted.
Never: create a replacement because a write failed, without asking; post a replacement without confirming the original is unposted; delete posted documents without an accountant's decision; hide the fact that a duplicate was created; or call the correction finished without an explicit "old → new" table.
12. Cleaning up an import run
When documents have to be removed, the accountant does it in 1C — and what you tell them to select decides whether they delete the right ones.
Never give them a filter on the comment prose. mapping.json's comment is the same string on every run — «Создано из банковской выписки…» — so a filter on it selects every import that base has ever received, including the corrected one you just wrote. An instruction like that deletes a month of good work along with the duplicates it replaced, and nobody notices until the numbers are missing.
Filter on the run anchor instead. Every document this kit writes carries [plank:doc=…;run=…] in its comment, and the run= key identifies one import of one source file:
python3 scripts/1c/odata/find-runs.py # every run in this base
python3 scripts/1c/odata/find-runs.py --run 3f9c1a4b77de # one run, document by document
It is read-only. It prints the exact string to paste into the 1C journal's search box and the Ref_Key of every document in that run, so the selection can be checked before and after. Documents written by a hand-written script carry no anchor, cannot be listed, and cannot be cleaned up selectively — which is one more reason to adapt the kit rather than write your own writer.
11. What does not travel between databases
This is why the kit keeps every deployment-specific value in scripts/1c/mapping.json — adapting to a new database should be a config edit, not a code edit.
Be explicit in your scripts about what is portable and what isn't.
- Hard-coded
Ref_Keyvalues — template documents, tax items, VAT rates, accounting policy references. These are meaningless in another database and worth re-checking periodically even in their own. Prefer resolving them at runtime: find the template by operation type and the most recent verified document, the tax by name/code, the VAT rate by its value. If you must pin them, keep them in a config JSON, not scattered through the code. - Local accounting policy — how a particular business classifies own-funds transfers, which counterparty stands in for a payment aggregator, how personal amounts are separated. These are decisions of one organisation, confirmed by that user. Record them in the workspace's own notes and never carry them into another database automatically.
- Case-specific scripts. A script written for one period, with that period's dates and references baked in, is a record of what was done — not a reusable importer. Name it so that's obvious, and never run it against a new period without adapting it and re-running the dry run.