]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
ERP-396: оборудование магазина + фото в city_store_params/city_store
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 3 Jun 2026 10:23:46 +0000 (13:23 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 3 Jun 2026 10:23:46 +0000 (13:23 +0300)
Оборудование (camera_model, camera_count, microphone_model, router_model,
internet_provider, cash_register_info):
- Миграция с идемпотентными проверками
- CityStoreParams: @property + правила валидации
- Controller: возврат в getStore, сохранение в saveCityStoreParams
- Карточка: read-only тайлы вместо stub-заглушек
- Операционное: редактируемые поля с сабтитлом «Техника»

Фото магазина (city_store.image_sm / image_big):
- actionUploadStorePhoto: resize 300×200 thumbnail через ImageHelper
- Hero: photo-area с превью, кнопкой камеры и click-to-enlarge
- Модал просмотра большой фотографии

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
erp24/controllers/CityStoreManagementController.php
erp24/migrations/m260603_120000_add_equipment_to_city_store_params.php [new file with mode: 0644]
erp24/records/CityStoreParams.php
erp24/views/city-store-management/index.php
erp24/web/css/city-store-management.css
erp24/web/js/city-store-management/city-store-management.js

index 85b0bef2992fe29a85526da15bda4fb09061e702..6932252c04cd547e7e7b8c118b85d27e3601ab9d 100644 (file)
@@ -15,6 +15,8 @@ use yii_app\records\StoreCityList;
 use yii_app\records\StoreDynamic;
 use yii_app\records\AssortmentLabel;
 use yii_app\records\StoreType;
+use yii\web\UploadedFile;
+use yii_app\helpers\ImageHelper;
 
 class CityStoreManagementController extends Controller
 {
@@ -152,6 +154,12 @@ class CityStoreManagementController extends Controller
                 'addressDistrictName' => ($params && !empty($params->address_district)) ? $params->addressDistrict?->name : null,
                 'matrixType'      => $params?->matrix_type ? explode(',', $params->matrix_type) : [],
                 'isActive'        => $params !== null ? (bool)$params->is_active : true,
+                'cameraModel'     => $params?->camera_model,
+                'cameraCount'     => $params?->camera_count,
+                'microphoneModel' => $params?->microphone_model,
+                'routerModel'     => $params?->router_model,
+                'internetProvider'=> $params?->internet_provider,
+                'cashRegisterInfo'=> $params?->cash_register_info,
 
                 // store_dynamic
                 'bushChefFloristId'   => $bushChefFloristDyn?->value_int,
@@ -275,6 +283,8 @@ class CityStoreManagementController extends Controller
         $allowed = [
             'store_type', 'store_area', 'showcase_volume', 'freeze_area', 'freeze_volume',
             'address_region', 'address_city', 'address_district', 'is_active',
+            'camera_model', 'camera_count', 'microphone_model', 'router_model',
+            'internet_provider', 'cash_register_info',
         ];
 
         foreach ($allowed as $field) {
@@ -386,4 +396,55 @@ class CityStoreManagementController extends Controller
             Yii::error('StoreDynamic (str) save error: ' . json_encode($record->getErrors()), __CLASS__);
         }
     }
+
+    public function actionUploadStorePhoto(): array
+    {
+        Yii::$app->response->format = Response::FORMAT_JSON;
+
+        $storeId = (int)Yii::$app->request->post('store_id');
+        $store = $storeId ? CityStore::findOne($storeId) : null;
+        if (!$store) {
+            return ['success' => false, 'message' => 'Магазин не найден'];
+        }
+
+        $file = UploadedFile::getInstanceByName('photo');
+        if (!$file || $file->error !== UPLOAD_ERR_OK) {
+            return ['success' => false, 'message' => 'Файл не выбран или ошибка загрузки'];
+        }
+
+        $ext = strtolower($file->extension);
+        if ($ext === 'jpeg') {
+            $ext = 'jpg';
+        }
+        if (!in_array($ext, ['jpg', 'png', 'webp'], true)) {
+            return ['success' => false, 'message' => 'Допустимые форматы: jpg, png, webp'];
+        }
+
+        $adminId = Yii::$app->user->id;
+        $dateDir = date('Y') . '/' . date('m') . '/' . date('d');
+        $dir = Yii::getAlias('@uploads') . "/{$adminId}/{$dateDir}/";
+
+        if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) {
+            return ['success' => false, 'message' => 'Ошибка создания директории'];
+        }
+
+        $base   = 'store_' . $storeId . '_' . time();
+        $bigFile = $dir . $base . '_big.' . $ext;
+        $smFile  = $dir . $base . '_sm.jpg';
+
+        $file->saveAs($bigFile);
+        ImageHelper::resizeImage($bigFile, $smFile, 300, 200, 85);
+
+        $urlDir = '/uploads/' . $adminId . '/' . $dateDir . '/';
+        $bigUrl = $urlDir . $base . '_big.' . $ext;
+        $smUrl  = $urlDir . $base . '_sm.jpg';
+
+        $store->image_big = $bigUrl;
+        $store->image_sm  = $smUrl;
+        if (!$store->save(false, ['image_big', 'image_sm'])) {
+            return ['success' => false, 'message' => 'Ошибка сохранения в БД'];
+        }
+
+        return ['success' => true, 'imageSm' => $smUrl, 'imageBig' => $bigUrl];
+    }
 }
