Changeset 3776286
- Timestamp:
- 10/31/2026 02:56:18 AM (33 hours ago)
- Location:
- clearwayforge
- Files:
-
- 22 added
- 70 edited
- 1 copied
-
tags/1.0.3 (copied) (copied from clearwayforge/trunk )
-
tags/1.0.3/assets/admin/admin.js (modified) (9 diffs)
-
tags/1.0.3/clearwayforge.php (modified) (2 diffs)
-
tags/1.0.3/languages/clearwayforge.pot (modified) (4 diffs)
-
tags/1.0.3/readme.txt (modified) (2 diffs)
-
tags/1.0.3/src/Admin/AgentRevenueScreen.php (modified) (2 diffs)
-
tags/1.0.3/src/Admin/Assets.php (modified) (2 diffs)
-
tags/1.0.3/src/Admin/Menu.php (modified) ( previous)
-
tags/1.0.3/src/Domain/Attribution/Allocation.php (modified) (2 diffs)
-
tags/1.0.3/src/Domain/Attribution/AttributionModel.php (modified) (1 diff)
-
tags/1.0.3/src/Domain/Crawler/ClientAddress.php (added)
-
tags/1.0.3/src/Domain/Crawler/TrustedProxies.php (added)
-
tags/1.0.3/src/Domain/Llms/LlmsGenerator.php (modified) ( previous)
-
tags/1.0.3/src/Domain/Llms/StoreProfile.php (modified) (2 diffs)
-
tags/1.0.3/src/Domain/Report/AgentRevenueTable.php (modified) (4 diffs)
-
tags/1.0.3/src/Domain/Report/DashboardBuilder.php (modified) (10 diffs)
-
tags/1.0.3/src/Domain/Report/DashboardReport.php (modified) ( previous)
-
tags/1.0.3/src/Domain/Report/MoneyText.php (modified) (2 diffs)
-
tags/1.0.3/src/Domain/Report/RefundSplit.php (added)
-
tags/1.0.3/src/Domain/Report/SourceRow.php (modified) (2 diffs)
-
tags/1.0.3/src/Infra/Attribution/OrderObserver.php (modified) (1 diff)
-
tags/1.0.3/src/Infra/Attribution/OrderRefunds.php (added)
-
tags/1.0.3/src/Infra/Attribution/RefundBackfill.php (added)
-
tags/1.0.3/src/Infra/Attribution/RefundObserver.php (added)
-
tags/1.0.3/src/Infra/Attribution/WcOrderRefunds.php (added)
-
tags/1.0.3/src/Infra/Collect/RequestAddress.php (added)
-
tags/1.0.3/src/Infra/Collect/VisitCollector.php (modified) (1 diff)
-
tags/1.0.3/src/Infra/Db/Schema.php (modified) (3 diffs)
-
tags/1.0.3/src/Infra/Health/HealthReport.php (modified) (1 diff)
-
tags/1.0.3/src/Infra/Llms/DocumentCaching.php (added)
-
tags/1.0.3/src/Infra/Llms/LlmsRoutes.php (modified) (7 diffs)
-
tags/1.0.3/src/Infra/Llms/ProductTranslations.php (added)
-
tags/1.0.3/src/Infra/Llms/StoreProfileBuilder.php (modified) (7 diffs)
-
tags/1.0.3/src/Infra/Plugin.php (modified) ( previous)
-
tags/1.0.3/src/Infra/Privacy/PersonalData.php (modified) ( previous)
-
tags/1.0.3/src/Infra/Pro/Bridge.php (modified) ( previous)
-
tags/1.0.3/src/Infra/Report/DashboardAssembler.php (modified) (6 diffs)
-
tags/1.0.3/src/Infra/Report/StoreMinorUnit.php (added)
-
tags/1.0.3/src/Infra/Repository/TouchRepository.php (modified) (6 diffs)
-
tags/1.0.3/src/Infra/Rest/Routes.php (modified) (1 diff)
-
tags/1.0.3/src/Infra/Rest/SettingsController.php (modified) (5 diffs)
-
tags/1.0.3/src/Infra/Scheduler/Maintenance.php (modified) (3 diffs)
-
tags/1.0.3/src/Infra/Support/Options.php (modified) ( previous)
-
tags/1.0.3/uninstall.php (modified) (2 diffs)
-
tags/1.0.3/vendor/composer/autoload_classmap.php (modified) (6 diffs)
-
tags/1.0.3/vendor/composer/autoload_static.php (modified) (6 diffs)
-
tags/1.0.3/vendor/composer/installed.php (modified) (2 diffs)
-
trunk/assets/admin/admin.js (modified) (9 diffs)
-
trunk/clearwayforge.php (modified) (2 diffs)
-
trunk/languages/clearwayforge.pot (modified) (4 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/src/Admin/AgentRevenueScreen.php (modified) (2 diffs)
-
trunk/src/Admin/Assets.php (modified) (2 diffs)
-
trunk/src/Admin/Menu.php (modified) ( previous)
-
trunk/src/Domain/Attribution/Allocation.php (modified) (2 diffs)
-
trunk/src/Domain/Attribution/AttributionModel.php (modified) (1 diff)
-
trunk/src/Domain/Crawler/ClientAddress.php (added)
-
trunk/src/Domain/Crawler/TrustedProxies.php (added)
-
trunk/src/Domain/Llms/LlmsGenerator.php (modified) ( previous)
-
trunk/src/Domain/Llms/StoreProfile.php (modified) (2 diffs)
-
trunk/src/Domain/Report/AgentRevenueTable.php (modified) (4 diffs)
-
trunk/src/Domain/Report/DashboardBuilder.php (modified) (10 diffs)
-
trunk/src/Domain/Report/DashboardReport.php (modified) ( previous)
-
trunk/src/Domain/Report/MoneyText.php (modified) (2 diffs)
-
trunk/src/Domain/Report/RefundSplit.php (added)
-
trunk/src/Domain/Report/SourceRow.php (modified) (2 diffs)
-
trunk/src/Infra/Attribution/OrderObserver.php (modified) (1 diff)
-
trunk/src/Infra/Attribution/OrderRefunds.php (added)
-
trunk/src/Infra/Attribution/RefundBackfill.php (added)
-
trunk/src/Infra/Attribution/RefundObserver.php (added)
-
trunk/src/Infra/Attribution/WcOrderRefunds.php (added)
-
trunk/src/Infra/Collect/RequestAddress.php (added)
-
trunk/src/Infra/Collect/VisitCollector.php (modified) (1 diff)
-
trunk/src/Infra/Db/Schema.php (modified) (3 diffs)
-
trunk/src/Infra/Health/HealthReport.php (modified) (1 diff)
-
trunk/src/Infra/Llms/DocumentCaching.php (added)
-
trunk/src/Infra/Llms/LlmsRoutes.php (modified) (7 diffs)
-
trunk/src/Infra/Llms/ProductTranslations.php (added)
-
trunk/src/Infra/Llms/StoreProfileBuilder.php (modified) (7 diffs)
-
trunk/src/Infra/Plugin.php (modified) ( previous)
-
trunk/src/Infra/Privacy/PersonalData.php (modified) ( previous)
-
trunk/src/Infra/Pro/Bridge.php (modified) ( previous)
-
trunk/src/Infra/Report/DashboardAssembler.php (modified) (6 diffs)
-
trunk/src/Infra/Report/StoreMinorUnit.php (added)
-
trunk/src/Infra/Repository/TouchRepository.php (modified) (6 diffs)
-
trunk/src/Infra/Rest/Routes.php (modified) (1 diff)
-
trunk/src/Infra/Rest/SettingsController.php (modified) (5 diffs)
-
trunk/src/Infra/Scheduler/Maintenance.php (modified) (3 diffs)
-
trunk/src/Infra/Support/Options.php (modified) ( previous)
-
trunk/uninstall.php (modified) (2 diffs)
-
trunk/vendor/composer/autoload_classmap.php (modified) (6 diffs)
-
trunk/vendor/composer/autoload_static.php (modified) (6 diffs)
-
trunk/vendor/composer/installed.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
clearwayforge/tags/1.0.3/assets/admin/admin.js
r3702012 r3776286 11 11 * ── Почему числа форматируются здесь, а считаются на сервере ───────────── 12 12 * 13 * Все суммы приходят целыми центами, доли — базисными пунктами. Клиент их 14 * только показывает. Любая арифметика здесь означала бы второй источник 13 * Все суммы приходят целыми минорными единицами, доли — базисными пунктами. 14 * Клиент их только показывает; сколько знаков после запятой у единицы, говорит 15 * сервер (`cwforgeAdmin.minorUnit` = `wc_get_price_decimals()`, та же точность, 16 * с которой заказ записан): иены (0 знаков) и динары (3) — не центы. Любая арифметика здесь означала бы второй источник 15 17 * истины: экран и письмо-дайджест начали бы показывать разные числа, и 16 18 * поймать это можно было бы только глазами. … … 29 31 var apiFetch = wp.apiFetch; 30 32 31 /** Центы в строку с валютой. Форматирование локали делает браузер. */ 33 /** 34 * Записанные единицы в строку с валютой. Форматирование локали делает 35 * браузер, а место точки — настройка магазина `config.minorUnit`: 1500 36 * при нуле знаков это 1500 иен, а не 15 (BUG-CLEAR-ATTR-02). WordPress 37 * отдаёт скаляры локализации строками, поэтому число разбирается заново; 38 * без него или вне 0-4 остаются два знака, как было. 39 */ 32 40 function money( cents, currency ) { 33 var value = ( cents || 0 ) / 100; 41 var digits = parseInt( config.minorUnit, 10 ); 42 if ( isNaN( digits ) || digits < 0 || digits > 4 ) { 43 digits = 2; 44 } 45 var value = ( cents || 0 ) / Math.pow( 10, digits ); 34 46 try { 35 47 return new Intl.NumberFormat( undefined, { 36 48 style: 'currency', 37 49 currency: currency || 'USD', 38 maximumFractionDigits: 2, 50 minimumFractionDigits: digits, 51 maximumFractionDigits: digits, 39 52 } ).format( value ); 40 53 } catch ( e ) { 41 54 // Неизвестный код валюты роняет Intl. Магазин с экзотической 42 55 // валютой не должен получать пустой экран вместо цифр. 43 return value.toFixed( 2) + ' ' + ( currency || '' );56 return value.toFixed( digits ) + ' ' + ( currency || '' ); 44 57 } 45 58 } … … 444 457 { className: 'cwforge-stats' }, 445 458 el( Stat, { label: 'AI-attributed orders', value: String( data.totals.orders ) } ), 459 // Выручка выше уже за вычетом возвратов; сами возвраты отдельной строкой. 460 el( Stat, { label: 'Refunded', value: money( data.totals.refunded_cents || 0, currency ) } ), 446 461 el( Stat, { label: 'AI sessions', value: String( data.totals.sessions ) } ), 447 462 el( Stat, { label: 'Recovered sessions', value: percent( recovered.share_bp ) } ), … … 743 758 var uninstall = uninstallState[ 0 ]; 744 759 var setUninstall = uninstallState[ 1 ]; 760 // Доверенные прокси: черновик текста правится в поле и уходит одной 761 // кнопкой; сервер сохраняет годные записи и называет отброшенные. 762 var proxiesState = useState( { value: '', saved: '', message: '', rejected: [], overLimit: 0, limit: 0 } ); 763 var proxies = proxiesState[ 0 ]; 764 var setProxies = proxiesState[ 1 ]; 765 // Список прокси правит администратор; менеджеру магазина поле только читается. 766 var proxiesAllowedState = useState( false ); 767 var proxiesAllowed = proxiesAllowedState[ 0 ]; 768 var setProxiesAllowed = proxiesAllowedState[ 1 ]; 745 769 746 770 function load() { … … 764 788 saved: '', 765 789 } ); 790 setProxies( { value: data.trusted_proxies || '', saved: '', message: '', rejected: [], overLimit: 0, limit: data.trusted_proxies_limit || 0 } ); 791 setProxiesAllowed( !! data.can_change_proxies ); 766 792 } ) 767 793 .catch( function ( error ) { … … 814 840 .catch( function () { 815 841 setUninstall( { value: ! next, allowed: uninstall.allowed, saved: 'error' } ); 842 } ); 843 } 844 845 function saveProxies() { 846 var draft = proxies.value; 847 setProxies( { value: draft, saved: 'saving', message: '', rejected: [], overLimit: 0, limit: proxies.limit } ); 848 apiFetch( { 849 path: '/cwforge/v1/settings', 850 method: 'POST', 851 data: { trusted_proxies: draft }, 852 } ) 853 .then( function ( data ) { 854 // Показываем то, что сервер сохранил (без повторов и лишних пробелов), 855 // и называем записи, которые он отбросил. 856 setProxies( { 857 value: data.trusted_proxies || '', 858 saved: 'saved', 859 message: '', 860 rejected: Array.isArray( data.trusted_proxies_rejected ) ? data.trusted_proxies_rejected : [], 861 overLimit: data.trusted_proxies_over_limit || 0, 862 limit: data.trusted_proxies_limit || proxies.limit, 863 } ); 864 } ) 865 .catch( function ( error ) { 866 // Черновик остаётся как есть: владелец правит запись, а не вводит всё заново. 867 setProxies( { 868 value: draft, 869 saved: 'error', 870 message: error && error.message ? String( error.message ) : '', 871 rejected: [], 872 overLimit: 0, 873 limit: proxies.limit, 874 } ); 816 875 } ); 817 876 } … … 919 978 'p', 920 979 { className: 'description' }, 921 'The dashboard and the revenue by AI agent report credit every order in the window with this model. ' + 980 'The model applies to orders placed from the moment you switch it. Earlier orders keep the model they ' + 981 'were recorded with, so past reports are not recalculated. ' + 922 982 'The AI channel column on an order keeps the model that was in force when the order was placed.' 923 983 ), … … 948 1008 ), 949 1009 savedNote( ranges.saved ), 1010 el( 'h3', null, 'Trusted reverse proxies' ), 1011 el( 1012 'label', 1013 { htmlFor: 'cwforge-trusted-proxies' }, 1014 'Networks of the proxies in front of this site, one per line (a network such as 10.0.0.0/8, or a single address)' 1015 ), 1016 el( 'textarea', { 1017 id: 'cwforge-trusted-proxies', 1018 className: 'large-text code', 1019 rows: 4, 1020 placeholder: '10.0.0.0/8', 1021 value: proxies.value, 1022 disabled: ! proxiesAllowed || proxies.saved === 'saving', 1023 onChange: function ( event ) { 1024 setProxies( { value: event.target.value, saved: '', message: '', rejected: [], overLimit: 0, limit: proxies.limit } ); 1025 }, 1026 } ), 1027 el( 1028 'p', 1029 { className: 'description' }, 1030 'Leave this empty unless a reverse proxy or load balancer that you control sits in front of the site. ' + 1031 'While the list is empty the plugin checks crawler visits against the address of the connection only ' + 1032 'and never reads the X-Forwarded-For header, which any visitor can forge. Behind a proxy that address ' + 1033 'is the proxy itself, so crawler visits are shown as "declared". When the connection comes from a ' + 1034 'network listed here, the plugin uses the right-most X-Forwarded-For address that is not in the list.' 1035 ), 1036 el( 1037 'button', 1038 { 1039 type: 'button', 1040 className: 'button', 1041 disabled: ! proxiesAllowed || proxies.saved === 'saving', 1042 onClick: saveProxies, 1043 }, 1044 'Save proxies' 1045 ), 1046 proxiesAllowed 1047 ? null 1048 : el( 'p', { className: 'description' }, 'Only an administrator can change the trusted proxies.' ), 1049 proxies.saved === 'saved' ? el( 'p', { role: 'status' }, 'Saved.' ) : null, 1050 proxies.saved === 'saved' && proxies.rejected.length 1051 ? el( 1052 'p', 1053 { className: 'notice notice-warning', role: 'alert' }, 1054 'Not saved, because they are not an IP address or network: ' + proxies.rejected.join( ', ' ) 1055 ) 1056 : null, 1057 proxies.saved === 'saved' && proxies.overLimit 1058 ? el( 1059 'p', 1060 { className: 'notice notice-warning', role: 'alert' }, 1061 'Only the first ' + proxies.limit + ' entries are kept; ' + proxies.overLimit + ' more were not saved.' 1062 ) 1063 : null, 1064 proxies.saved === 'error' 1065 ? el( 1066 'p', 1067 { className: 'notice notice-error' }, 1068 'Could not save' + ( proxies.message ? ': ' + proxies.message : '. Try again.' ) 1069 ) 1070 : null, 950 1071 el( 'h3', null, 'When the plugin is deleted' ), 951 1072 el( … … 983 1104 /* 984 1105 * Экраны бесплатного ядра, и только они. Очередь одобрений сюда не 985 * входит и входить не может: это код уровня Growth, а этот файл едет1106 * входит и входить не может: это платный код Guard, а этот файл едет 986 1107 * в бесплатном zip на wp.org, где реализация платной функции на диске 987 1108 * — отказ на ревью (docs/free-pro-split.md §5). Её рисует свой скрипт -
clearwayforge/tags/1.0.3/clearwayforge.php
r3720707 r3776286 2 2 3 3 /** 4 * Plugin Name: Clearway Forge for WooCommerce4 * Plugin Name: Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce 5 5 * Plugin URI: https://clearwayforge.com/ 6 6 * Description: See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database. 7 * Version: 1.0. 27 * Version: 1.0.3 8 8 * Requires at least: 6.5 9 9 * Requires PHP: 8.2 … … 38 38 } 39 39 40 const CWFORGE_VERSION = '1.0. 2';40 const CWFORGE_VERSION = '1.0.3'; 41 41 const CWFORGE_MIN_PHP = '8.2'; 42 42 const CWFORGE_MIN_WP = '6.5'; -
clearwayforge/tags/1.0.3/languages/clearwayforge.pot
r3702012 r3776286 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: Clearway Forge for WooCommerce 0.1.0\n"6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/ clearwayforge\n"5 "Project-Id-Version: Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce 1.0.3\n" 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/plugin\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" 8 8 "Language-Team: LANGUAGE <LL@li.org>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026- 08-26T18:36:47+00:00\n"12 "POT-Creation-Date: 2026-10-30T22:19:42+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 17 17 #. Plugin Name of the plugin 18 18 #: clearwayforge.php 19 msgid "Clearway Forge for WooCommerce"19 msgid "Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce" 20 20 msgstr "" 21 21 22 22 #. Plugin URI of the plugin 23 23 #: clearwayforge.php 24 msgid "https:// github.com/avakode/clearway-forge"24 msgid "https://clearwayforge.com/" 25 25 msgstr "" 26 26 27 27 #. Description of the plugin 28 28 #: clearwayforge.php 29 msgid "See what AI assistants earn you , make your store readable to them, and keep agent actions under your control."29 msgid "See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database." 30 30 msgstr "" 31 31 … … 35 35 msgstr "" 36 36 37 #: src/Admin/Menu.php:47 38 #: src/Admin/Menu.php:48 39 #: src/Admin/Menu.php:109 37 #: src/Admin/AgentRevenueScreen.php:83 38 #: src/Admin/Menu.php:101 39 msgid "Revenue by AI agent" 40 msgstr "" 41 42 #. translators: %d: number of days in the longest window the report can show. 43 #: src/Admin/AgentRevenueScreen.php:94 44 #, php-format 45 msgid "Showing the last %d days, the longest window the report can show. Nothing was deleted." 46 msgstr "" 47 48 #: src/Admin/AgentRevenueScreen.php:126 49 msgid "Period" 50 msgstr "" 51 52 #: src/Admin/AgentRevenueScreen.php:139 53 msgid "Show" 54 msgstr "" 55 56 #: src/Admin/AgentRevenueScreen.php:151 57 msgid "AI agent" 58 msgstr "" 59 60 #: src/Admin/AgentRevenueScreen.php:152 61 msgid "Revenue" 62 msgstr "" 63 64 #: src/Admin/AgentRevenueScreen.php:153 65 msgid "Orders" 66 msgstr "" 67 68 #: src/Admin/AgentRevenueScreen.php:154 69 msgid "Sessions" 70 msgstr "" 71 72 #: src/Admin/AgentRevenueScreen.php:155 73 msgid "Conversion" 74 msgstr "" 75 76 #: src/Admin/AgentRevenueScreen.php:156 77 msgid "Average order" 78 msgstr "" 79 80 #: src/Admin/AgentRevenueScreen.php:157 81 msgid "Share" 82 msgstr "" 83 84 #: src/Admin/AgentRevenueScreen.php:167 85 msgid "No AI-attributed orders in this period yet." 86 msgstr "" 87 88 #: src/Admin/AgentRevenueScreen.php:187 89 msgid "Total" 90 msgstr "" 91 92 #. translators: %s: revenue proven by a referrer or a campaign tag. 93 #: src/Admin/AgentRevenueScreen.php:194 94 #, php-format 95 msgid "%s proven by a referrer or a tag" 96 msgstr "" 97 98 #: src/Admin/AgentRevenueScreen.php:203 99 msgid "Refunded" 100 msgstr "" 101 102 #: src/Admin/AgentRevenueScreen.php:205 103 msgid "Already taken off the revenue above." 104 msgstr "" 105 106 #. translators: 1: start date, 2: end date, 3: IANA time zone name. 107 #: src/Admin/AgentRevenueScreen.php:214 108 #, php-format 109 msgid "Computed by your own store, %1$s to %2$s in %3$s." 110 msgstr "" 111 112 #: src/Admin/AgentRevenueScreen.php:220 113 msgid "Nothing left your server to produce it." 114 msgstr "" 115 116 #: src/Admin/Menu.php:86 117 #: src/Admin/Menu.php:200 40 118 msgid "AI Channel" 41 119 msgstr "" 42 120 43 #: src/Admin/Menu.php:58 44 #: src/Admin/Menu.php:114 121 #: src/Admin/Menu.php:91 122 msgid "Measurement" 123 msgstr "" 124 125 #: src/Admin/Menu.php:102 126 msgid "Revenue by agent" 127 msgstr "" 128 129 #: src/Admin/Menu.php:110 130 #: src/Admin/Menu.php:205 45 131 msgid "AI Channel settings" 46 132 msgstr "" 47 133 48 #: src/Admin/Menu.php: 59134 #: src/Admin/Menu.php:111 49 135 msgid "Settings" 50 136 msgstr "" 51 137 52 #: src/Admin/Menu.php:67 138 #: src/Admin/Menu.php:119 139 #: src/Admin/Menu.php:211 140 msgid "AI Channel health" 141 msgstr "" 142 53 143 #: src/Admin/Menu.php:120 54 msgid "AI Channel health"55 msgstr ""56 57 #: src/Admin/Menu.php:6858 144 msgid "Health" 59 145 msgstr "" 60 146 61 #: src/Admin/Menu.php:136 147 #: src/Admin/Menu.php:131 148 msgid "AI Channel catalog" 149 msgstr "" 150 151 #: src/Admin/Menu.php:132 152 #: src/Admin/Menu.php:217 153 msgid "Catalog" 154 msgstr "" 155 156 #: src/Admin/Menu.php:141 157 msgid "AI Channel agents" 158 msgstr "" 159 160 #: src/Admin/Menu.php:142 161 #: src/Admin/Menu.php:223 162 msgid "Agents" 163 msgstr "" 164 165 #: src/Admin/Menu.php:274 166 msgid "The Clearway Forge add-on has not added any screens here yet." 167 msgstr "" 168 169 #: src/Admin/Menu.php:304 170 msgid "Catalog needs an active WooCommerce store." 171 msgstr "" 172 173 #: src/Admin/Menu.php:305 174 msgid "Feed drift and SKU tasks come with the separate Clearway Forge add-on." 175 msgstr "" 176 177 #: src/Admin/Menu.php:309 178 msgid "Agents needs an active WooCommerce store." 179 msgstr "" 180 181 #: src/Admin/Menu.php:310 182 msgid "Agent keys, scopes and the journal come with the separate Clearway Forge add-on." 183 msgstr "" 184 185 #: src/Admin/Menu.php:334 186 #: src/Admin/Menu.php:393 187 msgid "About the add-on" 188 msgstr "" 189 190 #: src/Admin/Menu.php:367 62 191 msgid "Loading your AI channel data..." 63 192 msgstr "" 64 193 65 #: src/Admin/Menu.php:155 66 msgid "Available in the paid add-on" 67 msgstr "" 68 69 #: src/Admin/Menu.php:162 70 msgid "(active)" 71 msgstr "" 72 73 #: src/Admin/Menu.php:167 74 msgid "What the add-on adds" 75 msgstr "" 76 77 #. translators: %d: maximum number of days available without the paid add-on. 78 #: src/Infra/Rest/Routes.php:143 79 #, php-format 80 msgid "Reporting windows longer than %d days need the paid add-on." 81 msgstr "" 194 #: src/Admin/Menu.php:385 195 msgid "In the separate Clearway Forge add-on" 196 msgstr "" 197 198 #: src/Admin/OrdersColumn.php:99 199 #: src/Admin/OrdersColumn.php:105 200 msgid "AI channel" 201 msgstr "" 202 203 #. translators: %d: largest reporting window the plugin accepts. 204 #: src/Infra/Rest/Routes.php:189 205 #, php-format 206 msgid "Reporting windows longer than %d days are not supported." 207 msgstr "" -
clearwayforge/tags/1.0.3/readme.txt
r3774326 r3776286 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.2 7 Stable tag: 1.0. 27 Stable tag: 1.0.3 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 168 168 == Changelog == 169 169 170 = 1.0.3 = 171 * Revenue is shown net of refunds. The dashboard and Revenue by agent take a refund off the order's own period and show the refunded total on its own line. Refunds made before the update are counted in the background, 200 orders at a time. 172 * Changing the attribution model no longer hides your history: each order stays under the model it was recorded with, and the new model applies to orders from then on. 173 * Amounts follow your store's number of price decimals (zero-decimal currencies included) in the dashboard and Revenue by agent. The privacy export shows the attributed amount as an amount instead of a raw count of cents. 174 * llms.txt and llms-full.txt: page-cache plugins are told not to store them (the DONOTCACHEPAGE constant, and a no-cache header for LiteSpeed Cache). Both documents now share one freshness of 15 minutes: Cache-Control said 1 hour and the server copy of llms.txt lived 6 hours. Saving a product still clears the server copy. 175 * llms.txt and llms-full.txt on Polylang and WPML sites list one product per translation group, in your default language. Without a multilingual plugin nothing changes. 176 * llms-full.txt lists up to 500 products instead of repeating the short list, says "Showing N of M products" when it is cut, and points to the JSON catalogue feed when the paid add-on serves it. 177 * On a multisite network with subdirectories, /sub/llms.txt now returns that site's own document. 178 * A listener on the `clearwayforge/ai_order` hook that throws no longer breaks checkout. The exception is written as one line to WooCommerce > Status > Logs (source clearwayforge) by class name and the folder of the plugin it came from, with no message and no order data, and the order goes through. 179 * New setting, Trusted reverse proxies: crawler address verification reads X-Forwarded-For for requests that arrive from a proxy on your list and ignores it for every other request. The list is empty by default, so the header is ignored. Changing it needs an administrator. IPv4 addresses carried in IPv6 notation now match the IPv4 vendor ranges. 180 * Health: the UTC timezone warning no longer misses sites whose timezone is set by name. 181 * The retention clean-up job is put back in the queue from the admin when activation missed it (an FTP update, a migrated database). 182 * Deleting the plugin before the paid add-on keeps your "delete all data" choice for the add-on, so its tables go too. 183 170 184 = 1.0.2 = 171 185 * llms.txt and llms-full.txt show prices, names and descriptions as plain characters: a dollar price carried the HTML entity for the "$" sign instead of the sign itself, and a sale price repeated itself. A sale now reads the way WooCommerce says it to screen readers ("Original price was: $150.00. Current price is: $129.00."). The documents cached by 1.0.1 are not served after the update. -
clearwayforge/tags/1.0.3/src/Admin/AgentRevenueScreen.php
r3702012 r3776286 91 91 esc_html( 92 92 sprintf( 93 /* translators: %d: number of days the free plugincan show. */93 /* translators: %d: number of days in the longest window the report can show. */ 94 94 __( 95 'Showing the last %d days . Longer windows need the paid add-on; nothing was deleted.',95 'Showing the last %d days, the longest window the report can show. Nothing was deleted.', 96 96 'clearwayforge' 97 97 ), … … 196 196 ) 197 197 )); 198 echo '</tr></tfoot></table>'; 198 echo '</tr>'; 199 // Выручка в таблице уже за вычетом возвратов; сумма возвратов стоит 200 // отдельной строкой (BUG-CLEAR-MISC-01, п. 14). 201 printf( 202 '<tr class="cwforge-refunded"><th scope="row">%s</th><td>%s</td><td colspan="5">%s</td></tr>', 203 esc_html__('Refunded', 'clearwayforge'), 204 esc_html($totals['refunded']), 205 esc_html__('Already taken off the revenue above.', 'clearwayforge') 206 ); 207 echo '</tfoot></table>'; 199 208 200 209 printf( -
clearwayforge/tags/1.0.3/src/Admin/Assets.php
r3702012 r3776286 27 27 namespace ClearwayForge\Admin; 28 28 29 use ClearwayForge\Infra\Report\StoreMinorUnit; 29 30 use ClearwayForge\Infra\Rest\Routes; 30 31 … … 55 56 'nonce' => wp_create_nonce('wp_rest'), 56 57 'currency' => get_woocommerce_currency(), 58 // Знаков после запятой у записанных сумм: `money()` ставит точку по 59 // ним, а не делением на сто (BUG-CLEAR-ATTR-02). 60 'minorUnit' => StoreMinorUnit::current(), 57 61 'timezone' => wp_timezone_string(), 58 62 'settingsUrl' => esc_url_raw(admin_url('admin.php?page=cwforge-settings')), -
clearwayforge/tags/1.0.3/src/Domain/Attribution/Allocation.php
r3702012 r3776286 3 3 /** 4 4 * Доля заказа, приписанная одному касанию. 5 * 6 * ── Возврат (BUG-CLEAR-MISC-01, п. 14) ─────────────────────────────────── 7 * 8 * `$amount` — сумма доли в момент заказа, она не меняется: движок атрибуции 9 * считает её один раз. Возврат приходит позже и хранится рядом 10 * (`$refundedCents`, доля возврата, пришедшаяся на эту строку): отчёты 11 * показывают выручку за вычетом возврата (`netCents()`), а сам возврат 12 * отдельной строкой. 5 13 */ 6 14 … … 16 24 public readonly Touch $touch, 17 25 public readonly Money $amount, 18 public readonly int $weight 26 public readonly int $weight, 27 public readonly int $refundedCents = 0 19 28 ) { 20 29 } 30 31 /** 32 * Возврат в пределах доли: вернуть больше, чем заплачено за долю, нельзя, 33 * поэтому лишнее (повторный разбор с меньшей суммой, ошибка округления) 34 * отрезается, а выручка не уходит в минус. 35 */ 36 public function effectiveRefundCents(): int 37 { 38 return max(0, min($this->refundedCents, $this->amount->cents)); 39 } 40 41 /** Доля за вычетом возврата — то, что попадает в выручку. */ 42 public function netCents(): int 43 { 44 return $this->amount->cents - $this->effectiveRefundCents(); 45 } 21 46 } -
clearwayforge/tags/1.0.3/src/Domain/Attribution/AttributionModel.php
r3702012 r3776286 4 4 * Как делить заказ между касаниями. 5 5 * 6 * Free-тариф получает только `LastTouch`, и это не искусственное урезание: 7 * мульти-тач осмыслен, когда истории накопилось хотя бы на месяц, а тридцать 8 * дней истории — ровно граница free. 6 * Все четыре модели входят в бесплатный плагин (readme на wp.org обещает это 7 * прямо), и платный аддон их не отнимает. Модель, выбранная в настройках, 8 * действует на заказы с момента выбора: каждый заказ записывается под ней и 9 * потом читается под ней же, задним числом отчёты не пересчитываются 10 * (`TouchRepository`, `DashboardAssembler`). 9 11 */ 10 12 -
clearwayforge/tags/1.0.3/src/Domain/Llms/StoreProfile.php
r3702012 r3776286 19 19 * @param list<LlmsLink> $policies 20 20 * @param list<LlmsLink> $pages 21 * @param ?int $productsTotal Сколько товаров в каталоге всего. `products` 22 * — только та часть, что загружена; без этого 23 * числа документ не может сказать, что обрезан. 21 24 */ 22 25 public function __construct( … … 30 33 public readonly string $currency = 'USD', 31 34 public readonly ?string $shippingNote = null, 32 public readonly ?string $returnsNote = null 35 public readonly ?string $returnsNote = null, 36 public readonly ?int $productsTotal = null 33 37 ) { 34 38 } -
clearwayforge/tags/1.0.3/src/Domain/Report/AgentRevenueTable.php
r3702012 r3776286 32 32 { 33 33 /** 34 * `revenue_cents` — за вычетом возвратов, `refunded_cents` — сами возвраты 35 * по этому источнику (BUG-CLEAR-MISC-01, п. 14). 36 * 34 37 * @return list<array{agent:string,label:string,orders:int,sessions:int,revenue_cents:int, 35 * revenue:string, aov:string,aov_cents:int,conversion:string,conversion_bp:int,36 * share:string,share_bp:int}>38 * revenue:string,refunded_cents:int,refunded:string,aov:string,aov_cents:int, 39 * conversion:string,conversion_bp:int,share:string,share_bp:int}> 37 40 */ 38 41 public static function rows(DashboardReport $report): array … … 47 50 'sessions' => $row->sessions, 48 51 'revenue_cents' => $row->revenueCents, 49 'revenue' => MoneyText::format($row->revenueCents, $report->currency), 50 'aov' => MoneyText::format($row->aovCents(), $report->currency), 52 'revenue' => MoneyText::format($row->revenueCents, $report->currency, $report->minorUnit), 53 'refunded_cents' => $row->refundedCents, 54 'refunded' => MoneyText::format($row->refundedCents, $report->currency, $report->minorUnit), 55 'aov' => MoneyText::format($row->aovCents(), $report->currency, $report->minorUnit), 51 56 'aov_cents' => $row->aovCents(), 52 57 'conversion' => MoneyText::percent($row->conversionBp()), … … 68 73 * молча чинить. 69 74 * 70 * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string,proven:string} 75 * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string, 76 * refunded_cents:int,refunded:string,proven:string} 71 77 */ 72 78 public static function totals(DashboardReport $report): array … … 82 88 'revenue_cents' => $report->revenueCents, 83 89 'rows_cents' => $rowsCents, 84 'revenue' => MoneyText::format($report->revenueCents, $report->currency), 85 'proven' => MoneyText::format($report->provenRevenueCents, $report->currency), 90 'revenue' => MoneyText::format($report->revenueCents, $report->currency, $report->minorUnit), 91 'refunded_cents' => $report->refundedCents, 92 'refunded' => MoneyText::format($report->refundedCents, $report->currency, $report->minorUnit), 93 'proven' => MoneyText::format($report->provenRevenueCents, $report->currency, $report->minorUnit), 86 94 ]; 87 95 } -
clearwayforge/tags/1.0.3/src/Domain/Report/DashboardBuilder.php
r3702012 r3776286 24 24 * @param list<Touch> $sessions По одному касанию на сессию окна. 25 25 * @param list<CrawlerRow> $crawlers Агрегаты заходов ботов за окно. 26 * @param int $minorUnit Знаков после запятой у записанных сумм 27 * (`wc_get_price_decimals()`), отчёт только несёт их до печати. 26 28 */ 27 29 public function build( … … 30 32 array $sessions, 31 33 array $crawlers = [], 32 string $currency = 'USD' 34 string $currency = 'USD', 35 int $minorUnit = 2 33 36 ): DashboardReport { 34 37 $orders = array_values(array_filter($orders, static fn (AttributedOrder $o): bool => $window->contains($o->atMs))); … … 38 41 $provenRevenueBySource = []; 39 42 $ordersBySource = []; 43 $refundedBySource = []; 40 44 $totalRevenue = 0; 41 45 $provenRevenue = 0; 46 $totalRefunded = 0; 42 47 $attributedOrders = 0; 43 48 … … 54 59 foreach ($order->result->allocations as $allocation) { 55 60 $key = $allocation->touch->source->value; 56 $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $allocation->amount->cents; 57 $totalRevenue += $allocation->amount->cents; 61 // Выручка — за вычетом возврата по этой доле (BUG-CLEAR-MISC-01, 62 // п. 14); сам возврат копится отдельно. 63 $net = $allocation->netCents(); 64 $refunded = $allocation->effectiveRefundCents(); 65 $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $net; 66 $refundedBySource[$key] = ($refundedBySource[$key] ?? 0) + $refunded; 67 $totalRevenue += $net; 68 $totalRefunded += $refunded; 58 69 if ($allocation->touch->isProof()) { 59 $provenRevenue += $ allocation->amount->cents;70 $provenRevenue += $net; 60 71 // Та же доказанность, только по строке источника, а не 61 72 // по всему магазину — таблица «Where the money came 62 73 // from» обязана иметь ту же пару observed/estimated, 63 74 // что и заголовок (ревью major-3). 64 $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $ allocation->amount->cents;75 $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $net; 65 76 } 66 77 if (!isset($seenSources[$key])) { … … 84 95 } 85 96 86 $bySource = $this->sourceRows($revenueBySource, $ordersBySource, $sessionsBySource, $provenRevenueBySource); 97 $bySource = $this->sourceRows( 98 $revenueBySource, 99 $ordersBySource, 100 $sessionsBySource, 101 $provenRevenueBySource, 102 $refundedBySource 103 ); 87 104 $days = $this->days($window, $orders); 88 105 … … 98 115 recoveredSessions: $recoveredSessions, 99 116 provenRevenueCents: $provenRevenue, 117 refundedCents: $totalRefunded, 118 minorUnit: $minorUnit, 100 119 ); 101 120 } … … 111 130 * @param array<string,int> $provenRevenue Доля `$revenue` за этот источник, 112 131 * доказанная реферером или меткой (ревью major-3). 132 * @param array<string,int> $refunded Возвраты по источнику (уже вычтены из `$revenue`). 113 133 * @return list<SourceRow> 114 134 */ 115 private function sourceRows(array $revenue, array $orders, array $sessions, array $provenRevenue = []): array 116 { 135 private function sourceRows( 136 array $revenue, 137 array $orders, 138 array $sessions, 139 array $provenRevenue = [], 140 array $refunded = [] 141 ): array { 117 142 $present = []; 118 143 foreach (AiSource::ordered() as $source) { … … 153 178 $provenRevenue[$key] ?? 0, 154 179 $observedShares[$i] ?? 0, 180 $refunded[$key] ?? 0, 155 181 ); 156 182 } … … 190 216 } 191 217 $revenue[$key] += array_sum(array_map( 192 static fn (Allocation $a): int => $a-> amount->cents,218 static fn (Allocation $a): int => $a->netCents(), 193 219 $order->result->allocations 194 220 )); … … 197 223 // observed/estimated, что и заголовок (ревью major-3). 198 224 $provenRevenue[$key] += array_sum(array_map( 199 static fn (Allocation $a): int => $a->touch->isProof() ? $a-> amount->cents: 0,225 static fn (Allocation $a): int => $a->touch->isProof() ? $a->netCents() : 0, 200 226 $order->result->allocations 201 227 )); -
clearwayforge/tags/1.0.3/src/Domain/Report/MoneyText.php
r3702012 r3776286 18 18 * ── Про арифметику ─────────────────────────────────────────────────────── 19 19 * 20 * Её здесь нет. Центы уже целые, деление на сто — это разрез строки, а не21 * деление; `float` не участвует ни на одном шаге (AGENTS.md §3).20 * Её здесь нет. Единицы уже целые, деление на степень десяти — это разрез 21 * строки, а не деление; `float` не участвует ни на одном шаге (AGENTS.md §3). 22 22 * 23 23 * ── Про скудный список символов ────────────────────────────────────────── … … 34 34 final class MoneyText 35 35 { 36 /** Целые центы в строку: `-1234550` и `USD` дают `-$12,345.50`. */ 37 public static function format(int $cents, string $currency = 'USD'): string 36 /** 37 * Целые единицы в строку: `-1234550` и `USD` дают `-$12,345.50`. 38 * 39 * `$decimals` — знаков после запятой у записанной суммы (BUG-CLEAR-ATTR-02): 40 * 1500 иен при нуле знаков печатаются «JPY 1,500», 12345 динаров-миллей при 41 * трёх — «BHD 12.345». Прежняя константа «сто» превращала их в «15.00» и 42 * «123.45». Границы 0–4 — те же, что принимает `Money::fromDecimalString()`. 43 */ 44 public static function format(int $cents, string $currency = 'USD', int $decimals = 2): string 38 45 { 46 $decimals = max(0, min(4, $decimals)); 39 47 $sign = $cents < 0 ? '-' : ''; 40 48 $abs = abs($cents); 49 $text = $sign . self::symbol($currency); 41 50 42 return $sign 43 . self::symbol($currency) 44 . self::group(intdiv($abs, 100)) 51 if ($decimals === 0) { 52 return $text . self::group($abs); 53 } 54 55 $factor = 10 ** $decimals; 56 57 return $text 58 . self::group(intdiv($abs, $factor)) 45 59 . '.' 46 . str_pad((string) ($abs % 100), 2, '0', STR_PAD_LEFT);60 . str_pad((string) ($abs % $factor), $decimals, '0', STR_PAD_LEFT); 47 61 } 48 62 -
clearwayforge/tags/1.0.3/src/Domain/Report/SourceRow.php
r3702012 r3776286 50 50 public readonly int $provenRevenueCents = 0, 51 51 /** Доля в базисных пунктах, посчитанная по весам `provenRevenueCents`, а не по `revenueCents`. */ 52 public readonly int $observedShareBp = 0 52 public readonly int $observedShareBp = 0, 53 /** Возвраты по заказам этого источника; уже вычтены из `revenueCents`. */ 54 public readonly int $refundedCents = 0 53 55 ) { 54 56 } … … 113 115 'observed_aov_cents' => $this->observedAovCents(), 114 116 'observed_share_bp' => $this->observedShareBp, 117 // BUG-CLEAR-MISC-01, п. 14: `revenue_cents` выше уже за вычетом 118 // возвратов, а сам возврат — здесь, отдельным числом. 119 'refunded_cents' => $this->refundedCents, 115 120 ]; 116 121 } -
clearwayforge/tags/1.0.3/src/Infra/Attribution/OrderObserver.php
r3702012 r3776286 140 140 } 141 141 142 /** 143 * Заказ засчитан ИИ-каналу. 144 * 145 * Срабатывает один раз на заказ — не на касание, — сразу после того, 146 * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с 147 * наибольшей долей; полная раскладка мультитач-заказа едет в 148 * `$attribution['sources']`. 149 * 150 * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё, 151 * прочитал бы таблицу без этого заказа и решил, что его нет. 152 * 153 * @since 1.0.0 154 * 155 * @param int $order_id Идентификатор заказа WooCommerce. 156 * @param string $agent Значение AiSource: 'chatgpt', 'perplexity'. 157 * @param array<string,mixed> $attribution Модель, валюта, суммы в целых 158 * центах и раскладка по источникам. 142 /* 143 * Хук — побочный: чужой код атрибуции не должен стоить магазину 144 * продажи. `do_action()` не ловит исключений слушателей, а мы стоим 145 * внутри оформления заказа (после записи разбора, до оплаты), поэтому 146 * без `try` ошибка чужого слушателя дошла бы до покупателя как 147 * «не удалось оформить заказ». Ловим всё, что бросается, включая 148 * `\Error` (несовпавшая сигнатура слушателя). 159 149 */ 160 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает 161 do_action(AiOrderEvent::HOOK, ...$event->hookArgs()); 150 try { 151 /** 152 * Заказ засчитан ИИ-каналу. 153 * 154 * Срабатывает один раз на заказ — не на касание, — сразу после того, 155 * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с 156 * наибольшей долей; полная раскладка мультитач-заказа едет в 157 * `$attribution['sources']`. 158 * 159 * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё, 160 * прочитал бы таблицу без этого заказа и решил, что его нет. 161 * 162 * Гарантия слушателям (CLEAR-ATTR-15): A listener exception does not 163 * break checkout. The observer catches any `\Throwable`, writes one 164 * WooCommerce log line (level `warning`, source `clearwayforge`; see 165 * WooCommerce > Status > Logs) with the hook name, the exception class 166 * and the plugin folder (or file name) it came from, and nothing else 167 * (no message, order number, email or amounts), and the order goes 168 * through. 169 * The listeners after the failing one are not called on that call, 170 * because the exception stops `do_action()` for that firing. A 171 * listener must therefore be safe to skip and must not rely on being 172 * last; one that must not be skipped belongs first in the priority 173 * order or has to catch its own errors. 174 * 175 * @since 1.0.0 176 * 177 * @param int $order_id Идентификатор заказа WooCommerce. 178 * @param string $agent Значение AiSource: 'chatgpt', 'perplexity'. 179 * @param array<string,mixed> $attribution Модель, валюта, суммы в целых 180 * центах и раскладка по источникам. 181 */ 182 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает 183 do_action(AiOrderEvent::HOOK, ...$event->hookArgs()); 184 } catch (\Throwable $listenerError) { 185 /* 186 * Одна строка: имя хука, класс исключения и виновник — папка 187 * плагина, из файла которого оно прилетело (первый сегмент пути 188 * под WP_PLUGIN_DIR), иначе имя файла. Ни текста исключения 189 * (в нём чужой код может держать номер заказа или почту), ни 190 * аргументов хука, ни полного пути. Имя класса анонимного 191 * исключения (`RuntimeException@anonymous\0<файл>:<строка>$0`) — 192 * до байта NUL: после него идут полный путь и номер строки, а 193 * байту NUL в строке лога не место. 194 * 195 * Строка идёт в журнал WooCommerce (WooCommerce > Status > Logs, 196 * источник `clearwayforge`), а не в error_log: в пакет wordpress.org 197 * вызовы error_log не попадают. Без WooCommerce заказов нет, и 198 * писать нечего. 199 */ 200 if (function_exists('wc_get_logger')) { 201 try { 202 wc_get_logger()->warning( 203 sprintf( 204 '[clearwayforge] a listener on %s threw %s in %s; the order was not affected.', 205 AiOrderEvent::HOOK, 206 explode("\0", $listenerError::class, 2)[0], 207 self::listenerSource($listenerError->getFile(), defined('WP_PLUGIN_DIR') ? (string) constant('WP_PLUGIN_DIR') : null) 208 ), 209 ['source' => 'clearwayforge'] 210 ); 211 } catch (\Throwable) { 212 // Сломанный обработчик журнала (WooCommerce даёт их добавлять другим плагинам) не отменяет гарантию выше: заказ всё равно проходит. 213 } 214 } 215 } 216 } 217 218 /** 219 * Кто бросил исключение, для строки лога: имя папки плагина, если файл 220 * лежит под каталогом плагинов (первый сегмент пути после него; плагин из 221 * одного файла — имя этого файла), иначе имя файла. Каталог плагинов 222 * проверяется и как задан, и по `realpath()`: он может быть символической 223 * ссылкой, а PHP отдаёт путь файла уже раскрытым. Полный путь в лог не 224 * попадает. 225 * 226 * @internal Открыт только ради теста. 227 */ 228 public static function listenerSource(string $file, ?string $pluginDir): string 229 { 230 $file = str_replace('\\', '/', $file); 231 232 $roots = []; 233 if ($pluginDir !== null && $pluginDir !== '') { 234 $roots[] = $pluginDir; 235 $real = realpath($pluginDir); 236 if ($real !== false) { 237 $roots[] = $real; 238 } 239 } 240 241 foreach ($roots as $root) { 242 $root = rtrim(str_replace('\\', '/', $root), '/') . '/'; 243 if (strpos($file, $root) !== 0) { 244 continue; 245 } 246 $first = explode('/', substr($file, strlen($root)), 2)[0]; 247 if ($first !== '') { 248 return $first; 249 } 250 } 251 252 return basename($file); 162 253 } 163 254 -
clearwayforge/tags/1.0.3/src/Infra/Collect/VisitCollector.php
r3702012 r3776286 153 153 * Адрес для проверки сети краулера. 154 154 * 155 * Читается только `REMOTE_ADDR`: заголовки вроде `X-Forwarded-For` 156 * подделываются кем угодно, и поверить им означало бы дать любому 157 * скраперу способ объявить себя проверенным GPTBot одной строкой. За 158 * обратным прокси адрес будет неверным — и тогда классификатор честно 159 * скажет `declared` вместо `verified`, что лучше, чем ложное `verified`. 155 * Читается `REMOTE_ADDR`, а заголовки прокси (`X-Forwarded-For`) — только 156 * если владелец назвал доверенные прокси в настройках, и по правилам 157 * `RequestAddress`. Без списка заголовок не читается никогда: 158 * подделываемый кем угодно, он давал бы любому скраперу способ объявить 159 * себя проверенным GPTBot одной строкой. За обратным прокси без списка 160 * адрес будет неверным — и классификатор честно скажет `declared` вместо 161 * `verified`, что лучше, чем ложное `verified`. 160 162 */ 161 163 private function trustedAddress(): ?string 162 164 { 163 if (!isset($_SERVER['REMOTE_ADDR']) || !is_string($_SERVER['REMOTE_ADDR'])) { 164 return null; 165 } 166 167 $address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])); 168 169 return $address === '' ? null : $address; 165 return RequestAddress::fromServer($_SERVER); 170 166 } 171 167 -
clearwayforge/tags/1.0.3/src/Infra/Db/Schema.php
r3702012 r3776286 36 36 * Версия схемы. Инкрементируется при любом изменении определений ниже; 37 37 * `Migrator` сравнивает её с сохранённой и решает, звать ли `dbDelta`. 38 * 39 * 2 — `touches.refunded_cents` (BUG-CLEAR-MISC-01, п. 14): возврат по 40 * заказу хранится рядом с суммой доли, отчёты вычитают его при чтении. 38 41 */ 39 public const VERSION = 1;42 public const VERSION = 2; 40 43 41 44 /** Общий префикс таблиц плагина поверх префикса WordPress. */ … … 115 118 * сменить, и старые отчёты обязаны остаться такими, какими он их 116 119 * видел, иначе выручка прошлого месяца меняется задним числом. 120 * 121 * `refunded_cents` — доля возврата, пришедшаяся на эту строку 122 * (`TouchRepository::setOrderRefund()`). Сумма доли остаётся 123 * как была, отчёты вычитают возврат при чтении: возврат уменьшает 124 * заказ, к которому сделан, в периоде самого заказа. 117 125 */ 118 126 'touches' => "CREATE TABLE {$p}touches ( … … 125 133 model VARCHAR(16) NOT NULL DEFAULT 'last', 126 134 amount_cents BIGINT(20) NOT NULL DEFAULT 0, 135 refunded_cents BIGINT(20) NOT NULL DEFAULT 0, 127 136 currency CHAR(3) NOT NULL DEFAULT 'USD', 128 137 captured_ms BIGINT(20) UNSIGNED NOT NULL, -
clearwayforge/tags/1.0.3/src/Infra/Health/HealthReport.php
r3702012 r3776286 107 107 [ 108 108 'label' => 'Site timezone', 109 'problem' => (wp_timezone_string() === 'UTC' && get_option('gmt_offset') === '0') 110 ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.' 111 : null, 109 'problem' => self::timezoneProblem(wp_timezone_string(), get_option('gmt_offset')), 112 110 ], 113 111 ]; 112 } 113 114 /** 115 * Предупреждение о поясе UTC — или `null`, если сайт считает сутки по местному поясу. 116 * 117 * Смещение сравнивается числом, а не строкой: на сайте с поясом по названию ядро 118 * отвечает на `get_option('gmt_offset')` через `pre_option_gmt_offset` и отдаёт 119 * `float 0.0`, а не `'0'`, и строгое сравнение со строкой скрывало предупреждение 120 * на каждом таком сайте (BUG-CLEAR-DASH-01). 121 * 122 * @param mixed $gmtOffset Значение `get_option('gmt_offset')`: число, строка или `false`. 123 */ 124 public static function timezoneProblem(string $timezone, mixed $gmtOffset): ?string 125 { 126 $offset = is_int($gmtOffset) || is_float($gmtOffset) || is_string($gmtOffset) ? (float) $gmtOffset : 0.0; 127 128 return $timezone === 'UTC' && $offset === 0.0 129 ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.' 130 : null; 114 131 } 115 132 -
clearwayforge/tags/1.0.3/src/Infra/Llms/LlmsRoutes.php
r3720707 r3776286 16 16 * кеша каждый заход бота собирал бы полный список категорий и товаров — 17 17 * несколько запросов к базе на каждый из тысяч заходов в сутки. 18 * 19 * Страничные кеши плагинов (WP Super Cache, LiteSpeed Cache и другие) 20 * документ не держат, а всё, что стоит перед сайтом, держит его 21 * `DocumentCaching::MAX_AGE_SECONDS` (15 минут, как `catalog.json`). Чужие 22 * кеши мы не чистим (NEIGH-04). 18 23 */ 19 24 … … 23 28 24 29 use ClearwayForge\Domain\Llms\LlmsGenerator; 30 use ClearwayForge\Infra\Pro\Bridge; 25 31 use ClearwayForge\Infra\Support\Options; 26 32 … … 34 40 * 35 41 * Число в конце ключа — версия того, как собирается документ. Меняется 36 * сборка — меняется число: иначе обновлённый сайт до шести часовотдаёт42 * сборка — меняется число: иначе обновлённый сайт до пятнадцати минут отдаёт 37 43 * текст, собранный прошлой версией, а сторож дрейфа принимает разницу 38 44 * между старым кешем и новой сборкой за изменение каталога. 2 — 1.0.2: 39 * цены и названия без HTML-сущностей. 40 */ 41 public const CACHE_MAP = 'cwforge_llms_map_2'; 42 public const CACHE_FULL = 'cwforge_llms_full_2'; 43 public const CACHE_TTL = 6 * HOUR_IN_SECONDS; 45 * цены и названия без HTML-сущностей. 3 — полная выгрузка грузит до 46 * `LlmsGenerator::FULL_PRODUCT_LIMIT` товаров, а обе пишут «Showing N of 47 * M products», когда каталог больше окна (BUG-CLEAR-MISC-01). 4 (только 48 * полная выгрузка) — она называет адрес машинного фида, когда магазин его 49 * отдаёт, и без обрезки (BUG-CLEAR-LLMS-02). 50 */ 51 public const CACHE_MAP = 'cwforge_llms_map_3'; 52 public const CACHE_FULL = 'cwforge_llms_full_4'; 53 54 /** 55 * Серверный кеш живёт столько же, сколько документ обещает всем, кто стоит 56 * перед сайтом: одна константа `DocumentCaching::MAX_AGE_SECONDS` (900 с) 57 * на `llms.txt`, `llms-full.txt` и `catalog.json`. Сохранение товара кеш 58 * сбрасывает сразу (`invalidate()`), срок — страховка от правок мимо 59 * сохранения (импорт, прямая запись в базу). 60 */ 61 public const CACHE_TTL = DocumentCaching::MAX_AGE_SECONDS; 62 63 /** 64 * Хук аддона: набор платных возможностей изменился (активация, окончание, 65 * освобождение лицензии). Ядро о лицензиях не знает и только сбрасывает кеш. 66 */ 67 public const PAID_FEATURES_CHANGED = 'cwforge_paid_features_changed'; 44 68 45 69 /** Ключи прошлых версий сборки: их чистит удаление плагина. */ 46 public const RETIRED_CACHE_KEYS = ['cwforge_llms_map', 'cwforge_llms_full']; 70 public const RETIRED_CACHE_KEYS = [ 71 'cwforge_llms_map', 72 'cwforge_llms_full', 73 'cwforge_llms_map_2', 74 'cwforge_llms_full_2', 75 'cwforge_llms_full_3', 76 ]; 47 77 48 78 public function register(): void … … 53 83 add_action('woocommerce_update_product', [$this, 'invalidate']); 54 84 add_action('update_option_' . Options::NAME, [$this, 'invalidate']); 85 // Строка про машинный фид зависит от лицензии аддона (`feedUrl()`): аддон сообщает 86 // об изменении набора платных возможностей, и документ собирается заново. 87 add_action(self::PAID_FEATURES_CHANGED, [$this, 'invalidate']); 55 88 } 56 89 … … 66 99 $path = (string) (wp_parse_url($requestUri, PHP_URL_PATH) ?? '/'); 67 100 } 68 $path = '/' . ltrim($path, '/'); 101 // Дочерний сайт мультисайта с подпапками живёт по `/sub/`, и его документ — `/sub/llms.txt` 102 // (BUG-CLEAR-LLMS-01): путь главной страницы этого сайта снимается до сравнения. 103 $homePath = (string) (wp_parse_url(home_url('/'), PHP_URL_PATH) ?? '/'); 104 105 $document = self::documentFor($path, $homePath); 106 if ($document === 'map') { 107 $this->serve(self::CACHE_MAP, false); 108 } 109 if ($document === 'full') { 110 $this->serve(self::CACHE_FULL, true); 111 } 112 } 113 114 /** 115 * Какой документ просит этот путь: `map` (`llms.txt`), `full` (`llms-full.txt`) или `null`. 116 * 117 * Сравнение точное: ни регистра, ни косой черты в конце, ни вложенных папок. Корневой 118 * `/llms.txt` для сайта с home `/sub/` — документ корневого сайта, а не этого; каталог 119 * корня ему не подставляется. 120 * 121 * @param string $requestPath Путь запроса, как пришёл (`/sub/llms.txt`). 122 * @param string $homePath Путь главной страницы этого сайта: `/` или `/sub/` на мультисайте с подпапками. 123 */ 124 public static function documentFor(string $requestPath, string $homePath): ?string 125 { 126 $path = '/' . ltrim($requestPath, '/'); 127 $home = rtrim('/' . ltrim($homePath, '/'), '/'); 128 129 if ($home !== '') { 130 if (strpos($path, $home . '/') !== 0) { 131 return null; 132 } 133 $path = substr($path, strlen($home)); 134 } 69 135 70 136 if ($path === '/llms.txt') { 71 $this->serve(self::CACHE_MAP, false);137 return 'map'; 72 138 } 73 139 if ($path === '/llms-full.txt') { 74 $this->serve(self::CACHE_FULL, true); 75 } 140 return 'full'; 141 } 142 143 return null; 144 } 145 146 /** 147 * Строки заголовков ответа: тип, длина, запрет угадывания типа и срок жизни. 148 * 149 * Отдельным методом, чтобы заголовки проверялись без вывода и `exit`. 150 * Срок свежести и отказ страничным кешам берутся из `DocumentCaching`: 151 * число одно с `catalog.json` платного аддона. 152 * 153 * @return list<string> 154 */ 155 public static function responseHeaders(int $length): array 156 { 157 return [ 158 'Content-Type: text/plain; charset=utf-8', 159 'Content-Length: ' . $length, 160 'X-Robots-Tag: noindex', 161 // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом. 162 'X-Content-Type-Options: nosniff', 163 'Cache-Control: ' . DocumentCaching::cacheControl(), 164 DocumentCaching::LITESPEED_HEADER . ': ' . DocumentCaching::liteSpeedControl(), 165 ]; 166 } 167 168 /** 169 * Положить собранный документ в серверный кеш на `CACHE_TTL`. 170 * 171 * Отдельным методом, чтобы срок проверялся без вывода и `exit`. 172 */ 173 public static function remember(string $cacheKey, string $body): void 174 { 175 set_transient($cacheKey, $body, self::CACHE_TTL); 76 176 } 77 177 … … 80 180 $body = get_transient($cacheKey); 81 181 if (!is_string($body) || $body === '') { 82 $store = (new StoreProfileBuilder())->build( );83 $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', 50)));182 $store = (new StoreProfileBuilder())->build($full); 183 $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT))); 84 184 $document = $full 85 ? $generator->full($store )86 : $generator->map($store, rest_url('cwforge/v1/catalog.json'));185 ? $generator->full($store, self::feedUrl()) 186 : $generator->map($store, self::feedUrl()); 87 187 $body = $document->render(); 88 se t_transient($cacheKey, $body, self::CACHE_TTL);188 self::remember($cacheKey, $body); 89 189 } 90 190 91 191 // Заголовки ставятся до вывода и включают длину: бот, читающий поток 92 192 // без длины, не может отличить обрыв соединения от конца документа. 93 header('Content-Type: text/plain; charset=utf-8'); 94 header('Content-Length: ' . strlen($body)); 95 header('X-Robots-Tag: noindex'); 96 // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом. 97 header('X-Content-Type-Options: nosniff'); 98 header('Cache-Control: public, max-age=3600'); 193 foreach (self::responseHeaders(strlen($body)) as $line) { 194 header($line); 195 } 196 // Страничные кеши плагинов документ не держат; свежесть для всего, что стоит перед сайтом, — наша (DocumentCaching). 197 DocumentCaching::excludeFromPageCaches(); 99 198 100 199 echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ответ text/plain с nosniff: HTML-экранирование исказило бы документ для агентов. … … 102 201 } 103 202 203 /** 204 * Адрес машинного фида — только если этот магазин его отдаёт. 205 * 206 * Фид живёт в платном аддоне и закрыт без лицензии. Строка «полный 207 * каталог — по такому-то адресу», ведущая на отказ, хуже её отсутствия. 208 */ 209 private static function feedUrl(): ?string 210 { 211 return Bridge::instance()->isEnabled('catalog-feed') ? rest_url('cwforge/v1/catalog.json') : null; 212 } 213 104 214 public function invalidate(): void 105 215 { -
clearwayforge/tags/1.0.3/src/Infra/Llms/StoreProfileBuilder.php
r3720707 r3776286 25 25 namespace ClearwayForge\Infra\Llms; 26 26 27 use ClearwayForge\Domain\Llms\LlmsGenerator; 27 28 use ClearwayForge\Domain\Llms\LlmsLink; 28 29 use ClearwayForge\Domain\Llms\PlainText; … … 34 35 final class StoreProfileBuilder 35 36 { 36 public function build(): StoreProfile 37 { 37 /** 38 * @param bool $full Профиль для `/llms-full.txt`: товаров грузится больше 39 * (`LlmsGenerator::fetchLimit()`), а общее число уходит в документ. 40 */ 41 public function build(bool $full = false): StoreProfile 42 { 43 [$products, $total] = $this->products($full); 44 38 45 return new StoreProfile( 39 46 name: PlainText::fromHtml(get_bloginfo('name')), … … 41 48 description: PlainText::fromHtml(get_bloginfo('description')), 42 49 categories: $this->categories(), 43 products: $ this->products(),50 products: $products, 44 51 policies: $this->policies(), 45 52 pages: $this->pages(), … … 47 54 shippingNote: $this->shippingNote(), 48 55 returnsNote: null, 56 productsTotal: $total, 49 57 ); 50 58 } … … 84 92 } 85 93 86 /** @return list<LlmsLink> */ 87 private function products(): array 88 { 89 $limit = max(1, Options::int('llms_product_limit', 50)); 90 91 $products = wc_get_products([ 94 /** @return array{0:list<LlmsLink>,1:?int} Товары окна и сколько их в каталоге всего. */ 95 private function products(bool $full): array 96 { 97 $limit = LlmsGenerator::fetchLimit($full, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT)); 98 99 // Один товар на группу переводов, как в catalog.json (NEIGH-07): `ProductTranslations` — единственное место правила. 100 $result = ProductTranslations::products([ 92 101 'status' => 'publish', 93 102 'limit' => $limit, … … 95 104 'order' => 'DESC', 96 105 'visibility' => 'catalog', 106 'paginate' => true, 97 107 ]); 98 108 109 // С `paginate` WooCommerce отдаёт объект с `products` и `total`; без 110 // него (старые версии, чужие фильтры) — просто список. 111 $total = null; 112 $products = $result; 113 if (is_object($result)) { 114 $products = $result->products ?? []; 115 $total = isset($result->total) ? (int) $result->total : null; 116 } 117 99 118 if (!is_array($products)) { 100 return [ ];119 return [[], null]; 101 120 } 102 121 … … 113 132 } 114 133 115 return $links;134 return [$links, $total]; 116 135 } 117 136 -
clearwayforge/tags/1.0.3/src/Infra/Report/DashboardAssembler.php
r3702012 r3776286 25 25 * ── Что здесь НЕ происходит ────────────────────────────────────────────── 26 26 * 27 * Возвраты (BUG-CLEAR-MISC-01, п. 14): сумма доли хранится как была, а 28 * возврат по заказу лежит рядом (`refunded_cents`) и приходит в `Allocation`. 29 * Отчёт показывает выручку за вычетом возвратов в периоде самого заказа, 30 * а сумму возвратов — отдельным числом; здесь возврат только передаётся 31 * дальше, вычитает его `DashboardBuilder`. 32 * 27 33 * Пересчёта атрибуции нет: суммы посчитаны в момент заказа и сохранены. 28 34 * Пересчитать их сейчас значило бы применить сегодняшнюю модель к прошлому 29 35 * месяцу и показать владельцу не ту выручку, которую он видел вчера. 36 * 37 * Тем более текущая модель не отбирает строки: заказ читается под той 38 * моделью, под которой записан (`TouchRepository::inWindow()`), и после 39 * смены настройки старые заказы остаются в отчёте (BUG-CLEAR-ATTR-01). 40 * Поэтому `$model` в `assemble()` — не фильтр, а запасное значение для 41 * строки, записанной моделью, которой эта версия не знает. 30 42 */ 31 43 … … 62 74 } 63 75 64 public function assemble(ReportWindow $window, AttributionModel $model, string $currency): DashboardReport 65 { 76 /** 77 * @param int|null $minorUnit Знаков после запятой у записанных сумм. Без 78 * указания — настройка магазина, та же, что 79 * читала запись заказа (BUG-CLEAR-ATTR-02). 80 */ 81 public function assemble( 82 ReportWindow $window, 83 AttributionModel $model, 84 string $currency, 85 ?int $minorUnit = null 86 ): DashboardReport { 66 87 return (new DashboardBuilder())->build( 67 88 $window, … … 70 91 $this->crawlerRows($window), 71 92 $currency, 93 $minorUnit ?? StoreMinorUnit::current(), 72 94 ); 73 95 } … … 81 103 { 82 104 $byOrder = []; 83 foreach ($this->touches->inWindow($fromMs, $toMs , $model->value) as $row) {105 foreach ($this->touches->inWindow($fromMs, $toMs) as $row) { 84 106 $source = AiSource::tryFrom($row['source']); 85 107 $via = RecoveredVia::tryFrom($row['recovered_via']); … … 99 121 $byOrder[$row['order_id']]['at'] = $row['captured_ms']; 100 122 $byOrder[$row['order_id']]['currency'] = $row['currency']; 123 $byOrder[$row['order_id']]['model'] = AttributionModel::tryFrom($row['model']) ?? $model; 101 124 $byOrder[$row['order_id']]['allocations'][] = new Allocation( 102 125 $touch, 103 126 Money::fromCents($row['amount_cents'], $row['currency']), 104 1 127 1, 128 $row['refunded_cents'] 105 129 ); 106 130 } … … 115 139 (int) $orderId, 116 140 (int) $data['at'], 117 new AttributionResult($ model, $data['allocations'], $total)141 new AttributionResult($data['model'], $data['allocations'], $total) 118 142 ); 119 143 } -
clearwayforge/tags/1.0.3/src/Infra/Repository/TouchRepository.php
r3702012 r3776286 7 7 * модель атрибуции, и отчёт за прошлый месяц обязан остаться таким, каким он 8 8 * его видел. Выручка, меняющаяся задним числом, обесценивает весь дашборд. 9 * 10 * Отсюда правило чтения (BUG-CLEAR-ATTR-01): заказ считается той моделью, под 11 * которой он записан первой, и текущая настройка модели строки не отбирает. 12 * Смена модели действует на заказы с момента смены (`OrderObserver` пишет их 13 * под новой), а прежние заказы остаются в дашборде, Revenue by agent, CSV и 14 * дайджесте с теми же суммами. Если у заказа есть строки под двумя моделями 15 * (повторный разбор), читается набор первой записанной: иначе заказ 16 * посчитался бы дважды. 9 17 */ 10 18 … … 31 39 32 40 use ClearwayForge\Domain\Attribution\AttributionResult; 41 use ClearwayForge\Domain\Report\RefundSplit; 33 42 use ClearwayForge\Infra\Db\Schema; 34 43 … … 88 97 89 98 /** 90 * Разобранные заказы окна. 99 * Разобранные заказы окна — каждый под моделью, с которой он записан. 100 * 101 * Подзапрос берёт модель самой ранней строки заказа (`id` растёт со 102 * вставкой), так что повторный разбор под другой моделью выручку не 103 * удваивает и не подменяет. Уникальный ключ (order_id, source, model) 104 * ведёт подзапрос по префиксу `order_id`. 105 * 106 * `refunded_cents` — доля возврата, пришедшаяся на строку; отчёт вычитает 107 * её из суммы строки в периоде самого заказа (BUG-CLEAR-MISC-01, п. 14). 91 108 * 92 109 * @return list<array{order_id:int,source:string,recovered_via:string,confidence:int, 93 * amount_cents:int, currency:string,captured_ms:int,model:string}>94 */ 95 public function inWindow(int $fromMs, int $toMs , string $model): array110 * amount_cents:int,refunded_cents:int,currency:string,captured_ms:int,model:string}> 111 */ 112 public function inWindow(int $fromMs, int $toMs): array 96 113 { 97 114 global $wpdb; … … 100 117 $rows = $wpdb->get_results( 101 118 $wpdb->prepare( 102 'SELECT order_id, source, recovered_via, confidence, amount_cents, currency, captured_ms, model 103 FROM %i 104 WHERE captured_ms >= %d AND captured_ms <= %d AND model = %s 105 ORDER BY captured_ms ASC', 119 'SELECT t.order_id, t.source, t.recovered_via, t.confidence, t.amount_cents, 120 t.refunded_cents, t.currency, t.captured_ms, t.model 121 FROM %i t 122 WHERE t.captured_ms >= %d AND t.captured_ms <= %d 123 AND t.model = ( 124 SELECT h.model FROM %i h 125 WHERE h.order_id = t.order_id 126 ORDER BY h.id ASC 127 LIMIT 1 128 ) 129 ORDER BY t.captured_ms ASC, t.id ASC', 106 130 $table, 107 131 $fromMs, 108 132 $toMs, 109 $ model133 $table 110 134 ), 111 135 ARRAY_A … … 123 147 'confidence' => (int) $r['confidence'], 124 148 'amount_cents' => (int) $r['amount_cents'], 149 'refunded_cents' => (int) $r['refunded_cents'], 125 150 'currency' => (string) $r['currency'], 126 151 'captured_ms' => (int) $r['captured_ms'], … … 131 156 } 132 157 158 /** Есть ли у заказа разбор: дёшево отсекает возвраты по заказам, которых мы не считали. */ 159 public function hasOrder(int $orderId): bool 160 { 161 global $wpdb; 162 163 $table = Schema::table($this->wpPrefix, 'touches'); 164 165 return $wpdb->get_var($wpdb->prepare('SELECT 1 FROM %i WHERE order_id = %d LIMIT 1', $table, $orderId)) !== null; 166 } 167 168 /** 169 * Записать, сколько по заказу возвращено всего (BUG-CLEAR-MISC-01, п. 14). 170 * 171 * Принимает итог, а не приращение: WooCommerce отвечает «возвращено по 172 * заказу столько», и повторный вызов с тем же числом, удаление возврата и 173 * новый частичный возврат дают верный результат без учёта порядка событий. 174 * Итог делится между строками заказа пропорционально их суммам 175 * (`RefundSplit`) и ложится в те строки, что читает `inWindow()` — 176 * записанные под первой моделью заказа. 177 * 178 * @return int Сколько строк заказа обновлено; 0 — разбора у заказа нет. 179 */ 180 public function setOrderRefund(int $orderId, int $refundedCents): int 181 { 182 global $wpdb; 183 184 $table = Schema::table($this->wpPrefix, 'touches'); 185 $rows = $wpdb->get_results( 186 $wpdb->prepare( 187 'SELECT t.id, t.amount_cents 188 FROM %i t 189 WHERE t.order_id = %d 190 AND t.model = ( 191 SELECT h.model FROM %i h 192 WHERE h.order_id = t.order_id 193 ORDER BY h.id ASC 194 LIMIT 1 195 ) 196 ORDER BY t.id ASC', 197 $table, 198 $orderId, 199 $table 200 ), 201 ARRAY_A 202 ); 203 204 if (!is_array($rows) || $rows === []) { 205 return 0; 206 } 207 208 $parts = RefundSplit::apportion( 209 $refundedCents, 210 array_values(array_map(static fn (array $r): int => (int) $r['amount_cents'], $rows)) 211 ); 212 213 foreach (array_values($rows) as $i => $row) { 214 $wpdb->query( 215 $wpdb->prepare( 216 'UPDATE %i SET refunded_cents = %d WHERE id = %d', 217 $table, 218 $parts[$i], 219 (int) $row['id'] 220 ) 221 ); 222 } 223 224 return count($rows); 225 } 226 227 /** 228 * Следующая пачка различных заказов с разбором, по возрастанию номера. 229 * 230 * Курсором служит сам номер заказа: «всё, что больше последнего 231 * обработанного». Ключ `(order_id, source, model)` отдаёт такую выборку 232 * без сортировки по таблице целиком (досчёт возвратов, п. 14). 233 * 234 * @return list<int> 235 */ 236 public function orderIdsAfter(int $afterOrderId, int $limit): array 237 { 238 global $wpdb; 239 240 $table = Schema::table($this->wpPrefix, 'touches'); 241 $ids = $wpdb->get_col( 242 $wpdb->prepare( 243 'SELECT DISTINCT order_id FROM %i WHERE order_id > %d ORDER BY order_id ASC LIMIT %d', 244 $table, 245 $afterOrderId, 246 max(1, $limit) 247 ) 248 ); 249 250 return is_array($ids) ? array_values(array_map('intval', $ids)) : []; 251 } 252 133 253 public function deleteOrder(int $orderId): int 134 254 { -
clearwayforge/tags/1.0.3/src/Infra/Rest/Routes.php
r3702012 r3776286 132 132 'required' => false, 133 133 ), 134 'trusted_proxies' => array( 135 'type' => 'string', 136 'required' => false, 137 ), 134 138 'delete_data_on_uninstall' => array( 135 139 'type' => 'boolean', -
clearwayforge/tags/1.0.3/src/Infra/Rest/SettingsController.php
r3702012 r3776286 26 26 27 27 use ClearwayForge\Domain\Attribution\AttributionModel; 28 use ClearwayForge\Domain\Crawler\TrustedProxies; 28 29 use ClearwayForge\Infra\Scheduler\CrawlerRangeSources; 29 30 use ClearwayForge\Infra\Scheduler\Maintenance; … … 74 75 } 75 76 77 /* 78 * Доверенные прокси (BUG-CLEAR-MISC-01, п. 10): от списка зависит, чьему 79 * слову об адресе верит проверка краулеров. Запись, не являющаяся 80 * адресом или сетью, отбрасывается, остальные сохраняются; отброшенные 81 * возвращаются в ответе по имени (`trusted_proxies_rejected`), чтобы 82 * опечатка не осталась незамеченной. Пустая строка очищает список — и 83 * заголовки прокси снова не читаются. 84 */ 85 $rejectedProxies = []; 86 $overLimitProxies = 0; 87 if ($request->get_param('trusted_proxies') !== null) { 88 if (!current_user_can('manage_options')) { 89 // Список решает, чьему заголовку верить, — это доверие к сети, а 90 // не настройка отчётов: менеджеру магазина (`manage_woocommerce`) 91 // оно не принадлежит. Отказ до любой записи, как у удаления данных. 92 return new WP_REST_Response( 93 ['code' => 'cwforge_forbidden', 'message' => 'Only an administrator can change the trusted proxies.'], 94 403 95 ); 96 } 97 98 $proxies = TrustedProxies::fromText((string) $request->get_param('trusted_proxies')); 99 $values['trusted_proxies'] = $proxies->cidrs(); 100 $rejectedProxies = $proxies->rejected(); 101 $overLimitProxies = count($proxies->overLimit()); 102 } 103 76 104 $deleteOnUninstall = $request->get_param('delete_data_on_uninstall'); 77 105 if ($deleteOnUninstall !== null && !current_user_can('delete_plugins')) { … … 96 124 } 97 125 98 return new WP_REST_Response(self::payload( ), 200);126 return new WP_REST_Response(self::payload($rejectedProxies, $overLimitProxies), 200); 99 127 } 100 128 101 129 /** 130 * @param list<string> $rejectedProxies Записи списка прокси, отброшенные в этом запросе как «не адрес». 131 * @param int $overLimitProxies Сколько годных записей не поместилось в потолок списка в этом запросе. 102 132 * @return array{ 103 133 * show_estimated_revenue:bool, … … 106 136 * crawler_ranges_refresh:bool, 107 137 * crawler_ranges_hosts:list<string>, 138 * trusted_proxies:string, 139 * trusted_proxies_rejected:list<string>, 140 * trusted_proxies_over_limit:int, 141 * trusted_proxies_limit:int, 142 * can_change_proxies:bool, 108 143 * delete_data_on_uninstall:bool, 109 144 * can_change_uninstall:bool 110 145 * } 111 146 */ 112 private static function payload( ): array147 private static function payload(array $rejectedProxies = [], int $overLimitProxies = 0): array 113 148 { 114 149 $current = AttributionModel::tryFrom(Options::string('attribution_model', 'last')) ?? AttributionModel::LastTouch; … … 125 160 // не из текста: согласие даётся на то, что реально произойдёт. 126 161 'crawler_ranges_hosts' => self::hosts((new CrawlerRangeSources())->all()), 162 // Список как текст для поля ввода: по записи на строку. 163 'trusted_proxies' => implode("\n", TrustedProxies::fromStored(Options::get('trusted_proxies', array()))->cidrs()), 164 // Что не прошло при последнем сохранении; на чтении всегда пусто. 165 'trusted_proxies_rejected' => $rejectedProxies, 166 // Годные записи сверх потолка: считаются отдельно, на чтении всегда 0. 167 'trusted_proxies_over_limit' => $overLimitProxies, 168 'trusted_proxies_limit' => TrustedProxies::MAX, 169 'can_change_proxies' => current_user_can('manage_options'), 127 170 'delete_data_on_uninstall' => get_option(Options::DELETE_ON_UNINSTALL) === 'yes', 128 171 'can_change_uninstall' => current_user_can('delete_plugins'), -
clearwayforge/tags/1.0.3/src/Infra/Scheduler/Maintenance.php
r3720707 r3776286 36 36 public const GROUP = 'clearwayforge'; 37 37 38 /** Метка «очередь проверена»: самолечение не чаще раза в окно, а не на каждый запрос админки. */ 39 public const SCHEDULE_CHECKED = 'cwforge_schedule_checked'; 40 38 41 /** Скачанные сети вендоров. */ 39 42 public const RANGES_OPTION = 'cwforge_crawler_ranges'; … … 54 57 add_action(self::HOOK_PURGE, [$this, 'purge']); 55 58 add_action(self::HOOK_RANGES, [$this, 'refreshRanges']); 59 // Только запросы админки: на витрине лишний вопрос к очереди — это 60 // накладные расходы на каждом хите покупателя (принцип 1 в AGENTS.md). 61 add_action('admin_init', [self::class, 'ensureScheduled']); 62 } 63 64 /** 65 * Вернуть уборку в очередь, если активация её не поставила. 66 * 67 * `scheduleAll()` зовёт только активация, а плагин попадает на сайт и 68 * без неё: обновление по FTP, перенос базы без таблиц Action Scheduler, 69 * очередь, вычищенная чужим инструментом, Action Scheduler, которого не 70 * было при активации. Без задачи `cwforge_purge_old_data` срок хранения 71 * (`retention_days`) тихо перестаёт действовать, и таблицы растут. Ту же 72 * оговорку про FTP несёт `Migrator::migrateIfNeeded()` в `Plugin::wire()`. 73 * 74 * Метка «проверено» ставится только когда очередь доступна: иначе одна 75 * неудачная попытка отключила бы самолечение на всё окно. 76 */ 77 public static function ensureScheduled(): void 78 { 79 if (!function_exists('as_next_scheduled_action')) { 80 return; 81 } 82 83 if (get_transient(self::SCHEDULE_CHECKED) !== false) { 84 return; 85 } 86 87 set_transient(self::SCHEDULE_CHECKED, 1, 12 * HOUR_IN_SECONDS); 88 self::scheduleAll(); 56 89 } 57 90 … … 81 114 82 115 as_unschedule_all_actions(self::HOOK_PURGE, [], self::GROUP); 116 // Досчёт возвратов: поставленная пачка не должна пережить плагин (п. 14). 117 as_unschedule_all_actions(\ClearwayForge\Infra\Attribution\RefundBackfill::HOOK, [], self::GROUP); 83 118 self::unscheduleRanges(); 84 119 } -
clearwayforge/tags/1.0.3/uninstall.php
r3720707 r3776286 39 39 \ClearwayForge\Infra\Support\Options::NAME, 40 40 \ClearwayForge\Infra\Scheduler\Maintenance::RANGES_OPTION, 41 \ClearwayForge\Infra\ Support\Options::DELETE_ON_UNINSTALL,41 \ClearwayForge\Infra\Attribution\RefundBackfill::OPTION, 42 42 ] as $cwforge_option 43 43 ) { … … 45 45 } 46 46 47 /* 48 * Согласие владельца общее для Free и платного аддона: надпись на экране 49 * настроек — «Delete all Clearway Forge data». Пока на сайте остались 50 * данные аддона (его метка версии схемы), флаг остаётся, иначе аддон, 51 * удаляемый вторым, не найдёт согласия и оставит свои таблицы 52 * (BUG-CLEAR-MISC-01). Аддон уберёт флаг сам, когда уйдёт последним. 53 */ 54 if (get_option('cwforge_pro_schema_version', null) === null) { 55 delete_option(\ClearwayForge\Infra\Support\Options::DELETE_ON_UNINSTALL); 56 } 57 58 delete_transient(\ClearwayForge\Infra\Scheduler\Maintenance::SCHEDULE_CHECKED); 47 59 delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_MAP); 48 60 delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_FULL); -
clearwayforge/tags/1.0.3/vendor/composer/autoload_classmap.php
r3720707 r3776286 25 25 'ClearwayForge\\Domain\\Attribution\\SourceDetector' => $baseDir . '/src/Domain/Attribution/SourceDetector.php', 26 26 'ClearwayForge\\Domain\\Attribution\\Touch' => $baseDir . '/src/Domain/Attribution/Touch.php', 27 'ClearwayForge\\Domain\\Crawler\\ClientAddress' => $baseDir . '/src/Domain/Crawler/ClientAddress.php', 27 28 'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => $baseDir . '/src/Domain/Crawler/CrawlerClassifier.php', 28 29 'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => $baseDir . '/src/Domain/Crawler/CrawlerCorpus.php', … … 31 32 'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => $baseDir . '/src/Domain/Crawler/CrawlerKind.php', 32 33 'ClearwayForge\\Domain\\Crawler\\IpRange' => $baseDir . '/src/Domain/Crawler/IpRange.php', 34 'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => $baseDir . '/src/Domain/Crawler/TrustedProxies.php', 33 35 'ClearwayForge\\Domain\\Crawler\\Verification' => $baseDir . '/src/Domain/Crawler/Verification.php', 34 36 'ClearwayForge\\Domain\\Llms\\LlmsBlock' => $baseDir . '/src/Domain/Llms/LlmsBlock.php', … … 52 54 'ClearwayForge\\Domain\\Report\\MoneyText' => $baseDir . '/src/Domain/Report/MoneyText.php', 53 55 'ClearwayForge\\Domain\\Report\\PeriodSelector' => $baseDir . '/src/Domain/Report/PeriodSelector.php', 56 'ClearwayForge\\Domain\\Report\\RefundSplit' => $baseDir . '/src/Domain/Report/RefundSplit.php', 54 57 'ClearwayForge\\Domain\\Report\\ReportWindow' => $baseDir . '/src/Domain/Report/ReportWindow.php', 55 58 'ClearwayForge\\Domain\\Report\\SourceRow' => $baseDir . '/src/Domain/Report/SourceRow.php', … … 63 66 'ClearwayForge\\Infra\\Attribution\\OrderMeta' => $baseDir . '/src/Infra/Attribution/OrderMeta.php', 64 67 'ClearwayForge\\Infra\\Attribution\\OrderObserver' => $baseDir . '/src/Infra/Attribution/OrderObserver.php', 68 'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => $baseDir . '/src/Infra/Attribution/OrderRefunds.php', 65 69 'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => $baseDir . '/src/Infra/Attribution/OrderVerdictWriter.php', 70 'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => $baseDir . '/src/Infra/Attribution/RefundBackfill.php', 71 'ClearwayForge\\Infra\\Attribution\\RefundObserver' => $baseDir . '/src/Infra/Attribution/RefundObserver.php', 66 72 'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => $baseDir . '/src/Infra/Attribution/WcOrderMeta.php', 73 'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => $baseDir . '/src/Infra/Attribution/WcOrderRefunds.php', 74 'ClearwayForge\\Infra\\Collect\\RequestAddress' => $baseDir . '/src/Infra/Collect/RequestAddress.php', 67 75 'ClearwayForge\\Infra\\Collect\\RequestSignals' => $baseDir . '/src/Infra/Collect/RequestSignals.php', 68 76 'ClearwayForge\\Infra\\Collect\\VisitCollector' => $baseDir . '/src/Infra/Collect/VisitCollector.php', … … 73 81 'ClearwayForge\\Infra\\Db\\Schema' => $baseDir . '/src/Infra/Db/Schema.php', 74 82 'ClearwayForge\\Infra\\Health\\HealthReport' => $baseDir . '/src/Infra/Health/HealthReport.php', 83 'ClearwayForge\\Infra\\Llms\\DocumentCaching' => $baseDir . '/src/Infra/Llms/DocumentCaching.php', 75 84 'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => $baseDir . '/src/Infra/Llms/LlmsRoutes.php', 85 'ClearwayForge\\Infra\\Llms\\ProductTranslations' => $baseDir . '/src/Infra/Llms/ProductTranslations.php', 76 86 'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => $baseDir . '/src/Infra/Llms/StoreProfileBuilder.php', 77 87 'ClearwayForge\\Infra\\Plugin' => $baseDir . '/src/Infra/Plugin.php', … … 79 89 'ClearwayForge\\Infra\\Pro\\Bridge' => $baseDir . '/src/Infra/Pro/Bridge.php', 80 90 'ClearwayForge\\Infra\\Report\\DashboardAssembler' => $baseDir . '/src/Infra/Report/DashboardAssembler.php', 91 'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => $baseDir . '/src/Infra/Report/StoreMinorUnit.php', 81 92 'ClearwayForge\\Infra\\Repository\\HitRepository' => $baseDir . '/src/Infra/Repository/HitRepository.php', 82 93 'ClearwayForge\\Infra\\Repository\\SessionRepository' => $baseDir . '/src/Infra/Repository/SessionRepository.php', -
clearwayforge/tags/1.0.3/vendor/composer/autoload_static.php
r3720707 r3776286 40 40 'ClearwayForge\\Domain\\Attribution\\SourceDetector' => __DIR__ . '/../..' . '/src/Domain/Attribution/SourceDetector.php', 41 41 'ClearwayForge\\Domain\\Attribution\\Touch' => __DIR__ . '/../..' . '/src/Domain/Attribution/Touch.php', 42 'ClearwayForge\\Domain\\Crawler\\ClientAddress' => __DIR__ . '/../..' . '/src/Domain/Crawler/ClientAddress.php', 42 43 'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerClassifier.php', 43 44 'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerCorpus.php', … … 46 47 'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerKind.php', 47 48 'ClearwayForge\\Domain\\Crawler\\IpRange' => __DIR__ . '/../..' . '/src/Domain/Crawler/IpRange.php', 49 'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => __DIR__ . '/../..' . '/src/Domain/Crawler/TrustedProxies.php', 48 50 'ClearwayForge\\Domain\\Crawler\\Verification' => __DIR__ . '/../..' . '/src/Domain/Crawler/Verification.php', 49 51 'ClearwayForge\\Domain\\Llms\\LlmsBlock' => __DIR__ . '/../..' . '/src/Domain/Llms/LlmsBlock.php', … … 67 69 'ClearwayForge\\Domain\\Report\\MoneyText' => __DIR__ . '/../..' . '/src/Domain/Report/MoneyText.php', 68 70 'ClearwayForge\\Domain\\Report\\PeriodSelector' => __DIR__ . '/../..' . '/src/Domain/Report/PeriodSelector.php', 71 'ClearwayForge\\Domain\\Report\\RefundSplit' => __DIR__ . '/../..' . '/src/Domain/Report/RefundSplit.php', 69 72 'ClearwayForge\\Domain\\Report\\ReportWindow' => __DIR__ . '/../..' . '/src/Domain/Report/ReportWindow.php', 70 73 'ClearwayForge\\Domain\\Report\\SourceRow' => __DIR__ . '/../..' . '/src/Domain/Report/SourceRow.php', … … 78 81 'ClearwayForge\\Infra\\Attribution\\OrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderMeta.php', 79 82 'ClearwayForge\\Infra\\Attribution\\OrderObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderObserver.php', 83 'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderRefunds.php', 80 84 'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderVerdictWriter.php', 85 'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundBackfill.php', 86 'ClearwayForge\\Infra\\Attribution\\RefundObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundObserver.php', 81 87 'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderMeta.php', 88 'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderRefunds.php', 89 'ClearwayForge\\Infra\\Collect\\RequestAddress' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestAddress.php', 82 90 'ClearwayForge\\Infra\\Collect\\RequestSignals' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestSignals.php', 83 91 'ClearwayForge\\Infra\\Collect\\VisitCollector' => __DIR__ . '/../..' . '/src/Infra/Collect/VisitCollector.php', … … 88 96 'ClearwayForge\\Infra\\Db\\Schema' => __DIR__ . '/../..' . '/src/Infra/Db/Schema.php', 89 97 'ClearwayForge\\Infra\\Health\\HealthReport' => __DIR__ . '/../..' . '/src/Infra/Health/HealthReport.php', 98 'ClearwayForge\\Infra\\Llms\\DocumentCaching' => __DIR__ . '/../..' . '/src/Infra/Llms/DocumentCaching.php', 90 99 'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => __DIR__ . '/../..' . '/src/Infra/Llms/LlmsRoutes.php', 100 'ClearwayForge\\Infra\\Llms\\ProductTranslations' => __DIR__ . '/../..' . '/src/Infra/Llms/ProductTranslations.php', 91 101 'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => __DIR__ . '/../..' . '/src/Infra/Llms/StoreProfileBuilder.php', 92 102 'ClearwayForge\\Infra\\Plugin' => __DIR__ . '/../..' . '/src/Infra/Plugin.php', … … 94 104 'ClearwayForge\\Infra\\Pro\\Bridge' => __DIR__ . '/../..' . '/src/Infra/Pro/Bridge.php', 95 105 'ClearwayForge\\Infra\\Report\\DashboardAssembler' => __DIR__ . '/../..' . '/src/Infra/Report/DashboardAssembler.php', 106 'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => __DIR__ . '/../..' . '/src/Infra/Report/StoreMinorUnit.php', 96 107 'ClearwayForge\\Infra\\Repository\\HitRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/HitRepository.php', 97 108 'ClearwayForge\\Infra\\Repository\\SessionRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/SessionRepository.php', -
clearwayforge/tags/1.0.3/vendor/composer/installed.php
r3720707 r3776286 4 4 'pretty_version' => 'dev-main', 5 5 'version' => 'dev-main', 6 'reference' => ' ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',6 'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c', 7 7 'type' => 'wordpress-plugin', 8 8 'install_path' => __DIR__ . '/../../', … … 14 14 'pretty_version' => 'dev-main', 15 15 'version' => 'dev-main', 16 'reference' => ' ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',16 'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c', 17 17 'type' => 'wordpress-plugin', 18 18 'install_path' => __DIR__ . '/../../', -
clearwayforge/trunk/assets/admin/admin.js
r3702012 r3776286 11 11 * ── Почему числа форматируются здесь, а считаются на сервере ───────────── 12 12 * 13 * Все суммы приходят целыми центами, доли — базисными пунктами. Клиент их 14 * только показывает. Любая арифметика здесь означала бы второй источник 13 * Все суммы приходят целыми минорными единицами, доли — базисными пунктами. 14 * Клиент их только показывает; сколько знаков после запятой у единицы, говорит 15 * сервер (`cwforgeAdmin.minorUnit` = `wc_get_price_decimals()`, та же точность, 16 * с которой заказ записан): иены (0 знаков) и динары (3) — не центы. Любая арифметика здесь означала бы второй источник 15 17 * истины: экран и письмо-дайджест начали бы показывать разные числа, и 16 18 * поймать это можно было бы только глазами. … … 29 31 var apiFetch = wp.apiFetch; 30 32 31 /** Центы в строку с валютой. Форматирование локали делает браузер. */ 33 /** 34 * Записанные единицы в строку с валютой. Форматирование локали делает 35 * браузер, а место точки — настройка магазина `config.minorUnit`: 1500 36 * при нуле знаков это 1500 иен, а не 15 (BUG-CLEAR-ATTR-02). WordPress 37 * отдаёт скаляры локализации строками, поэтому число разбирается заново; 38 * без него или вне 0-4 остаются два знака, как было. 39 */ 32 40 function money( cents, currency ) { 33 var value = ( cents || 0 ) / 100; 41 var digits = parseInt( config.minorUnit, 10 ); 42 if ( isNaN( digits ) || digits < 0 || digits > 4 ) { 43 digits = 2; 44 } 45 var value = ( cents || 0 ) / Math.pow( 10, digits ); 34 46 try { 35 47 return new Intl.NumberFormat( undefined, { 36 48 style: 'currency', 37 49 currency: currency || 'USD', 38 maximumFractionDigits: 2, 50 minimumFractionDigits: digits, 51 maximumFractionDigits: digits, 39 52 } ).format( value ); 40 53 } catch ( e ) { 41 54 // Неизвестный код валюты роняет Intl. Магазин с экзотической 42 55 // валютой не должен получать пустой экран вместо цифр. 43 return value.toFixed( 2) + ' ' + ( currency || '' );56 return value.toFixed( digits ) + ' ' + ( currency || '' ); 44 57 } 45 58 } … … 444 457 { className: 'cwforge-stats' }, 445 458 el( Stat, { label: 'AI-attributed orders', value: String( data.totals.orders ) } ), 459 // Выручка выше уже за вычетом возвратов; сами возвраты отдельной строкой. 460 el( Stat, { label: 'Refunded', value: money( data.totals.refunded_cents || 0, currency ) } ), 446 461 el( Stat, { label: 'AI sessions', value: String( data.totals.sessions ) } ), 447 462 el( Stat, { label: 'Recovered sessions', value: percent( recovered.share_bp ) } ), … … 743 758 var uninstall = uninstallState[ 0 ]; 744 759 var setUninstall = uninstallState[ 1 ]; 760 // Доверенные прокси: черновик текста правится в поле и уходит одной 761 // кнопкой; сервер сохраняет годные записи и называет отброшенные. 762 var proxiesState = useState( { value: '', saved: '', message: '', rejected: [], overLimit: 0, limit: 0 } ); 763 var proxies = proxiesState[ 0 ]; 764 var setProxies = proxiesState[ 1 ]; 765 // Список прокси правит администратор; менеджеру магазина поле только читается. 766 var proxiesAllowedState = useState( false ); 767 var proxiesAllowed = proxiesAllowedState[ 0 ]; 768 var setProxiesAllowed = proxiesAllowedState[ 1 ]; 745 769 746 770 function load() { … … 764 788 saved: '', 765 789 } ); 790 setProxies( { value: data.trusted_proxies || '', saved: '', message: '', rejected: [], overLimit: 0, limit: data.trusted_proxies_limit || 0 } ); 791 setProxiesAllowed( !! data.can_change_proxies ); 766 792 } ) 767 793 .catch( function ( error ) { … … 814 840 .catch( function () { 815 841 setUninstall( { value: ! next, allowed: uninstall.allowed, saved: 'error' } ); 842 } ); 843 } 844 845 function saveProxies() { 846 var draft = proxies.value; 847 setProxies( { value: draft, saved: 'saving', message: '', rejected: [], overLimit: 0, limit: proxies.limit } ); 848 apiFetch( { 849 path: '/cwforge/v1/settings', 850 method: 'POST', 851 data: { trusted_proxies: draft }, 852 } ) 853 .then( function ( data ) { 854 // Показываем то, что сервер сохранил (без повторов и лишних пробелов), 855 // и называем записи, которые он отбросил. 856 setProxies( { 857 value: data.trusted_proxies || '', 858 saved: 'saved', 859 message: '', 860 rejected: Array.isArray( data.trusted_proxies_rejected ) ? data.trusted_proxies_rejected : [], 861 overLimit: data.trusted_proxies_over_limit || 0, 862 limit: data.trusted_proxies_limit || proxies.limit, 863 } ); 864 } ) 865 .catch( function ( error ) { 866 // Черновик остаётся как есть: владелец правит запись, а не вводит всё заново. 867 setProxies( { 868 value: draft, 869 saved: 'error', 870 message: error && error.message ? String( error.message ) : '', 871 rejected: [], 872 overLimit: 0, 873 limit: proxies.limit, 874 } ); 816 875 } ); 817 876 } … … 919 978 'p', 920 979 { className: 'description' }, 921 'The dashboard and the revenue by AI agent report credit every order in the window with this model. ' + 980 'The model applies to orders placed from the moment you switch it. Earlier orders keep the model they ' + 981 'were recorded with, so past reports are not recalculated. ' + 922 982 'The AI channel column on an order keeps the model that was in force when the order was placed.' 923 983 ), … … 948 1008 ), 949 1009 savedNote( ranges.saved ), 1010 el( 'h3', null, 'Trusted reverse proxies' ), 1011 el( 1012 'label', 1013 { htmlFor: 'cwforge-trusted-proxies' }, 1014 'Networks of the proxies in front of this site, one per line (a network such as 10.0.0.0/8, or a single address)' 1015 ), 1016 el( 'textarea', { 1017 id: 'cwforge-trusted-proxies', 1018 className: 'large-text code', 1019 rows: 4, 1020 placeholder: '10.0.0.0/8', 1021 value: proxies.value, 1022 disabled: ! proxiesAllowed || proxies.saved === 'saving', 1023 onChange: function ( event ) { 1024 setProxies( { value: event.target.value, saved: '', message: '', rejected: [], overLimit: 0, limit: proxies.limit } ); 1025 }, 1026 } ), 1027 el( 1028 'p', 1029 { className: 'description' }, 1030 'Leave this empty unless a reverse proxy or load balancer that you control sits in front of the site. ' + 1031 'While the list is empty the plugin checks crawler visits against the address of the connection only ' + 1032 'and never reads the X-Forwarded-For header, which any visitor can forge. Behind a proxy that address ' + 1033 'is the proxy itself, so crawler visits are shown as "declared". When the connection comes from a ' + 1034 'network listed here, the plugin uses the right-most X-Forwarded-For address that is not in the list.' 1035 ), 1036 el( 1037 'button', 1038 { 1039 type: 'button', 1040 className: 'button', 1041 disabled: ! proxiesAllowed || proxies.saved === 'saving', 1042 onClick: saveProxies, 1043 }, 1044 'Save proxies' 1045 ), 1046 proxiesAllowed 1047 ? null 1048 : el( 'p', { className: 'description' }, 'Only an administrator can change the trusted proxies.' ), 1049 proxies.saved === 'saved' ? el( 'p', { role: 'status' }, 'Saved.' ) : null, 1050 proxies.saved === 'saved' && proxies.rejected.length 1051 ? el( 1052 'p', 1053 { className: 'notice notice-warning', role: 'alert' }, 1054 'Not saved, because they are not an IP address or network: ' + proxies.rejected.join( ', ' ) 1055 ) 1056 : null, 1057 proxies.saved === 'saved' && proxies.overLimit 1058 ? el( 1059 'p', 1060 { className: 'notice notice-warning', role: 'alert' }, 1061 'Only the first ' + proxies.limit + ' entries are kept; ' + proxies.overLimit + ' more were not saved.' 1062 ) 1063 : null, 1064 proxies.saved === 'error' 1065 ? el( 1066 'p', 1067 { className: 'notice notice-error' }, 1068 'Could not save' + ( proxies.message ? ': ' + proxies.message : '. Try again.' ) 1069 ) 1070 : null, 950 1071 el( 'h3', null, 'When the plugin is deleted' ), 951 1072 el( … … 983 1104 /* 984 1105 * Экраны бесплатного ядра, и только они. Очередь одобрений сюда не 985 * входит и входить не может: это код уровня Growth, а этот файл едет1106 * входит и входить не может: это платный код Guard, а этот файл едет 986 1107 * в бесплатном zip на wp.org, где реализация платной функции на диске 987 1108 * — отказ на ревью (docs/free-pro-split.md §5). Её рисует свой скрипт -
clearwayforge/trunk/clearwayforge.php
r3720707 r3776286 2 2 3 3 /** 4 * Plugin Name: Clearway Forge for WooCommerce4 * Plugin Name: Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce 5 5 * Plugin URI: https://clearwayforge.com/ 6 6 * Description: See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database. 7 * Version: 1.0. 27 * Version: 1.0.3 8 8 * Requires at least: 6.5 9 9 * Requires PHP: 8.2 … … 38 38 } 39 39 40 const CWFORGE_VERSION = '1.0. 2';40 const CWFORGE_VERSION = '1.0.3'; 41 41 const CWFORGE_MIN_PHP = '8.2'; 42 42 const CWFORGE_MIN_WP = '6.5'; -
clearwayforge/trunk/languages/clearwayforge.pot
r3702012 r3776286 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: Clearway Forge for WooCommerce 0.1.0\n"6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/ clearwayforge\n"5 "Project-Id-Version: Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce 1.0.3\n" 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/plugin\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" 8 8 "Language-Team: LANGUAGE <LL@li.org>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026- 08-26T18:36:47+00:00\n"12 "POT-Creation-Date: 2026-10-30T22:19:42+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 17 17 #. Plugin Name of the plugin 18 18 #: clearwayforge.php 19 msgid "Clearway Forge for WooCommerce"19 msgid "Clearway Forge – AI Traffic Analytics & llms.txt for WooCommerce" 20 20 msgstr "" 21 21 22 22 #. Plugin URI of the plugin 23 23 #: clearwayforge.php 24 msgid "https:// github.com/avakode/clearway-forge"24 msgid "https://clearwayforge.com/" 25 25 msgstr "" 26 26 27 27 #. Description of the plugin 28 28 #: clearwayforge.php 29 msgid "See what AI assistants earn you , make your store readable to them, and keep agent actions under your control."29 msgid "See what AI assistants earn your store, make your catalogue readable to them, and keep the numbers in your own database." 30 30 msgstr "" 31 31 … … 35 35 msgstr "" 36 36 37 #: src/Admin/Menu.php:47 38 #: src/Admin/Menu.php:48 39 #: src/Admin/Menu.php:109 37 #: src/Admin/AgentRevenueScreen.php:83 38 #: src/Admin/Menu.php:101 39 msgid "Revenue by AI agent" 40 msgstr "" 41 42 #. translators: %d: number of days in the longest window the report can show. 43 #: src/Admin/AgentRevenueScreen.php:94 44 #, php-format 45 msgid "Showing the last %d days, the longest window the report can show. Nothing was deleted." 46 msgstr "" 47 48 #: src/Admin/AgentRevenueScreen.php:126 49 msgid "Period" 50 msgstr "" 51 52 #: src/Admin/AgentRevenueScreen.php:139 53 msgid "Show" 54 msgstr "" 55 56 #: src/Admin/AgentRevenueScreen.php:151 57 msgid "AI agent" 58 msgstr "" 59 60 #: src/Admin/AgentRevenueScreen.php:152 61 msgid "Revenue" 62 msgstr "" 63 64 #: src/Admin/AgentRevenueScreen.php:153 65 msgid "Orders" 66 msgstr "" 67 68 #: src/Admin/AgentRevenueScreen.php:154 69 msgid "Sessions" 70 msgstr "" 71 72 #: src/Admin/AgentRevenueScreen.php:155 73 msgid "Conversion" 74 msgstr "" 75 76 #: src/Admin/AgentRevenueScreen.php:156 77 msgid "Average order" 78 msgstr "" 79 80 #: src/Admin/AgentRevenueScreen.php:157 81 msgid "Share" 82 msgstr "" 83 84 #: src/Admin/AgentRevenueScreen.php:167 85 msgid "No AI-attributed orders in this period yet." 86 msgstr "" 87 88 #: src/Admin/AgentRevenueScreen.php:187 89 msgid "Total" 90 msgstr "" 91 92 #. translators: %s: revenue proven by a referrer or a campaign tag. 93 #: src/Admin/AgentRevenueScreen.php:194 94 #, php-format 95 msgid "%s proven by a referrer or a tag" 96 msgstr "" 97 98 #: src/Admin/AgentRevenueScreen.php:203 99 msgid "Refunded" 100 msgstr "" 101 102 #: src/Admin/AgentRevenueScreen.php:205 103 msgid "Already taken off the revenue above." 104 msgstr "" 105 106 #. translators: 1: start date, 2: end date, 3: IANA time zone name. 107 #: src/Admin/AgentRevenueScreen.php:214 108 #, php-format 109 msgid "Computed by your own store, %1$s to %2$s in %3$s." 110 msgstr "" 111 112 #: src/Admin/AgentRevenueScreen.php:220 113 msgid "Nothing left your server to produce it." 114 msgstr "" 115 116 #: src/Admin/Menu.php:86 117 #: src/Admin/Menu.php:200 40 118 msgid "AI Channel" 41 119 msgstr "" 42 120 43 #: src/Admin/Menu.php:58 44 #: src/Admin/Menu.php:114 121 #: src/Admin/Menu.php:91 122 msgid "Measurement" 123 msgstr "" 124 125 #: src/Admin/Menu.php:102 126 msgid "Revenue by agent" 127 msgstr "" 128 129 #: src/Admin/Menu.php:110 130 #: src/Admin/Menu.php:205 45 131 msgid "AI Channel settings" 46 132 msgstr "" 47 133 48 #: src/Admin/Menu.php: 59134 #: src/Admin/Menu.php:111 49 135 msgid "Settings" 50 136 msgstr "" 51 137 52 #: src/Admin/Menu.php:67 138 #: src/Admin/Menu.php:119 139 #: src/Admin/Menu.php:211 140 msgid "AI Channel health" 141 msgstr "" 142 53 143 #: src/Admin/Menu.php:120 54 msgid "AI Channel health"55 msgstr ""56 57 #: src/Admin/Menu.php:6858 144 msgid "Health" 59 145 msgstr "" 60 146 61 #: src/Admin/Menu.php:136 147 #: src/Admin/Menu.php:131 148 msgid "AI Channel catalog" 149 msgstr "" 150 151 #: src/Admin/Menu.php:132 152 #: src/Admin/Menu.php:217 153 msgid "Catalog" 154 msgstr "" 155 156 #: src/Admin/Menu.php:141 157 msgid "AI Channel agents" 158 msgstr "" 159 160 #: src/Admin/Menu.php:142 161 #: src/Admin/Menu.php:223 162 msgid "Agents" 163 msgstr "" 164 165 #: src/Admin/Menu.php:274 166 msgid "The Clearway Forge add-on has not added any screens here yet." 167 msgstr "" 168 169 #: src/Admin/Menu.php:304 170 msgid "Catalog needs an active WooCommerce store." 171 msgstr "" 172 173 #: src/Admin/Menu.php:305 174 msgid "Feed drift and SKU tasks come with the separate Clearway Forge add-on." 175 msgstr "" 176 177 #: src/Admin/Menu.php:309 178 msgid "Agents needs an active WooCommerce store." 179 msgstr "" 180 181 #: src/Admin/Menu.php:310 182 msgid "Agent keys, scopes and the journal come with the separate Clearway Forge add-on." 183 msgstr "" 184 185 #: src/Admin/Menu.php:334 186 #: src/Admin/Menu.php:393 187 msgid "About the add-on" 188 msgstr "" 189 190 #: src/Admin/Menu.php:367 62 191 msgid "Loading your AI channel data..." 63 192 msgstr "" 64 193 65 #: src/Admin/Menu.php:155 66 msgid "Available in the paid add-on" 67 msgstr "" 68 69 #: src/Admin/Menu.php:162 70 msgid "(active)" 71 msgstr "" 72 73 #: src/Admin/Menu.php:167 74 msgid "What the add-on adds" 75 msgstr "" 76 77 #. translators: %d: maximum number of days available without the paid add-on. 78 #: src/Infra/Rest/Routes.php:143 79 #, php-format 80 msgid "Reporting windows longer than %d days need the paid add-on." 81 msgstr "" 194 #: src/Admin/Menu.php:385 195 msgid "In the separate Clearway Forge add-on" 196 msgstr "" 197 198 #: src/Admin/OrdersColumn.php:99 199 #: src/Admin/OrdersColumn.php:105 200 msgid "AI channel" 201 msgstr "" 202 203 #. translators: %d: largest reporting window the plugin accepts. 204 #: src/Infra/Rest/Routes.php:189 205 #, php-format 206 msgid "Reporting windows longer than %d days are not supported." 207 msgstr "" -
clearwayforge/trunk/readme.txt
r3774326 r3776286 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.2 7 Stable tag: 1.0. 27 Stable tag: 1.0.3 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 168 168 == Changelog == 169 169 170 = 1.0.3 = 171 * Revenue is shown net of refunds. The dashboard and Revenue by agent take a refund off the order's own period and show the refunded total on its own line. Refunds made before the update are counted in the background, 200 orders at a time. 172 * Changing the attribution model no longer hides your history: each order stays under the model it was recorded with, and the new model applies to orders from then on. 173 * Amounts follow your store's number of price decimals (zero-decimal currencies included) in the dashboard and Revenue by agent. The privacy export shows the attributed amount as an amount instead of a raw count of cents. 174 * llms.txt and llms-full.txt: page-cache plugins are told not to store them (the DONOTCACHEPAGE constant, and a no-cache header for LiteSpeed Cache). Both documents now share one freshness of 15 minutes: Cache-Control said 1 hour and the server copy of llms.txt lived 6 hours. Saving a product still clears the server copy. 175 * llms.txt and llms-full.txt on Polylang and WPML sites list one product per translation group, in your default language. Without a multilingual plugin nothing changes. 176 * llms-full.txt lists up to 500 products instead of repeating the short list, says "Showing N of M products" when it is cut, and points to the JSON catalogue feed when the paid add-on serves it. 177 * On a multisite network with subdirectories, /sub/llms.txt now returns that site's own document. 178 * A listener on the `clearwayforge/ai_order` hook that throws no longer breaks checkout. The exception is written as one line to WooCommerce > Status > Logs (source clearwayforge) by class name and the folder of the plugin it came from, with no message and no order data, and the order goes through. 179 * New setting, Trusted reverse proxies: crawler address verification reads X-Forwarded-For for requests that arrive from a proxy on your list and ignores it for every other request. The list is empty by default, so the header is ignored. Changing it needs an administrator. IPv4 addresses carried in IPv6 notation now match the IPv4 vendor ranges. 180 * Health: the UTC timezone warning no longer misses sites whose timezone is set by name. 181 * The retention clean-up job is put back in the queue from the admin when activation missed it (an FTP update, a migrated database). 182 * Deleting the plugin before the paid add-on keeps your "delete all data" choice for the add-on, so its tables go too. 183 170 184 = 1.0.2 = 171 185 * llms.txt and llms-full.txt show prices, names and descriptions as plain characters: a dollar price carried the HTML entity for the "$" sign instead of the sign itself, and a sale price repeated itself. A sale now reads the way WooCommerce says it to screen readers ("Original price was: $150.00. Current price is: $129.00."). The documents cached by 1.0.1 are not served after the update. -
clearwayforge/trunk/src/Admin/AgentRevenueScreen.php
r3702012 r3776286 91 91 esc_html( 92 92 sprintf( 93 /* translators: %d: number of days the free plugincan show. */93 /* translators: %d: number of days in the longest window the report can show. */ 94 94 __( 95 'Showing the last %d days . Longer windows need the paid add-on; nothing was deleted.',95 'Showing the last %d days, the longest window the report can show. Nothing was deleted.', 96 96 'clearwayforge' 97 97 ), … … 196 196 ) 197 197 )); 198 echo '</tr></tfoot></table>'; 198 echo '</tr>'; 199 // Выручка в таблице уже за вычетом возвратов; сумма возвратов стоит 200 // отдельной строкой (BUG-CLEAR-MISC-01, п. 14). 201 printf( 202 '<tr class="cwforge-refunded"><th scope="row">%s</th><td>%s</td><td colspan="5">%s</td></tr>', 203 esc_html__('Refunded', 'clearwayforge'), 204 esc_html($totals['refunded']), 205 esc_html__('Already taken off the revenue above.', 'clearwayforge') 206 ); 207 echo '</tfoot></table>'; 199 208 200 209 printf( -
clearwayforge/trunk/src/Admin/Assets.php
r3702012 r3776286 27 27 namespace ClearwayForge\Admin; 28 28 29 use ClearwayForge\Infra\Report\StoreMinorUnit; 29 30 use ClearwayForge\Infra\Rest\Routes; 30 31 … … 55 56 'nonce' => wp_create_nonce('wp_rest'), 56 57 'currency' => get_woocommerce_currency(), 58 // Знаков после запятой у записанных сумм: `money()` ставит точку по 59 // ним, а не делением на сто (BUG-CLEAR-ATTR-02). 60 'minorUnit' => StoreMinorUnit::current(), 57 61 'timezone' => wp_timezone_string(), 58 62 'settingsUrl' => esc_url_raw(admin_url('admin.php?page=cwforge-settings')), -
clearwayforge/trunk/src/Domain/Attribution/Allocation.php
r3702012 r3776286 3 3 /** 4 4 * Доля заказа, приписанная одному касанию. 5 * 6 * ── Возврат (BUG-CLEAR-MISC-01, п. 14) ─────────────────────────────────── 7 * 8 * `$amount` — сумма доли в момент заказа, она не меняется: движок атрибуции 9 * считает её один раз. Возврат приходит позже и хранится рядом 10 * (`$refundedCents`, доля возврата, пришедшаяся на эту строку): отчёты 11 * показывают выручку за вычетом возврата (`netCents()`), а сам возврат 12 * отдельной строкой. 5 13 */ 6 14 … … 16 24 public readonly Touch $touch, 17 25 public readonly Money $amount, 18 public readonly int $weight 26 public readonly int $weight, 27 public readonly int $refundedCents = 0 19 28 ) { 20 29 } 30 31 /** 32 * Возврат в пределах доли: вернуть больше, чем заплачено за долю, нельзя, 33 * поэтому лишнее (повторный разбор с меньшей суммой, ошибка округления) 34 * отрезается, а выручка не уходит в минус. 35 */ 36 public function effectiveRefundCents(): int 37 { 38 return max(0, min($this->refundedCents, $this->amount->cents)); 39 } 40 41 /** Доля за вычетом возврата — то, что попадает в выручку. */ 42 public function netCents(): int 43 { 44 return $this->amount->cents - $this->effectiveRefundCents(); 45 } 21 46 } -
clearwayforge/trunk/src/Domain/Attribution/AttributionModel.php
r3702012 r3776286 4 4 * Как делить заказ между касаниями. 5 5 * 6 * Free-тариф получает только `LastTouch`, и это не искусственное урезание: 7 * мульти-тач осмыслен, когда истории накопилось хотя бы на месяц, а тридцать 8 * дней истории — ровно граница free. 6 * Все четыре модели входят в бесплатный плагин (readme на wp.org обещает это 7 * прямо), и платный аддон их не отнимает. Модель, выбранная в настройках, 8 * действует на заказы с момента выбора: каждый заказ записывается под ней и 9 * потом читается под ней же, задним числом отчёты не пересчитываются 10 * (`TouchRepository`, `DashboardAssembler`). 9 11 */ 10 12 -
clearwayforge/trunk/src/Domain/Llms/StoreProfile.php
r3702012 r3776286 19 19 * @param list<LlmsLink> $policies 20 20 * @param list<LlmsLink> $pages 21 * @param ?int $productsTotal Сколько товаров в каталоге всего. `products` 22 * — только та часть, что загружена; без этого 23 * числа документ не может сказать, что обрезан. 21 24 */ 22 25 public function __construct( … … 30 33 public readonly string $currency = 'USD', 31 34 public readonly ?string $shippingNote = null, 32 public readonly ?string $returnsNote = null 35 public readonly ?string $returnsNote = null, 36 public readonly ?int $productsTotal = null 33 37 ) { 34 38 } -
clearwayforge/trunk/src/Domain/Report/AgentRevenueTable.php
r3702012 r3776286 32 32 { 33 33 /** 34 * `revenue_cents` — за вычетом возвратов, `refunded_cents` — сами возвраты 35 * по этому источнику (BUG-CLEAR-MISC-01, п. 14). 36 * 34 37 * @return list<array{agent:string,label:string,orders:int,sessions:int,revenue_cents:int, 35 * revenue:string, aov:string,aov_cents:int,conversion:string,conversion_bp:int,36 * share:string,share_bp:int}>38 * revenue:string,refunded_cents:int,refunded:string,aov:string,aov_cents:int, 39 * conversion:string,conversion_bp:int,share:string,share_bp:int}> 37 40 */ 38 41 public static function rows(DashboardReport $report): array … … 47 50 'sessions' => $row->sessions, 48 51 'revenue_cents' => $row->revenueCents, 49 'revenue' => MoneyText::format($row->revenueCents, $report->currency), 50 'aov' => MoneyText::format($row->aovCents(), $report->currency), 52 'revenue' => MoneyText::format($row->revenueCents, $report->currency, $report->minorUnit), 53 'refunded_cents' => $row->refundedCents, 54 'refunded' => MoneyText::format($row->refundedCents, $report->currency, $report->minorUnit), 55 'aov' => MoneyText::format($row->aovCents(), $report->currency, $report->minorUnit), 51 56 'aov_cents' => $row->aovCents(), 52 57 'conversion' => MoneyText::percent($row->conversionBp()), … … 68 73 * молча чинить. 69 74 * 70 * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string,proven:string} 75 * @return array{orders:int,sessions:int,revenue_cents:int,rows_cents:int,revenue:string, 76 * refunded_cents:int,refunded:string,proven:string} 71 77 */ 72 78 public static function totals(DashboardReport $report): array … … 82 88 'revenue_cents' => $report->revenueCents, 83 89 'rows_cents' => $rowsCents, 84 'revenue' => MoneyText::format($report->revenueCents, $report->currency), 85 'proven' => MoneyText::format($report->provenRevenueCents, $report->currency), 90 'revenue' => MoneyText::format($report->revenueCents, $report->currency, $report->minorUnit), 91 'refunded_cents' => $report->refundedCents, 92 'refunded' => MoneyText::format($report->refundedCents, $report->currency, $report->minorUnit), 93 'proven' => MoneyText::format($report->provenRevenueCents, $report->currency, $report->minorUnit), 86 94 ]; 87 95 } -
clearwayforge/trunk/src/Domain/Report/DashboardBuilder.php
r3702012 r3776286 24 24 * @param list<Touch> $sessions По одному касанию на сессию окна. 25 25 * @param list<CrawlerRow> $crawlers Агрегаты заходов ботов за окно. 26 * @param int $minorUnit Знаков после запятой у записанных сумм 27 * (`wc_get_price_decimals()`), отчёт только несёт их до печати. 26 28 */ 27 29 public function build( … … 30 32 array $sessions, 31 33 array $crawlers = [], 32 string $currency = 'USD' 34 string $currency = 'USD', 35 int $minorUnit = 2 33 36 ): DashboardReport { 34 37 $orders = array_values(array_filter($orders, static fn (AttributedOrder $o): bool => $window->contains($o->atMs))); … … 38 41 $provenRevenueBySource = []; 39 42 $ordersBySource = []; 43 $refundedBySource = []; 40 44 $totalRevenue = 0; 41 45 $provenRevenue = 0; 46 $totalRefunded = 0; 42 47 $attributedOrders = 0; 43 48 … … 54 59 foreach ($order->result->allocations as $allocation) { 55 60 $key = $allocation->touch->source->value; 56 $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $allocation->amount->cents; 57 $totalRevenue += $allocation->amount->cents; 61 // Выручка — за вычетом возврата по этой доле (BUG-CLEAR-MISC-01, 62 // п. 14); сам возврат копится отдельно. 63 $net = $allocation->netCents(); 64 $refunded = $allocation->effectiveRefundCents(); 65 $revenueBySource[$key] = ($revenueBySource[$key] ?? 0) + $net; 66 $refundedBySource[$key] = ($refundedBySource[$key] ?? 0) + $refunded; 67 $totalRevenue += $net; 68 $totalRefunded += $refunded; 58 69 if ($allocation->touch->isProof()) { 59 $provenRevenue += $ allocation->amount->cents;70 $provenRevenue += $net; 60 71 // Та же доказанность, только по строке источника, а не 61 72 // по всему магазину — таблица «Where the money came 62 73 // from» обязана иметь ту же пару observed/estimated, 63 74 // что и заголовок (ревью major-3). 64 $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $ allocation->amount->cents;75 $provenRevenueBySource[$key] = ($provenRevenueBySource[$key] ?? 0) + $net; 65 76 } 66 77 if (!isset($seenSources[$key])) { … … 84 95 } 85 96 86 $bySource = $this->sourceRows($revenueBySource, $ordersBySource, $sessionsBySource, $provenRevenueBySource); 97 $bySource = $this->sourceRows( 98 $revenueBySource, 99 $ordersBySource, 100 $sessionsBySource, 101 $provenRevenueBySource, 102 $refundedBySource 103 ); 87 104 $days = $this->days($window, $orders); 88 105 … … 98 115 recoveredSessions: $recoveredSessions, 99 116 provenRevenueCents: $provenRevenue, 117 refundedCents: $totalRefunded, 118 minorUnit: $minorUnit, 100 119 ); 101 120 } … … 111 130 * @param array<string,int> $provenRevenue Доля `$revenue` за этот источник, 112 131 * доказанная реферером или меткой (ревью major-3). 132 * @param array<string,int> $refunded Возвраты по источнику (уже вычтены из `$revenue`). 113 133 * @return list<SourceRow> 114 134 */ 115 private function sourceRows(array $revenue, array $orders, array $sessions, array $provenRevenue = []): array 116 { 135 private function sourceRows( 136 array $revenue, 137 array $orders, 138 array $sessions, 139 array $provenRevenue = [], 140 array $refunded = [] 141 ): array { 117 142 $present = []; 118 143 foreach (AiSource::ordered() as $source) { … … 153 178 $provenRevenue[$key] ?? 0, 154 179 $observedShares[$i] ?? 0, 180 $refunded[$key] ?? 0, 155 181 ); 156 182 } … … 190 216 } 191 217 $revenue[$key] += array_sum(array_map( 192 static fn (Allocation $a): int => $a-> amount->cents,218 static fn (Allocation $a): int => $a->netCents(), 193 219 $order->result->allocations 194 220 )); … … 197 223 // observed/estimated, что и заголовок (ревью major-3). 198 224 $provenRevenue[$key] += array_sum(array_map( 199 static fn (Allocation $a): int => $a->touch->isProof() ? $a-> amount->cents: 0,225 static fn (Allocation $a): int => $a->touch->isProof() ? $a->netCents() : 0, 200 226 $order->result->allocations 201 227 )); -
clearwayforge/trunk/src/Domain/Report/MoneyText.php
r3702012 r3776286 18 18 * ── Про арифметику ─────────────────────────────────────────────────────── 19 19 * 20 * Её здесь нет. Центы уже целые, деление на сто — это разрез строки, а не21 * деление; `float` не участвует ни на одном шаге (AGENTS.md §3).20 * Её здесь нет. Единицы уже целые, деление на степень десяти — это разрез 21 * строки, а не деление; `float` не участвует ни на одном шаге (AGENTS.md §3). 22 22 * 23 23 * ── Про скудный список символов ────────────────────────────────────────── … … 34 34 final class MoneyText 35 35 { 36 /** Целые центы в строку: `-1234550` и `USD` дают `-$12,345.50`. */ 37 public static function format(int $cents, string $currency = 'USD'): string 36 /** 37 * Целые единицы в строку: `-1234550` и `USD` дают `-$12,345.50`. 38 * 39 * `$decimals` — знаков после запятой у записанной суммы (BUG-CLEAR-ATTR-02): 40 * 1500 иен при нуле знаков печатаются «JPY 1,500», 12345 динаров-миллей при 41 * трёх — «BHD 12.345». Прежняя константа «сто» превращала их в «15.00» и 42 * «123.45». Границы 0–4 — те же, что принимает `Money::fromDecimalString()`. 43 */ 44 public static function format(int $cents, string $currency = 'USD', int $decimals = 2): string 38 45 { 46 $decimals = max(0, min(4, $decimals)); 39 47 $sign = $cents < 0 ? '-' : ''; 40 48 $abs = abs($cents); 49 $text = $sign . self::symbol($currency); 41 50 42 return $sign 43 . self::symbol($currency) 44 . self::group(intdiv($abs, 100)) 51 if ($decimals === 0) { 52 return $text . self::group($abs); 53 } 54 55 $factor = 10 ** $decimals; 56 57 return $text 58 . self::group(intdiv($abs, $factor)) 45 59 . '.' 46 . str_pad((string) ($abs % 100), 2, '0', STR_PAD_LEFT);60 . str_pad((string) ($abs % $factor), $decimals, '0', STR_PAD_LEFT); 47 61 } 48 62 -
clearwayforge/trunk/src/Domain/Report/SourceRow.php
r3702012 r3776286 50 50 public readonly int $provenRevenueCents = 0, 51 51 /** Доля в базисных пунктах, посчитанная по весам `provenRevenueCents`, а не по `revenueCents`. */ 52 public readonly int $observedShareBp = 0 52 public readonly int $observedShareBp = 0, 53 /** Возвраты по заказам этого источника; уже вычтены из `revenueCents`. */ 54 public readonly int $refundedCents = 0 53 55 ) { 54 56 } … … 113 115 'observed_aov_cents' => $this->observedAovCents(), 114 116 'observed_share_bp' => $this->observedShareBp, 117 // BUG-CLEAR-MISC-01, п. 14: `revenue_cents` выше уже за вычетом 118 // возвратов, а сам возврат — здесь, отдельным числом. 119 'refunded_cents' => $this->refundedCents, 115 120 ]; 116 121 } -
clearwayforge/trunk/src/Infra/Attribution/OrderObserver.php
r3702012 r3776286 140 140 } 141 141 142 /** 143 * Заказ засчитан ИИ-каналу. 144 * 145 * Срабатывает один раз на заказ — не на касание, — сразу после того, 146 * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с 147 * наибольшей долей; полная раскладка мультитач-заказа едет в 148 * `$attribution['sources']`. 149 * 150 * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё, 151 * прочитал бы таблицу без этого заказа и решил, что его нет. 152 * 153 * @since 1.0.0 154 * 155 * @param int $order_id Идентификатор заказа WooCommerce. 156 * @param string $agent Значение AiSource: 'chatgpt', 'perplexity'. 157 * @param array<string,mixed> $attribution Модель, валюта, суммы в целых 158 * центах и раскладка по источникам. 142 /* 143 * Хук — побочный: чужой код атрибуции не должен стоить магазину 144 * продажи. `do_action()` не ловит исключений слушателей, а мы стоим 145 * внутри оформления заказа (после записи разбора, до оплаты), поэтому 146 * без `try` ошибка чужого слушателя дошла бы до покупателя как 147 * «не удалось оформить заказ». Ловим всё, что бросается, включая 148 * `\Error` (несовпавшая сигнатура слушателя). 159 149 */ 160 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает 161 do_action(AiOrderEvent::HOOK, ...$event->hookArgs()); 150 try { 151 /** 152 * Заказ засчитан ИИ-каналу. 153 * 154 * Срабатывает один раз на заказ — не на касание, — сразу после того, 155 * как разбор лёг в таблицу. Аргумент `$agent` несёт источник с 156 * наибольшей долей; полная раскладка мультитач-заказа едет в 157 * `$attribution['sources']`. 158 * 159 * Хук идёт ПОСЛЕ записи намеренно: слушатель, разбуженный до неё, 160 * прочитал бы таблицу без этого заказа и решил, что его нет. 161 * 162 * Гарантия слушателям (CLEAR-ATTR-15): A listener exception does not 163 * break checkout. The observer catches any `\Throwable`, writes one 164 * WooCommerce log line (level `warning`, source `clearwayforge`; see 165 * WooCommerce > Status > Logs) with the hook name, the exception class 166 * and the plugin folder (or file name) it came from, and nothing else 167 * (no message, order number, email or amounts), and the order goes 168 * through. 169 * The listeners after the failing one are not called on that call, 170 * because the exception stops `do_action()` for that firing. A 171 * listener must therefore be safe to skip and must not rely on being 172 * last; one that must not be skipped belongs first in the priority 173 * order or has to catch its own errors. 174 * 175 * @since 1.0.0 176 * 177 * @param int $order_id Идентификатор заказа WooCommerce. 178 * @param string $agent Значение AiSource: 'chatgpt', 'perplexity'. 179 * @param array<string,mixed> $attribution Модель, валюта, суммы в целых 180 * центах и раскладка по источникам. 181 */ 182 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- имя в константе: AiOrderEvent::HOOK = 'clearwayforge/ai_order', префикс на месте, но статически WPCS его не разворачивает 183 do_action(AiOrderEvent::HOOK, ...$event->hookArgs()); 184 } catch (\Throwable $listenerError) { 185 /* 186 * Одна строка: имя хука, класс исключения и виновник — папка 187 * плагина, из файла которого оно прилетело (первый сегмент пути 188 * под WP_PLUGIN_DIR), иначе имя файла. Ни текста исключения 189 * (в нём чужой код может держать номер заказа или почту), ни 190 * аргументов хука, ни полного пути. Имя класса анонимного 191 * исключения (`RuntimeException@anonymous\0<файл>:<строка>$0`) — 192 * до байта NUL: после него идут полный путь и номер строки, а 193 * байту NUL в строке лога не место. 194 * 195 * Строка идёт в журнал WooCommerce (WooCommerce > Status > Logs, 196 * источник `clearwayforge`), а не в error_log: в пакет wordpress.org 197 * вызовы error_log не попадают. Без WooCommerce заказов нет, и 198 * писать нечего. 199 */ 200 if (function_exists('wc_get_logger')) { 201 try { 202 wc_get_logger()->warning( 203 sprintf( 204 '[clearwayforge] a listener on %s threw %s in %s; the order was not affected.', 205 AiOrderEvent::HOOK, 206 explode("\0", $listenerError::class, 2)[0], 207 self::listenerSource($listenerError->getFile(), defined('WP_PLUGIN_DIR') ? (string) constant('WP_PLUGIN_DIR') : null) 208 ), 209 ['source' => 'clearwayforge'] 210 ); 211 } catch (\Throwable) { 212 // Сломанный обработчик журнала (WooCommerce даёт их добавлять другим плагинам) не отменяет гарантию выше: заказ всё равно проходит. 213 } 214 } 215 } 216 } 217 218 /** 219 * Кто бросил исключение, для строки лога: имя папки плагина, если файл 220 * лежит под каталогом плагинов (первый сегмент пути после него; плагин из 221 * одного файла — имя этого файла), иначе имя файла. Каталог плагинов 222 * проверяется и как задан, и по `realpath()`: он может быть символической 223 * ссылкой, а PHP отдаёт путь файла уже раскрытым. Полный путь в лог не 224 * попадает. 225 * 226 * @internal Открыт только ради теста. 227 */ 228 public static function listenerSource(string $file, ?string $pluginDir): string 229 { 230 $file = str_replace('\\', '/', $file); 231 232 $roots = []; 233 if ($pluginDir !== null && $pluginDir !== '') { 234 $roots[] = $pluginDir; 235 $real = realpath($pluginDir); 236 if ($real !== false) { 237 $roots[] = $real; 238 } 239 } 240 241 foreach ($roots as $root) { 242 $root = rtrim(str_replace('\\', '/', $root), '/') . '/'; 243 if (strpos($file, $root) !== 0) { 244 continue; 245 } 246 $first = explode('/', substr($file, strlen($root)), 2)[0]; 247 if ($first !== '') { 248 return $first; 249 } 250 } 251 252 return basename($file); 162 253 } 163 254 -
clearwayforge/trunk/src/Infra/Collect/VisitCollector.php
r3702012 r3776286 153 153 * Адрес для проверки сети краулера. 154 154 * 155 * Читается только `REMOTE_ADDR`: заголовки вроде `X-Forwarded-For` 156 * подделываются кем угодно, и поверить им означало бы дать любому 157 * скраперу способ объявить себя проверенным GPTBot одной строкой. За 158 * обратным прокси адрес будет неверным — и тогда классификатор честно 159 * скажет `declared` вместо `verified`, что лучше, чем ложное `verified`. 155 * Читается `REMOTE_ADDR`, а заголовки прокси (`X-Forwarded-For`) — только 156 * если владелец назвал доверенные прокси в настройках, и по правилам 157 * `RequestAddress`. Без списка заголовок не читается никогда: 158 * подделываемый кем угодно, он давал бы любому скраперу способ объявить 159 * себя проверенным GPTBot одной строкой. За обратным прокси без списка 160 * адрес будет неверным — и классификатор честно скажет `declared` вместо 161 * `verified`, что лучше, чем ложное `verified`. 160 162 */ 161 163 private function trustedAddress(): ?string 162 164 { 163 if (!isset($_SERVER['REMOTE_ADDR']) || !is_string($_SERVER['REMOTE_ADDR'])) { 164 return null; 165 } 166 167 $address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])); 168 169 return $address === '' ? null : $address; 165 return RequestAddress::fromServer($_SERVER); 170 166 } 171 167 -
clearwayforge/trunk/src/Infra/Db/Schema.php
r3702012 r3776286 36 36 * Версия схемы. Инкрементируется при любом изменении определений ниже; 37 37 * `Migrator` сравнивает её с сохранённой и решает, звать ли `dbDelta`. 38 * 39 * 2 — `touches.refunded_cents` (BUG-CLEAR-MISC-01, п. 14): возврат по 40 * заказу хранится рядом с суммой доли, отчёты вычитают его при чтении. 38 41 */ 39 public const VERSION = 1;42 public const VERSION = 2; 40 43 41 44 /** Общий префикс таблиц плагина поверх префикса WordPress. */ … … 115 118 * сменить, и старые отчёты обязаны остаться такими, какими он их 116 119 * видел, иначе выручка прошлого месяца меняется задним числом. 120 * 121 * `refunded_cents` — доля возврата, пришедшаяся на эту строку 122 * (`TouchRepository::setOrderRefund()`). Сумма доли остаётся 123 * как была, отчёты вычитают возврат при чтении: возврат уменьшает 124 * заказ, к которому сделан, в периоде самого заказа. 117 125 */ 118 126 'touches' => "CREATE TABLE {$p}touches ( … … 125 133 model VARCHAR(16) NOT NULL DEFAULT 'last', 126 134 amount_cents BIGINT(20) NOT NULL DEFAULT 0, 135 refunded_cents BIGINT(20) NOT NULL DEFAULT 0, 127 136 currency CHAR(3) NOT NULL DEFAULT 'USD', 128 137 captured_ms BIGINT(20) UNSIGNED NOT NULL, -
clearwayforge/trunk/src/Infra/Health/HealthReport.php
r3702012 r3776286 107 107 [ 108 108 'label' => 'Site timezone', 109 'problem' => (wp_timezone_string() === 'UTC' && get_option('gmt_offset') === '0') 110 ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.' 111 : null, 109 'problem' => self::timezoneProblem(wp_timezone_string(), get_option('gmt_offset')), 112 110 ], 113 111 ]; 112 } 113 114 /** 115 * Предупреждение о поясе UTC — или `null`, если сайт считает сутки по местному поясу. 116 * 117 * Смещение сравнивается числом, а не строкой: на сайте с поясом по названию ядро 118 * отвечает на `get_option('gmt_offset')` через `pre_option_gmt_offset` и отдаёт 119 * `float 0.0`, а не `'0'`, и строгое сравнение со строкой скрывало предупреждение 120 * на каждом таком сайте (BUG-CLEAR-DASH-01). 121 * 122 * @param mixed $gmtOffset Значение `get_option('gmt_offset')`: число, строка или `false`. 123 */ 124 public static function timezoneProblem(string $timezone, mixed $gmtOffset): ?string 125 { 126 $offset = is_int($gmtOffset) || is_float($gmtOffset) || is_string($gmtOffset) ? (float) $gmtOffset : 0.0; 127 128 return $timezone === 'UTC' && $offset === 0.0 129 ? 'The site timezone is UTC. Daily figures will be grouped in UTC, not in your local day.' 130 : null; 114 131 } 115 132 -
clearwayforge/trunk/src/Infra/Llms/LlmsRoutes.php
r3720707 r3776286 16 16 * кеша каждый заход бота собирал бы полный список категорий и товаров — 17 17 * несколько запросов к базе на каждый из тысяч заходов в сутки. 18 * 19 * Страничные кеши плагинов (WP Super Cache, LiteSpeed Cache и другие) 20 * документ не держат, а всё, что стоит перед сайтом, держит его 21 * `DocumentCaching::MAX_AGE_SECONDS` (15 минут, как `catalog.json`). Чужие 22 * кеши мы не чистим (NEIGH-04). 18 23 */ 19 24 … … 23 28 24 29 use ClearwayForge\Domain\Llms\LlmsGenerator; 30 use ClearwayForge\Infra\Pro\Bridge; 25 31 use ClearwayForge\Infra\Support\Options; 26 32 … … 34 40 * 35 41 * Число в конце ключа — версия того, как собирается документ. Меняется 36 * сборка — меняется число: иначе обновлённый сайт до шести часовотдаёт42 * сборка — меняется число: иначе обновлённый сайт до пятнадцати минут отдаёт 37 43 * текст, собранный прошлой версией, а сторож дрейфа принимает разницу 38 44 * между старым кешем и новой сборкой за изменение каталога. 2 — 1.0.2: 39 * цены и названия без HTML-сущностей. 40 */ 41 public const CACHE_MAP = 'cwforge_llms_map_2'; 42 public const CACHE_FULL = 'cwforge_llms_full_2'; 43 public const CACHE_TTL = 6 * HOUR_IN_SECONDS; 45 * цены и названия без HTML-сущностей. 3 — полная выгрузка грузит до 46 * `LlmsGenerator::FULL_PRODUCT_LIMIT` товаров, а обе пишут «Showing N of 47 * M products», когда каталог больше окна (BUG-CLEAR-MISC-01). 4 (только 48 * полная выгрузка) — она называет адрес машинного фида, когда магазин его 49 * отдаёт, и без обрезки (BUG-CLEAR-LLMS-02). 50 */ 51 public const CACHE_MAP = 'cwforge_llms_map_3'; 52 public const CACHE_FULL = 'cwforge_llms_full_4'; 53 54 /** 55 * Серверный кеш живёт столько же, сколько документ обещает всем, кто стоит 56 * перед сайтом: одна константа `DocumentCaching::MAX_AGE_SECONDS` (900 с) 57 * на `llms.txt`, `llms-full.txt` и `catalog.json`. Сохранение товара кеш 58 * сбрасывает сразу (`invalidate()`), срок — страховка от правок мимо 59 * сохранения (импорт, прямая запись в базу). 60 */ 61 public const CACHE_TTL = DocumentCaching::MAX_AGE_SECONDS; 62 63 /** 64 * Хук аддона: набор платных возможностей изменился (активация, окончание, 65 * освобождение лицензии). Ядро о лицензиях не знает и только сбрасывает кеш. 66 */ 67 public const PAID_FEATURES_CHANGED = 'cwforge_paid_features_changed'; 44 68 45 69 /** Ключи прошлых версий сборки: их чистит удаление плагина. */ 46 public const RETIRED_CACHE_KEYS = ['cwforge_llms_map', 'cwforge_llms_full']; 70 public const RETIRED_CACHE_KEYS = [ 71 'cwforge_llms_map', 72 'cwforge_llms_full', 73 'cwforge_llms_map_2', 74 'cwforge_llms_full_2', 75 'cwforge_llms_full_3', 76 ]; 47 77 48 78 public function register(): void … … 53 83 add_action('woocommerce_update_product', [$this, 'invalidate']); 54 84 add_action('update_option_' . Options::NAME, [$this, 'invalidate']); 85 // Строка про машинный фид зависит от лицензии аддона (`feedUrl()`): аддон сообщает 86 // об изменении набора платных возможностей, и документ собирается заново. 87 add_action(self::PAID_FEATURES_CHANGED, [$this, 'invalidate']); 55 88 } 56 89 … … 66 99 $path = (string) (wp_parse_url($requestUri, PHP_URL_PATH) ?? '/'); 67 100 } 68 $path = '/' . ltrim($path, '/'); 101 // Дочерний сайт мультисайта с подпапками живёт по `/sub/`, и его документ — `/sub/llms.txt` 102 // (BUG-CLEAR-LLMS-01): путь главной страницы этого сайта снимается до сравнения. 103 $homePath = (string) (wp_parse_url(home_url('/'), PHP_URL_PATH) ?? '/'); 104 105 $document = self::documentFor($path, $homePath); 106 if ($document === 'map') { 107 $this->serve(self::CACHE_MAP, false); 108 } 109 if ($document === 'full') { 110 $this->serve(self::CACHE_FULL, true); 111 } 112 } 113 114 /** 115 * Какой документ просит этот путь: `map` (`llms.txt`), `full` (`llms-full.txt`) или `null`. 116 * 117 * Сравнение точное: ни регистра, ни косой черты в конце, ни вложенных папок. Корневой 118 * `/llms.txt` для сайта с home `/sub/` — документ корневого сайта, а не этого; каталог 119 * корня ему не подставляется. 120 * 121 * @param string $requestPath Путь запроса, как пришёл (`/sub/llms.txt`). 122 * @param string $homePath Путь главной страницы этого сайта: `/` или `/sub/` на мультисайте с подпапками. 123 */ 124 public static function documentFor(string $requestPath, string $homePath): ?string 125 { 126 $path = '/' . ltrim($requestPath, '/'); 127 $home = rtrim('/' . ltrim($homePath, '/'), '/'); 128 129 if ($home !== '') { 130 if (strpos($path, $home . '/') !== 0) { 131 return null; 132 } 133 $path = substr($path, strlen($home)); 134 } 69 135 70 136 if ($path === '/llms.txt') { 71 $this->serve(self::CACHE_MAP, false);137 return 'map'; 72 138 } 73 139 if ($path === '/llms-full.txt') { 74 $this->serve(self::CACHE_FULL, true); 75 } 140 return 'full'; 141 } 142 143 return null; 144 } 145 146 /** 147 * Строки заголовков ответа: тип, длина, запрет угадывания типа и срок жизни. 148 * 149 * Отдельным методом, чтобы заголовки проверялись без вывода и `exit`. 150 * Срок свежести и отказ страничным кешам берутся из `DocumentCaching`: 151 * число одно с `catalog.json` платного аддона. 152 * 153 * @return list<string> 154 */ 155 public static function responseHeaders(int $length): array 156 { 157 return [ 158 'Content-Type: text/plain; charset=utf-8', 159 'Content-Length: ' . $length, 160 'X-Robots-Tag: noindex', 161 // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом. 162 'X-Content-Type-Options: nosniff', 163 'Cache-Control: ' . DocumentCaching::cacheControl(), 164 DocumentCaching::LITESPEED_HEADER . ': ' . DocumentCaching::liteSpeedControl(), 165 ]; 166 } 167 168 /** 169 * Положить собранный документ в серверный кеш на `CACHE_TTL`. 170 * 171 * Отдельным методом, чтобы срок проверялся без вывода и `exit`. 172 */ 173 public static function remember(string $cacheKey, string $body): void 174 { 175 set_transient($cacheKey, $body, self::CACHE_TTL); 76 176 } 77 177 … … 80 180 $body = get_transient($cacheKey); 81 181 if (!is_string($body) || $body === '') { 82 $store = (new StoreProfileBuilder())->build( );83 $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', 50)));182 $store = (new StoreProfileBuilder())->build($full); 183 $generator = new LlmsGenerator(max(1, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT))); 84 184 $document = $full 85 ? $generator->full($store )86 : $generator->map($store, rest_url('cwforge/v1/catalog.json'));185 ? $generator->full($store, self::feedUrl()) 186 : $generator->map($store, self::feedUrl()); 87 187 $body = $document->render(); 88 se t_transient($cacheKey, $body, self::CACHE_TTL);188 self::remember($cacheKey, $body); 89 189 } 90 190 91 191 // Заголовки ставятся до вывода и включают длину: бот, читающий поток 92 192 // без длины, не может отличить обрыв соединения от конца документа. 93 header('Content-Type: text/plain; charset=utf-8'); 94 header('Content-Length: ' . strlen($body)); 95 header('X-Robots-Tag: noindex'); 96 // Без этого браузер вправе «угадать» HTML в тексте из названий товаров; с ним вывод остаётся текстом. 97 header('X-Content-Type-Options: nosniff'); 98 header('Cache-Control: public, max-age=3600'); 193 foreach (self::responseHeaders(strlen($body)) as $line) { 194 header($line); 195 } 196 // Страничные кеши плагинов документ не держат; свежесть для всего, что стоит перед сайтом, — наша (DocumentCaching). 197 DocumentCaching::excludeFromPageCaches(); 99 198 100 199 echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- ответ text/plain с nosniff: HTML-экранирование исказило бы документ для агентов. … … 102 201 } 103 202 203 /** 204 * Адрес машинного фида — только если этот магазин его отдаёт. 205 * 206 * Фид живёт в платном аддоне и закрыт без лицензии. Строка «полный 207 * каталог — по такому-то адресу», ведущая на отказ, хуже её отсутствия. 208 */ 209 private static function feedUrl(): ?string 210 { 211 return Bridge::instance()->isEnabled('catalog-feed') ? rest_url('cwforge/v1/catalog.json') : null; 212 } 213 104 214 public function invalidate(): void 105 215 { -
clearwayforge/trunk/src/Infra/Llms/StoreProfileBuilder.php
r3720707 r3776286 25 25 namespace ClearwayForge\Infra\Llms; 26 26 27 use ClearwayForge\Domain\Llms\LlmsGenerator; 27 28 use ClearwayForge\Domain\Llms\LlmsLink; 28 29 use ClearwayForge\Domain\Llms\PlainText; … … 34 35 final class StoreProfileBuilder 35 36 { 36 public function build(): StoreProfile 37 { 37 /** 38 * @param bool $full Профиль для `/llms-full.txt`: товаров грузится больше 39 * (`LlmsGenerator::fetchLimit()`), а общее число уходит в документ. 40 */ 41 public function build(bool $full = false): StoreProfile 42 { 43 [$products, $total] = $this->products($full); 44 38 45 return new StoreProfile( 39 46 name: PlainText::fromHtml(get_bloginfo('name')), … … 41 48 description: PlainText::fromHtml(get_bloginfo('description')), 42 49 categories: $this->categories(), 43 products: $ this->products(),50 products: $products, 44 51 policies: $this->policies(), 45 52 pages: $this->pages(), … … 47 54 shippingNote: $this->shippingNote(), 48 55 returnsNote: null, 56 productsTotal: $total, 49 57 ); 50 58 } … … 84 92 } 85 93 86 /** @return list<LlmsLink> */ 87 private function products(): array 88 { 89 $limit = max(1, Options::int('llms_product_limit', 50)); 90 91 $products = wc_get_products([ 94 /** @return array{0:list<LlmsLink>,1:?int} Товары окна и сколько их в каталоге всего. */ 95 private function products(bool $full): array 96 { 97 $limit = LlmsGenerator::fetchLimit($full, Options::int('llms_product_limit', LlmsGenerator::MAP_PRODUCT_LIMIT)); 98 99 // Один товар на группу переводов, как в catalog.json (NEIGH-07): `ProductTranslations` — единственное место правила. 100 $result = ProductTranslations::products([ 92 101 'status' => 'publish', 93 102 'limit' => $limit, … … 95 104 'order' => 'DESC', 96 105 'visibility' => 'catalog', 106 'paginate' => true, 97 107 ]); 98 108 109 // С `paginate` WooCommerce отдаёт объект с `products` и `total`; без 110 // него (старые версии, чужие фильтры) — просто список. 111 $total = null; 112 $products = $result; 113 if (is_object($result)) { 114 $products = $result->products ?? []; 115 $total = isset($result->total) ? (int) $result->total : null; 116 } 117 99 118 if (!is_array($products)) { 100 return [ ];119 return [[], null]; 101 120 } 102 121 … … 113 132 } 114 133 115 return $links;134 return [$links, $total]; 116 135 } 117 136 -
clearwayforge/trunk/src/Infra/Report/DashboardAssembler.php
r3702012 r3776286 25 25 * ── Что здесь НЕ происходит ────────────────────────────────────────────── 26 26 * 27 * Возвраты (BUG-CLEAR-MISC-01, п. 14): сумма доли хранится как была, а 28 * возврат по заказу лежит рядом (`refunded_cents`) и приходит в `Allocation`. 29 * Отчёт показывает выручку за вычетом возвратов в периоде самого заказа, 30 * а сумму возвратов — отдельным числом; здесь возврат только передаётся 31 * дальше, вычитает его `DashboardBuilder`. 32 * 27 33 * Пересчёта атрибуции нет: суммы посчитаны в момент заказа и сохранены. 28 34 * Пересчитать их сейчас значило бы применить сегодняшнюю модель к прошлому 29 35 * месяцу и показать владельцу не ту выручку, которую он видел вчера. 36 * 37 * Тем более текущая модель не отбирает строки: заказ читается под той 38 * моделью, под которой записан (`TouchRepository::inWindow()`), и после 39 * смены настройки старые заказы остаются в отчёте (BUG-CLEAR-ATTR-01). 40 * Поэтому `$model` в `assemble()` — не фильтр, а запасное значение для 41 * строки, записанной моделью, которой эта версия не знает. 30 42 */ 31 43 … … 62 74 } 63 75 64 public function assemble(ReportWindow $window, AttributionModel $model, string $currency): DashboardReport 65 { 76 /** 77 * @param int|null $minorUnit Знаков после запятой у записанных сумм. Без 78 * указания — настройка магазина, та же, что 79 * читала запись заказа (BUG-CLEAR-ATTR-02). 80 */ 81 public function assemble( 82 ReportWindow $window, 83 AttributionModel $model, 84 string $currency, 85 ?int $minorUnit = null 86 ): DashboardReport { 66 87 return (new DashboardBuilder())->build( 67 88 $window, … … 70 91 $this->crawlerRows($window), 71 92 $currency, 93 $minorUnit ?? StoreMinorUnit::current(), 72 94 ); 73 95 } … … 81 103 { 82 104 $byOrder = []; 83 foreach ($this->touches->inWindow($fromMs, $toMs , $model->value) as $row) {105 foreach ($this->touches->inWindow($fromMs, $toMs) as $row) { 84 106 $source = AiSource::tryFrom($row['source']); 85 107 $via = RecoveredVia::tryFrom($row['recovered_via']); … … 99 121 $byOrder[$row['order_id']]['at'] = $row['captured_ms']; 100 122 $byOrder[$row['order_id']]['currency'] = $row['currency']; 123 $byOrder[$row['order_id']]['model'] = AttributionModel::tryFrom($row['model']) ?? $model; 101 124 $byOrder[$row['order_id']]['allocations'][] = new Allocation( 102 125 $touch, 103 126 Money::fromCents($row['amount_cents'], $row['currency']), 104 1 127 1, 128 $row['refunded_cents'] 105 129 ); 106 130 } … … 115 139 (int) $orderId, 116 140 (int) $data['at'], 117 new AttributionResult($ model, $data['allocations'], $total)141 new AttributionResult($data['model'], $data['allocations'], $total) 118 142 ); 119 143 } -
clearwayforge/trunk/src/Infra/Repository/TouchRepository.php
r3702012 r3776286 7 7 * модель атрибуции, и отчёт за прошлый месяц обязан остаться таким, каким он 8 8 * его видел. Выручка, меняющаяся задним числом, обесценивает весь дашборд. 9 * 10 * Отсюда правило чтения (BUG-CLEAR-ATTR-01): заказ считается той моделью, под 11 * которой он записан первой, и текущая настройка модели строки не отбирает. 12 * Смена модели действует на заказы с момента смены (`OrderObserver` пишет их 13 * под новой), а прежние заказы остаются в дашборде, Revenue by agent, CSV и 14 * дайджесте с теми же суммами. Если у заказа есть строки под двумя моделями 15 * (повторный разбор), читается набор первой записанной: иначе заказ 16 * посчитался бы дважды. 9 17 */ 10 18 … … 31 39 32 40 use ClearwayForge\Domain\Attribution\AttributionResult; 41 use ClearwayForge\Domain\Report\RefundSplit; 33 42 use ClearwayForge\Infra\Db\Schema; 34 43 … … 88 97 89 98 /** 90 * Разобранные заказы окна. 99 * Разобранные заказы окна — каждый под моделью, с которой он записан. 100 * 101 * Подзапрос берёт модель самой ранней строки заказа (`id` растёт со 102 * вставкой), так что повторный разбор под другой моделью выручку не 103 * удваивает и не подменяет. Уникальный ключ (order_id, source, model) 104 * ведёт подзапрос по префиксу `order_id`. 105 * 106 * `refunded_cents` — доля возврата, пришедшаяся на строку; отчёт вычитает 107 * её из суммы строки в периоде самого заказа (BUG-CLEAR-MISC-01, п. 14). 91 108 * 92 109 * @return list<array{order_id:int,source:string,recovered_via:string,confidence:int, 93 * amount_cents:int, currency:string,captured_ms:int,model:string}>94 */ 95 public function inWindow(int $fromMs, int $toMs , string $model): array110 * amount_cents:int,refunded_cents:int,currency:string,captured_ms:int,model:string}> 111 */ 112 public function inWindow(int $fromMs, int $toMs): array 96 113 { 97 114 global $wpdb; … … 100 117 $rows = $wpdb->get_results( 101 118 $wpdb->prepare( 102 'SELECT order_id, source, recovered_via, confidence, amount_cents, currency, captured_ms, model 103 FROM %i 104 WHERE captured_ms >= %d AND captured_ms <= %d AND model = %s 105 ORDER BY captured_ms ASC', 119 'SELECT t.order_id, t.source, t.recovered_via, t.confidence, t.amount_cents, 120 t.refunded_cents, t.currency, t.captured_ms, t.model 121 FROM %i t 122 WHERE t.captured_ms >= %d AND t.captured_ms <= %d 123 AND t.model = ( 124 SELECT h.model FROM %i h 125 WHERE h.order_id = t.order_id 126 ORDER BY h.id ASC 127 LIMIT 1 128 ) 129 ORDER BY t.captured_ms ASC, t.id ASC', 106 130 $table, 107 131 $fromMs, 108 132 $toMs, 109 $ model133 $table 110 134 ), 111 135 ARRAY_A … … 123 147 'confidence' => (int) $r['confidence'], 124 148 'amount_cents' => (int) $r['amount_cents'], 149 'refunded_cents' => (int) $r['refunded_cents'], 125 150 'currency' => (string) $r['currency'], 126 151 'captured_ms' => (int) $r['captured_ms'], … … 131 156 } 132 157 158 /** Есть ли у заказа разбор: дёшево отсекает возвраты по заказам, которых мы не считали. */ 159 public function hasOrder(int $orderId): bool 160 { 161 global $wpdb; 162 163 $table = Schema::table($this->wpPrefix, 'touches'); 164 165 return $wpdb->get_var($wpdb->prepare('SELECT 1 FROM %i WHERE order_id = %d LIMIT 1', $table, $orderId)) !== null; 166 } 167 168 /** 169 * Записать, сколько по заказу возвращено всего (BUG-CLEAR-MISC-01, п. 14). 170 * 171 * Принимает итог, а не приращение: WooCommerce отвечает «возвращено по 172 * заказу столько», и повторный вызов с тем же числом, удаление возврата и 173 * новый частичный возврат дают верный результат без учёта порядка событий. 174 * Итог делится между строками заказа пропорционально их суммам 175 * (`RefundSplit`) и ложится в те строки, что читает `inWindow()` — 176 * записанные под первой моделью заказа. 177 * 178 * @return int Сколько строк заказа обновлено; 0 — разбора у заказа нет. 179 */ 180 public function setOrderRefund(int $orderId, int $refundedCents): int 181 { 182 global $wpdb; 183 184 $table = Schema::table($this->wpPrefix, 'touches'); 185 $rows = $wpdb->get_results( 186 $wpdb->prepare( 187 'SELECT t.id, t.amount_cents 188 FROM %i t 189 WHERE t.order_id = %d 190 AND t.model = ( 191 SELECT h.model FROM %i h 192 WHERE h.order_id = t.order_id 193 ORDER BY h.id ASC 194 LIMIT 1 195 ) 196 ORDER BY t.id ASC', 197 $table, 198 $orderId, 199 $table 200 ), 201 ARRAY_A 202 ); 203 204 if (!is_array($rows) || $rows === []) { 205 return 0; 206 } 207 208 $parts = RefundSplit::apportion( 209 $refundedCents, 210 array_values(array_map(static fn (array $r): int => (int) $r['amount_cents'], $rows)) 211 ); 212 213 foreach (array_values($rows) as $i => $row) { 214 $wpdb->query( 215 $wpdb->prepare( 216 'UPDATE %i SET refunded_cents = %d WHERE id = %d', 217 $table, 218 $parts[$i], 219 (int) $row['id'] 220 ) 221 ); 222 } 223 224 return count($rows); 225 } 226 227 /** 228 * Следующая пачка различных заказов с разбором, по возрастанию номера. 229 * 230 * Курсором служит сам номер заказа: «всё, что больше последнего 231 * обработанного». Ключ `(order_id, source, model)` отдаёт такую выборку 232 * без сортировки по таблице целиком (досчёт возвратов, п. 14). 233 * 234 * @return list<int> 235 */ 236 public function orderIdsAfter(int $afterOrderId, int $limit): array 237 { 238 global $wpdb; 239 240 $table = Schema::table($this->wpPrefix, 'touches'); 241 $ids = $wpdb->get_col( 242 $wpdb->prepare( 243 'SELECT DISTINCT order_id FROM %i WHERE order_id > %d ORDER BY order_id ASC LIMIT %d', 244 $table, 245 $afterOrderId, 246 max(1, $limit) 247 ) 248 ); 249 250 return is_array($ids) ? array_values(array_map('intval', $ids)) : []; 251 } 252 133 253 public function deleteOrder(int $orderId): int 134 254 { -
clearwayforge/trunk/src/Infra/Rest/Routes.php
r3702012 r3776286 132 132 'required' => false, 133 133 ), 134 'trusted_proxies' => array( 135 'type' => 'string', 136 'required' => false, 137 ), 134 138 'delete_data_on_uninstall' => array( 135 139 'type' => 'boolean', -
clearwayforge/trunk/src/Infra/Rest/SettingsController.php
r3702012 r3776286 26 26 27 27 use ClearwayForge\Domain\Attribution\AttributionModel; 28 use ClearwayForge\Domain\Crawler\TrustedProxies; 28 29 use ClearwayForge\Infra\Scheduler\CrawlerRangeSources; 29 30 use ClearwayForge\Infra\Scheduler\Maintenance; … … 74 75 } 75 76 77 /* 78 * Доверенные прокси (BUG-CLEAR-MISC-01, п. 10): от списка зависит, чьему 79 * слову об адресе верит проверка краулеров. Запись, не являющаяся 80 * адресом или сетью, отбрасывается, остальные сохраняются; отброшенные 81 * возвращаются в ответе по имени (`trusted_proxies_rejected`), чтобы 82 * опечатка не осталась незамеченной. Пустая строка очищает список — и 83 * заголовки прокси снова не читаются. 84 */ 85 $rejectedProxies = []; 86 $overLimitProxies = 0; 87 if ($request->get_param('trusted_proxies') !== null) { 88 if (!current_user_can('manage_options')) { 89 // Список решает, чьему заголовку верить, — это доверие к сети, а 90 // не настройка отчётов: менеджеру магазина (`manage_woocommerce`) 91 // оно не принадлежит. Отказ до любой записи, как у удаления данных. 92 return new WP_REST_Response( 93 ['code' => 'cwforge_forbidden', 'message' => 'Only an administrator can change the trusted proxies.'], 94 403 95 ); 96 } 97 98 $proxies = TrustedProxies::fromText((string) $request->get_param('trusted_proxies')); 99 $values['trusted_proxies'] = $proxies->cidrs(); 100 $rejectedProxies = $proxies->rejected(); 101 $overLimitProxies = count($proxies->overLimit()); 102 } 103 76 104 $deleteOnUninstall = $request->get_param('delete_data_on_uninstall'); 77 105 if ($deleteOnUninstall !== null && !current_user_can('delete_plugins')) { … … 96 124 } 97 125 98 return new WP_REST_Response(self::payload( ), 200);126 return new WP_REST_Response(self::payload($rejectedProxies, $overLimitProxies), 200); 99 127 } 100 128 101 129 /** 130 * @param list<string> $rejectedProxies Записи списка прокси, отброшенные в этом запросе как «не адрес». 131 * @param int $overLimitProxies Сколько годных записей не поместилось в потолок списка в этом запросе. 102 132 * @return array{ 103 133 * show_estimated_revenue:bool, … … 106 136 * crawler_ranges_refresh:bool, 107 137 * crawler_ranges_hosts:list<string>, 138 * trusted_proxies:string, 139 * trusted_proxies_rejected:list<string>, 140 * trusted_proxies_over_limit:int, 141 * trusted_proxies_limit:int, 142 * can_change_proxies:bool, 108 143 * delete_data_on_uninstall:bool, 109 144 * can_change_uninstall:bool 110 145 * } 111 146 */ 112 private static function payload( ): array147 private static function payload(array $rejectedProxies = [], int $overLimitProxies = 0): array 113 148 { 114 149 $current = AttributionModel::tryFrom(Options::string('attribution_model', 'last')) ?? AttributionModel::LastTouch; … … 125 160 // не из текста: согласие даётся на то, что реально произойдёт. 126 161 'crawler_ranges_hosts' => self::hosts((new CrawlerRangeSources())->all()), 162 // Список как текст для поля ввода: по записи на строку. 163 'trusted_proxies' => implode("\n", TrustedProxies::fromStored(Options::get('trusted_proxies', array()))->cidrs()), 164 // Что не прошло при последнем сохранении; на чтении всегда пусто. 165 'trusted_proxies_rejected' => $rejectedProxies, 166 // Годные записи сверх потолка: считаются отдельно, на чтении всегда 0. 167 'trusted_proxies_over_limit' => $overLimitProxies, 168 'trusted_proxies_limit' => TrustedProxies::MAX, 169 'can_change_proxies' => current_user_can('manage_options'), 127 170 'delete_data_on_uninstall' => get_option(Options::DELETE_ON_UNINSTALL) === 'yes', 128 171 'can_change_uninstall' => current_user_can('delete_plugins'), -
clearwayforge/trunk/src/Infra/Scheduler/Maintenance.php
r3720707 r3776286 36 36 public const GROUP = 'clearwayforge'; 37 37 38 /** Метка «очередь проверена»: самолечение не чаще раза в окно, а не на каждый запрос админки. */ 39 public const SCHEDULE_CHECKED = 'cwforge_schedule_checked'; 40 38 41 /** Скачанные сети вендоров. */ 39 42 public const RANGES_OPTION = 'cwforge_crawler_ranges'; … … 54 57 add_action(self::HOOK_PURGE, [$this, 'purge']); 55 58 add_action(self::HOOK_RANGES, [$this, 'refreshRanges']); 59 // Только запросы админки: на витрине лишний вопрос к очереди — это 60 // накладные расходы на каждом хите покупателя (принцип 1 в AGENTS.md). 61 add_action('admin_init', [self::class, 'ensureScheduled']); 62 } 63 64 /** 65 * Вернуть уборку в очередь, если активация её не поставила. 66 * 67 * `scheduleAll()` зовёт только активация, а плагин попадает на сайт и 68 * без неё: обновление по FTP, перенос базы без таблиц Action Scheduler, 69 * очередь, вычищенная чужим инструментом, Action Scheduler, которого не 70 * было при активации. Без задачи `cwforge_purge_old_data` срок хранения 71 * (`retention_days`) тихо перестаёт действовать, и таблицы растут. Ту же 72 * оговорку про FTP несёт `Migrator::migrateIfNeeded()` в `Plugin::wire()`. 73 * 74 * Метка «проверено» ставится только когда очередь доступна: иначе одна 75 * неудачная попытка отключила бы самолечение на всё окно. 76 */ 77 public static function ensureScheduled(): void 78 { 79 if (!function_exists('as_next_scheduled_action')) { 80 return; 81 } 82 83 if (get_transient(self::SCHEDULE_CHECKED) !== false) { 84 return; 85 } 86 87 set_transient(self::SCHEDULE_CHECKED, 1, 12 * HOUR_IN_SECONDS); 88 self::scheduleAll(); 56 89 } 57 90 … … 81 114 82 115 as_unschedule_all_actions(self::HOOK_PURGE, [], self::GROUP); 116 // Досчёт возвратов: поставленная пачка не должна пережить плагин (п. 14). 117 as_unschedule_all_actions(\ClearwayForge\Infra\Attribution\RefundBackfill::HOOK, [], self::GROUP); 83 118 self::unscheduleRanges(); 84 119 } -
clearwayforge/trunk/uninstall.php
r3720707 r3776286 39 39 \ClearwayForge\Infra\Support\Options::NAME, 40 40 \ClearwayForge\Infra\Scheduler\Maintenance::RANGES_OPTION, 41 \ClearwayForge\Infra\ Support\Options::DELETE_ON_UNINSTALL,41 \ClearwayForge\Infra\Attribution\RefundBackfill::OPTION, 42 42 ] as $cwforge_option 43 43 ) { … … 45 45 } 46 46 47 /* 48 * Согласие владельца общее для Free и платного аддона: надпись на экране 49 * настроек — «Delete all Clearway Forge data». Пока на сайте остались 50 * данные аддона (его метка версии схемы), флаг остаётся, иначе аддон, 51 * удаляемый вторым, не найдёт согласия и оставит свои таблицы 52 * (BUG-CLEAR-MISC-01). Аддон уберёт флаг сам, когда уйдёт последним. 53 */ 54 if (get_option('cwforge_pro_schema_version', null) === null) { 55 delete_option(\ClearwayForge\Infra\Support\Options::DELETE_ON_UNINSTALL); 56 } 57 58 delete_transient(\ClearwayForge\Infra\Scheduler\Maintenance::SCHEDULE_CHECKED); 47 59 delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_MAP); 48 60 delete_transient(\ClearwayForge\Infra\Llms\LlmsRoutes::CACHE_FULL); -
clearwayforge/trunk/vendor/composer/autoload_classmap.php
r3720707 r3776286 25 25 'ClearwayForge\\Domain\\Attribution\\SourceDetector' => $baseDir . '/src/Domain/Attribution/SourceDetector.php', 26 26 'ClearwayForge\\Domain\\Attribution\\Touch' => $baseDir . '/src/Domain/Attribution/Touch.php', 27 'ClearwayForge\\Domain\\Crawler\\ClientAddress' => $baseDir . '/src/Domain/Crawler/ClientAddress.php', 27 28 'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => $baseDir . '/src/Domain/Crawler/CrawlerClassifier.php', 28 29 'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => $baseDir . '/src/Domain/Crawler/CrawlerCorpus.php', … … 31 32 'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => $baseDir . '/src/Domain/Crawler/CrawlerKind.php', 32 33 'ClearwayForge\\Domain\\Crawler\\IpRange' => $baseDir . '/src/Domain/Crawler/IpRange.php', 34 'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => $baseDir . '/src/Domain/Crawler/TrustedProxies.php', 33 35 'ClearwayForge\\Domain\\Crawler\\Verification' => $baseDir . '/src/Domain/Crawler/Verification.php', 34 36 'ClearwayForge\\Domain\\Llms\\LlmsBlock' => $baseDir . '/src/Domain/Llms/LlmsBlock.php', … … 52 54 'ClearwayForge\\Domain\\Report\\MoneyText' => $baseDir . '/src/Domain/Report/MoneyText.php', 53 55 'ClearwayForge\\Domain\\Report\\PeriodSelector' => $baseDir . '/src/Domain/Report/PeriodSelector.php', 56 'ClearwayForge\\Domain\\Report\\RefundSplit' => $baseDir . '/src/Domain/Report/RefundSplit.php', 54 57 'ClearwayForge\\Domain\\Report\\ReportWindow' => $baseDir . '/src/Domain/Report/ReportWindow.php', 55 58 'ClearwayForge\\Domain\\Report\\SourceRow' => $baseDir . '/src/Domain/Report/SourceRow.php', … … 63 66 'ClearwayForge\\Infra\\Attribution\\OrderMeta' => $baseDir . '/src/Infra/Attribution/OrderMeta.php', 64 67 'ClearwayForge\\Infra\\Attribution\\OrderObserver' => $baseDir . '/src/Infra/Attribution/OrderObserver.php', 68 'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => $baseDir . '/src/Infra/Attribution/OrderRefunds.php', 65 69 'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => $baseDir . '/src/Infra/Attribution/OrderVerdictWriter.php', 70 'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => $baseDir . '/src/Infra/Attribution/RefundBackfill.php', 71 'ClearwayForge\\Infra\\Attribution\\RefundObserver' => $baseDir . '/src/Infra/Attribution/RefundObserver.php', 66 72 'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => $baseDir . '/src/Infra/Attribution/WcOrderMeta.php', 73 'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => $baseDir . '/src/Infra/Attribution/WcOrderRefunds.php', 74 'ClearwayForge\\Infra\\Collect\\RequestAddress' => $baseDir . '/src/Infra/Collect/RequestAddress.php', 67 75 'ClearwayForge\\Infra\\Collect\\RequestSignals' => $baseDir . '/src/Infra/Collect/RequestSignals.php', 68 76 'ClearwayForge\\Infra\\Collect\\VisitCollector' => $baseDir . '/src/Infra/Collect/VisitCollector.php', … … 73 81 'ClearwayForge\\Infra\\Db\\Schema' => $baseDir . '/src/Infra/Db/Schema.php', 74 82 'ClearwayForge\\Infra\\Health\\HealthReport' => $baseDir . '/src/Infra/Health/HealthReport.php', 83 'ClearwayForge\\Infra\\Llms\\DocumentCaching' => $baseDir . '/src/Infra/Llms/DocumentCaching.php', 75 84 'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => $baseDir . '/src/Infra/Llms/LlmsRoutes.php', 85 'ClearwayForge\\Infra\\Llms\\ProductTranslations' => $baseDir . '/src/Infra/Llms/ProductTranslations.php', 76 86 'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => $baseDir . '/src/Infra/Llms/StoreProfileBuilder.php', 77 87 'ClearwayForge\\Infra\\Plugin' => $baseDir . '/src/Infra/Plugin.php', … … 79 89 'ClearwayForge\\Infra\\Pro\\Bridge' => $baseDir . '/src/Infra/Pro/Bridge.php', 80 90 'ClearwayForge\\Infra\\Report\\DashboardAssembler' => $baseDir . '/src/Infra/Report/DashboardAssembler.php', 91 'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => $baseDir . '/src/Infra/Report/StoreMinorUnit.php', 81 92 'ClearwayForge\\Infra\\Repository\\HitRepository' => $baseDir . '/src/Infra/Repository/HitRepository.php', 82 93 'ClearwayForge\\Infra\\Repository\\SessionRepository' => $baseDir . '/src/Infra/Repository/SessionRepository.php', -
clearwayforge/trunk/vendor/composer/autoload_static.php
r3720707 r3776286 40 40 'ClearwayForge\\Domain\\Attribution\\SourceDetector' => __DIR__ . '/../..' . '/src/Domain/Attribution/SourceDetector.php', 41 41 'ClearwayForge\\Domain\\Attribution\\Touch' => __DIR__ . '/../..' . '/src/Domain/Attribution/Touch.php', 42 'ClearwayForge\\Domain\\Crawler\\ClientAddress' => __DIR__ . '/../..' . '/src/Domain/Crawler/ClientAddress.php', 42 43 'ClearwayForge\\Domain\\Crawler\\CrawlerClassifier' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerClassifier.php', 43 44 'ClearwayForge\\Domain\\Crawler\\CrawlerCorpus' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerCorpus.php', … … 46 47 'ClearwayForge\\Domain\\Crawler\\CrawlerKind' => __DIR__ . '/../..' . '/src/Domain/Crawler/CrawlerKind.php', 47 48 'ClearwayForge\\Domain\\Crawler\\IpRange' => __DIR__ . '/../..' . '/src/Domain/Crawler/IpRange.php', 49 'ClearwayForge\\Domain\\Crawler\\TrustedProxies' => __DIR__ . '/../..' . '/src/Domain/Crawler/TrustedProxies.php', 48 50 'ClearwayForge\\Domain\\Crawler\\Verification' => __DIR__ . '/../..' . '/src/Domain/Crawler/Verification.php', 49 51 'ClearwayForge\\Domain\\Llms\\LlmsBlock' => __DIR__ . '/../..' . '/src/Domain/Llms/LlmsBlock.php', … … 67 69 'ClearwayForge\\Domain\\Report\\MoneyText' => __DIR__ . '/../..' . '/src/Domain/Report/MoneyText.php', 68 70 'ClearwayForge\\Domain\\Report\\PeriodSelector' => __DIR__ . '/../..' . '/src/Domain/Report/PeriodSelector.php', 71 'ClearwayForge\\Domain\\Report\\RefundSplit' => __DIR__ . '/../..' . '/src/Domain/Report/RefundSplit.php', 69 72 'ClearwayForge\\Domain\\Report\\ReportWindow' => __DIR__ . '/../..' . '/src/Domain/Report/ReportWindow.php', 70 73 'ClearwayForge\\Domain\\Report\\SourceRow' => __DIR__ . '/../..' . '/src/Domain/Report/SourceRow.php', … … 78 81 'ClearwayForge\\Infra\\Attribution\\OrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderMeta.php', 79 82 'ClearwayForge\\Infra\\Attribution\\OrderObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderObserver.php', 83 'ClearwayForge\\Infra\\Attribution\\OrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderRefunds.php', 80 84 'ClearwayForge\\Infra\\Attribution\\OrderVerdictWriter' => __DIR__ . '/../..' . '/src/Infra/Attribution/OrderVerdictWriter.php', 85 'ClearwayForge\\Infra\\Attribution\\RefundBackfill' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundBackfill.php', 86 'ClearwayForge\\Infra\\Attribution\\RefundObserver' => __DIR__ . '/../..' . '/src/Infra/Attribution/RefundObserver.php', 81 87 'ClearwayForge\\Infra\\Attribution\\WcOrderMeta' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderMeta.php', 88 'ClearwayForge\\Infra\\Attribution\\WcOrderRefunds' => __DIR__ . '/../..' . '/src/Infra/Attribution/WcOrderRefunds.php', 89 'ClearwayForge\\Infra\\Collect\\RequestAddress' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestAddress.php', 82 90 'ClearwayForge\\Infra\\Collect\\RequestSignals' => __DIR__ . '/../..' . '/src/Infra/Collect/RequestSignals.php', 83 91 'ClearwayForge\\Infra\\Collect\\VisitCollector' => __DIR__ . '/../..' . '/src/Infra/Collect/VisitCollector.php', … … 88 96 'ClearwayForge\\Infra\\Db\\Schema' => __DIR__ . '/../..' . '/src/Infra/Db/Schema.php', 89 97 'ClearwayForge\\Infra\\Health\\HealthReport' => __DIR__ . '/../..' . '/src/Infra/Health/HealthReport.php', 98 'ClearwayForge\\Infra\\Llms\\DocumentCaching' => __DIR__ . '/../..' . '/src/Infra/Llms/DocumentCaching.php', 90 99 'ClearwayForge\\Infra\\Llms\\LlmsRoutes' => __DIR__ . '/../..' . '/src/Infra/Llms/LlmsRoutes.php', 100 'ClearwayForge\\Infra\\Llms\\ProductTranslations' => __DIR__ . '/../..' . '/src/Infra/Llms/ProductTranslations.php', 91 101 'ClearwayForge\\Infra\\Llms\\StoreProfileBuilder' => __DIR__ . '/../..' . '/src/Infra/Llms/StoreProfileBuilder.php', 92 102 'ClearwayForge\\Infra\\Plugin' => __DIR__ . '/../..' . '/src/Infra/Plugin.php', … … 94 104 'ClearwayForge\\Infra\\Pro\\Bridge' => __DIR__ . '/../..' . '/src/Infra/Pro/Bridge.php', 95 105 'ClearwayForge\\Infra\\Report\\DashboardAssembler' => __DIR__ . '/../..' . '/src/Infra/Report/DashboardAssembler.php', 106 'ClearwayForge\\Infra\\Report\\StoreMinorUnit' => __DIR__ . '/../..' . '/src/Infra/Report/StoreMinorUnit.php', 96 107 'ClearwayForge\\Infra\\Repository\\HitRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/HitRepository.php', 97 108 'ClearwayForge\\Infra\\Repository\\SessionRepository' => __DIR__ . '/../..' . '/src/Infra/Repository/SessionRepository.php', -
clearwayforge/trunk/vendor/composer/installed.php
r3720707 r3776286 4 4 'pretty_version' => 'dev-main', 5 5 'version' => 'dev-main', 6 'reference' => ' ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',6 'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c', 7 7 'type' => 'wordpress-plugin', 8 8 'install_path' => __DIR__ . '/../../', … … 14 14 'pretty_version' => 'dev-main', 15 15 'version' => 'dev-main', 16 'reference' => ' ad83e89e38c6b6fbc028bc3dc13d42efbd4497a4',16 'reference' => '7fe110de51f63a39ee1c6e61f2b9cbed4b17852c', 17 17 'type' => 'wordpress-plugin', 18 18 'install_path' => __DIR__ . '/../../',
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)