1542 lines
50 KiB
Python
1542 lines
50 KiB
Python
import io
|
||
import uuid
|
||
from datetime import timedelta
|
||
from decimal import Decimal
|
||
from unittest.mock import Mock, patch
|
||
|
||
import httpx
|
||
import pytest
|
||
from celery.exceptions import SoftTimeLimitExceeded
|
||
from django.db import connection as db_connection
|
||
from django.db import transaction
|
||
from django.test.utils import CaptureQueriesContext
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
from PIL import Image
|
||
from rest_framework import status
|
||
|
||
from commerce.integrations.moysklad import (
|
||
MoySkladClient,
|
||
MoySkladError,
|
||
MoySkladRemoteImage,
|
||
)
|
||
from commerce.media import (
|
||
_prepare_product_image,
|
||
sync_moysklad_product_images,
|
||
)
|
||
from commerce.models import (
|
||
IntegrationConnection,
|
||
IntegrationStatus,
|
||
IntegrationSyncRun,
|
||
IntegrationSyncStage,
|
||
Product,
|
||
ProductBinding,
|
||
ProductImage,
|
||
Sale,
|
||
SourceRecord,
|
||
SourceSystem,
|
||
StockSnapshot,
|
||
)
|
||
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update
|
||
from commerce.services.moysklad_sync import (
|
||
MoySkladSyncStats,
|
||
_position_cost,
|
||
_position_revenue,
|
||
_sync_images_stage,
|
||
normalize_moysklad_assortment_row,
|
||
normalize_moysklad_document,
|
||
normalize_moysklad_stock_row,
|
||
reconcile_moysklad_variant_parents,
|
||
sync_moysklad_stock_rows,
|
||
)
|
||
from commerce.services.sync_runs import STALE_RUNNING_ERROR
|
||
from commerce.tasks import (
|
||
STALE_QUEUED_ERROR,
|
||
SYNC_TIME_LIMIT_ERROR,
|
||
dispatch_due_connection_syncs,
|
||
enqueue_connection_sync,
|
||
sync_moysklad_run,
|
||
)
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
|
||
|
||
def _jpeg_bytes():
|
||
output = io.BytesIO()
|
||
Image.new("RGB", (80, 60), (42, 133, 210)).save(output, format="JPEG")
|
||
return output.getvalue()
|
||
|
||
|
||
def test_product_thumbnail_safely_resizes_decompression_bomb(monkeypatch):
|
||
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
|
||
monkeypatch.setattr("commerce.media.MAX_PRODUCT_IMAGE_PIXELS", 100)
|
||
|
||
stored, thumbnail, suffix, was_resized = _prepare_product_image(_jpeg_bytes())
|
||
|
||
with Image.open(io.BytesIO(stored)) as resized:
|
||
assert resized.width * resized.height <= 100
|
||
assert resized.format == "WEBP"
|
||
with Image.open(io.BytesIO(thumbnail)) as preview:
|
||
assert preview.width * preview.height <= 100
|
||
assert preview.format == "WEBP"
|
||
assert suffix == ".webp"
|
||
assert was_resized is True
|
||
|
||
|
||
def test_moysklad_client_keeps_api_prefix(settings):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
requested_urls = []
|
||
|
||
def handler(request):
|
||
requested_urls.append(str(request.url))
|
||
return httpx.Response(200, json={"name": "Администратор"})
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
try:
|
||
client.employee_context()
|
||
finally:
|
||
client.close()
|
||
|
||
assert requested_urls == ["https://api.example/api/remap/1.2/context/employee"]
|
||
|
||
|
||
def test_moysklad_client_accepts_token_with_bearer_prefix():
|
||
client = MoySkladClient(
|
||
auth_type="token",
|
||
credentials={"token": " Bearer access-token-value "},
|
||
)
|
||
try:
|
||
assert client.client.headers["Authorization"] == "Bearer access-token-value"
|
||
finally:
|
||
client.close()
|
||
|
||
|
||
def test_moysklad_client_uses_vendor_accept_header():
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
try:
|
||
assert client.client.headers["Accept"] == "application/json;charset=utf-8"
|
||
assert client.client.timeout.read == 90.0
|
||
finally:
|
||
client.close()
|
||
|
||
|
||
def test_moysklad_client_retries_transient_get_errors_on_same_page(settings):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
attempts = []
|
||
failures = [httpx.ReadTimeout, httpx.ConnectTimeout, httpx.ReadError]
|
||
|
||
def handler(request):
|
||
attempts.append(str(request.url))
|
||
if failures:
|
||
error_type = failures.pop(0)
|
||
raise error_type("temporary upstream failure", request=request)
|
||
return httpx.Response(200, json={"rows": []}, request=request)
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
with patch("commerce.integrations.moysklad.time.sleep") as sleep:
|
||
try:
|
||
payload = client._get(
|
||
"/entity/demand",
|
||
params={"limit": 100, "offset": 1600},
|
||
)
|
||
finally:
|
||
client.close()
|
||
|
||
assert payload == {"rows": []}
|
||
assert len(attempts) == 4
|
||
assert len(set(attempts)) == 1
|
||
assert "offset=1600" in attempts[0]
|
||
assert [call.args[0] for call in sleep.call_args_list] == [2.0, 5.0, 10.0]
|
||
|
||
|
||
def test_moysklad_client_logs_original_error_before_wrapping(
|
||
settings,
|
||
caplog,
|
||
):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
|
||
def handler(request):
|
||
raise httpx.ReadTimeout("upstream headers timeout", request=request)
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
with (
|
||
patch("commerce.integrations.moysklad.time.sleep"),
|
||
pytest.raises(MoySkladError) as raised,
|
||
):
|
||
try:
|
||
client._get(
|
||
"/entity/demand",
|
||
params={"limit": 100, "offset": 1600},
|
||
)
|
||
finally:
|
||
client.close()
|
||
|
||
assert isinstance(raised.value.__cause__, httpx.ReadTimeout)
|
||
assert any(
|
||
getattr(record, "error_type", "") == "ReadTimeout"
|
||
and getattr(record, "request_offset", None) == 1600
|
||
for record in caplog.records
|
||
)
|
||
|
||
|
||
def test_moysklad_client_reads_variant_images_and_downloads_file(settings):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
image_content = _jpeg_bytes()
|
||
|
||
def handler(request):
|
||
if request.url.path.endswith("/images"):
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"rows": [
|
||
{
|
||
"filename": "variant.jpg",
|
||
"title": "Главное фото",
|
||
"size": len(image_content),
|
||
"updated": "2026-08-17 12:00:00.000",
|
||
"meta": {
|
||
"href": "https://api.example/api/remap/1.2/entity/variant/product-1/images/image-1",
|
||
"downloadHref": "https://api.example/download/image-1",
|
||
},
|
||
}
|
||
]
|
||
},
|
||
request=request,
|
||
)
|
||
return httpx.Response(
|
||
200,
|
||
content=image_content,
|
||
headers={"Content-Type": "image/jpeg"},
|
||
request=request,
|
||
)
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
headers={"Authorization": "Bearer test"},
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
try:
|
||
images = client.product_images("variant", "product-1")
|
||
content, content_type = client.download_image(images[0].download_href)
|
||
finally:
|
||
client.close()
|
||
|
||
assert images[0].external_id == "image-1"
|
||
assert images[0].filename == "variant.jpg"
|
||
assert content == image_content
|
||
assert content_type == "image/jpeg"
|
||
|
||
|
||
def test_assortment_expands_images_in_supported_page_size(settings):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
queries = []
|
||
|
||
def handler(request):
|
||
queries.append(dict(request.url.params))
|
||
return httpx.Response(200, json={"rows": []}, request=request)
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
try:
|
||
assert list(client.iter_assortment(limit=1000)) == []
|
||
finally:
|
||
client.close()
|
||
|
||
assert queries == [
|
||
{"limit": "100", "offset": "0", "stockMode": "all", "expand": "images"},
|
||
{
|
||
"limit": "100",
|
||
"offset": "0",
|
||
"filter": "archived=true",
|
||
"expand": "images",
|
||
},
|
||
{
|
||
"limit": "100",
|
||
"offset": "0",
|
||
"filter": "archived=true",
|
||
"expand": "images",
|
||
},
|
||
{
|
||
"limit": "100",
|
||
"offset": "0",
|
||
"filter": "archived=true",
|
||
"expand": "images",
|
||
},
|
||
{
|
||
"limit": "100",
|
||
"offset": "0",
|
||
"filter": "archived=true",
|
||
"expand": "images",
|
||
},
|
||
]
|
||
|
||
|
||
def test_assortment_includes_archived_entity_endpoints(settings):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
|
||
def handler(request):
|
||
entity_type = request.url.path.rstrip("/").rsplit("/", 1)[-1]
|
||
if entity_type == "assortment":
|
||
rows = [{"id": "active", "name": "Активный", "archived": False}]
|
||
else:
|
||
rows = [
|
||
{
|
||
"id": f"archived-{entity_type}",
|
||
"name": f"Архивный {entity_type}",
|
||
"archived": True,
|
||
"meta": {"type": entity_type},
|
||
}
|
||
]
|
||
return httpx.Response(200, json={"rows": rows}, request=request)
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
try:
|
||
rows = list(client.iter_assortment())
|
||
finally:
|
||
client.close()
|
||
|
||
assert [row["id"] for row in rows] == [
|
||
"active",
|
||
"archived-product",
|
||
"archived-variant",
|
||
"archived-bundle",
|
||
"archived-service",
|
||
]
|
||
|
||
|
||
def test_product_images_are_stored_deduplicated_and_removed_with_source_state():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
row = {
|
||
"id": "external-product-1",
|
||
"name": "Товар с фото",
|
||
"article": "PHOTO-1",
|
||
"meta": {"type": "variant"},
|
||
"images": {"meta": {"size": 1}},
|
||
}
|
||
normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=timezone.now(),
|
||
)
|
||
content = _jpeg_bytes()
|
||
remote = MoySkladRemoteImage(
|
||
external_id="image-1",
|
||
filename="photo.jpg",
|
||
title="Главное фото",
|
||
size=len(content),
|
||
updated="2026-08-17 12:00:00.000",
|
||
download_href="https://api.example/download/image-1",
|
||
)
|
||
client = Mock()
|
||
client.product_images.return_value = [remote]
|
||
client.download_image.return_value = (content, "image/jpeg")
|
||
|
||
first_count = sync_moysklad_product_images(
|
||
connection=connection,
|
||
row=row,
|
||
client=client,
|
||
)
|
||
second_count = sync_moysklad_product_images(
|
||
connection=connection,
|
||
row=row,
|
||
client=client,
|
||
)
|
||
|
||
image = ProductImage.objects.get(source=source, external_id="image-1")
|
||
assert first_count == second_count == 1
|
||
assert image.product.sku == "PHOTO-1"
|
||
assert image.file.name.endswith(".jpg")
|
||
assert image.thumbnail.name.endswith(".webp")
|
||
assert image.is_primary is True
|
||
assert client.download_image.call_count == 1
|
||
|
||
client.product_images.return_value = []
|
||
assert (
|
||
sync_moysklad_product_images(
|
||
connection=connection,
|
||
row=row,
|
||
client=client,
|
||
)
|
||
== 0
|
||
)
|
||
assert not ProductImage.objects.filter(pk=image.pk).exists()
|
||
|
||
|
||
def test_one_broken_product_image_does_not_abort_remaining_images():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
row = {
|
||
"id": "external-product-images",
|
||
"name": "Товар с частично повреждёнными фото",
|
||
"article": "PHOTO-PARTIAL",
|
||
"meta": {"type": "product"},
|
||
"images": {"meta": {"size": 2}},
|
||
}
|
||
normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=timezone.now(),
|
||
)
|
||
remotes = [
|
||
MoySkladRemoteImage(
|
||
external_id="broken-image",
|
||
filename="broken.jpg",
|
||
title="Повреждённое",
|
||
size=100,
|
||
updated="2026-08-17 12:00:00.000",
|
||
download_href="https://api.example/download/broken",
|
||
),
|
||
MoySkladRemoteImage(
|
||
external_id="valid-image",
|
||
filename="valid.jpg",
|
||
title="Рабочее",
|
||
size=len(_jpeg_bytes()),
|
||
updated="2026-08-17 12:00:00.000",
|
||
download_href="https://api.example/download/valid",
|
||
),
|
||
]
|
||
client = Mock()
|
||
client.product_images.return_value = remotes
|
||
|
||
def download(download_href):
|
||
if download_href.endswith("/broken"):
|
||
raise OSError("image cannot be decoded")
|
||
return _jpeg_bytes(), "image/jpeg"
|
||
|
||
client.download_image.side_effect = download
|
||
failures = []
|
||
|
||
synchronized = sync_moysklad_product_images(
|
||
connection=connection,
|
||
row=row,
|
||
client=client,
|
||
error_callback=lambda remote, error: failures.append(
|
||
(remote.external_id, str(error))
|
||
),
|
||
)
|
||
|
||
assert synchronized == 1
|
||
assert failures == [("broken-image", "image cannot be decoded")]
|
||
assert (
|
||
ProductImage.objects.filter(
|
||
source=source,
|
||
external_id="valid-image",
|
||
).count()
|
||
== 1
|
||
)
|
||
assert not ProductImage.objects.filter(
|
||
source=source,
|
||
external_id="broken-image",
|
||
).exists()
|
||
|
||
|
||
def test_product_image_sync_does_not_swallow_celery_soft_time_limit():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
row = {
|
||
"id": "external-product-timeout",
|
||
"name": "Товар с долгой загрузкой",
|
||
"article": "PHOTO-TIMEOUT",
|
||
"meta": {"type": "product"},
|
||
"images": {"meta": {"size": 1}},
|
||
}
|
||
normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=timezone.now(),
|
||
)
|
||
remote = MoySkladRemoteImage(
|
||
external_id="slow-image",
|
||
filename="slow.jpg",
|
||
title="Долгая загрузка",
|
||
size=100,
|
||
updated="2026-08-17 12:00:00.000",
|
||
download_href="https://api.example/download/slow",
|
||
)
|
||
client = Mock()
|
||
client.product_images.return_value = [remote]
|
||
client.download_image.side_effect = SoftTimeLimitExceeded()
|
||
|
||
with pytest.raises(SoftTimeLimitExceeded):
|
||
sync_moysklad_product_images(
|
||
connection=connection,
|
||
row=row,
|
||
client=client,
|
||
error_callback=Mock(),
|
||
)
|
||
|
||
|
||
def test_image_stage_does_not_swallow_celery_soft_time_limit():
|
||
connection = Mock(pk=uuid.uuid4(), provider="moysklad")
|
||
|
||
with (
|
||
patch(
|
||
"commerce.services.moysklad_sync.sync_moysklad_product_images",
|
||
side_effect=SoftTimeLimitExceeded(),
|
||
),
|
||
pytest.raises(SoftTimeLimitExceeded),
|
||
):
|
||
_sync_images_stage(
|
||
connection,
|
||
Mock(),
|
||
[{"id": "slow-product", "meta": {}, "images": {}}],
|
||
MoySkladSyncStats(),
|
||
Mock(),
|
||
run_id="run-timeout",
|
||
task_id="task-timeout",
|
||
)
|
||
|
||
|
||
def test_moysklad_client_includes_safe_upstream_error_detail(settings):
|
||
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
|
||
|
||
def handler(request):
|
||
return httpx.Response(
|
||
400,
|
||
json={"errors": [{"error": "Некорректный токен доступа"}]},
|
||
request=request,
|
||
)
|
||
|
||
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
|
||
client.client.close()
|
||
client.client = httpx.Client(
|
||
base_url=client.base_url,
|
||
transport=httpx.MockTransport(handler),
|
||
)
|
||
try:
|
||
with pytest.raises(MoySkladError, match="Некорректный токен доступа"):
|
||
client.employee_context()
|
||
finally:
|
||
client.close()
|
||
|
||
|
||
def test_connection_update_is_sent_to_shared_realtime_group():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.SYNCING,
|
||
)
|
||
run = connection.sync_runs.create(status="running")
|
||
sent = []
|
||
|
||
class ChannelLayer:
|
||
async def group_send(self, group, event):
|
||
sent.append((group, event))
|
||
|
||
with patch("commerce.realtime.get_channel_layer", return_value=ChannelLayer()):
|
||
send_connection_update(connection, run=run)
|
||
|
||
assert sent[0][0] == COMMERCE_CONNECTIONS_GROUP
|
||
assert sent[0][1]["type"] == "commerce_connection_updated"
|
||
assert sent[0][1]["connection"]["status"] == IntegrationStatus.SYNCING
|
||
assert sent[0][1]["connection"]["recent_run"]["id"] == str(run.pk)
|
||
|
||
|
||
def test_staff_connects_moysklad_without_exposing_token(api_client, user_factory):
|
||
user = user_factory(staff=True)
|
||
api_client.force_authenticate(user=user)
|
||
|
||
response = api_client.post(
|
||
reverse("api:v1:commerce:connection-list"),
|
||
{"provider": "moysklad", "auth_type": "token", "token": "secret-token"},
|
||
format="json",
|
||
)
|
||
|
||
assert response.status_code == status.HTTP_201_CREATED
|
||
assert "token" not in response.data
|
||
assert response.data["credentials_configured"] is True
|
||
connection = IntegrationConnection.objects.get()
|
||
assert "secret-token" not in connection.encrypted_credentials
|
||
assert connection.source.code == "moysklad"
|
||
|
||
|
||
def test_connection_test_action_updates_account(api_client, user_factory):
|
||
user = user_factory(staff=True)
|
||
api_client.force_authenticate(user=user)
|
||
create_response = api_client.post(
|
||
reverse("api:v1:commerce:connection-list"),
|
||
{"provider": "moysklad", "auth_type": "token", "token": "secret-token"},
|
||
format="json",
|
||
)
|
||
|
||
with patch(
|
||
"api.v1.commerce.viewsets.test_moysklad_connection",
|
||
return_value={"account_id": "account-1", "account_name": "Иван Иванов"},
|
||
):
|
||
response = api_client.post(
|
||
reverse(
|
||
"api:v1:commerce:connection-test",
|
||
args=[create_response.data["id"]],
|
||
)
|
||
)
|
||
|
||
assert response.status_code == status.HTTP_200_OK
|
||
assert response.data["status"] == IntegrationStatus.READY
|
||
assert response.data["account_name"] == "Иван Иванов"
|
||
|
||
|
||
def test_connection_sync_action_queues_background_run(
|
||
api_client,
|
||
user_factory,
|
||
django_capture_on_commit_callbacks,
|
||
):
|
||
user = user_factory(staff=True)
|
||
api_client.force_authenticate(user=user)
|
||
create_response = api_client.post(
|
||
reverse("api:v1:commerce:connection-list"),
|
||
{"provider": "moysklad", "auth_type": "token", "token": "secret-token"},
|
||
format="json",
|
||
)
|
||
with (
|
||
patch("commerce.tasks.celery_uuid", return_value="celery-task-1"),
|
||
patch("commerce.tasks.sync_connection_run.apply_async") as apply_async,
|
||
django_capture_on_commit_callbacks(execute=True),
|
||
):
|
||
response = api_client.post(
|
||
reverse(
|
||
"api:v1:commerce:connection-sync",
|
||
args=[create_response.data["id"]],
|
||
)
|
||
)
|
||
|
||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||
assert response.data["status"] == "queued"
|
||
connection = IntegrationConnection.objects.get()
|
||
run = IntegrationSyncRun.objects.get()
|
||
assert connection.status == IntegrationStatus.QUEUED
|
||
assert connection.task_id == "celery-task-1"
|
||
assert run.task_id == "celery-task-1"
|
||
apply_async.assert_called_once_with(
|
||
args=[str(run.pk), str(connection.pk)],
|
||
task_id="celery-task-1",
|
||
)
|
||
|
||
|
||
@pytest.mark.django_db(transaction=True)
|
||
def test_connection_sync_is_published_only_after_outer_transaction_commit():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.READY,
|
||
)
|
||
publication_checks = []
|
||
|
||
def verify_committed(*, args, task_id):
|
||
publication_checks.append(
|
||
{
|
||
"in_atomic_block": transaction.get_connection().in_atomic_block,
|
||
"run_exists": IntegrationSyncRun.objects.filter(
|
||
pk=args[0], task_id=task_id
|
||
).exists(),
|
||
"connection_has_task": IntegrationConnection.objects.filter(
|
||
pk=args[1], task_id=task_id
|
||
).exists(),
|
||
}
|
||
)
|
||
|
||
with (
|
||
patch("commerce.tasks.celery_uuid", return_value="commit-safe-task"),
|
||
patch(
|
||
"commerce.tasks.sync_connection_run.apply_async",
|
||
side_effect=verify_committed,
|
||
) as apply_async,
|
||
):
|
||
with transaction.atomic():
|
||
run = enqueue_connection_sync(connection)
|
||
assert run.task_id == "commit-safe-task"
|
||
assert apply_async.call_count == 0
|
||
|
||
assert apply_async.call_count == 1
|
||
|
||
assert publication_checks == [
|
||
{
|
||
"in_atomic_block": False,
|
||
"run_exists": True,
|
||
"connection_has_task": True,
|
||
}
|
||
]
|
||
|
||
|
||
@pytest.mark.django_db(transaction=True)
|
||
def test_connection_sync_is_not_published_after_transaction_rollback():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.READY,
|
||
)
|
||
|
||
with (
|
||
patch("commerce.tasks.celery_uuid", return_value="rolled-back-task"),
|
||
patch("commerce.tasks.sync_connection_run.apply_async") as apply_async,
|
||
pytest.raises(RuntimeError, match="force rollback"),
|
||
):
|
||
with transaction.atomic():
|
||
enqueue_connection_sync(connection)
|
||
raise RuntimeError("force rollback")
|
||
|
||
apply_async.assert_not_called()
|
||
assert not IntegrationSyncRun.objects.filter(task_id="rolled-back-task").exists()
|
||
connection.refresh_from_db()
|
||
assert connection.status == IntegrationStatus.READY
|
||
assert connection.task_id == ""
|
||
|
||
|
||
def test_enqueue_failure_transitions_run_and_connection_to_error(
|
||
django_capture_on_commit_callbacks,
|
||
):
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.READY,
|
||
)
|
||
|
||
with (
|
||
patch("commerce.tasks.celery_uuid", return_value="failed-task"),
|
||
patch(
|
||
"commerce.tasks.sync_connection_run.apply_async",
|
||
side_effect=RuntimeError("broker unavailable"),
|
||
),
|
||
patch("commerce.tasks.send_connection_update"),
|
||
pytest.raises(RuntimeError, match="broker unavailable"),
|
||
django_capture_on_commit_callbacks(execute=True),
|
||
):
|
||
enqueue_connection_sync(connection)
|
||
|
||
connection.refresh_from_db()
|
||
run = IntegrationSyncRun.objects.get()
|
||
assert connection.status == IntegrationStatus.ERROR
|
||
assert run.status == "error"
|
||
assert run.finished_at is not None
|
||
|
||
|
||
def test_missing_sync_run_does_not_leave_connection_queued():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.QUEUED,
|
||
task_id="missing-run-task",
|
||
)
|
||
|
||
with patch("commerce.tasks.send_connection_update"):
|
||
result = sync_moysklad_run.apply(
|
||
args=[str(uuid.uuid4()), str(uuid.uuid4())],
|
||
task_id="missing-run-task",
|
||
).get()
|
||
|
||
connection.refresh_from_db()
|
||
assert result["status"] == "missing"
|
||
assert connection.status == IntegrationStatus.ERROR
|
||
assert connection.last_error
|
||
|
||
|
||
def test_sync_run_persists_product_image_progress():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.QUEUED,
|
||
task_id="sync-task",
|
||
)
|
||
run = connection.sync_runs.create(task_id="sync-task")
|
||
observed_progress = []
|
||
|
||
def synchronize(_connection, *, progress_callback, run_id, **_kwargs):
|
||
stats = MoySkladSyncStats(
|
||
processed=25,
|
||
created_products=4,
|
||
stock_snapshots=20,
|
||
images=12,
|
||
sales=3,
|
||
)
|
||
for stage, processed, total in (
|
||
(IntegrationSyncStage.CATALOG, 25, 25),
|
||
(IntegrationSyncStage.IMAGES, 12, 12),
|
||
(IntegrationSyncStage.VARIANT_LINKS, 25, 25),
|
||
(IntegrationSyncStage.STOCKS, 20, 20),
|
||
(IntegrationSyncStage.SALES, 3, 3),
|
||
(IntegrationSyncStage.RETURNS, 1, 1),
|
||
(IntegrationSyncStage.FINALIZING, 1, 1),
|
||
):
|
||
progress_callback(
|
||
stats,
|
||
stage=stage,
|
||
stage_processed=processed,
|
||
stage_total=total,
|
||
)
|
||
progress = IntegrationSyncRun.objects.get(pk=run_id)
|
||
observed_progress.append(
|
||
(
|
||
progress.stage,
|
||
progress.stage_processed,
|
||
progress.stage_total,
|
||
progress.stock_snapshots,
|
||
progress.last_progress_at,
|
||
)
|
||
)
|
||
return stats
|
||
|
||
with (
|
||
patch("commerce.tasks.sync_moysklad_connection", side_effect=synchronize),
|
||
patch("commerce.tasks.send_connection_update"),
|
||
):
|
||
result = sync_moysklad_run.run(str(run.pk))
|
||
|
||
run.refresh_from_db()
|
||
connection.refresh_from_db()
|
||
assert result["images"] == 12
|
||
assert run.status == "success"
|
||
assert run.processed == 25
|
||
assert run.images == 12
|
||
assert run.image_errors == 0
|
||
assert run.sales == 3
|
||
assert run.stage == IntegrationSyncStage.FINALIZING
|
||
assert run.stage_processed == run.stage_total == 1
|
||
assert run.last_progress_at is not None
|
||
assert [item[0] for item in observed_progress] == [
|
||
IntegrationSyncStage.CATALOG,
|
||
IntegrationSyncStage.IMAGES,
|
||
IntegrationSyncStage.VARIANT_LINKS,
|
||
IntegrationSyncStage.STOCKS,
|
||
IntegrationSyncStage.SALES,
|
||
IntegrationSyncStage.RETURNS,
|
||
IntegrationSyncStage.FINALIZING,
|
||
]
|
||
assert observed_progress[3][1:4] == (20, 20, 20)
|
||
assert all(item[4] is not None for item in observed_progress)
|
||
assert connection.status == IntegrationStatus.READY
|
||
assert connection.last_stats["images"] == 12
|
||
|
||
|
||
def test_sync_run_records_explicit_soft_time_limit_error():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.QUEUED,
|
||
task_id="sync-timeout-task",
|
||
)
|
||
run = connection.sync_runs.create(task_id="sync-timeout-task")
|
||
|
||
with (
|
||
patch(
|
||
"commerce.tasks.sync_moysklad_connection",
|
||
side_effect=SoftTimeLimitExceeded(),
|
||
),
|
||
patch("commerce.tasks.send_connection_update"),
|
||
pytest.raises(SoftTimeLimitExceeded),
|
||
):
|
||
sync_moysklad_run.run(str(run.pk))
|
||
|
||
run.refresh_from_db()
|
||
connection.refresh_from_db()
|
||
assert run.status == "error"
|
||
assert run.error == SYNC_TIME_LIMIT_ERROR
|
||
assert connection.status == IntegrationStatus.ERROR
|
||
assert connection.last_error == SYNC_TIME_LIMIT_ERROR
|
||
|
||
|
||
def test_beat_dispatcher_queues_due_connection(django_capture_on_commit_callbacks):
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.READY,
|
||
auto_sync=True,
|
||
sync_interval_minutes=15,
|
||
next_sync_at=timezone.now() - timedelta(minutes=1),
|
||
)
|
||
with (
|
||
patch("commerce.tasks.celery_uuid", return_value="scheduled-task-1"),
|
||
patch("commerce.tasks.sync_connection_run.apply_async") as apply_async,
|
||
django_capture_on_commit_callbacks(execute=True),
|
||
):
|
||
result = dispatch_due_connection_syncs()
|
||
|
||
connection.refresh_from_db()
|
||
assert result == {"queued": 1}
|
||
assert connection.status == IntegrationStatus.QUEUED
|
||
assert connection.task_id == "scheduled-task-1"
|
||
assert connection.next_sync_at > timezone.now()
|
||
apply_async.assert_called_once()
|
||
|
||
|
||
def test_dispatcher_recovers_old_queued_run_before_syncing_timeout(settings):
|
||
settings.COMMERCE_QUEUED_STALE_SECONDS = 60
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.QUEUED,
|
||
task_id="lost-task",
|
||
auto_sync=False,
|
||
)
|
||
run = connection.sync_runs.create(status="queued", task_id="lost-task")
|
||
stale_at = timezone.now() - timedelta(minutes=2)
|
||
IntegrationSyncRun.objects.filter(pk=run.pk).update(created_at=stale_at)
|
||
|
||
with patch("commerce.tasks.send_connection_update"):
|
||
result = dispatch_due_connection_syncs()
|
||
|
||
connection.refresh_from_db()
|
||
run.refresh_from_db()
|
||
assert result == {"queued": 0}
|
||
assert connection.status == IntegrationStatus.ERROR
|
||
assert connection.last_error == STALE_QUEUED_ERROR
|
||
assert run.status == "error"
|
||
assert run.error == STALE_QUEUED_ERROR
|
||
assert run.finished_at is not None
|
||
|
||
|
||
def test_dispatcher_recovers_stale_running_run_by_heartbeat(settings):
|
||
settings.COMMERCE_RUNNING_STALE_SECONDS = 60
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.SYNCING,
|
||
task_id="lost-running-task",
|
||
auto_sync=False,
|
||
)
|
||
stale_at = timezone.now() - timedelta(minutes=2)
|
||
run = connection.sync_runs.create(
|
||
status="running",
|
||
task_id="lost-running-task",
|
||
stage=IntegrationSyncStage.STOCKS,
|
||
started_at=stale_at,
|
||
last_progress_at=stale_at,
|
||
)
|
||
|
||
with patch("commerce.tasks.send_connection_update"):
|
||
result = dispatch_due_connection_syncs()
|
||
|
||
connection.refresh_from_db()
|
||
run.refresh_from_db()
|
||
assert result == {"queued": 0}
|
||
assert connection.status == IntegrationStatus.ERROR
|
||
assert connection.last_error == STALE_RUNNING_ERROR
|
||
assert run.status == "error"
|
||
assert run.error == STALE_RUNNING_ERROR
|
||
assert run.stage == IntegrationSyncStage.STOCKS
|
||
assert run.finished_at is not None
|
||
|
||
|
||
def test_dispatcher_keeps_running_run_with_fresh_heartbeat(settings):
|
||
settings.COMMERCE_RUNNING_STALE_SECONDS = 60
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
status=IntegrationStatus.SYNCING,
|
||
task_id="active-running-task",
|
||
auto_sync=False,
|
||
)
|
||
run = connection.sync_runs.create(
|
||
status="running",
|
||
task_id="active-running-task",
|
||
stage=IntegrationSyncStage.IMAGES,
|
||
started_at=timezone.now() - timedelta(hours=1),
|
||
last_progress_at=timezone.now(),
|
||
)
|
||
|
||
result = dispatch_due_connection_syncs()
|
||
|
||
connection.refresh_from_db()
|
||
run.refresh_from_db()
|
||
assert result == {"queued": 0}
|
||
assert connection.status == IntegrationStatus.SYNCING
|
||
assert run.status == "running"
|
||
|
||
|
||
def test_schedule_update_does_not_disconnect_connection(api_client, user_factory):
|
||
user = user_factory(staff=True)
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="encrypted",
|
||
status=IntegrationStatus.READY,
|
||
)
|
||
api_client.force_authenticate(user=user)
|
||
|
||
response = api_client.patch(
|
||
reverse("api:v1:commerce:connection-detail", args=[connection.pk]),
|
||
{"auto_sync": True, "sync_interval_minutes": 60},
|
||
format="json",
|
||
)
|
||
|
||
assert response.status_code == status.HTTP_200_OK
|
||
connection.refresh_from_db()
|
||
assert connection.status == IntegrationStatus.READY
|
||
assert connection.sync_interval_minutes == 60
|
||
assert connection.next_sync_at is not None
|
||
|
||
|
||
def test_moysklad_row_creates_product_binding_and_raw_record():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
row = {
|
||
"id": "external-product-1",
|
||
"name": "Товар из МойСклад",
|
||
"article": "MS-001",
|
||
"description": "Описание",
|
||
"barcodes": [{"ean13": "460000000001"}],
|
||
"stock": 12,
|
||
"reserve": 2,
|
||
"inTransit": 3,
|
||
"updated": "2026-08-17 12:00:00.000",
|
||
"meta": {"type": "product"},
|
||
}
|
||
|
||
created, matched, stock_created = normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=timezone.now(),
|
||
)
|
||
|
||
assert created is True
|
||
assert matched is False
|
||
assert stock_created is True
|
||
product = Product.objects.get(sku="MS-001")
|
||
assert product.barcode == "460000000001"
|
||
assert ProductBinding.objects.filter(
|
||
product=product,
|
||
source=source,
|
||
external_id="external-product-1",
|
||
).exists()
|
||
assert SourceRecord.objects.filter(
|
||
source=source,
|
||
entity_type="product",
|
||
external_id="external-product-1",
|
||
).exists()
|
||
assert not StockSnapshot.objects.filter(product=product, source=source).exists()
|
||
|
||
|
||
def test_moysklad_stock_report_creates_separate_warehouse_snapshots():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
product = Product.objects.create(sku="STOCK-1", name="Товар")
|
||
ProductBinding.objects.create(
|
||
source=source,
|
||
product=product,
|
||
external_id="external-product-1",
|
||
)
|
||
captured_at = timezone.now()
|
||
|
||
created = normalize_moysklad_stock_row(
|
||
connection=connection,
|
||
captured_at=captured_at,
|
||
row={
|
||
"meta": {
|
||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/product/external-product-1?expand=supplier"
|
||
},
|
||
"stockByStore": [
|
||
{
|
||
"name": "Основной склад",
|
||
"stock": 12,
|
||
"reserve": 2,
|
||
"inTransit": 3,
|
||
"meta": {
|
||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-1"
|
||
},
|
||
},
|
||
{
|
||
"name": "Магазин",
|
||
"stock": 4,
|
||
"reserve": 1,
|
||
"inTransit": 0,
|
||
"meta": {
|
||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-2"
|
||
},
|
||
},
|
||
],
|
||
},
|
||
)
|
||
|
||
assert created == 2
|
||
assert set(
|
||
StockSnapshot.objects.values_list("warehouse_external_id", flat=True)
|
||
) == {"store-1", "store-2"}
|
||
first = StockSnapshot.objects.get(warehouse_external_id="store-1")
|
||
assert first.quantity == 12
|
||
assert first.reserved == 2
|
||
|
||
|
||
def test_moysklad_stock_sync_is_batched_idempotent_and_reports_progress():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
rows = []
|
||
for index in range(3):
|
||
external_id = f"stock-product-{index}"
|
||
product = Product.objects.create(sku=f"STOCK-{index}", name=f"Товар {index}")
|
||
ProductBinding.objects.create(
|
||
source=source,
|
||
product=product,
|
||
external_id=external_id,
|
||
)
|
||
rows.append(
|
||
{
|
||
"meta": {
|
||
"href": (
|
||
"https://api.moysklad.ru/api/remap/1.2/entity/product/"
|
||
f"{external_id}"
|
||
)
|
||
},
|
||
"stockByStore": [
|
||
{
|
||
"name": "Основной склад",
|
||
"stock": index + 1,
|
||
"reserve": 0,
|
||
"inTransit": 0,
|
||
"meta": {
|
||
"href": (
|
||
"https://api.moysklad.ru/api/remap/1.2/entity/store/"
|
||
"main-store"
|
||
)
|
||
},
|
||
}
|
||
],
|
||
}
|
||
)
|
||
captured_at = timezone.now()
|
||
progress = []
|
||
|
||
with CaptureQueriesContext(db_connection) as queries:
|
||
first_count = sync_moysklad_stock_rows(
|
||
connection=connection,
|
||
rows=iter(rows),
|
||
captured_at=captured_at,
|
||
batch_size=2,
|
||
progress_callback=lambda processed, synchronized: progress.append(
|
||
(processed, synchronized)
|
||
),
|
||
)
|
||
second_count = sync_moysklad_stock_rows(
|
||
connection=connection,
|
||
rows=iter(rows),
|
||
captured_at=captured_at,
|
||
batch_size=2,
|
||
)
|
||
|
||
binding_selects = [
|
||
query["sql"]
|
||
for query in queries.captured_queries
|
||
if "commerce_productbinding" in query["sql"].lower()
|
||
and query["sql"].lstrip().upper().startswith("SELECT")
|
||
]
|
||
assert first_count == second_count == 3
|
||
assert progress == [(2, 2), (3, 3)]
|
||
assert len(binding_selects) == 1
|
||
assert StockSnapshot.objects.filter(source=source).count() == 3
|
||
|
||
|
||
def test_repeated_moysklad_documents_do_not_duplicate_sales_or_returns():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
product = Product.objects.create(sku="REPEAT-1", name="Повторяемый товар")
|
||
ProductBinding.objects.create(
|
||
source=source,
|
||
product=product,
|
||
external_id="repeat-product",
|
||
)
|
||
base_document = {
|
||
"id": "repeat-document",
|
||
"name": "R-1",
|
||
"moment": "2026-08-17 13:00:00.000",
|
||
"positions": {
|
||
"rows": [
|
||
{
|
||
"id": "repeat-position",
|
||
"quantity": 1,
|
||
"price": 10000,
|
||
"assortment": {
|
||
"id": "repeat-product",
|
||
"name": "Повторяемый товар",
|
||
"article": "REPEAT-1",
|
||
},
|
||
}
|
||
]
|
||
},
|
||
}
|
||
|
||
for document_type in ("demand", "salesreturn"):
|
||
normalize_moysklad_document(
|
||
connection=connection,
|
||
row=base_document,
|
||
document_type=document_type,
|
||
)
|
||
normalize_moysklad_document(
|
||
connection=connection,
|
||
row=base_document,
|
||
document_type=document_type,
|
||
)
|
||
|
||
assert Sale.objects.filter(source=source).count() == 2
|
||
assert sum(sale.lines.count() for sale in Sale.objects.filter(source=source)) == 2
|
||
assert (
|
||
SourceRecord.objects.filter(
|
||
source=source,
|
||
external_id="repeat-document",
|
||
).count()
|
||
== 2
|
||
)
|
||
|
||
|
||
def test_moysklad_row_matches_existing_product_by_sku():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
product = Product.objects.create(sku="SAME-001", name="Существующий товар")
|
||
|
||
created, matched, _ = normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row={
|
||
"id": "external-product-2",
|
||
"name": "Название в МойСклад",
|
||
"article": "SAME-001",
|
||
"meta": {"type": "product"},
|
||
},
|
||
captured_at=timezone.now(),
|
||
)
|
||
|
||
assert created is False
|
||
assert matched is True
|
||
assert (
|
||
ProductBinding.objects.get(
|
||
source=source, external_id="external-product-2"
|
||
).product
|
||
== product
|
||
)
|
||
assert Product.objects.count() == 1
|
||
|
||
|
||
def test_moysklad_variant_is_not_merged_with_parent_and_parent_is_reconciled():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
captured_at = timezone.now()
|
||
variant_row = {
|
||
"id": "variant-1",
|
||
"name": "Футболка / XL",
|
||
"article": "SHIRT-1",
|
||
"meta": {"type": "variant"},
|
||
"product": {
|
||
"meta": {
|
||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/product/product-1"
|
||
}
|
||
},
|
||
"characteristics": [{"name": "Размер", "value": "XL"}],
|
||
}
|
||
parent_row = {
|
||
"id": "product-1",
|
||
"name": "Футболка",
|
||
"article": "SHIRT-1",
|
||
"meta": {"type": "product"},
|
||
}
|
||
|
||
normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=variant_row,
|
||
captured_at=captured_at,
|
||
)
|
||
normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=parent_row,
|
||
captured_at=captured_at,
|
||
)
|
||
assert reconcile_moysklad_variant_parents(source) == 1
|
||
|
||
variant = ProductBinding.objects.get(
|
||
source=source,
|
||
external_id="variant-1",
|
||
)
|
||
parent = ProductBinding.objects.get(
|
||
source=source,
|
||
external_id="product-1",
|
||
)
|
||
assert variant.product != parent.product
|
||
assert variant.product.parent == parent.product
|
||
assert variant.metadata["characteristics"] == [{"name": "Размер", "value": "XL"}]
|
||
|
||
|
||
def test_moysklad_existing_binding_refreshes_product_fields():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
captured_at = timezone.now()
|
||
row = {
|
||
"id": "product-1",
|
||
"name": "Старое название",
|
||
"article": "REFRESH-1",
|
||
"meta": {"type": "product"},
|
||
}
|
||
normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=captured_at,
|
||
)
|
||
row.update(
|
||
{
|
||
"name": "Новое название",
|
||
"description": "Новое описание",
|
||
"barcodes": [{"ean13": "460000000002"}],
|
||
"archived": True,
|
||
}
|
||
)
|
||
|
||
created, matched, _ = normalize_moysklad_assortment_row(
|
||
connection=connection,
|
||
row=row,
|
||
captured_at=captured_at,
|
||
)
|
||
|
||
product = ProductBinding.objects.get(
|
||
source=source,
|
||
external_id="product-1",
|
||
).product
|
||
assert created is False
|
||
assert matched is True
|
||
assert product.name == "Новое название"
|
||
assert product.description == "Новое описание"
|
||
assert product.barcode == "460000000002"
|
||
assert product.is_active is False
|
||
assert Product.objects.filter(sku="REFRESH-1").count() == 1
|
||
assert (
|
||
ProductBinding.objects.filter(
|
||
source=source,
|
||
external_id="product-1",
|
||
).count()
|
||
== 1
|
||
)
|
||
assert (
|
||
SourceRecord.objects.filter(
|
||
source=source,
|
||
entity_type="product",
|
||
external_id="product-1",
|
||
).count()
|
||
== 1
|
||
)
|
||
|
||
|
||
def test_moysklad_demand_creates_sale_and_matches_lines():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
product = Product.objects.create(sku="SALE-001", name="Товар")
|
||
ProductBinding.objects.create(
|
||
source=source,
|
||
product=product,
|
||
external_id="external-product-1",
|
||
external_sku="SALE-001",
|
||
)
|
||
row = {
|
||
"id": "demand-1",
|
||
"name": "00042",
|
||
"moment": "2026-08-17 13:00:00.000",
|
||
"updated": "2026-08-17 13:10:00.000",
|
||
"applicable": True,
|
||
"state": {"name": "Отгружен"},
|
||
"salesChannel": {
|
||
"meta": {
|
||
"href": "https://api.moysklad.ru/api/remap/1.2/entity/saleschannel/channel-1"
|
||
}
|
||
},
|
||
"positions": {
|
||
"rows": [
|
||
{
|
||
"id": "position-1",
|
||
"quantity": 2,
|
||
"price": 15000,
|
||
"discount": 10,
|
||
"assortment": {
|
||
"id": "external-product-1",
|
||
"name": "Товар",
|
||
"article": "SALE-001",
|
||
"buyPrice": {"value": 7000},
|
||
},
|
||
}
|
||
]
|
||
},
|
||
}
|
||
|
||
line_count = normalize_moysklad_document(
|
||
connection=connection,
|
||
row=row,
|
||
document_type="demand",
|
||
channel_names={"channel-1": "robotail.pro"},
|
||
)
|
||
|
||
assert line_count == 1
|
||
sale = Sale.objects.get(source=source, external_id="demand:demand-1")
|
||
assert sale.number == "00042"
|
||
assert sale.status == "Отгружен"
|
||
assert sale.channel == "robotail.pro"
|
||
line = sale.lines.get()
|
||
assert line.product == product
|
||
assert line.gross_amount == 300
|
||
assert line.discount_amount == 30
|
||
assert line.cost_amount == 140
|
||
assert line.metadata["cost_source"] == "assortment.buyPrice"
|
||
|
||
|
||
def test_moysklad_position_amounts_keep_source_precision_and_external_vat():
|
||
precise_position = {
|
||
"quantity": 2007,
|
||
"price": 1400.0996512207275,
|
||
"discount": 0,
|
||
"cost": 700.0498256103638,
|
||
}
|
||
|
||
gross, discount, vat_amount = _position_revenue({}, precise_position)
|
||
cost, cost_source = _position_cost(precise_position, Decimal("2007"))
|
||
|
||
assert gross == Decimal("28100.00")
|
||
assert discount == Decimal("0.00")
|
||
assert vat_amount == Decimal("0.00")
|
||
assert cost == Decimal("14050.00")
|
||
assert cost_source == "position.cost"
|
||
|
||
gross, discount, vat_amount = _position_revenue(
|
||
{"vatIncluded": False},
|
||
{
|
||
"quantity": 1,
|
||
"price": 19500000,
|
||
"discount": 0,
|
||
"vat": 5,
|
||
"vatEnabled": True,
|
||
},
|
||
)
|
||
|
||
assert gross == Decimal("204750.00")
|
||
assert discount == Decimal("0.00")
|
||
assert vat_amount == Decimal("9750.00")
|
||
|
||
|
||
def test_moysklad_document_retains_product_missing_from_current_assortment():
|
||
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
|
||
connection = IntegrationConnection.objects.create(
|
||
source=source,
|
||
provider="moysklad",
|
||
encrypted_credentials="unused",
|
||
)
|
||
row = {
|
||
"id": "demand-historical",
|
||
"name": "00043",
|
||
"moment": "2026-08-17 13:00:00.000",
|
||
"positions": {
|
||
"rows": [
|
||
{
|
||
"id": "position-historical",
|
||
"quantity": 1,
|
||
"price": 10000,
|
||
"discount": 0,
|
||
"assortment": {
|
||
"id": "deleted-product-1",
|
||
"name": "Удалённый товар",
|
||
"article": "OLD-001",
|
||
"meta": {"type": "product"},
|
||
},
|
||
}
|
||
]
|
||
},
|
||
}
|
||
|
||
normalize_moysklad_document(
|
||
connection=connection,
|
||
row=row,
|
||
document_type="demand",
|
||
)
|
||
|
||
line = Sale.objects.get(external_id="demand:demand-historical").lines.get()
|
||
assert line.product is not None
|
||
assert line.product.name == "Удалённый товар"
|
||
assert line.product.is_active is False
|
||
binding = ProductBinding.objects.get(
|
||
source=source,
|
||
external_id="deleted-product-1",
|
||
)
|
||
assert binding.product == line.product
|
||
assert binding.metadata["historical_from_document"] is True
|