Міркування безпеки в електронній комерції майже завжди мають технічний характер — ін'єкції, авторизація, секрети, обмеження частоти запитів. Але найдорожчі проблеми безпеки у зрілому ритейлі майже ніколи не бувають технічними. Це процесні вразливості — вони виникають на стиках двох систем, які по-різному трактують один і той самий об'єкт, і у фігурі людини за прилавком, яка має кнопку override і не має даних, щоб засумніватися.
Спільний патерн усіх дір
Перш ніж перейти до списку — патерн, який пов'язує всі десять дір. У кожному випадку факт, зафіксований у момент замовлення — ціна, умовна кошикова знижка, частка платіжних інструментів, ідентичність одиниці товару, відвантажена кількість — використовується в момент повернення актором, який бачить цей факт інакше: як просту, безумовну, порядкову величину. Дві системи розходяться у припущенні:
| Об'єкт | Система A | Система B |
|---|---|---|
| Ціна | «ціна в рядку замовлення зафіксована на €3 000» (замовлення) | «цей SKU коштує за сьогоднішньою поличною ціною — €4 500» (каталог POS) |
| Знижка | «знижка — властивість усього кошика» (движок промоакцій) | «у кожного рядка фіксована ціна; повернення порядкове» (флоу повернення) |
| Платіж | «замовлення оплачено на 60% подарунковою карткою / на 40% карткою» (шар списання) | «є одна сума замовлення; повернути її на картку» (сервіс повернення) |
| Одиниця | «рядок 2 — це якийсь CAM-X100» — ідентичність за типом (рядок замовлення) | «EAN збігся, стан ок — та сама одиниця» (працівник магазину) |
| Статус | «ця заявка на повернення ще відкрита; я маю повернути гроші після отримання товару» (повернення) | «забрав товар і розрахувався — готово» (POS/OMS) |
У всіх випадках ліки — той самий архітектурний хід: призначити єдине авторитетне джерело істини для спірної величини і змусити кожен канал — веб, POS, CRM, OMS — спиратися на це джерело, а не перераховувати величину локально. Якщо величина за визначенням живе поза SAP Commerce Cloud, то контролем безпеки стає зв'язка зворотного запису або звірки (write-back / reconciliation), і її відсутність — це і є вразливість.
Ця стаття — red-team процесів. Її написано спеціально під SAP Commerce Cloud, але по суті вона універсальна. Для кожної діри я розділяю три речі, які зазвичай змішують: що платформа вже покриває «з коробки», що доведеться будувати самому в кастомній розробці, а що взагалі не її завдання — величина чи рішення, що живе в зовнішній системі (POS, OMS, лояльність, платежі, ERP). Тримати ці три речі окремо — це різниця між дизайном, який тихо припускає, що платформа тебе прикриє, і дизайном, який ставить контроль саме там, де насправді витікають гроші.
Ціноутворення
Повернення за поличною ціною, а не за ціною рядка замовлення
Сценарій експлуатації. Дмитро купує еспресо-машину онлайн під час флеш-розпродажу: базова каталожна ціна €4 500, знижка на третину, сплачено €3 000. Через два тижні, коли розпродаж закінчився і поличну ціну повернуто до €4 500, він повертає цю одиницю у флагманський магазин. Працівник сканує SKU; каталог POS бачить, що сьогодні цей SKU коштує €4 500, і повертає повну ціну на картку Дмитра. Він отримує +€1 500 з кожної повернутої одиниці — досить купити десять одиниць за промо і повернути десять за поличною ціною, +€15 000. Варіант «повернення без чека» — та сама вразливість із додатковим прикриттям у вигляді політики: повернення за поточною ціною, бо замовлення «втрачено».
Одна реальна перешкода, яку варто назвати одразу: зв'язане (linked) повернення у більшості PSP не може перевищувати вихідне списання в €3 000, тому таке над-повернення потребує або обмеженого незв'язаного (blind) кредиту, або — набагато частіше — локального автономного повернення на боці POS, яке взагалі не бачить ліміту вихідного списання. Саме на цьому POS-локальному шляху і живе ця діра.
Механізм. Замовлення стверджує: «ціна цієї одиниці — зафіксована ціна зі знижкою, €3 000». Каталог POS стверджує: «цей SKU коштує стільки, скільки написано в нашому прайсі». Повернення рахується за хибним припущенням про ціну одиниці, бо ніщо не зобов'язує POS використовувати зафіксовану в замовленні ціну. Щойно одиницю відсканували на стійці повернення, вона назавжди втрачає ідентичність «рядка в замовленні ORD-88421».
Вердикт.
Моделюється платформою — у SAP Commerce Cloud достатньо зберігання чи контролю, щоб «з коробки» бути коректним (або вона дає примітиви, щоб бути коректним).
Віддано на вашу кастомну розробку — дані є, але поведінка за замовчуванням або межа інтеграції залишає діру відкритою; закрити її — завдання вашого проєкту.
Поза зоною відповідальності платформи — величина чи рішення зберігається у зовнішній системі (POS, зовнішній OMS, програма лояльності, мережа кешбеку, BNPL, міжфірмовий облік в ERP, договір франчайзингу). SAP Commerce Cloud у найкращому випадку може дати можливість звірити цю величину.
Скрізь, де поведінка залежить від вашої версії, редакції (stock accelerator проти SAP Order Management) або попередніх доробок, ми кажемо «перевірте у своїй збірці», а не стверджуємо. Не сприймайте жоден вердикт нижче як заміну тестуванню вашого власного флоу повернення. І це десять показових прикладів, а не канонічний чи вичерпний набір — короткий список того, що ми залишили поза межами, — наприкінці.
/ Усе необхідне для коректного розрахунку повернення платформа записує — AbstractOrderEntry зберігає basePrice, totalPrice, quantity і знижки через discountValues (список DiscountValue) плюс знижки рівня замовлення globalDiscountValues, і все це зафіксовано в замовленні, а не підтягується «наживо». Але треба бути точним щодо поведінки за замовчуванням: нативний DefaultReturnService.createRefund(...) бере RefundEntry.amount із базової ціни товару (OrderEntry.basePrice, до знижки), якщо суму не передано явно, — а не з totalPrice зі знижкою. У замовленні Дмитра (база €4 500, сплачено €3 000) повернення «з коробки» становитиме €4 500 — дефолт самої платформи «протікає» на промо. Тож повернення саме сплаченої ціни рядка — питання того, що ви обчислите й передасте, а не автоматична гарантія; безкоштовно платформа дає лише те, що величина зафіксована в замовленні, а не в каталозі. У решті домен повернень повноцінний — ReturnRequest, ReturnEntry/RefundEntry, що працюють через ReturnService (DefaultReturnService), RefundService і, на боці OMS, OmsReturnFacade.
Діра лежить на межі POS ↔ Commerce. POS має якось зіставити фізичний товар із вихідним рядком замовлення і витягти сплачену ціну — через REST-ендпойнт OCC /orders/{code}, окремий ендпойнт повернень або шар OMS/RMA. Якщо ж POS створює локальне повернення і лише повідомляє Commerce, що «повернення сталося», то Commerce перетворюється на пасивний реєстр, а авторитет щодо ціни лишається на боці POS. ASM (Assisted Service Module) — штатний механізм, що закриває діру, якщо працівники ведуть процес повернення в Commerce, а не в POS.
Фізичний POS, ручне перевизначення ціни працівником, політика повернення без чека і сторонній OMS, що володіє платіжним інструментом повернення. SAP Commerce не може змусити чужий POS поважати OrderEntry.totalPrice; він може лише надати це через API і записати результат.
Як виявити. Звірте суму повернення зі сплаченою ціною рядка:
SELECT {o.code}, {e.entryNumber}, {e.totalPrice} AS paidLine,
{re.amount} AS refundAmt, {e.product}
FROM {RefundEntry AS re
JOIN ReturnEntry AS ren ON {re.pk}={ren.pk}
JOIN OrderEntry AS e ON {ren.orderEntry}={e.pk}
JOIN Order AS o ON {e.order}={o.pk}}
WHERE {re.amount} > ({e.totalPrice} / {e.quantity})Будь-який рядок, де сума повернення перевищує сплачену ціну рядка, — це дрейф до поличної ціни (або легітимне збільшення повернення; потрібна перевірка). Якщо повернення породжуються в POS, артефакт лежить у журналі POS / аудиті SAP CAR / лозі повернень OMS і має звірятися з рядками замовлень Commerce за кодом замовлення. Також перевірте Spring-бін за returnService/refundService і будь-яке вхідне інтеграційне відображення, що задає RefundEntry.amount із зовнішнього поля.
Тест. Оформіть замовлення за активною промоакцією (сплачено €3 000, база €4 500), завершіть промо, потім поверніть одну одиницю через кожен канал — нативний ReturnService, OCC returns API і реальний магазинний POS — і подивіться RefundEntry.amount. Врахуйте, що дефолт «з коробки» видасть €4 500, якщо суму не передано явно; правильна ціль — €3 000. Будь-який канал, що мовчки видає €4 500, перераховує ціну (з каталогу або з дефолту за базовою ціною).
Як запобігти. Зробіть зафіксовану сплачену ціну рядка єдиним джерелом істини щодо ціни: кожен шлях повернення резолвиться в OrderEntry і повертає сплачену суму рядка, що закріплено в кастомному розрахунку суми повернення — так це працює незалежно від каналу і перевизначає дефолт за базовою ціною. Прийміть архітектуру RMA-first — онлайн породжує авторизацію повернення, що несе сплачену ціну і ключ замовлення; POS зобов'язаний її відсканувати і звернутися в Commerce/OMS, щоб обчислити суму, а не пред'являти свою. Повернення без чека за замовчуванням — за найнижчою ціною за останні N днів або у вигляді store credit, з перевірками частоти. Рольово обмежуйте й забезпечуйте кодом причини кожне перевизначення ціни та відправляйте його назад для аудиту, описаного вище.
Перерозподіл кошикової знижки при частковому поверненні
Експлойт. Белла купує три сорочки по €3 000 кожна. Промо «3 за ціною 2» дає знижку €3 000 на найдешевшу, тож вона платить €6 000. Движок «саджає» −€3 000 на рядок 3. Вона повертає рядки 1 і 2 — дві сорочки за повною ціною — і залишає рядок 3. Наївне порядкове повернення віддає €6 000 за два повернуті рядки, і вона залишає собі сорочку, за яку тепер заплатила нуль. Промо вимагало купити три; вона «купила» одну безкоштовно.
Пороговий варіант тонший. Купити A=€6 000 і B=€5 000, підсумок €11 000, «−20% при сумі від €10 000» → сплачено €8 800. Повертаємо B. Навіть якщо повернення враховує розподілену частку B (€5 000 − €1 000 = €4 000), у решті кошика тепер лише €6 000 — нижче порога €10 000, — тому A взагалі не мала зберігати свою знижку −€1 200. Перерахунку немає, і Белла залишає A за €4 800 замість €6 000. Безкоштовна доставка від порога влаштована так само: повернути товари, щоб опуститися нижче порога, — і списану доставку вже не утримують назад.
Механізм. Движок промоакцій трактує знижку як властивість усього кошика, зумовлену його складом. Флоу повернення трактує кожен рядок як такий, що має фіксовану ціну зі «вшитою» знижкою, і ніколи не перепитує, чи задовольняє кошик умови, як і раніше.
Вердикт SAP Commerce.
Rule-based promotionengine (на Drools: RuleBasedPromotion, дії на кшталт RuleBasedOrderAdjustTotalAction, обчислювані в PromotionResult) фіксує, які саме промо спрацювали і з якими наслідками. Знижки зберігаються з розподілом — на рівні рядків у discountValues, на рівні замовлення в globalDiscountValues, рознесені по рядках при calculateTotals. Платформа зберігає достатньо, щоб обчислити пропорційне повернення.
Важливе застереження заради чесності: дефолт «з коробки» бере RefundEntry.amount із базової ціни товару (без знижки), тому без доробок поведінкою за замовчуванням є протікання на повну ціну рядка — платформа не розподіляє знижку на повернення за вас. І, що критично, флоу повернення не перезапускає движок промоакцій на кошик після повернення, щоб перерозподілити спільну знижку на решту рядків або відкликати вигоду, поріг якої більше не виконується. (Примітив перерахунку підсумків існує — RefundService.createRefundOrderPreview(order) клонує замовлення і перераховує підсумки, — але він не переоцінює rule-based промо на гіпотетичний решту кошика.) Чи змінювали це попередні доробки — перевірте тестом; але не розраховуйте, що платформа робить умовний перерахунок: за замовчуванням — ні.
Бізнес-рішення про те, чи «закріплюється» промо назавжди одного разу заробленим (легітимна політика доброї волі), чи строго умовно; і будь-який зовнішній OMS або податковий движок, що володіє розрахунком повернення.
Як виявити. Виділіть часткові повернення за замовленнями, де була знижка рівня замовлення, і порівняйте повернуту суму з маржинальною вартістю повернутих товарів:
SELECT {o.code}, {o.globalDiscountValuesInternal},
{re.amount} AS refundAmt, {ren.receivedQuantity} AS retQty,
{e.entryNumber}, {e.totalPrice}
FROM {ReturnRequest AS rr
JOIN Order AS o ON {rr.order}={o.pk}
JOIN ReturnEntry AS ren ON {ren.returnRequest}={rr.pk}
JOIN RefundEntry AS re ON {re.pk}={ren.pk}
JOIN OrderEntry AS e ON {ren.orderEntry}={e.pk}}
WHERE {o.globalDiscountValuesInternal} IS NOT NULLЦей запит лише відбирає кандидатів; позначення робиться на аналізі. globalDiscountValues зберігається серіалізованим, тож декодуйте його, а потім позначайте будь-яке часткове повернення, де підсумок повернення перевищує paidTotal − retainedPaidTotal. Переконайтеся за рядками PromotionResult, що при поверненні не запускалася нова оцінка, і звірте deliveryCost з рештою підсумку на предмет неутриманої безкоштовної доставки.
Тест. Запустіть «3 за ціною 2» (3 × €3 000, сплачено €6 000), зазначте, на якому рядку лежить −€3 000, поверніть два рядки за повною ціною і подивіться два значення RefundEntry.amount: чи складаються вони в €6 000 (протікання — безкоштовна залишена сорочка) чи в €3 000 (правильно — промо розпалося)? Потім пороговий випадок: A=€6 000 + B=€5 000 при «−20% від €10 000», поверніть B і перевірте, чи буде повернення €5 000, €4 000 чи економічно правильні €2 800 (сплачено €8 800 мінус €6 000, у стільки тепер має обійтися залишена A за повною ціною).
Як запобігти. Правильний примітив: refund = paidTotal(original) − paidTotal(решта кошика після перезапуску движка промо). Реалізуйте кастомний розрахунок суми повернення, який клонує замовлення без повернутих кількостей, проганяє PromotionEngineService, викликає calculateTotals і бере різницю. Це одним узгодженим механізмом покриває розпад «3 за ціною 2», втрату порога і пропорційний перерозподіл. Зробіть утримання безкоштовної доставки перемикачем у процесі повернення. І головне — зробіть політику («перераховувати промо при поверненні» проти «зароблене промо зберігається») явним, задокументованим рішенням. Провал — це коли політики немає, і наївний дефолт (порядковий або за базовою ціною) вирішує мовчки.
Негрошові засоби
Обидві діри цього розділу за своєю суттю лежать поза нативною зоною SAP Commerce Cloud — і в цьому вся суть. Платформа володіє замовленням, заявкою на повернення, сумою повернення і follow-on-поверненням на картку — і приблизно на цьому її знання закінчується. Лояльність, кешбек, подарункові картки з балансом і BNPL — усе це живе в зовнішніх системах. Експлойт ніколи не стається всередині платформи; він стається на стиках.
Бали лояльності й кешбек не відкликаються
Експлойт. Даріо купує машину за €600 онлайн. Програма лояльності нараховує 6 000 балів при списанні оплати. Протягом 48 годин він витрачає 6 000 балів на окреме невелике замовлення й отримує товар. Потім він повертає машину і отримує повне повернення €600. Відкликання балів намагається списати 6 000 з балансу, де зараз лише 200, — і якщо правило «баланс не може піти в мінус», списується 200, а 5 800 балів (~€58) списуються у збиток. Бали взаємозамінні й витрачаються миттєво; повернення повільне й асинхронне. Вікно між нарахуванням і відкликанням — це і є вся поверхня атаки. (Заодно їде й кешбек за карткою чи порталом — але див. застереження нижче; ця частина зазвичай відновлюється, із затримкою.)
Механізм. Система лояльності вважає, що нарахування — функція події розміщення замовлення, і вважає нарахування остаточним. Система комерції/повернень вважає, що повернення — функція грошової вартості рядка замовлення, і не несе зобов'язань перед реєстром лояльності. Ніхто не володіє «чистою заробленою вартістю після розрахунків». Нарахування рахується на брутто-замовлення; відкликання, якщо воно є, рахується від того, що залишилося на балансі, — а не від вихідного нарахування.
Вердикт SAP Commerce.
З коробки платформа цього не робить зовсім — і саме ця рамка важлива. У SAP Commerce немає LoyaltyPointsService, немає реєстру балів, немає движка нарахування/списання. Лояльність живе в зовнішній системі (SAP Emarsys Loyalty, Talon.One, Antavo, Annex Cloud або самописній). Кешбек спрацьовує в еквайра/емітента/агрегатора (Rakuten, банк-емітент) за подією транзакції і керується їхнім SLA щодо виявлення повернень. У SAP Commerce нульова видимість і того, і іншого.
Чим платформа володіє — це механікою, щоб ініціювати відкликання у потрібний момент: флоу ReturnRequest / RefundEntry, бізнес-процес повернення і платіжний REFUND_FOLLOW_ON проти вихідного списання. Вона може випустити подію; вести реєстр вона не може.
Саме відкликання — кастомна інтеграція. Повісьте на завершення процесу повернення (RETURN_COMPLETED) виклик API відкликання в системі лояльності з вихідним нарахуванням, прив'язаним до конкретних повернутих рядків, — а отже, у момент нарахування треба зберігати, скільки балів породив кожен рядок замовлення (кастомний LoyaltyAccrualEntry, пов'язаний з AbstractOrderEntry), щоб часткові повернення відкликалися пропорційно. Політика від'ємного балансу має бути явним контрактом з вендором. Використовуйте ключі ідемпотентності, щоб повторюваний крок процесу не спрацював двічі.
Як виявити. Найпоказовіший звіт пов'язує реєстр лояльності із замовленнями: нарахування, у чийого батьківського замовлення є завершений ReturnRequest, але немає відповідного відкликання (або відкликання менше нарахування). Додайте звіт «списаних у збиток відкликань», що підсумовує відкликання, які вперлися в нульову підлогу, — цей підсумок і є кількісно виміряним протіканням. Щодо кешбеку звіряйте щомісячну виписку про відкликання від агрегатора з вашим реєстром повернень. Перевірте у вендора налаштування «дозволити від'ємний баланс» і «відкликання при поверненні» і те, чи є в return-process крок відкликання взагалі.
Тест. Оформіть замовлення, підтвердьте нарахування балів, витратьте весь баланс на другому замовленні, потім повністю поверніть перше. Перевірте, що в реєстрі є відкликання, рівне вихідному нарахуванню, і баланс іде в мінус (або створюється запис про борг/списання). Повторіть із частковим поверненням і перевірте пропорційне відкликання.
Як запобігти. Відкликайте проти вихідного нарахування, а не поточного балансу. Дозвольте від'ємні баланси за контрактом (відкликання при наступному нарахуванні) або конвертуйте неповоротну частину в явний запис про стягнення готівкою — але ніколи мовчки не впирайтеся в нуль. Ще краще — затримуйте великі нарахування за межі вікна повернення: нараховуйте при замовленні, але робіть бали доступними до витрачання лише після періоду права на повернення, або тримайте частину «в очікуванні». Де програма дозволяє — випускайте подію нарахування за чистою розрахованою сумою, а не за фактом розміщення замовлення. Запускайте щоденну тристоронню звірку: повернення commerce ↔ реєстр лояльності ↔ розрахунки за платежами/кешбеком.
Перетворення негрошових інструментів на готівку
Експлойт. Мара купує товарів на €500, оплачуючи €400 промо-подарунковою карткою (випущеною за акцією «витратьте €300 — отримайте €100 безкоштовно», тобто €100 цього балансу — промо-бонус) і €100 дебетовою карткою. Вона повертає все. Інтерфейс повернення бачить «сума замовлення €500» і повертає €500 на її дебетову картку. Реальні витрати Мари — €400 (€300 за подарункову картку + €100 дебет); вона отримує €500 — чистий прибуток €100, рівно промо-бонус, обготівлений у кеш. Подарункова картка, яку слід було відновити як негрошовий store credit, випарувалася на банківський рахунок.
Варіант із BNPL — гірший. Мара купує диван за €900, розбиваючи на €300 передоплати плюс Klarna «Pay in 3» на €600. Klarna вже заплатила продавцю наперед. Вона повертає диван і отримує €900 на картку — при цьому розстрочка на €600 лишається активною і має бути окремо скасована або відкликана через API Klarna. Зробивши це неправильно, продавець повернув гроші поза каналом і все ще має розмотати фінансування Klarna: подвійна експозиція на BNPL-плечі.
Механізм. Сервіс повернення вважає, що замовлення — єдина сума, погашена одним інструментом (картка у файлі). Шар списання — і зовнішній провайдер подарункової картки чи BNPL — знає, що замовлення профінансовано кількома інструментами у певних пропорціях, у кожного своя семантика повернення. Ідентичність «готівка проти неготівки» губиться між списанням і поверненням, тому повернення схлопує різнорідний набір інструментів в однорідну готівку.
Вердикт SAP Commerce.
Лояльність, подарункові картки з балансом і BNPL — усе зовнішнє. Voucher/PromotionVoucher/SerialVoucher у SAP Commerce — це знижкові ваучери, а не інструменти зі збереженою вартістю; справжня функціональність подарункових карток (реєстр балансу, активація, поповнення) — це кастомний тип плюс зовнішній провайдер (Blackhawk, SVS/Fiserv, Givex). Провайдери BNPL (Klarna/Affirm/Afterpay) інтегруються як спосіб оплати; у платформи немає нативного розуміння, що повернення має скасувати розстрочку.
Скажемо прямо про слабкість платформи: у SAP Commerce «з коробки» дуже обмежена підтримка спліт-оплати. Класична модель — один PaymentInfo на замовлення; немає повноцінного «списку інструментів із сумами», який флоу повернення поважав би нативно. Модель даних може представити кілька списань — paymentTransactions це колекція, кожна PaymentTransaction тримає записи AUTHORIZATION/CAPTURE/REFUND_FOLLOW_ON, — але штатна логіка повернення не розкладає повернення за цими вихідними транзакціями пропорційно, а RefundEntry несе суму, а не інструмент.
Тож поведінка з урахуванням інструментів — цілком кастомна, і в неї три частини. По-перше, збережіть структурований реєстр інструментів при списанні — кастомний OrderTenderEntry або дисципліновано теговані PaymentTransaction — із записом, який провайдер, який інструмент і на скільки. По-друге, прочитайте його у флоу повернення й розкладіть кожен RefundEntry за вихідними інструментами у вихідних пропорціях. По-третє, оркеструйте правильний розворот за кожним інструментом: REFUND_FOLLOW_ON на конкретне списання картки, повторне зарахування зовнішньому провайдеру подарункової картки через його API, розворот лояльності через її API і POST-повернення провайдеру BNPL, щоб розстрочка зменшилася. Жорсткий інваріант поверх усіх трьох: негрошове ніколи не має повертатися в готівку.
Точна поведінка сильно залежить від PSP. Adyen, наприклад, підтримує повернення за кожним способом оплати на боці PSP і може нести ідентичність інструмента, якщо інтеграція її прокидає, — але SAP Commerce не зробить пропорційну розбивку за вас без коду. Перевіряйте за кожною інтеграцією.
Як виявити. Звіт-доказ пов'язує записи списання PaymentTransactionEntry (за інструментом/провайдером) із записами REFUND_FOLLOW_ON за тим самим замовленням і позначає будь-яке замовлення, де розподіл повернення за інструментами відрізняється від розподілу списання — особливо повернення на картку при списаннях з подарункової картки чи BNPL. Звірте зовнішній реєстр подарункових карток на предмет відсутніх повторних зарахувань і звіт за розрахунками BNPL на предмет повернутих замовлень, чия розстрочка так і не була скасована (з продавця все ще списують).
Тест. Оформіть замовлення 60% подарунковою карткою / 40% карткою, поверніть повністю і перевірте, що 60% зараховано провайдеру подарункової картки (перевірте зовнішній баланс), а 40% — REFUND_FOLLOW_ON на картку, а не 100% на картку. Повторіть із частковим поверненням (перевірте пропорційну розбивку) і з BNPL-замовленням (перевірте, що розстрочку скасовано у провайдера — в його порталі).
Як запобігти. Зробіть повернення на вихідний інструмент, за кожним інструментом, пропорційно незаперечним інваріантом сервісу повернення. Моделюйте спліт-оплату явно, раз «з коробки» її немає. Спрямовуйте BNPL-повернення через API провайдера, щоб розстрочка коригувалася. Позначайте промо-вартість подарункової картки як неповоротну в готівку і відстежуйте промо-фінансовану частку окремо, щоб «безкоштовна» вартість ніколи не відновлювалася нічим більшим, ніж негрошовий store credit. Звіряйте щоденно: повернення commerce ↔ реєстр подарункових карток ↔ розрахунки PSP/BNPL.
Фізична одиниця товару
Одна структурна істина для цього розділу: рядок замовлення в SAP Commerce Cloud ідентифіковано за типом (товар / варіант / EAN), а не за екземпляром. Тому будь-який контроль нижче, заснований на серіалізації, — кастомний, і — оскільки модуль комплектів активно розкладає комплект на компонентні рядки замовлення — будь-який контроль «має повернутися як єдине ціле» теж кастомний.
Підміна одиниці при несеріалізованому обліку
Експлойт. SKU CAM-X100 (бездзеркальна камера, «тушка»), один EAN, продається онлайн за €1 199 як нова, поточного року випуску. Той самий EAN є і на розпродажних одиницях минулої ревізії в магазині-партнері (€649), і на open-box одиницях сірого ринку на маркетплейсі (€520). Марек купує одиницю за €1 199 онлайн і залишає її собі. Окремо він придбає open-box одиницю за €520 з тим самим EAN, ініціює онлайн-повернення і здає цю одиницю. Оператор сканує EAN, він збігається з ReturnEntry, стан виглядає прийнятним → повертається €1 199. Марек залишає собі нову одиницю. Чистий збиток ~€679 за цикл (€1 199 повернення мінус ~€520 вартості підмінної одиниці, яку забирає ритейлер) плюс прихований другий збиток, коли одиниця нижчого ґатунку знову потрапляє у продаваний сток за повною вартістю. Варіант з одягом — той самий трюк, коли один EAN надруковано на всьому розмірному ряду.
Механізм. Сервіс замовлення/фулфілменту вважає, що ReturnEntry ідентифікує відвантажену річ, — але він посилається лише на код Product і кількість, без ідентичності екземпляра. Оператор вважає: «скан збігся з рядком + стан ок = та сама одиниця». Скан підтверджує належність до класу товару, а не походження екземпляра.
Вердикт SAP Commerce.
/ Платформа моделює ієрархію товарів/варіантів (VariantProduct, GenericVariantProduct, ApparelSizeVariantProduct, VariantValueCategory), StockLevel (кількості за складами, не ідентичності) і граф повернень із гранулярністю рядок замовлення + кількість. Зверніть увагу на термінологічну пастку: Unit/UnitModel у товару — це одиниця вимірювання, а не серіалізована фізична одиниця. Ядро SAP Commerce не відстежує індивідуальні серійні номери для звичайних товарів. Немає OOTB-атрибута на OrderEntry/ConsignmentEntry/ReturnEntry для поштучного серійника чи IMEI. Тому ідентичність повертаного товару платформою не перевіряється — вона може підтвердити «товар з EAN X, кількість 1», але ніколи — «конкретну одиницю, відвантажену за консигнацією C». Можливості, суміжні із серіалізацією, живуть в інших продуктах SAP (управління серійниками/партіями в S/4HANA, Advanced Track & Trace, handling units в EWM), а не в рядку замовлення commerce.
Щоб закрити: фіксуйте серійник/IMEI/мітку на фулфілменті (розширте ConsignmentEntry або додайте тип ProductInstance, пов'язаний з консигнацією і рядком замовлення) і додайте в процес повернення валідацію, що відхиляє або утримує будь-який ReturnEntry, чий відсканований серійник не входить у відвантажений за цим замовленням набір, — блокуючи RefundEntry до проходження. Робіть це обов'язковим лише для конфігурованого набору дорогих/ризикових товарів; не серіалізуйте весь каталог. Додайте обов'язкову оцінку стану при прийманні і ніколи не повертайте автоматично в InStock за повною вартістю без оцінки ґатунку.
Фізичний огляд (та це одиниця чи ні, той рік випуску чи ні, нова чи вживана), скан на POS і майстер-дані серійників/партій/гарантій в ERP.
Як виявити. Знайдіть дорогі SKU, чий EAN розділяється кількома варіантами або базовими товарами, — це кандидати на колізію: запит Product з групуванням за ean і умовою count(distinct code) > 1, відранжований за ціною. Потім звіт «повернення проти собівартості»: згрупуйте RefundEntry.amount за повернутими рядками, що сильно перевищують середньозважену собівартість SKU. І аудит повторного вводу: товари, позначені як повернуті та повернуті у продаваний сток без атрибута стану/ґатунку.
Тест. Відвантажте рядок для SKU з EAN E за ціною P; ініціюйте повернення, але при прийманні відскануйте іншу фізичну одиницю, що легітимно несе EAN E (інша партія / open-box). Чи прийме система receivedQuantity і авторизує повне повернення за P без перевірки серійника чи стану? Якщо так — ви вразливі. Прямe аудиторськe питання: «Для будь-якого завершеного дорогого повернення — чи можете ви довести, що отриманий фізичний екземпляр дорівнює екземпляру, відвантаженому за цим замовленням? Якщо відповідь „ми відсканували EAN“ — контролю не існує».
Як запобігти. Серіалізуйте на фулфілменті дорогі/схильні до колізій товари; жорстко прив'язуйте повернення до збігу серійника; вбивайте колізії EAN у майстер-даних (унікальний EAN на продаваний варіант, через валідаційний інтерцептор); оцінюйте стан при прийманні, і нехай ґатунок визначає і суму повернення, і право на повернення в сток; вимагайте збігу серійника або схвалення супервайзера для повернень вище маржинального порога.
Комплекти й різні юрособи
Експлойт. «Home Theatre Pack» продається онлайн за €899 = саундбар (прайс €599) + сабвуфер (€299) + тилові колонки (€199), знижка за комплект €198. Прія купує його, хоче лише саундбар і повертає в магазин тільки сабвуфер і тили. Працівник сканує їх як окремі товари за індивідуальними прайсовими цінами і повертає €498. Чиста вартість саундбара для Прії — €401 проти €599 при окремому продажі: вона поклала собі в кишеню знижку за комплект, яка була зумовлена збереженням набору. Дзеркальний експлойт — повернути повну коробку без одного компонента і отримати повні €899, якщо перевірки комплектності немає.
Версія взагалі без зловмисного покупця: те саме повернення прийнято у франчайзі (окрема юрособа). Франчайзі повертає гроші покупцю, потім виставляє міжфірмову заявку на відшкодування по €1 097 (сума частин), тоді як бренд отримав лише €899. Дельта €198 — чисте міжфірмове витікання, врегульоване тижні по тому пакетно, де розбіжність невидима. Помножте на тисячі франчайзі-повернень: структурна ерозія маржі, за якою не стежить жодна антифрод-команда.
Механізм. Сервіс замовлення/ціноутворення трактує комплект як єдину цінову одиницю з умовною знижкою; сервіс повернення в магазині трактує те, що перед ним, як N незалежних товарів з незалежними цінами і EAN. Вони розходяться в тому, один це об'єкт чи три, і передумова знижки — цілісність набору — не доноситься у шлях повернення. У міжфірмовому випадку продавальна і приймальна юрособи розходяться в тому, кому належать гроші і за якою оцінкою кредитується повернення.
Вердикт SAP Commerce.
Модуль комплектів (configurablebundleservices/bundleservices) моделює BundleTemplate, товари-компоненти і ціноутворення через правила комплекту — але, що важливо, представляє комплект у замовленні як кілька рядків AbstractOrderEntry, пов'язаних bundleNo, а не як один атомарний рядок. Тобто на рівні рядків замовлення комплект уже розкладено, і ReturnEntry (на рядок замовлення + кількість) з радістю створить повернення на один компонент. Повернення на рівні компонента — це дефолт, а не заблокований крайовий випадок; немає OOTB-обмеження, що комплект повертається цілком, і немає OOTB-перерахунку, що відкликає знижку за комплект із залишених компонентів. Закрити — кастомно: правило часу повернення за bundleNo, яке або вимагає повернути весь комплект, або запускає перерахунок «розбитого комплекту» (розширення AbstractBundleRule), що знімає знижку і переоцінює залишені компоненти за окремими цінами до розрахунку RefundEntry. (Якщо ж ви моделюєте набір як єдиний нерозкладений SKU, платформа не знає, що в коробці три сканувані товари, — перевірка комплектності цілком кастомна і почасти фізична.)
Міжфірмові фінансові взаєморозрахунки між брендом і франчайзі живуть в ERP (міжфірмовий білінг S/4HANA), а не в Commerce — немає нативного поняття «приймальна юрособа ≠ продавальна юрособа» для розрахунків. Договори франчайзингу (хто з'їдає розбіжність, за якою оцінкою) — юридично-комерційні. Максимум, що може платформа, — проштампувати приймальну юрособу і точну сплачену оцінку комплекту на кожному поверненні і випустити структурований запис розрахунку, щоб звірка була автоматичною і на одній базі, а не ручною заявкою за прайсом.
Як виявити. Знайдіть комплекти, повернуті як власна підмножина компонентів: згрупуйте OrderEntry за bundleNo, порахуйте рядки на комплект і порівняйте з числом ReturnEntry на bundleNo — будь-який комплект, де повернули лише частину компонентів, кандидат. Потім перевірка «повернення проти оплати»: підсумуйте RefundEntry.amount за комплектом проти сплаченої totalPrice комплекту, позначаючи, де сума повернень компонентів перевищує розподілену ціну комплекту. На боці ERP стежте за міжфірмовим кліринговим рахунком на предмет стійкого однонаправленого дисбалансу від франчайзі-заявок на повернення.
Тест. Купіть комплект за €899, поверніть два з трьох компонентів: чи буде повернення €498 (за прайсом, вразливо) чи відкликана розподілена вартість комплекту (правильно), і чи переоцінюється залишений компонент за окремою ціною? Потім міжфірмове питання: «Для комплекту, повернутого у франчайзі-магазин, покажіть запис автоматичної звірки — це відшкодування за розподіленою ціною комплекту, яку реально заплатив покупець, чи ручна заявка за прайсом компонентів?».
Як запобігти. Тримайте зв'язку bundleNo/bundleTemplate авторитетною і робіть так, щоб повернення компонента запускало оцінку всього комплекту — повернення комплекту цілком або перерахунок розбитого комплекту, але ніколи не повернення компонентів за прайсом із комплекту зі знижкою. Додайте перевірку комплектності для набір-SKU. Зробіть розрахунки обізнаними про юрособу: штампуйте приймальну юрособу і сплачену оцінку, випускайте міжфірмовий запис на одній базі в ERP і звіряйте за кожною транзакцією, а не пакетно.
Статуси й час
Подвійне повернення через асинхронність каналів
Експлойт. Майя купила еспресо-машину за €640 (один рядок, к-сть 1). О 10:12 вона починає онлайн-повернення — SAP Commerce створює RMA-77341, автосхвалену до WAIT. О 10:40 замість пункту видачі вона заходить у магазин; POS знаходить замовлення через OMS, бачить, що рядок ще повертаний, і повертає €640 на її картку — не звертаючись назад у граф ReturnRequest у Commerce. Онлайн-RMA вона так і не скасовує. Нічний пакет звірки імпортує магазинне повернення, але запис конфліктує і відкидається як попередження «дублікат», яке ніхто не читає. Через три дні склад сканує приймання за RMA-77341, процес повернення доходить до кроку повернення грошей — і розвертаються другі €640. Той самий трюк працює як «повернення в магазині плюс чарджбек у банку».
Механізм. POS/OMS вважає: «я забрав товар і розрахувався — готово». ReturnRequest у Commerce вважає: «ця RMA ще WAIT; у рядка ще є повертана кількість; коли прийде товар — я маю повернути гроші». Ніхто не трактує «гроші, що належать за цим рядком замовлення» як єдиний, заблокований, міжканальний факт.
Вердикт SAP Commerce.
Платформа добре моделює граф повернень: ReturnRequest зі своєю стейт-машиною ReturnStatus (APPROVAL_PENDING, WAIT, RECEIVED, PAYMENT_REVERSED, COMPLETED, CANCELED, CANCELLING, плюс статуси розвороту платежу/податку на кшталт PAYMENT_REVERSAL_FAILED та ін.), ReturnEntry, що вказує на рядок замовлення, RefundEntry із сумою, і task-engine return-process, де перехід у RECEIVED — штатний стик вивільнення грошей.
Штатний DefaultReturnService не запобігає двом одночасним ReturnRequest на один OrderEntry. Він лише перевіряє залишкову повертану кількість (getAllReturnableEntries / OrderReturnTool), і ця перевірка не заблокована транзакційно між двома створеннями — дві заявки, кожна читаючи «повертано = 1», можуть бути створені обидві до того, як будь-яка закомітить. Гірше того, облік повертаної кількості знає лише про повернення, створені всередині SAP Commerce; повернення POS/OMS, яке так і не записало ReturnEntry назад, для нього невидиме. Тож навіть ідеальний захист у межах однієї системи це не закриє. Потрібен єдиний real-time авторитет статусу повернення з гранулярністю рядка замовлення, розподілений ключ ідемпотентності / блокування на (orderCode, entryNumber, reason) і повернення грошей, прив'язані до підтвердженого приймання, а не до будь-якого статусу, який може виставити канал.
Магазинний POS і його рейки повернень, реєстр розрахунків зовнішнього OMS/ERP, події сканування кур'єра/ПВЗ і банківська мережа чарджбеків. У багатьох омніканальних ландшафтах SAP Commerce — учасник, а не реєстр — розрахунками володіє зовнішній OMS або S/4HANA, і зв'язує реєстри звірка, а не зовнішній ключ.
Як виявити. Знайдіть рядки замовлення з більш ніж одним поверненням і суми повернень, що перевищують вартість рядка:
SELECT {o.code}, {oe.entryNumber},
SUM({rf.amount}) AS refunded, {oe.totalPrice} AS lineTotal
FROM {RefundEntry AS rf
JOIN ReturnEntry AS re ON {rf.pk}={re.pk}
JOIN OrderEntry AS oe ON {re.orderEntry}={oe.pk}
JOIN Order AS o ON {oe.order}={o.pk}}
GROUP BY {o.code}, {oe.entryNumber}, {oe.totalPrice}
HAVING SUM({rf.amount}) > {oe.totalPrice}Зіставте бізнес-процеси return-process у стані SUCCEEDED із замовленнями, які також з'являються у фіді звірки магазинних повернень, і перевірте історію CronJob цього імпорту на предмет проковтнутих попереджень «дублікат повернення» — ось де ховається колізія. На боці платежів шукайте два повернення на одну картку проти однієї вихідної авторизації в межах вікна звірки.
Тест. Створіть дві ReturnRequest на один OrderEntry з двох точок входу одночасно (нативний сервіс + OCC API, перетинні транзакції). Чи проходять обидві? Просуньте обидві в RECEIVED і порахуйте рядки RefundEntry і сумарно захоплене. Потім вкиньте запис магазинного повернення за тим самим рядком через фід звірки і подивіться, чи відхилить система, змерджить чи проведе двічі. Аудиторське питання: «Покажіть транзакційну межу і блокування, що гарантує, що сума проведених повернень за одним рядком замовлення ніколи не перевищить його чисту вартість — через POS, OMS і Commerce». Якщо відповідь «нічна звірка ловить це» — діра відкрита.
Як запобігти. Прив'язуйте гроші тільки до фізичного RECEIVED, який виставляє склад проти збіглого SKU/серійника, — ніколи до створення заявки в порталі чи до «прийнято» на POS. Поставте єдиний реєстр повернень (виділений сервіс або призначений OMS) на чолі стану повернення за кожним рядком замовлення; кожен канал синхронно звертається до нього, щоб зарезервувати повертану кількість, перш ніж обіцяти повернення. Навішуйте розподілені ключі ідемпотентності на кожну команду повернення, щоб повтор або друге проведення були no-op. Моделюйте повернення-і-виплату як сагу з транзакційним outbox, щоб падіння не випустило двічі. Залиште звірку, але підвищте її попередження «дублікат» до жорстких блокувальних алертів з автоутриманням другої виплати.
Виплати за скануванням і фантомні повернення через ПВЗ
Експлойт. Дві форми. Скасування після відвантаження: Ден замовляє ноутбук за €1 200; о 18:00 консигнація SHIPPED і в кур'єра; о 18:03 він тисне «Скасувати замовлення», флоу скасування налаштований на авто-повернення за підтвердженням, і €1 200 зараховуються. Посилка все одно доїжджає. Ден залишає ноутбук. Фантомне повернення через ПВЗ: Надя повертає куртку за €300 через пункт видачі; скан кур'єра RETURN_ACCEPTED відображається вхідною інтеграцією в ReturnStatus = RECEIVED, процес повернення просувається, €300 повертаються — а через шість днів коробка доїжджає до складу з цеглиною всередині. Перевірка вмісту не прив'язана до повернення, бо воно вже спрацювало. При тисячах посилок на день це і є системне протікання.
Механізм. Дві системи розходяться в тому, яка подія означає «гроші належать». Кур'єр/ПВЗ випускає «посилка надійшла до мене» — логістичний факт. Тригер повернення трактує будь-який сигнал виду RECEIVED як «у нас верифікований, правильний товар» — розрахунковий факт. Для скасування-після-відвантаження сервіс скасування вважає «скасування запитано ⇒ відвантаження можна зупинити ⇒ безпечно повертати гроші», тоді як кур'єр уже пройшов точку неповернення.
Вердикт SAP Commerce.
Платформа моделює скасування (OrderCancelService, CancelRequestRecordEntry, право на скасування за статусом консигнації) і перехід повернення в RECEIVED як штатний стик вивільнення грошей, з ручним кроком приймання товару в стандартному процесі.
Чи тригерить скасування авто-повернення — рішення конфігурації/розширення; у цьому і небезпека скасування-після-відвантаження. А RECEIVED може виставити будь-яка інтеграція: якщо фід ПВЗ/кур'єра зробить PATCH, виплата спрацьовує без перевірки вмісту, бо немає вбудованого гейта збігу серійника/SKU між RECEIVED і кроком повернення, а крок контролю якості, який заблокував би повернення, — вибір моделювання, а не дефолт. Платформа нативно не розрізняє «кур'єр прийняв» і «склад верифікував правильний товар» — обидва схлопуються в RECEIVED. Виправлення — двофазний статус (CARRIER_ACCEPTED, інформаційний, без грошей; RECEIVED_VERIFIED, вміст складу + збіг серійника, вивільняє гроші), обов'язковий блокувальний вузол перевірки вмісту перед кроком повернення і гейт повернення при скасуванні, який атомарно переперевіряє статус консигнації і перетворює скасування після SHIPPED на флоу «повернення за прибуттям».
Системи сканування кур'єра і ПВЗ і їхня семантика подій, фізична станція огляду на складі і зовнішній OMS, якщо він володіє скасуванням/розрахунками. Ці фіди приходять асинхронно (SCPI/Kafka/impex) без гарантій порядку і exactly-once.
Як виявити. Перевірте XML-визначення return-process на предмет того, чи стоїть вузол контролю якості/перевірки перед кроком повернення чи це пряме ребро RECEIVED → refund, і перевірте ProcessTaskLog на пропущені кроки огляду. Прогрепайте вхідну інтеграцію (SCPI iFlow / Kafka-консьюмер / impex), яка пише ReturnStatus: якщо кур'єрський RETURN_ACCEPTED відображається в RECEIVED — це доказ. Щодо скасування-після-відвантаження знайдіть рядки CancelRequestRecordEntry, чия консигнація вже SHIPPED, у кореляції з поверненнями, датованими після відвантаження. Поза платформою: посилки, позначені як прийняті в ПВЗ, без відповідного приймання на складі протягом N днів.
Тест. Просуньте консигнацію в SHIPPED, потім викличте OrderCancelService.requestOrderCancel — чи спрацьовує повернення негайно, без переперевірки відкликання товару? Окремо вкиньте синтетичну кур'єрську подію RETURN_ACCEPTED за відкритою RMA і перевірте, чи стрибає ReturnStatus в RECEIVED і чи породжується розворот платежу без будь-якого запису про складське приймання або контроль якості; потім доставте незбіглий SKU і подивіться, чи відкликається щось назад. Аудиторське питання: «Між статусом вивільнення грошей і фізичною подією — що перевіряє, що отримано правильний SKU/серійник? Назвіть обов'язковий вузол процесу і покажіть повернення, заблоковане через провалену перевірку».
Як запобігти. Прив'язуйте гроші тільки до верифікованого фізичного приймання — явний статус RECEIVED_VERIFIED, який виставляє склад після збігу SKU/серійника і стану, з ребром повернення, що виходить звідти, а не від кур'єрського скану. Нехай кур'єрські скани оновлюють видимий покупцю трекінговий статус, що не несе жодної розрахункової сили. Моделюйте скасування-після-відвантаження як сагу: після SHIPPED повернення стає компенсаційним кроком, прив'язаним до повернення товару, з таймаутом, який ескалює, а не платить автоматично. Приймайте події кур'єра/ПВЗ ідемпотентно і забезпечуйте легальність переходів стейт-машини, щоб пізній чи дублювальний скан не міг наново запустити вже проведений перехід.
Претензії та обміни
Претензія «товар не доклали» плюс повернення решти
Експлойт. Замовлення з трьох рядків — навушники (€220), SSD (€180), клавіатура (€140), разом €540 — їде однією посилкою; всі три доїжджають. Маркус відкриває претензію в CRM: «SSD не було в коробці». Агент підтримки оформлює досилання за доброю волею (або кредит €180) без звірки з підтвердженою доставкою кур'єра. Через два дні Маркус приходить у магазин з усіма трьома товарами; POS сканує штрихкод замовлення і показує три повертані рядки в замовленій кількості, і він повертає всі три за €540. За SSD платять двічі — раз досиланням, раз усередині повернення €540. Чисте протікання €180 за цикл, і ніщо не пов'язує претензію з повертаною кількістю.
Механізм. Актор претензій вважає «рядок 2 до покупця не дійшов» і видає компенсацію, не зменшуючи того, що в покупця ще на руках. Актор повернень вважає «повертана кількість = замовлено мінус уже повернено» і авторизує повернення за рядком 2, бо з погляду замовлення її ніколи не повертали. Жоден не звіряється з істиною доставлено-за-вирахуванням-претензії. Претензія і повернення — сіблінги, які ніколи не бачать одне одного.
Вердикт SAP Commerce.
Платформа моделює замовлену кількість (OrderEntry.quantity), сигнал доставки (ConsignmentEntry.shippedQuantity, ConsignmentStatus.DELIVERED) і граф повернень — але OOTB повертана кількість рахується як замовлено мінус уже повернено (DefaultReturnService.getAllReturnableEntries / OrderReturnTool). Вона не віднімає кількості, кредитовані за претензією про недостачу, і stock accelerator навіть не вимагає жорстко консигнацію DELIVERED. Закрити — кастомно: перевизначте getAllReturnableEntries (або навколишній фасад), щоб повертана к-сть = доставлено − уже повернено − заявлено/кредитовано-як-недостача; змоделюйте претензію як повноцінний об'єкт на замовленні (MissingItemClaim, що посилається на рядок замовлення), який враховується в розрахунку; і трактуйте досилання як витрачання права (обнуліть повертану к-сть вихідного рядка, перенесіть повертаність на консигнацію-заміну). Врахуйте: DELIVERED зазвичай відображає оновлення від кур'єра/складу, а не поштучне підтвердження покупця, що товар фізично був у коробці.
Претензія WISMO/про недостачу зазвичай народжується у зовнішній CRM/CCaaS (Salesforce Service Cloud, Zendesk, Genesys), і кредит за доброю волею може бути виданий там і ніколи не записаний назад у Commerce — це і є ключовий розрив: претензія живе в системі, яку движок повернень не читає. Магазинний POS-термінал і звірка ваги посилки в кур'єра теж зовнішні. (Якщо у вас SAP Order Management, а не stock accelerator, обробка DELIVERED і поведінка повертаної кількості багатші — перевірте свій OMS-фасад повернень.)
Як виявити. Знайдіть повернення за рядками, чия консигнація так і не досягла DELIVERED:
SELECT {o.code}, {oe.entryNumber}, {re.receivedQuantity},
{ce.shippedQuantity}, {c.status}
FROM {ReturnEntry AS re
JOIN OrderEntry AS oe ON {re.orderEntry}={oe.pk}
JOIN Order AS o ON {oe.order}={o.pk}
JOIN ConsignmentEntry AS ce ON {ce.orderEntry}={oe.pk}
JOIN Consignment AS c ON {ce.consignment}={c.pk}}
WHERE {c.status} <> ?deliveredВирішальне з'єднання не можна зробити всередині однієї системи: вивантажте кредити за доброю волею/досилання з CRM за кодом замовлення і знайдіть замовлення, у яких є і кредит за недостачею, і магазинне повернення за тим самим рядком. Це з'єднання — доказ, а його відсутність у будь-якій окремій системі — і є вся вразливість.
Тест. Створіть замовлення з трьох рядків однією посилкою, позначте консигнації DELIVERED, заведіть претензію про недостачу за рядком 2 в CRM і оформіть досилання, потім спробуйте магазинне повернення всіх трьох рядків. Чи пропонує UI повернень/POS рядок 2 як повертаний у повній кількості? Якщо так — система довіряє замовленій кількості, а не доставленому-за-вирахуванням-претензії. Спитайте архітектора: «Назвіть єдиний сервіс, що обчислює повертану кількість, і покажіть усі входи, які він читає. Чи приходить якийсь вхід із системи претензій?». Якщо ні — діра відкрита за побудовою.
Як запобігти. Зробіть один сервіс джерелом істини щодо повертаності — повертана к-сть = доставлено − уже повернено − заявлено/кредитовано-як-недостача — і єдиним шляхом, який викликають і веб-, і POS-повернення. Зберігайте претензії у графі замовлення і записуйте кредити за доброю волею з CRM назад синхронно (або блокуйте кредит, поки це не зроблено). Трактуйте досилання як заміну права. Гейтуйте повернення за доставкою, вимагаючи схвалення супервайзера з кодом причини для рядків без консигнації DELIVERED. Запускайте нічну звірку, що пов'язує кредити CRM з поверненнями Commerce за замовленням + рядком, з алертом до розрахунків.
Обмін як спосіб «обготівлитися»
Експлойт. Олена купує куртку A онлайн за промо: прайс €2 000, −40%, сплачено €1 200. Пізніше промо спливає; магазинна ціна A знову €2 000. Вона обмінює A в магазині на куртку B за магазинною ціною €3 500. Правильна доплата від того, що вона заплатила, — €3 500 − €1 200 = €2 300; але якщо обмін рахує різницю від магазинної ціни A, він бере €3 500 − €2 000 = €1 500 — вона недоплачує €800. Обмін карбує новий чек на B за €3 500: родовід знижки −40% зникає, а вікно повернення запускається наново. Вона обмінює B на куртку C за €5 000, різниця рахується від чекової вартості B (€5 000 − €3 500 = €1 500). Потім C, усередині свого свіжого вікна, повертається за €5 000.
Простежмо гроші: вона платить €1 200 (A) + €1 500 (A→B) + €1 500 (B→C) = €4 200 внесено; їй повертають €5 000 за C. Ритейлер у мінусі на ~€800 і отримує всі три куртки назад — і ці €800 — рівно та промо-вартість, що була обготівлена на переході A→B (списання за базою €2 000 замість сплаченої бази €1 200). Грошова цифра мала, але структурний збиток — ні: знижку −40% застосовано до товару, до якого вона ніколи не стосувалася, а вікно повернення скинуто так, що не закривається. Ланцюг таких обмінів — це необмежена, відмиваюча промо, вічно-повертана машина.
Механізм. Два актори розходяться в ідентичності й базі вартості «замовлення» при обміні. Вихідне замовлення (світ движка промо) несе PromotionResult, сплачену ціну €1 200 і таймер вікна повернення від вихідної доставки. Чек обміну (світ POS/нового замовлення) — цілком нове замовлення за поточною магазинною вартістю, без успадкованого промо, зі свіжим вікном і базою вартості, рівною вартості цього чека. Економічна позиція покупця в товарі змодельована як два непов'язані замовлення, тому замовлення №2 відмиває товар начисто від історії знижок і історії «одного разу повернутого».
Вердикт SAP Commerce.
/ Промо (PromotionResult, PromotionOrderEntryConsumed) обчислюються проти конкретного AbstractOrder і прив'язані до нього — немає OOTB-поняття родоводу промо, що йде за товаром в інше замовлення. Сплачена ціна живе на вихідному рядку замовлення; нове замовлення перераховується від поточних PriceRow і поточних промо. Критично: OOTB returns у SAP Commerce підтримує повернення грошей і скупу семантику заміни; справжній обмін, що створює замовлення-заміну на інший SKU з обчисленою різницею, майже завжди — кастомний флоу (або постачається SAP OMS / POS-продуктом). Тож уся проблема родоводу закривається кастомно: дайте кожному замовленню обміну/заміни жорстке посилання на вихідне замовлення і рядок; рахуйте різницю від вихідної OrderEntry.totalPrice за вирахуванням PromotionOrderEntryConsumed, а не від поточної PriceRow; переносьте політику родоводу промо явно (переносити пропорційно або рахувати обмін за повною різницею без знижки — виберіть одне, забороніть шлях мовчазного скидання); і успадковуйте вихідний початок вікна повернення на корені ланцюга, щоб у кожного обміняного товару був один таймер. Магазинний POS — це там, де це реально ламається: якщо POS використовує свій каталог і карбує свій номер чека, він перебазовує ціну і скидає вікно цілком поза видимістю Commerce.
Як виявити. Якщо ви додали посилання originalOrder на замовленнях обміну, знайдіть замовлення-нащадки обміну, що не несуть промо і запустили свіжий таймер:
SELECT {o.code}, {o.date}, {o.originalOrder}, {o.totalPrice}
FROM {Order AS o}
WHERE {o.originalOrder} IS NOT NULL
AND NOT EXISTS ({{ SELECT 1 FROM {PromotionResult AS pr}
WHERE {pr.order}={o.pk} }})Будь-який нащадок обміну із суттєво більшим підсумком, ніж у батька, і без успадкованого промо — кандидат. Також запросіть PromotionOrderEntryConsumed на знижки, «витрачені» на товар, якого в покупця вже немає, і проаудіюйте ланцюги чеків POS зі скидними датами і без зворотного посилання на вихідне веб-замовлення. Якщо зв'язки в ланцюзі немає зовсім — це відсутність і є знахідка.
Тест. Купіть за промо −40% онлайн; після закінчення промо обміняйте в магазині на дорожчий SKU, потім обміняйте ще раз. Потім перевірте три речі: чи рахувалася кожна доплата від початково сплаченої ціни чи від поточної магазинної ціни повертаного товару; чи несе фінальне замовлення хоч якийсь слід вихідного промо; і яка дата початку вікна повернення на фінальному товарі — вихідної купівлі чи останнього обміну? «Покажіть посилання чека обміну назад на вихідне веб-замовлення і яке цінове поле віднімає движок різниці». Немає зворотного посилання і поточне каталожне поле — отже, відкриті обидва протікання: перебазування і скидання вікна.
Як запобігти. Трактуйте обмін як повноцінну пов'язану транзакцію — а не як «повернення + новий продаж», бо саме це розкладання і рве родовід. Резолвіть перше замовлення в ланцюзі і для бази сплаченої ціни, і для початку вікна повернення; ніколи не давайте їм скинутися на переході. Зробіть один сервіс різниці — що читає сплачену ціну за вирахуванням спожитих промо — єдиним шляхом, який викликають і POS, і веб, і обмежте загальне число обмінів на ланцюг. Де POS працює офлайн — звіряйте і відкликайте при синхронізації.
Що лишилося поза десяткою
Ці десять обрано за те, наскільки чисто вони показують патерн «дві системи, один об'єкт, різне припущення», — а не тому, що вичерпують тему. Кілька відомих родичів, яким місце в будь-якому реальному аудиті:
- Різниця податкової/ПДВ-юрисдикції. Купівля онлайн (податок за ставкою місця доставки), повернення в магазині в іншій податковій юрисдикції; повернення перераховує податок за ставкою магазину і пере- або недоповертає податкову частину. Це діра №1 у податковому капелюсі — зафіксований податок замовлення проти локального податкового движка POS.
- Повернення на картку, відмінну від списаної. Діра №4 покриває пропорцію інструментів; окремий вектор — повернення карткової купівлі на картку, з якої ніколи не списували. Правила збіглого/референсного кредиту — стандартний контроль; перевірте, що ваш дійсно його застосовує.
- Міжрегіональний / міжвалютний ціновий арбітраж. Купівля в дешевій локалі чи валюті, повернення в дорогій — і різниця в кишені, якщо повернення перераховується за ринком повернення.
- Зловживання поверненням без повернення товару («залиште собі»). Миттєві повернення, видані до або без фізичного приймання заради економії на зворотній логістиці, експлуатовані серійними заявниками — навмисна, санкціонована політикою версія діри №8.
Кожен — тієї самої форми, і кожен вартий рядка у вашій власній версії таблиці нижче.
Хто відповідає за виправлення
Десять дір розподілені за трьома вердиктами нерівномірно, і сам розподіл — це і є головний висновок. SAP Commerce Cloud сильний у записі замовлення — він фіксує сплачену ціну, розподіляє знижки, моделює граф повернень і результати промо. Він слабкий рівно там, де омніканальному бізнесу це потрібніше за все: повернення сплаченої ціни замість дефолту за базовою ціною, перерахунок умовних промо при поверненні, повернення за кількома інструментами, ідентичність серіалізованої одиниці, міжканальне блокування повернень, кількість «доставлено-за-вирахуванням-претензії» і родовід обміну. А кілька найдорожчих дір — узагалі не його робота.
| № | Діра | Платформа | Кастом | Не платформа |
|---|---|---|---|---|
| 1 | Повернення за поличною ціною | Фіксує сплачену ціну в замовленні | Перевизначити дефолт за базовою ціною; резолвінг ціни POS↔Commerce; RMA-first | Каталог POS, кнопка override |
| 2 | Перерозподіл кошикової знижки | Зберігає розподіл знижки і PromotionResult | Перезапуск движка промо на решту кошика | Політика «закріплено одного разу заробленим» |
| 3 | Відкликання лояльності / кешбеку | Може ініціювати відкликання при поверненні | Інтеграція пропорційного відкликання | Реєстри лояльності й кешбеку |
| 4 | Негрошове → готівка | Колекція платіжних транзакцій | Реєстр інструментів; повернення на вихідний | Подарункова картка, BNPL, PSP |
| 5 | Підміна одиниці | Ідентичність за типом, варіанти, к-сть у стоку | Серіалізація на фулфілменті; гейт за серійником | Фізичний огляд, серійники в ERP |
| 6 | Комплекти / юрособи | Модуль комплектів; сплачена оцінка | Цілісність комплекту і відкликання знижки при поверненні | Міжфірмові розрахунки, договори |
| 7 | Подвійне повернення (асинхронність) | Стейт-машина статусів повернення | Міжканальне блокування; єдиний реєстр повернень | Рейки POS, мережа чарджбеків |
| 8 | Виплата за скануванням | Процеси скасування й повернення | Гейт верифікованого приймання; двофазний статус | Системи сканування кур'єра/ПВЗ |
| 9 | «Недостача» + повернення | Замовлена к-сть, статус доставки | Повертана к-сть «доставлено-за-вирахуванням-претензії» | Реєстр претензій CRM/CCaaS |
| 10 | Відмивання через обмін | PromotionResult прив'язаний до замовлення | Пов'язаний обмін; успадкування родоводу й вікна | Чек/каталог обміну на POS |
Два патерни варто проговорити прямо. По-перше, колонка майже завжди той самий хід — призначити єдиний авторитетний сервіс для спірної величини (повертана кількість, сума повернення, розбивка за інструментами, ідентичність одиниці) і змусити кожен канал викликати його. По-друге, колонка ніколи не вирішується фічею платформи — вона вирішується інтеграційним контрактом з ідемпотентною, пропорційною, обізнаною про інструменти, прив'язаною до верифікованого приймання семантикою плюс щоденною звіркою між реєстрами, кожен з яких тримає локально-вірну частину істини.
Discussion
Powered by GitHub Discussions — sign in with a GitHub account to join the conversation.