Complete reversible 1C saved-state prepare workflow
This commit is contained in:
@@ -308,18 +308,20 @@ dimension, or resource, also pass `member_ref` or `member_kind` +
|
|||||||
`member_name`; the adapter resolves the exact parent/member GUIDs and
|
`member_name`; the adapter resolves the exact parent/member GUIDs and
|
||||||
serialized tree path internally. The method supports `plan`, `apply`,
|
serialized tree path internally. The method supports `plan`, `apply`,
|
||||||
`apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state
|
`apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state
|
||||||
gates, and never writes active `Config`/`ConfigCAS`. Renaming an object and
|
gates, and never writes active `Config`/`ConfigCAS`. Renaming an object or
|
||||||
member, adding/removing collection items, and adding a new synonym locale
|
member, removing collection items, and adding a new synonym locale remain
|
||||||
remain intentionally disabled.
|
intentionally disabled.
|
||||||
|
|
||||||
`metadata.object.member.add` adds one new object requisite or tabular-section
|
`metadata.object.member.add` adds one new object requisite or tabular-section
|
||||||
column (`Attribute`) by cloning an existing attribute in the same collection.
|
column (`Attribute`) by cloning an existing attribute in the same collection.
|
||||||
The caller passes only
|
The caller passes only
|
||||||
`template_member_ref`, `new_member_name`, and optionally synonym/comment; the
|
`template_member_ref`, `new_member_name`, and optionally synonym/comment; the
|
||||||
adapter generates the GUID, preserves the template's type/settings, appends to
|
adapter generates the GUID, preserves the template's type/settings, appends to
|
||||||
the exact declared collection, and verifies the new identity after apply.
|
the exact declared collection, and verifies the new identity, empty/default
|
||||||
Arbitrary type construction and deletion are not supported by this first
|
comment, container, and preserved non-identity settings after apply. A template
|
||||||
structural route.
|
is blocked when its GUID or name is referenced outside its declared identity
|
||||||
|
fields. Arbitrary type construction and deletion are not supported by this
|
||||||
|
first structural route.
|
||||||
|
|
||||||
Managed form bodies in base `Config` are resolved from the public form GUID to
|
Managed form bodies in base `Config` are resolved from the public form GUID to
|
||||||
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
|
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
|
||||||
|
|||||||
@@ -1005,7 +1005,7 @@ METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = {
|
|||||||
},
|
},
|
||||||
"new_member_name": {"type": "string", "description": "New 1C Attribute name."},
|
"new_member_name": {"type": "string", "description": "New 1C Attribute name."},
|
||||||
"new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."},
|
"new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."},
|
||||||
"new_member_comment": {"type": "string", "description": "Optional comment; otherwise inherited from the template."},
|
"new_member_comment": {"type": "string", "description": "Optional comment; defaults to an empty string and is never inherited from the template."},
|
||||||
"extension": {"type": "string"},
|
"extension": {"type": "string"},
|
||||||
"layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]},
|
"layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]},
|
||||||
"expected_sha1": {"type": "string"},
|
"expected_sha1": {"type": "string"},
|
||||||
@@ -1018,6 +1018,89 @@ METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = {
|
|||||||
"include_storage": {"type": "boolean"},
|
"include_storage": {"type": "boolean"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
METHOD_INPUT_SCHEMAS["metadata.write"] = {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["base_id"],
|
||||||
|
"additionalProperties": True,
|
||||||
|
"properties": {
|
||||||
|
"base_id": {"type": "string", "description": "Adapter base identifier."},
|
||||||
|
"target": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": True,
|
||||||
|
"description": "Name-first target. Use kind/area=object|form|module|schedule plus a public ref, kind/name, or canonical_path.",
|
||||||
|
"properties": {
|
||||||
|
"kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
|
||||||
|
"area": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
|
||||||
|
"ref": {"type": "string", "description": "Public 1C object/member ref."},
|
||||||
|
"canonical_path": {"type": "string", "description": "Public 1C code or metadata path."},
|
||||||
|
"property": {"type": "string"},
|
||||||
|
"operation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Operation such as add_attribute, property_change, replace, or upsert_routine.",
|
||||||
|
},
|
||||||
|
"template_member_ref": {"type": "string"},
|
||||||
|
"new_member_name": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"target_kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
|
||||||
|
"ref": {"type": "string", "description": "Optional public 1C object ref."},
|
||||||
|
"kind": {"type": "string", "description": "Optional metadata kind in English or Russian."},
|
||||||
|
"name": {"type": "string", "description": "Optional exact 1C object name."},
|
||||||
|
"object_type": {"type": "string", "description": "MCP alias of kind."},
|
||||||
|
"object_name": {"type": "string", "description": "MCP alias of name."},
|
||||||
|
"extension": {"type": "string"},
|
||||||
|
"property": {"type": "string"},
|
||||||
|
"value": {},
|
||||||
|
"schedule": {"type": "object", "additionalProperties": True},
|
||||||
|
"template_member_ref": {"type": "string"},
|
||||||
|
"new_member_name": {"type": "string"},
|
||||||
|
"new_member_synonym": {"type": "string"},
|
||||||
|
"new_member_comment": {"type": "string"},
|
||||||
|
"execution_mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"],
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"],
|
||||||
|
},
|
||||||
|
"allow_saved_state_write": {"type": "boolean"},
|
||||||
|
"allow_sql_saved_state_prepare": {"type": "boolean"},
|
||||||
|
"allow_sql_saved_state_apply": {"type": "boolean"},
|
||||||
|
"allow_sql_saved_state_rollback": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"target": {
|
||||||
|
"area": "object",
|
||||||
|
"ref": "Catalog.Номенклатура",
|
||||||
|
"property": "synonym",
|
||||||
|
},
|
||||||
|
"value": "Номенклатура",
|
||||||
|
"mode": "plan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"target": {
|
||||||
|
"area": "object",
|
||||||
|
"operation": "add_attribute",
|
||||||
|
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
|
||||||
|
"new_member_name": "КодПоставщика",
|
||||||
|
},
|
||||||
|
"mode": "plan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"base_id": "upo_test",
|
||||||
|
"target": {
|
||||||
|
"kind": "schedule",
|
||||||
|
"ref": "ScheduledJob.ОбменДанными",
|
||||||
|
},
|
||||||
|
"schedule": {"begin_time": "09:00:00"},
|
||||||
|
"mode": "plan",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = {
|
METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["base_id"],
|
"required": ["base_id"],
|
||||||
@@ -3098,10 +3181,18 @@ METADATA_WRITE_CAPABILITIES = {
|
|||||||
"form_element_properties": {
|
"form_element_properties": {
|
||||||
"status": "partial_saved_state",
|
"status": "partial_saved_state",
|
||||||
"targets": ["form_element", "form_command", "form_attribute"],
|
"targets": ["form_element", "form_command", "form_attribute"],
|
||||||
"operations": ["plan_property_write", "apply_property_write"],
|
"operations": [
|
||||||
|
"plan_property_write",
|
||||||
|
"apply_property_write",
|
||||||
|
"move_sibling_target",
|
||||||
|
"upsert_command_button",
|
||||||
|
"upsert_handler_routine",
|
||||||
|
"verify_command_button_links",
|
||||||
|
],
|
||||||
"agent_method": "metadata.write",
|
"agent_method": "metadata.write",
|
||||||
"write_layer": "save",
|
"write_layer": "save",
|
||||||
"gaps": ["Complex command handler bindings and inherited form properties are not fully writable yet."],
|
"guards": ["expected_sha1", "backup", "semantic_readback", "cache_invalidation"],
|
||||||
|
"gaps": ["Cross-parent structural moves, arbitrary element deletion, complex command bindings, and inherited form properties are not fully writable yet."],
|
||||||
},
|
},
|
||||||
"object_metadata": {
|
"object_metadata": {
|
||||||
"status": "partial_saved_state",
|
"status": "partial_saved_state",
|
||||||
@@ -3109,8 +3200,8 @@ METADATA_WRITE_CAPABILITIES = {
|
|||||||
"operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"],
|
"operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"],
|
||||||
"agent_method": "metadata.write",
|
"agent_method": "metadata.write",
|
||||||
"write_layer": "save",
|
"write_layer": "save",
|
||||||
"guards": ["expected_old", "expected_sha1", "backup", "semantic_readback"],
|
"guards": ["expected_old", "expected_sha1", "backup", "semantic_readback", "settings_shape_verification", "cache_invalidation"],
|
||||||
"gaps": ["Attribute add currently requires an existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, and other structural collection writes remain disabled."],
|
"gaps": ["Attribute add currently requires a safe existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, Dimension/Resource/TabularSection creation, and other structural collection writes remain disabled."],
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"status": "read_only",
|
"status": "read_only",
|
||||||
@@ -17521,6 +17612,7 @@ def apply_saved_state_prepare_copy(
|
|||||||
file_names: list[str],
|
file_names: list[str],
|
||||||
*,
|
*,
|
||||||
expected_source_rows: int,
|
expected_source_rows: int,
|
||||||
|
prepared_rows: list[dict[str, Any]],
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
method = "metadata.saved_state.prepare"
|
method = "metadata.saved_state.prepare"
|
||||||
@@ -17573,6 +17665,13 @@ def apply_saved_state_prepare_copy(
|
|||||||
"counts": {"expected_insert_rows": expected_source_rows, "inserted_rows": inserted},
|
"counts": {"expected_insert_rows": expected_source_rows, "inserted_rows": inserted},
|
||||||
"diagnostics": {"message": "Copied row count did not match current source row count."},
|
"diagnostics": {"message": "Copied row count did not match current source row count."},
|
||||||
}
|
}
|
||||||
|
receipt = write_saved_state_prepare_receipt(
|
||||||
|
base_id=base_id,
|
||||||
|
config=config,
|
||||||
|
source_table=source_table,
|
||||||
|
target_table=target_table,
|
||||||
|
rows=prepared_rows,
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
try:
|
try:
|
||||||
@@ -17601,6 +17700,7 @@ def apply_saved_state_prepare_copy(
|
|||||||
"source": {"kind": "live_sql", "database": config["database"], "table": source_table},
|
"source": {"kind": "live_sql", "database": config["database"], "table": source_table},
|
||||||
"target": {"table": target_table},
|
"target": {"table": target_table},
|
||||||
"counts": {"inserted_rows": inserted, "file_names": len(names)},
|
"counts": {"inserted_rows": inserted, "file_names": len(names)},
|
||||||
|
"prepare_receipt": receipt,
|
||||||
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
|
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
|
||||||
base_id,
|
base_id,
|
||||||
reason="saved_state_prepare",
|
reason="saved_state_prepare",
|
||||||
@@ -17609,6 +17709,220 @@ def apply_saved_state_prepare_copy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def saved_state_prepare_receipt_dir() -> Path:
|
||||||
|
return storage_apply_backup_dir() / "prepare-receipts"
|
||||||
|
|
||||||
|
|
||||||
|
def write_saved_state_prepare_receipt(
|
||||||
|
*,
|
||||||
|
base_id: str,
|
||||||
|
config: dict[str, str],
|
||||||
|
source_table: str,
|
||||||
|
target_table: str,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
receipt_id = uuid.uuid4().hex
|
||||||
|
created_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
root = saved_state_prepare_receipt_dir()
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = root / f"{created_at.replace(':', '').replace('-', '')}-{receipt_id}.json"
|
||||||
|
normalized_rows = [
|
||||||
|
{
|
||||||
|
key: row.get(key)
|
||||||
|
for key in ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1")
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
evidence = {
|
||||||
|
"schema": "onec_saved_state_prepare_receipt.v1",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"created_at_utc": created_at,
|
||||||
|
"base_id": base_id,
|
||||||
|
"source": {
|
||||||
|
"server": config.get("server"),
|
||||||
|
"database": config.get("database"),
|
||||||
|
"table": source_table,
|
||||||
|
},
|
||||||
|
"target": {"table": target_table},
|
||||||
|
"rows": normalized_rows,
|
||||||
|
"status": "prepared",
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return {
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"path": str(path),
|
||||||
|
"rows": len(normalized_rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_saved_state_prepare_receipt(receipt_id: str) -> Path | None:
|
||||||
|
normalized = str(receipt_id or "").strip().lower()
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{32}", normalized):
|
||||||
|
return None
|
||||||
|
root = saved_state_prepare_receipt_dir()
|
||||||
|
matches = sorted(root.glob(f"*{normalized}.json")) if root.is_dir() else []
|
||||||
|
return matches[0] if len(matches) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_saved_state_prepare_receipt(
|
||||||
|
base_id: str,
|
||||||
|
receipt_id: str,
|
||||||
|
*,
|
||||||
|
timeout_seconds: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
path = resolve_saved_state_prepare_receipt(receipt_id)
|
||||||
|
if path is None:
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "not_found",
|
||||||
|
"applied": False,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
evidence = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "invalid_receipt",
|
||||||
|
"applied": False,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"diagnostics": {"message": str(exc)},
|
||||||
|
}
|
||||||
|
if str(evidence.get("base_id") or "") != str(base_id):
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "blocked",
|
||||||
|
"applied": False,
|
||||||
|
"error": "receipt_base_mismatch",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
}
|
||||||
|
if evidence.get("status") == "rolled_back":
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "already_rolled_back",
|
||||||
|
"applied": True,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
}
|
||||||
|
target = evidence.get("target") if isinstance(evidence.get("target"), dict) else {}
|
||||||
|
target_table = str(target.get("table") or "")
|
||||||
|
if target_table not in SAVED_STATE_SOURCE_BY_TARGET:
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "invalid_receipt",
|
||||||
|
"applied": False,
|
||||||
|
"error": "receipt_target_invalid",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
}
|
||||||
|
expected_rows = [
|
||||||
|
row for row in (evidence.get("rows") or [])
|
||||||
|
if isinstance(row, dict) and row.get("file_name") and Path(str(row["file_name"])).name == str(row["file_name"])
|
||||||
|
]
|
||||||
|
file_names = sorted({str(row["file_name"]) for row in expected_rows})
|
||||||
|
if not expected_rows or not file_names:
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "invalid_receipt",
|
||||||
|
"applied": False,
|
||||||
|
"error": "receipt_rows_missing",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, config, error = connect_live_sql(base_id, "metadata.saved_state.prepare.rollback", timeout_seconds=timeout_seconds)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
started = time.time()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor(as_dict=True)
|
||||||
|
placeholders = ",".join(["%s"] * len(file_names))
|
||||||
|
cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes,
|
||||||
|
CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1
|
||||||
|
FROM dbo.[{target_table}] WITH (UPDLOCK, HOLDLOCK)
|
||||||
|
WHERE FileName IN ({placeholders})
|
||||||
|
ORDER BY FileName, PartNo
|
||||||
|
""",
|
||||||
|
tuple(file_names),
|
||||||
|
)
|
||||||
|
current_rows = [
|
||||||
|
saved_state_row_public({key: jsonable(value) for key, value in row.items()})
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
comparable_fields = ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1")
|
||||||
|
expected_comparable = sorted(
|
||||||
|
tuple(row.get(field) for field in comparable_fields)
|
||||||
|
for row in expected_rows
|
||||||
|
)
|
||||||
|
current_comparable = sorted(
|
||||||
|
tuple(row.get(field) for field in comparable_fields)
|
||||||
|
for row in current_rows
|
||||||
|
)
|
||||||
|
if current_comparable != expected_comparable:
|
||||||
|
conn.rollback()
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "precondition_failed",
|
||||||
|
"applied": False,
|
||||||
|
"error": "prepared_rows_changed",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"counts": {
|
||||||
|
"expected_rows": len(expected_comparable),
|
||||||
|
"current_rows": len(current_comparable),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cursor.execute(
|
||||||
|
f"DELETE FROM dbo.[{target_table}] WHERE FileName IN ({placeholders})",
|
||||||
|
tuple(file_names),
|
||||||
|
)
|
||||||
|
deleted = int(cursor.rowcount or 0)
|
||||||
|
if deleted != len(expected_rows):
|
||||||
|
conn.rollback()
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "precondition_failed",
|
||||||
|
"applied": False,
|
||||||
|
"error": "prepared_row_delete_count_mismatch",
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"counts": {"expected_rows": len(expected_rows), "deleted_rows": deleted},
|
||||||
|
}
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "error",
|
||||||
|
"applied": False,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"diagnostics": {"message": str(exc)},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
evidence["status"] = "rolled_back"
|
||||||
|
evidence["rolled_back_at_utc"] = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return {
|
||||||
|
"schema": "onec_saved_state_prepare_rollback.v1",
|
||||||
|
"status": "rolled_back",
|
||||||
|
"applied": True,
|
||||||
|
"base_id": base_id,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"target": {"table": target_table},
|
||||||
|
"counts": {"deleted_rows": deleted, "file_names": len(file_names)},
|
||||||
|
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
|
||||||
|
base_id,
|
||||||
|
reason="saved_state_prepare_rollback",
|
||||||
|
),
|
||||||
|
"duration_ms": int((time.time() - started) * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None:
|
def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
if not isinstance(object_card, dict):
|
if not isinstance(object_card, dict):
|
||||||
return None
|
return None
|
||||||
@@ -17880,6 +18194,7 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
target_table,
|
target_table,
|
||||||
file_names,
|
file_names,
|
||||||
expected_source_rows=len(source_rows),
|
expected_source_rows=len(source_rows),
|
||||||
|
prepared_rows=public_source_rows,
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
)
|
)
|
||||||
result["apply_result"] = apply_result
|
result["apply_result"] = apply_result
|
||||||
@@ -42579,6 +42894,9 @@ def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"diagnostics": prepare_result.get("diagnostics") or {"message": "Could not resolve a writable saved-state copy for the named object."},
|
"diagnostics": prepare_result.get("diagnostics") or {"message": "Could not resolve a writable saved-state copy for the named object."},
|
||||||
}
|
}
|
||||||
return result if include_storage else sanitize_object_property_write_result(result)
|
return result if include_storage else sanitize_object_property_write_result(result)
|
||||||
|
prepare_apply_result = prepare_result.get("apply_result") if isinstance(prepare_result.get("apply_result"), dict) else {}
|
||||||
|
prepare_receipt = prepare_apply_result.get("prepare_receipt") if isinstance(prepare_apply_result.get("prepare_receipt"), dict) else {}
|
||||||
|
prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "")
|
||||||
|
|
||||||
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
|
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
|
||||||
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
|
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
|
||||||
@@ -42787,7 +43105,22 @@ def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
result["rollback_result"] = rollback_result
|
result["rollback_result"] = rollback_result
|
||||||
result["rolled_back"] = bool(rollback_result.get("applied"))
|
payload_rolled_back = bool(rollback_result.get("applied"))
|
||||||
|
prepare_rollback_result = None
|
||||||
|
if payload_rolled_back and prepare_receipt_id:
|
||||||
|
prepare_rollback_result = rollback_saved_state_prepare_receipt(
|
||||||
|
base_id,
|
||||||
|
prepare_receipt_id,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
result["prepare_rollback_result"] = prepare_rollback_result
|
||||||
|
result["rolled_back"] = bool(
|
||||||
|
payload_rolled_back
|
||||||
|
and (
|
||||||
|
not prepare_receipt_id
|
||||||
|
or (prepare_rollback_result or {}).get("applied")
|
||||||
|
)
|
||||||
|
)
|
||||||
result["status"] = (
|
result["status"] = (
|
||||||
"verified_and_rolled_back"
|
"verified_and_rolled_back"
|
||||||
if result["applied"] and verified and result["rolled_back"]
|
if result["applied"] and verified and result["rolled_back"]
|
||||||
@@ -42903,6 +43236,9 @@ def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"prepare_result": prepare_result,
|
"prepare_result": prepare_result,
|
||||||
}
|
}
|
||||||
return result if include_storage else sanitize_object_property_write_result(result)
|
return result if include_storage else sanitize_object_property_write_result(result)
|
||||||
|
prepare_apply_result = prepare_result.get("apply_result") if isinstance(prepare_result.get("apply_result"), dict) else {}
|
||||||
|
prepare_receipt = prepare_apply_result.get("prepare_receipt") if isinstance(prepare_apply_result.get("prepare_receipt"), dict) else {}
|
||||||
|
prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "")
|
||||||
|
|
||||||
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
|
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
|
||||||
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
|
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
|
||||||
@@ -43148,7 +43484,22 @@ def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
result["rollback_result"] = rollback_result
|
result["rollback_result"] = rollback_result
|
||||||
result["rolled_back"] = bool(rollback_result.get("applied"))
|
payload_rolled_back = bool(rollback_result.get("applied"))
|
||||||
|
prepare_rollback_result = None
|
||||||
|
if payload_rolled_back and prepare_receipt_id:
|
||||||
|
prepare_rollback_result = rollback_saved_state_prepare_receipt(
|
||||||
|
base_id,
|
||||||
|
prepare_receipt_id,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
result["prepare_rollback_result"] = prepare_rollback_result
|
||||||
|
result["rolled_back"] = bool(
|
||||||
|
payload_rolled_back
|
||||||
|
and (
|
||||||
|
not prepare_receipt_id
|
||||||
|
or (prepare_rollback_result or {}).get("applied")
|
||||||
|
)
|
||||||
|
)
|
||||||
result["status"] = "verified_and_rolled_back" if verified and result["rolled_back"] else "applied_rollback_failed"
|
result["status"] = "verified_and_rolled_back" if verified and result["rolled_back"] else "applied_rollback_failed"
|
||||||
return result if include_storage else sanitize_object_property_write_result(result)
|
return result if include_storage else sanitize_object_property_write_result(result)
|
||||||
|
|
||||||
@@ -52211,14 +52562,14 @@ def validate_metadata_write_payload(payload: dict[str, Any]) -> dict[str, Any] |
|
|||||||
target_dict = target if isinstance(target, dict) else {}
|
target_dict = target if isinstance(target, dict) else {}
|
||||||
route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area")
|
route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area")
|
||||||
if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in {
|
if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in {
|
||||||
"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные"
|
"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"
|
||||||
}:
|
}:
|
||||||
route_kind = target_dict.get("kind")
|
route_kind = target_dict.get("kind")
|
||||||
if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}:
|
if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}:
|
||||||
route_kind = payload.get("kind")
|
route_kind = payload.get("kind")
|
||||||
target_kind = str(route_kind or "form").strip().casefold()
|
target_kind = str(route_kind or "form").strip().casefold()
|
||||||
if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные"}:
|
if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"}:
|
||||||
return invalid_argument(method, "target.kind", "Only form, module, and object-property saved-state writes are currently routed.", allowed_values=["form", "module", "object"])
|
return invalid_argument(method, "target.kind", "Only form, module, object, and scheduled-job schedule saved-state writes are currently routed.", allowed_values=["form", "module", "object", "schedule"])
|
||||||
mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower()
|
mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower()
|
||||||
if mode not in FORM_ELEMENT_WRITE_APPLY_MODES:
|
if mode not in FORM_ELEMENT_WRITE_APPLY_MODES:
|
||||||
return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES))
|
return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES))
|
||||||
|
|||||||
@@ -394,6 +394,32 @@ TOOLS = [
|
|||||||
"mode": "plan",
|
"mode": "plan",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "metadata.write",
|
||||||
|
"payload": {
|
||||||
|
"base_id": "<base_id-from-project-context>",
|
||||||
|
"target": {
|
||||||
|
"area": "object",
|
||||||
|
"ref": "Справочник.<object-name>",
|
||||||
|
"property": "synonym",
|
||||||
|
},
|
||||||
|
"value": "<new-synonym>",
|
||||||
|
"mode": "plan",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "metadata.write",
|
||||||
|
"payload": {
|
||||||
|
"base_id": "<base_id-from-project-context>",
|
||||||
|
"target": {
|
||||||
|
"area": "object",
|
||||||
|
"operation": "add_attribute",
|
||||||
|
"template_member_ref": "Справочник.<object-name>.Реквизит.<template-attribute>",
|
||||||
|
"new_member_name": "<new-attribute-name>",
|
||||||
|
},
|
||||||
|
"mode": "plan",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "metadata.form.command_button.write",
|
"method": "metadata.form.command_button.write",
|
||||||
"payload": {
|
"payload": {
|
||||||
|
|||||||
@@ -291,6 +291,25 @@ def test_object_property_plan_writes_existing_member_by_name(monkeypatch) -> Non
|
|||||||
|
|
||||||
def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
|
def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
|
||||||
install_common_mocks(monkeypatch)
|
install_common_mocks(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
adapter,
|
||||||
|
"metadata_saved_state_prepare",
|
||||||
|
lambda _payload: {
|
||||||
|
"status": "verified",
|
||||||
|
"applied": True,
|
||||||
|
"object": {
|
||||||
|
"kind": "Catalog",
|
||||||
|
"name": "Номенклатура",
|
||||||
|
"guid": OBJECT_GUID,
|
||||||
|
},
|
||||||
|
"file_names": ["object.0"],
|
||||||
|
"apply_result": {
|
||||||
|
"prepare_receipt": {
|
||||||
|
"receipt_id": "a" * 32,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
|
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
|
||||||
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
|
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -320,6 +339,19 @@ def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
|
|||||||
"storage_saved_state_rollback",
|
"storage_saved_state_rollback",
|
||||||
lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]},
|
lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]},
|
||||||
)
|
)
|
||||||
|
prepare_rollback: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def fake_prepare_rollback(base_id: str, receipt_id: str, *, timeout_seconds: int) -> dict[str, Any]:
|
||||||
|
prepare_rollback.update(
|
||||||
|
{
|
||||||
|
"base_id": base_id,
|
||||||
|
"receipt_id": receipt_id,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"status": "rolled_back", "applied": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(adapter, "rollback_saved_state_prepare_receipt", fake_prepare_rollback)
|
||||||
|
|
||||||
result = adapter.metadata_object_property_write(
|
result = adapter.metadata_object_property_write(
|
||||||
base_payload(
|
base_payload(
|
||||||
@@ -332,6 +364,8 @@ def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
|
|||||||
assert result["status"] == "verified_and_rolled_back"
|
assert result["status"] == "verified_and_rolled_back"
|
||||||
assert result["applied"] is True
|
assert result["applied"] is True
|
||||||
assert result["rolled_back"] is True
|
assert result["rolled_back"] is True
|
||||||
|
assert result["prepare_rollback_result"]["status"] == "rolled_back"
|
||||||
|
assert prepare_rollback["receipt_id"] == "a" * 32
|
||||||
assert result["semantic_verification"] == {
|
assert result["semantic_verification"] == {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"expected": "Номенклатура товаров",
|
"expected": "Номенклатура товаров",
|
||||||
|
|||||||
@@ -1062,7 +1062,8 @@ def test_metadata_write_routes_schedule_target_without_changing_existing_form_mo
|
|||||||
lambda payload: captured.update(payload) or {"schema": "onec_scheduled_job_schedule_write.v1", "status": "planned"},
|
lambda payload: captured.update(payload) or {"schema": "onec_scheduled_job_schedule_write.v1", "status": "planned"},
|
||||||
)
|
)
|
||||||
|
|
||||||
result = adapter_server.metadata_write(
|
result = adapter_server.call_method(
|
||||||
|
"metadata.write",
|
||||||
{
|
{
|
||||||
"base_id": "upo_test",
|
"base_id": "upo_test",
|
||||||
"target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"},
|
"target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"},
|
||||||
|
|||||||
Reference in New Issue
Block a user