diff --git a/erp24/migrations/m260603_120000_add_equipment_to_city_store_params.php b/erp24/migrations/m260603_120000_add_equipment_to_city_store_params.php
new file mode 100644 (file)
index 0000000..b95976f
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+use yii\db\Migration;
+
+/**
+ * Добавляет поля оборудования в таблицу `city_store_params` (ERP-396).
+ * Все поля текстовые, nullable — заполняются вручную оператором.
+ */
+class m260603_120000_add_equipment_to_city_store_params extends Migration
+{
+    private const TABLE = 'city_store_params';
+
+    private const COLUMNS = [
+        'camera_model'       => 'varchar(150)',
+        'camera_count'       => 'smallint',
+        'microphone_model'   => 'varchar(150)',
+        'router_model'       => 'varchar(150)',
+        'internet_provider'  => 'varchar(150)',
+        'cash_register_info' => 'varchar(200)',
+    ];
+
+    public function safeUp(): void
+    {
+        $schema = $this->db->getTableSchema(self::TABLE, true);
+        if ($schema === null) {
+            return;
+        }
+
+        foreach (self::COLUMNS as $col => $type) {
+            if ($schema->getColumn($col) === null) {
+                $this->addColumn(self::TABLE, $col, $type . ' DEFAULT NULL');
+            }
+        }
+    }
+
+    public function safeDown(): void
+    {
+        $schema = $this->db->getTableSchema(self::TABLE, true);
+        if ($schema === null) {
+            return;
+        }
+
+        foreach (array_keys(self::COLUMNS) as $col) {
+            if ($schema->getColumn($col) !== null) {
+                $this->dropColumn(self::TABLE, $col);
+            }
+        }
+    }
+}
index 8149549bce2145d4ff98804a121d0772dcd82015..15369230d542cb4048a199c7b83d3c5b1347b6c9 100644 (file)
@@ -22,6 +22,12 @@ use yii\db\Expression;
  * @property float|null $freeze_area
  * @property float|null $freeze_volume
  * @property string|null $matrix_type
+ * @property string|null $camera_model
+ * @property int|null $camera_count
+ * @property string|null $microphone_model
+ * @property string|null $router_model
+ * @property string|null $internet_provider
+ * @property string|null $cash_register_info
  * @property int $created_by
  * @property string $created_at
  * @property int|null $updated_by
@@ -69,6 +75,9 @@ class CityStoreParams extends ActiveRecord
             [['address_city', 'address_region', 'address_district', 'matrix_type'], 'string'],
             [['is_active'], 'boolean'],
             [['is_active'], 'default', 'value' => true],
+            [['camera_model', 'microphone_model', 'router_model', 'internet_provider'], 'string', 'max' => 150],
+            [['cash_register_info'], 'string', 'max' => 200],
+            [['camera_count'], 'integer', 'min' => 0],
         ];
     }
 
index 8e7b85286ea73e029e63b6551fc0b8f815e21ec6..87c00f3da72e89d02715d87ef7e0cdf1136d81be 100644 (file)
@@ -21,6 +21,7 @@ $this->registerJs('
     getStore:            ' . json_encode(Url::to(['/city-store-management/get-store'])) . ',
     saveCityStore:       ' . json_encode(Url::to(['/city-store-management/save-city-store'])) . ',
     saveCityStoreParams: ' . json_encode(Url::to(['/city-store-management/save-city-store-params'])) . ',
+    uploadStorePhoto:    ' . json_encode(Url::to(['/city-store-management/upload-store-photo'])) . ',
     assortmentLabels:    ' . json_encode(Url::to(['/assortment-label/index'])) . ',
   };
 ', \yii\web\View::POS_HEAD);
