868 lines
27 KiB
Python
868 lines
27 KiB
Python
from datetime import timedelta
|
||
from decimal import Decimal
|
||
import io
|
||
from unittest.mock import Mock, patch
|
||
|
||
import httpx
|
||
import pytest
|
||
from PIL import Image
|
||
from django.urls import reverse
|
||
from django.utils import timezone
|
||
from rest_framework import status
|
||
|
||
from commerce.models import (
|
||
IntegrationConnection,
|
||
IntegrationStatus,
|
||
Product,
|
||
ProductBinding,
|
||
ProductImage,
|
||
Sale,
|
||
SourceRecord,
|
||
SourceSystem,
|
||
StockSnapshot,
|
||
)
|
||
from commerce.integrations.moysklad import (
|
||
MoySkladClient,
|
||
MoySkladError,
|
||
MoySkladRemoteImage,
|
||
)
|
||
from commerce.media import (
|
||
_validated_image_and_thumbnail,
|
||
sync_moysklad_product_images,
|
||
)
|
||
from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update
|
||
from commerce.services.moysklad_sync import (
|
||
MoySkladSyncStats,
|
||
_position_cost,
|
||
_position_revenue,
|
||
normalize_moysklad_assortment_row,
|
||
normalize_moysklad_document,
|
||
normalize_moysklad_stock_row,
|
||
reconcile_moysklad_variant_parents,
|
||
)
|
||
from commerce.tasks import dispatch_due_connection_syncs, 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_rejects_decompression_bomb(monkeypatch):
|
||
monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
|
||
|
||
with pytest.raises((Image.DecompressionBombError, Image.DecompressionBombWarning)):
|
||
_validated_image_and_thumbnail(_jpeg_bytes())
|
||
|
||
|
||
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"
|
||
finally:
|
||
client.close()
|
||
|
||
|
||
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_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):
|
||
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",
|
||
)
|
||
async_result = Mock(id="celery-task-1")
|
||
|
||
with patch("commerce.tasks.sync_moysklad_run.delay", return_value=async_result):
|
||
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()
|
||
assert connection.status == IntegrationStatus.QUEUED
|
||
assert connection.task_id == "celery-task-1"
|
||
|
||
|
||
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",
|
||
)
|
||
run = connection.sync_runs.create()
|
||
|
||
def synchronize(_connection, *, progress_callback):
|
||
stats = MoySkladSyncStats(
|
||
processed=25,
|
||
created_products=4,
|
||
stock_snapshots=20,
|
||
images=12,
|
||
sales=3,
|
||
)
|
||
progress_callback(stats)
|
||
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 connection.last_stats["images"] == 12
|
||
|
||
|
||
def test_beat_dispatcher_queues_due_connection():
|
||
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),
|
||
)
|
||
async_result = Mock(id="scheduled-task-1")
|
||
|
||
with patch("commerce.tasks.sync_moysklad_run.delay", return_value=async_result):
|
||
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()
|
||
|
||
|
||
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_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
|
||
|
||
|
||
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
|