@@ -67,6 +68,7 @@ $this->registerJs('
 
     <!-- Hero — краткая шапка магазина -->
     <div class="csm-hero" id="heroSection">
+      <div class="csm-hero-photo" id="heroPhoto"></div>
       <div class="csm-hero-info">
         <div class="csm-hero-name" id="heroName"></div>
         <div class="csm-hero-addr" id="heroAddr"></div>
@@ -123,6 +125,16 @@ $this->registerJs('
 <!-- Toast-уведомления -->
 <div class="csm-toast-w" id="toastW"></div>
 
+<!-- Скрытый input для загрузки фото магазина -->
+<input type="file" id="photoFileInput" accept="image/jpeg,image/png,image/webp"
+       style="display:none" onchange="handlePhotoUpload(this)">
+
+<!-- Модал: просмотр фото магазина -->
+<div class="csm-photo-modal" id="photoModal" style="display:none" onclick="closePhotoModal()">
+  <button class="csm-photo-modal-close" onclick="closePhotoModal()"><i class="fas fa-times"></i></button>
+  <img id="photoModalImg" src="" alt="Фото магазина" onclick="event.stopPropagation()">
+</div>
+
 <!-- Модал: выбор лейблов ассортимента -->
 <div class="lbl-modal" id="labelsModal" style="display:none" onclick="closeLabelsModal()">
   <div class="lbl-modal-box" onclick="event.stopPropagation()">
index 5ebd73668036ad778b1d9def826a3642a173c044..3e853b76b8a81f4fbc86cef112b1ea3d9fa8d740 100644 (file)
   border: 1px solid var(--csm-bd);
   border-radius: var(--csm-r);
   padding: 14px 18px;
-  display: flex;
-  justify-content: space-between;
-  align-items: flex-start;
+  display: grid;
+  grid-template-columns: 120px 1fr auto;
+  align-items: start;
   gap: 16px;
   margin-bottom: 0;
 }
+/* Фото в hero */
+.csm-hero-photo {
+  width: 120px; height: 90px;
+  border-radius: 4px; overflow: hidden; position: relative;
+  border: 1px solid #e0e0e0; cursor: pointer; flex-shrink: 0;
+}
+.csm-hero-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.csm-hero-photo-btn {
+  position: absolute; bottom: 4px; right: 4px;
+  background: rgba(0,0,0,.45); color: #fff; border: none;
+  border-radius: 3px; width: 22px; height: 22px; cursor: pointer;
+  font-size: 10px; display: flex; align-items: center; justify-content: center;
+  transition: background .15s;
+}
+.csm-hero-photo-btn:hover { background: rgba(0,0,0,.7); }
+.csm-hero-photo-empty {
+  width: 100%; height: 100%;
+  background: linear-gradient(135deg,#f5f7fa 0%,#e4e9f0 100%);
+  display: flex; flex-direction: column; align-items: center;
+  justify-content: center; gap: 5px;
+  color: #bbb; font-size: 11px; cursor: pointer;
+}
+.csm-hero-photo-empty i { font-size: 22px; }
+.csm-hero-photo-empty:hover { background: linear-gradient(135deg,#eef1f6 0%,#d8dfe9 100%); }
+/* Модал фото */
+.csm-photo-modal {
+  position: fixed; inset: 0; background: rgba(0,0,0,.82);
+  z-index: 9999; display: flex; align-items: center; justify-content: center;
+}
+.csm-photo-modal img { max-width: 90vw; max-height: 88vh; object-fit: contain; border-radius: 4px; }
+.csm-photo-modal-close {
+  position: absolute; top: 16px; right: 16px;
+  background: rgba(255,255,255,.18); color: #fff; border: none;
+  border-radius: 50%; width: 36px; height: 36px; cursor: pointer;
+  font-size: 18px; line-height: 1;
+}
+.csm-photo-modal-close:hover { background: rgba(255,255,255,.32); }
+/* eq-item текстовое значение (оборудование) */
+.eq-item-val.eq-item-text { font: 400 11px/1.4 inherit; white-space: normal; word-break: break-word; }
 .csm-hero-info { flex: 1; min-width: 0; }
 .csm-hero-name {
   font: 700 18px/1.3 inherit;
index 4b869fdf7e6d1dc695c192965cb8b871c8358962..aed9ffcd83d67447ee4549f284c4c49fe9ccf288 100644 (file)
@@ -133,6 +133,17 @@ function loadStore(id) {
 // ===== HERO =====
 function renderHero() {
     var s = D;
+    // Photo area
+    var photoHtml = '';
+    if (s.imageSm) {
+        photoHtml = '<img src="' + esc(s.imageSm) + '" onclick="openPhotoModal()" title="Нажмите для просмотра">'
+            + '<button class="csm-hero-photo-btn" onclick="triggerPhotoUpload(event)" title="Заменить фото"><i class="fas fa-camera"></i></button>';
+    } else {
+        photoHtml = '<div class="csm-hero-photo-empty" onclick="triggerPhotoUpload(event)">'
+            + '<i class="fas fa-image"></i><span>Фото</span></div>';
+    }
+    document.getElementById('heroPhoto').innerHTML = photoHtml;
+
     document.getElementById('heroName').innerHTML = esc(s.name)
         + (s.storeTypeName ? ' <span class="hs-bdg hs-type"><i class="fas fa-store"></i>' + esc(s.storeTypeName) + '</span>' : '')
         + (s.isActive
@@ -198,12 +209,12 @@ function renderCard() {
         + eqItem('fa-snowflake', 'Хол. пл.', s.freezeArea, 'м²')
         + eqItem('fa-snowflake', 'Хол. об.', s.freezeVolume, 'м³')
         + eqItem('fa-file-contract', 'Договор', s.squareStore, 'м²')
-        + '<div class="eq-divider"><i class="fas fa-microchip"></i>Техника <span class="stub-badge">stub</span></div>'
-        + eqItemStub('fa-video', 'Камера')
-        + eqItemStub('fa-microphone', 'Микрофон')
-        + eqItemStub('fa-wifi', 'Роутер')
-        + eqItemStub('fa-cash-register', 'Касса')
-        + eqItemStub('fa-globe', 'Интернет')
+        + '<div class="eq-divider"><i class="fas fa-microchip"></i>Техника</div>'
+        + eqText('fa-video', 'Камера', s.cameraModel ? esc(s.cameraModel) + (s.cameraCount ? ' <small>×' + s.cameraCount + '</small>' : '') : null)
+        + eqText('fa-microphone', 'Микрофон', s.microphoneModel)
+        + eqText('fa-wifi', 'Роутер', s.routerModel)
+        + eqText('fa-globe', 'Интернет', s.internetProvider)
+        + eqText('fa-cash-register', 'Касса', s.cashRegisterInfo)
         + '</div></div></div>';
 
     document.getElementById('contentArea').innerHTML = h;
@@ -260,8 +271,14 @@ function renderOps() {
         + numField('col-md-4', 'fSquareStore', s.squareStore, 'Договор (square_store, м²)', 'city_store')
         + '</div>'
         + '<div class="row g-3 mt-2">'
-        + '<div class="col-12"><div style="padding:8px 12px;background:#fff3e0;border-left:3px solid var(--warn);border-radius:4px;font-size:11px;color:#856404">'
-        + '<i class="fas fa-exclamation-triangle me-1"></i>Техника (камеры, роутеры, касса) — требует миграции БД <span class="stub-badge">stub</span></div></div>'
+        + '<div class="col-12" style="font:600 9px/1 inherit;text-transform:uppercase;letter-spacing:.3px;color:#888;padding-bottom:2px"><i class="fas fa-microchip me-1"></i>Техника</div>'
+        + textField('col-md-5', 'fCameraModel', s.cameraModel, 'Модель камеры')
+        + '<div class="col-md-2"><div class="form-group"><label><span class="acc-b acc-O">O</span>Кол-во камер</label>'
+        + '<input type="number" min="0" step="1" class="form-control" id="fCameraCount" value="' + esc(s.cameraCount !== null && s.cameraCount !== undefined ? s.cameraCount : '') + '" onchange="markDirty()"></div></div>'
+        + textField('col-md-5', 'fMicrophoneModel', s.microphoneModel, 'Модель микрофона')
+        + textField('col-md-4', 'fRouterModel', s.routerModel, 'Модель роутера')
+        + textField('col-md-4', 'fInternetProvider', s.internetProvider, 'Интернет-провайдер')
+        + textField('col-md-4', 'fCashRegisterInfo', s.cashRegisterInfo, 'Касса / эквайринг')
         + '</div></div></div>';
 
     // 5. Адрес
@@ -432,6 +449,9 @@ function renderSvc() {
             {k:'freeze_area',v:s.freezeArea},{k:'freeze_volume',v:s.freezeVolume},
             {k:'is_active',v:s.isActive?'true':'false'},
             {k:'matrix_type',v:s.matrixType ? s.matrixType.join(',') : null},
+            {k:'camera_model',v:s.cameraModel},{k:'camera_count',v:s.cameraCount},
+            {k:'microphone_model',v:s.microphoneModel},{k:'router_model',v:s.routerModel},
+            {k:'internet_provider',v:s.internetProvider},{k:'cash_register_info',v:s.cashRegisterInfo},
         ]},
         { title: 'city_store_params — Адрес FK', src: 'CSP', fields: [
             {k:'address_region',v:s.addressRegion},{k:'address_region_name',v:s.addressRegionName},
@@ -532,6 +552,12 @@ function saveChanges() {
         territorial_manager: val('fTerritorialManager'),
         is_active:          checked('fIsActive') ? 1 : 0,
         square_store:       val('fSquareStore'),
+        camera_model:       val('fCameraModel'),
+        camera_count:       val('fCameraCount'),
+        microphone_model:   val('fMicrophoneModel'),
+        router_model:       val('fRouterModel'),
+        internet_provider:  val('fInternetProvider'),
+        cash_register_info: val('fCashRegisterInfo'),
         label_ids:          (D.labelIds || []).join(','),
     };
 
@@ -775,9 +801,60 @@ function eqItem(icon, label, value, unit) {
         + '</div>';
 }
 
-function eqItemStub(icon, label) {
-    return '<div class="eq-item no-data">'
-        + '<div class="eq-item-lbl"><i class="fas ' + icon + '"></i>' + esc(label) + ' <span class="stub-badge">stub</span></div>'
-        + '<div class="eq-item-val">—</div>'
+function eqText(icon, label, value) {
+    var hasData = value !== null && value !== undefined && value !== '';
+    return '<div class="eq-item' + (!hasData ? ' no-data' : '') + '">'
+        + '<div class="eq-item-lbl"><i class="fas ' + icon + '"></i>' + esc(label) + '</div>'
+        + '<div class="eq-item-val eq-item-text">' + (hasData ? value : '—') + '</div>'
         + '</div>';
 }
+
+// ===== ФОТО МАГАЗИНА =====
+function openPhotoModal() {
+    if (!D || !D.imageBig) return;
+    document.getElementById('photoModalImg').src = D.imageBig;
+    document.getElementById('photoModal').style.display = 'flex';
+}
+
+function closePhotoModal() {
+    document.getElementById('photoModal').style.display = 'none';
+    document.getElementById('photoModalImg').src = '';
+}
+
+function triggerPhotoUpload(ev) {
+    if (ev) ev.stopPropagation();
+    document.getElementById('photoFileInput').click();
+}
+
+function handlePhotoUpload(input) {
+    var file = input.files[0];
+    if (!file || !D) return;
+    input.value = '';
+
+    var ph = document.getElementById('heroPhoto');
+    ph.innerHTML = '<div class="csm-hero-photo-empty"><i class="fas fa-spinner fa-spin"></i><span>Загрузка…</span></div>';
+
+    var fd = new FormData();
+    fd.append('photo', file);
+    fd.append('store_id', D.id);
+
+    fetch(CSM_URLS.uploadStorePhoto, {
+        method: 'POST',
+        headers: { 'X-CSRF-Token': csrfToken },
+        body: fd,
+    })
+    .then(function (r) { return r.json(); })
+    .then(function (resp) {
+        if (!resp.success) { toast(resp.message || 'Ошибка загрузки фото', 'err'); renderHero(); return; }
+        D.imageSm  = resp.imageSm;
+        D.imageBig = resp.imageBig;
+        renderHero();
+        // обновить поля SEO-вкладки если они отрисованы
+        var fSm = document.getElementById('fImageSm');
+        var fBig = document.getElementById('fImageBig');
+        if (fSm)  fSm.value  = resp.imageSm;
+        if (fBig) fBig.value = resp.imageBig;
+        toast('Фото загружено', 'ok');
+    })
+    .catch(function () { toast('Ошибка сети при загрузке фото', 'err'); renderHero(); });
+}