public function actionIndex()
{
$filter = new \yii\base\DynamicModel([
- 'category','subcategory','species',
- 'type','color','sort','size',
- 'date_from','date_to',
+ 'category', 'subcategory', 'species',
+ 'type', 'color', 'sort', 'size',
+ 'date_from', 'date_to',
]);
$filter->addRule([
- 'category','subcategory','species',
- 'type','color','sort','size',
- 'date_from','date_to',
+ 'category', 'subcategory', 'species',
+ 'type', 'color', 'sort', 'size',
+ 'date_from', 'date_to',
], 'safe');
- $filter->addRule(['onlyActive','onlyInactive'], 'boolean');
+ $filter->addRule(['onlyActive', 'onlyInactive'], 'boolean');
$filter->load(Yii::$app->request->get());
$dataProvider = new \yii\data\ActiveDataProvider([
'query' => $emptyQuery,
'pagination' => ['pageSize' => 50],
- 'sort' => ['defaultOrder'=>['name'=>SORT_ASC]],
+ 'sort' => ['defaultOrder' => ['name' => SORT_ASC]],
]);
$attrMap = [
'type' => ['type', 'тип'],
}
}
- $needJoin = $filter->onlyActive || $filter->onlyInactive || $filter->date_from || $filter->date_to;
- if ($needJoin) {
-
-
- if ($filter->onlyActive) {
- $query->innerJoin(
- Products1cNomenclatureActuality::tableName() . ' a',
- 'a.guid = n.id'
- );
- $query->andWhere(['a.guid' => 'n.id']);
- } elseif ($filter->onlyInactive) {
- // $query->andWhere([ 'not', 'a.guid = n.id']);
- }
-
- if ($filter->date_from || $filter->date_to) {
- if (!$filter->onlyActive && !$filter->onlyInactive) {
- $query->innerJoin(
- Products1cNomenclatureActuality::tableName() . ' a',
- 'a.guid = n.id'
- );
- }
+ if ($filter->onlyActive) {
+ $query->andWhere(['exists',
+ Products1cNomenclatureActuality::find()
+ ->where('guid = n.id')
+ ->select(new \yii\db\Expression('1'))
+ ]);
+ } elseif ($filter->onlyInactive) {
+ $query->andWhere(['not exists',
+ Products1cNomenclatureActuality::find()
+ ->where('guid = n.id')
+ ->select(new \yii\db\Expression('1'))
+ ]);
+ }
- if ($filter->date_from && $filter->date_to) {
+ if ($filter->date_from || $filter->date_to) {
+ $query->with(['actualities' => function ($q) use ($filter) {
+ if ($filter->date_from) {
$df = (new \DateTime("{$filter->date_from}-01"))
->setTime(0, 0, 0)->format('Y-m-d H:i:s');
+ $q->andWhere(['>=', 'date_to', $df]);
+ }
+ if ($filter->date_to) {
$dt = (new \DateTime("{$filter->date_to}-01"))
->modify('last day of this month')->setTime(23, 59, 59)
->format('Y-m-d H:i:s');
- $query->andWhere(['<=', 'a.date_from', $dt])
- ->andWhere(['>=', 'a.date_to', $df]);
- } elseif ($filter->date_from) {
- $df = (new \DateTime("{$filter->date_from}-01"))
- ->setTime(0, 0, 0)->format('Y-m-d H:i:s');
- $query->andWhere(['>=', 'a.date_to', $df]);
- } elseif ($filter->date_to) {
- $dt = (new \DateTime("{$filter->date_to}-01"))
- ->modify('last day of this month')->setTime(23, 59, 59)
- ->format('Y-m-d H:i:s');
- $query->andWhere(['<=', 'a.date_from', $dt]);
+ $q->andWhere(['<=', 'date_from', $dt]);
+ }
+ }]);
+ } else {
+ $query->with('actualities');
+ }
+
+ $products = $query->orderBy(['n.name' => SORT_ASC])->all();
+
+ $rows = [];
+ foreach ($products as $product) {
+ $acts = $product->actualities;
+ if ($acts) {
+ foreach ($acts as $act) {
+ $rows[] = [
+ 'product' => $product,
+ 'actuality' => $act,
+ ];
}
+ } else {
+ $rows[] = [
+ 'product' => $product,
+ 'actuality' => null,
+ ];
}
}
-
- $dataProvider = new \yii\data\ActiveDataProvider([
- 'query' => $query,
+ $dataProvider = new \yii\data\ArrayDataProvider([
+ 'allModels' => $rows,
'pagination' => ['pageSize' => 1000],
- 'sort' => ['defaultOrder' => ['name' => SORT_ASC]],
+ 'sort' => [
+ 'attributes' => [
+ 'product.name',
+ 'actuality.date_from',
+ 'actuality.date_to'
+ ],
+ 'defaultOrder' => ['product.name' => SORT_ASC],
+ ],
]);
}
$lists = [];
- foreach (['type','color','sort','size'] as $attr) {
+ foreach (['type', 'color', 'sort', 'size'] as $attr) {
$propIds = Products1cPropType::find()
->select('id')
- ->andWhere(['name'=>$attrMap[$attr]])
+ ->andWhere(['name' => $attrMap[$attr]])
->column();
$lists[$attr] = Products1cAdditionalCharacteristics::find()
->select('value')->distinct()
- ->where(['property_id'=>$propIds])
+ ->where(['property_id' => $propIds])
->column();
}
return $this->render('index', [
- 'filter' => $filter,
- 'dataProvider' => $dataProvider,
- 'categories' => array_combine($categories,$categories),
- 'subcategories' => array_combine($subcategories,$subcategories),
- 'species' => array_combine($species, $species),
- 'types' => array_combine($lists['type'], $lists['type']),
- 'colors' => array_combine($lists['color'], $lists['color']),
- 'sorts' => array_combine($lists['sort'], $lists['sort']),
- 'sizes' => array_combine($lists['size'], $lists['size']),
+ 'filter' => $filter,
+ 'dataProvider' => $dataProvider,
+ 'categories' => array_combine($categories, $categories),
+ 'subcategories' => array_combine($subcategories, $subcategories),
+ 'species' => array_combine($species, $species),
+ 'types' => array_combine($lists['type'], $lists['type']),
+ 'colors' => array_combine($lists['color'], $lists['color']),
+ 'sorts' => array_combine($lists['sort'], $lists['sort']),
+ 'sizes' => array_combine($lists['size'], $lists['size']),
]);
}
{
$request = Yii::$app->request;
- $historyDays = $request->get('historyDays');
+ $historyDays = $request->get('historyDays');
$intervalMonths = $request->get('intervalMonths');
$startFrom = $request->get('startFrom', date('Y-m-d'));
-
if ($historyDays === null || $intervalMonths === null) {
return $this->render('add-activity', [
- 'historyDays' => $historyDays ?? 14,
+ 'historyDays' => $historyDays ?? 14,
'intervalMonths' => $intervalMonths ?? 4,
+ 'startFrom' => $startFrom,
]);
}
- $endDate = date('Y-m-d', strtotime($startFrom));
+ $endDate = date('Y-m-d', strtotime($startFrom));
$startDate = date('Y-m-d', strtotime("-{$historyDays} days", strtotime($endDate)));
$productIds = (new Query())
->select('sp.product_id')
->from(['s' => 'sales'])
->innerJoin(['sp' => 'sales_products'], 's.id = sp.check_id')
- //->andWhere(['s.store_id' => $storeIds])
->innerJoin(['p1c' => 'products_1c_nomenclature'], 'p1c.id = sp.product_id')
->andWhere(['between', 's.date', "{$startDate} 00:00:00", "{$endDate} 23:59:59"])
->groupBy('sp.product_id')
->column();
if (empty($productIds)) {
- Yii::$app->session->setFlash('info', 'Нет товаров, удовлетворяющих условиям.');
+ Yii::$app->session->setFlash('info', 'Нет товаров за указанный период.');
return $this->render('add-activity', [
- 'historyDays' => $historyDays,
+ 'historyDays' => $historyDays,
'intervalMonths' => $intervalMonths,
- 'startFrom' => $startFrom,
+ 'startFrom' => $startFrom,
]);
}
$now = new \DateTime($endDate);
- $from = (clone $now)->modify("-{$intervalMonths} months")
- ->modify('first day of this month')->setTime(0,0,0)
- ->format('Y-m-d H:i:s');
- $to = (clone $now)->modify("+{$intervalMonths} months")
- ->modify('last day of this month')->setTime(23,59,59)
- ->format('Y-m-d H:i:s');
-
- $userId = Yii::$app->user->id;
- $createdAt = date('Y-m-d H:i:s');
- $toInsert = [];
- $toDeactivate = [];
-
- $existingActives = Products1cNomenclatureActuality::find()
- ->where(['guid' => $productIds, 'active' => 1])
- ->indexBy('guid')
- ->all();
-
+ $fromStr = (clone $now)
+ ->modify("-{$intervalMonths} months")
+ ->modify('first day of this month')->setTime(0, 0, 0)
+ ->format('Y-m-d H:i:s');
+ $toStr = (clone $now)
+ ->modify("+{$intervalMonths} months")
+ ->modify('last day of this month')->setTime(23, 59, 59)
+ ->format('Y-m-d H:i:s');
+
+ $rows = [];
foreach ($productIds as $pid) {
- $needNewRecord = true;
-
- if (isset($existingActives[$pid])) {
- $existing = $existingActives[$pid];
-
- if ($existing->date_from == $from && $existing->date_to == $to) {
- $needNewRecord = false;
- } else {
- $toDeactivate[] = $existing->id;
- }
- }
-
- if ($needNewRecord) {
- $toInsert[] = [
- 'guid' => $pid,
- 'date_from' => $from,
- 'date_to' => $to,
- 'active' => 1,
- 'created_at' => $createdAt,
- 'created_by' => $userId,
- ];
- }
+ $rows[] = [
+ 'guid' => $pid,
+ 'from' => date('Y-m', strtotime($fromStr)),
+ 'to' => date('Y-m', strtotime($toStr)),
+ ];
}
- $transaction = Yii::$app->db->beginTransaction();
- try {
- if (!empty($toDeactivate)) {
- Products1cNomenclatureActuality::updateAll(
- [
- 'active' => 0,
- 'updated_at' => $createdAt,
- 'updated_by' => $userId,
- ],
- ['id' => $toDeactivate]
- );
- }
-
- if (!empty($toInsert)) {
- Yii::$app->db->createCommand()
- ->batchInsert(
- Products1cNomenclatureActuality::tableName(),
- ['guid','date_from','date_to','active','created_at','created_by'],
- $toInsert
- )
- ->execute();
- }
-
- $transaction->commit();
+ $this->processBatchActuality($rows);
- $message = 'Таблица актуальности обновлена. ';
- $message .= 'Добавлено: ' . count($toInsert) . '; ';
- $message .= 'Деактивировано: ' . count($toDeactivate);
-
- Yii::$app->session->setFlash('success', $message);
+ Yii::$app->session->setFlash(
+ 'success',
+ "Обновлено актуальностей для " . count($rows) . " товаров."
+ );
- } catch (\Exception $e) {
- $transaction->rollBack();
- Yii::$app->session->setFlash('error', 'Ошибка: ' . $e->getMessage());
- }
return $this->render('add-activity', [
- 'historyDays' => $historyDays,
+ 'historyDays' => $historyDays,
'intervalMonths' => $intervalMonths,
'startFrom' => $startFrom,
]);
}
-
/**
* Обработка массового сохранения диапазонов актуальности.
* Если из/до нет или невалидны — пропускаем.
protected function processBatchActuality(array $post)
{
$userId = Yii::$app->user->id;
- $now = date('Y-m-d H:i:s');
+ $now = date('Y-m-d H:i:s');
foreach ($post as $row) {
if (empty($row['from']) || empty($row['to'])) {
}
$fromDt = \DateTime::createFromFormat('Y-m', $row['from']);
- if (!$fromDt) {
- continue;
- }
- $newFrom = $fromDt->format('Y-m-01 00:00:00');
-
$toDt = \DateTime::createFromFormat('Y-m', $row['to']);
- if (!$toDt) {
+ if (!$fromDt || !$toDt) {
continue;
}
- $toDt->modify('last day of this month')->setTime(23, 59, 59);
- $newTo = $toDt->format('Y-m-d H:i:s');
-
+ $fromDt->setDate((int)$fromDt->format('Y'), (int)$fromDt->format('m'), 1)
+ ->setTime(0, 0, 0);
+ $toDt->modify('last day of this month')
+ ->setTime(23, 59, 59);
- $warehouseNN = !empty($row['warehouse_nn']);
- $warehouseMS = !empty($row['warehouse_msk']);
- $supplier = $row['supplier'] ?? null;
- $plantation = $row['plantation'] ?? null;
+ $from = $fromDt->format('Y-m-d H:i:s');
+ $to = $toDt->format('Y-m-d H:i:s');
+ if ($from > $to) {
+ Yii::warning("GUID {$row['guid']}: пропускаем — from > to");
+ continue;
+ }
- $old = Products1cNomenclatureActuality::find()
- ->andWhere(['guid' => $row['guid'], 'active' => 1])
- ->one();
-
- if ($old) {
- if ($old->date_from === $newFrom && $old->date_to === $newTo) {
- continue;
+ $guid = $row['guid'];
+
+ /** @var Products1cNomenclatureActuality[] $hits */
+ $hits = Products1cNomenclatureActuality::find()
+ ->where(['guid' => $guid])
+ ->andWhere('date_to >= :from', [':from' => $from])
+ ->andWhere('date_from <= :to', [':to' => $to])
+ ->orderBy(['date_from' => SORT_ASC])
+ ->all();
+
+ if (empty($hits)) {
+ $new = new Products1cNomenclatureActuality([
+ 'guid' => $guid,
+ 'date_from' => $from,
+ 'date_to' => $to,
+ 'created_at' => $now,
+ 'created_by' => $userId,
+ ]);
+ if (!$new->save()) {
+ Yii::error("Ошибка создания GUID={$guid}: " . json_encode($new->getErrors(), JSON_UNESCAPED_UNICODE));
}
+ continue;
+ }
- $old->active = 0;
- $old->updated_by = $userId;
- $old->updated_at = $now;
+ $allFrom = array_map(fn($r) => $r->date_from, $hits);
+ $allTo = array_map(fn($r) => $r->date_to, $hits);
+ $minFrom = min($from, min($allFrom));
+ $maxTo = max($to, max($allTo));
- if (!$old->save()) {
- Yii::error('Ошибка сохранения' . json_encode(
- $old->getErrors(), JSON_UNESCAPED_UNICODE
- ));
- }
- }
+ $master = array_shift($hits);
+ $master->date_from = $minFrom;
+ $master->date_to = $maxTo;
+ $master->updated_at = $now;
+ $master->updated_by = $userId;
+ if (!$master->save()) {
+ Yii::error("Ошибка обновления GUID={$guid}: " . json_encode($master->getErrors(), JSON_UNESCAPED_UNICODE));
+ }
- $new = new Products1cNomenclatureActuality([
- 'guid' => $row['guid'],
- 'date_from' => $newFrom,
- 'date_to' => $newTo,
- 'active' => 1,
- 'created_at' => $now,
- 'created_by' => $userId,
- ]);
- $new->save(false);
+ foreach ($hits as $dup) {
+ $dup->delete();
+ }
}
}
}
$months = monthList();
+$monthOptions = '';
+foreach ($months as $k => $v) {
+ $monthOptions .= "<option value=\"$k\">$v</option>";
+}
?>
<div class="products1c-nomenclature-actuality-index p-4">
'floatHeader' => false,
'tableOptions' => ['class' => 'table table-bordered'],
'containerOptions' => ['style' => 'overflow:auto; max-height:500px;'],
- 'rowOptions' => function($model) use ($filter) {
- if ($model->hasActuality()) {
- return ['class' => 'table-success'];
- }
- return [];
+ 'rowOptions' => function($row) {
+ return $row['actuality'] ? ['class'=>'table-success'] : [];
},
'columns' => [
[
- 'attribute' => 'name',
'label' => 'Наименование',
'format' => 'raw',
- 'contentOptions' => ['style'=>'min-width:200px;'],
- 'value' => function ($m) {
- return Html::encode($m->name . ' (' . $m->id . ')');
+ 'contentOptions' => ['style'=>'min-width:150px;'],
+ 'value' => function ($row, $key, $index) {
+ $product = $row['product'];
+ $name = Html::encode($product->name . ' (' . $product->id . ')');
+ $btn = Html::button('+ Добавить интервал', [
+ 'class' => 'btn btn-xs btn-outline-primary ms-2 add-actuality-row',
+ 'type' => 'button',
+ 'title' => 'Добавить интервал',
+ 'data-guid' => $product->id,
+ 'data-name' => $product->name,
+ ]);
+ return '<div class="d-flex justify-content-between">' . $name . $btn . '</div>';
}
],
[
'label' => 'Актуальность ассортимента',
'format' => 'raw',
- 'contentOptions' => ['style'=>'white-space:nowrap; min-width:200px;'],
- 'value' => function ($m, $k, $i) use ($months, $filter) {
- $actuality = $m->getActualities()
- ->one();
- $from = $actuality ? (new \DateTime($actuality->date_from))->format('Y-m') : null;
- $to = $actuality ? (new \DateTime($actuality->date_to))->format('Y-m') : null;
- return Html::hiddenInput("actuality[$i][guid]", $m->id)
- . Html::tag('div',
- Html::dropDownList("actuality[$i][from]", $from, $months, [
- 'class'=>'form-select from-month form-select-sm me-1',
- 'prompt'=>'от',
-
- ])
- . Html::dropDownList("actuality[$i][to]", $to, $months, [
- 'class'=>'form-select to-month form-select-sm',
- 'prompt'=>'до',
-
- ]),
- ['class'=>'d-flex align-items-center']
- );
+ 'contentOptions' => ['style'=>'white-space:nowrap; min-width:100px;'],
+ 'value' => function ($row, $k, $i) use ($months) {
+ $product = $row['product'];
+ $actuality = $row['actuality'];
+ $from = $actuality ? substr($actuality->date_from, 0, 7) : null;
+ $to = $actuality ? substr($actuality->date_to, 0, 7) : null;
+ $inputs = Html::hiddenInput("actuality[$i][guid]", $product->id);
+ if ($actuality) {
+ $inputs .= Html::hiddenInput("actuality[$i][id]", $actuality->id);
+ }
+ $inputs .= Html::tag('div',
+ Html::dropDownList("actuality[$i][from]", $from, $months, [
+ 'class'=>'form-select from-month form-select-sm me-1',
+ 'prompt'=>'от',
+ 'style' => 'width:auto;display:inline-block'
+ ]) .
+ Html::dropDownList("actuality[$i][to]", $to, $months, [
+ 'class'=>'form-select to-month form-select-sm',
+ 'prompt'=>'до',
+ 'style' => 'width:auto;display:inline-block'
+ ]),
+ ['class'=>'d-flex align-items-center']
+ );
+ return $inputs;
}
],
[
<?php ActiveForm::end(); ?>
+ <script>
+ window.productActualityConfig = {
+ months: <?= json_encode($months, JSON_UNESCAPED_UNICODE) ?>
+ };
+ </script>
</div>
document.addEventListener("DOMContentLoaded", () => {
-$('.from-month').on('change', function(){
- var from = $(this).val(),
- to = $(this).closest('td').find('.to-month');
- to.find('option').each(function(){
- $(this).toggle($(this).val() >= from);
- });
- if (to.val() < from) {
- to.val(from);
- }
-});
-$('#filter-date-from').on('change', function(){
- var from = $(this).val();
- var to = $('#filter-date-to');
- to.find('option').each(function(){
- var val = $(this).val();
- if (val === '' || val >= from) {
- $(this).show();
+ const monthOptions = Object.entries(window.productActualityConfig.months || {}).map(
+ ([k, v]) => `<option value="${k}">${v}</option>`
+ ).join('');
+
+ let actualIdx = $('#actuality-form table tbody tr').length || 0;
+
+ $(document).on('click', '.add-actuality-row', function(){
+ const btn = $(this);
+ const guid = btn.data('guid');
+ const name = btn.data('name');
+ const table = $('#actuality-form table');
+ // Все строки для этого товара
+ const $rows = table.find('tbody tr').filter(function(){
+ return $(this).find('input[type=hidden][name*="[guid]"]').val() == guid;
+ });
+ const $lastRow = $rows.last();
+ actualIdx++;
+
+ const newRow = `
+ <tr>
+ <td>
+ ${name} (${guid})
+ </td>
+ <td>
+ <input type="hidden" name="actuality[${actualIdx}][guid]" value="${guid}">
+ <div class="d-flex align-items-center">
+ <select name="actuality[${actualIdx}][from]" class="form-select from-month form-select-sm me-1" style="width:auto;display:inline-block">
+ <option value="">от</option>${monthOptions}
+ </select>
+ <select name="actuality[${actualIdx}][to]" class="form-select to-month form-select-sm" style="width:auto;display:inline-block">
+ <option value="">до</option>${monthOptions}
+ </select>
+ </div>
+ </td>
+ <td style="width:60px;text-align:center;">
+ <input type="checkbox" name="actuality[${actualIdx}][warehouse_nn]">
+ </td>
+ <td style="width:60px;text-align:center;">
+ <input type="checkbox" name="actuality[${actualIdx}][warehouse_msk]">
+ </td>
+ <td style="min-width:150px;">–</td>
+ </tr>
+ `;
+
+ if ($lastRow.length) {
+ $lastRow.after(newRow);
} else {
- $(this).hide();
+ table.find('tbody').append(newRow);
}
});
- if (to.val() && to.val() < from) {
- to.val(from);
- }
-});
-$('.clear-btn').on('click', function(){
- var target = $(this).data('target');
- $('#' + target).val(null).trigger('change');
-});
+
+ $(document).on('change', '.from-month', function(){
+ var from = $(this).val(),
+ to = $(this).closest('td').find('.to-month');
+ to.find('option').each(function(){
+ $(this).toggle($(this).val() >= from || $(this).val() === "");
+ });
+ if (to.val() < from) {
+ to.val(from);
+ }
+ });
+
+ $('#filter-date-from').on('change', function(){
+ var from = $(this).val();
+ var to = $('#filter-date-to');
+ to.find('option').each(function(){
+ var val = $(this).val();
+ if (val === '' || val >= from) {
+ $(this).show();
+ } else {
+ $(this).hide();
+ }
+ });
+ if (to.val() && to.val() < from) {
+ to.val(from);
+ }
+ });
+
+ $('.clear-btn').on('click', function(){
+ var target = $(this).data('target');
+ $('#' + target).val(null).trigger('change');
+ });
var $onlyActiveCheckbox = $('#onlyActiveCheckbox');
var $onlyInactiveCheckbox = $('#onlyInactiveCheckbox');
}
});
-
if ($onlyActiveCheckbox.is(':checked')) {
$onlyInactiveCheckbox.prop('disabled', true);
} else if ($onlyInactiveCheckbox.is(':checked')) {
$onlyActiveCheckbox.prop('disabled', true);
}
-});
\ No newline at end of file
+ function checkIntervalsForGuid(guid) {
+ // Собираем все интервалы для товара (всех строк)
+ let intervals = [];
+ $('#actuality-form table tbody tr').each(function(){
+ let $row = $(this);
+ let rowGuid = $row.find('input[type=hidden][name*="[guid]"]').val();
+ if (rowGuid == guid) {
+ let from = $row.find('select.from-month').val();
+ let to = $row.find('select.to-month').val();
+ if (from && to) intervals.push({from, to, $row});
+ }
+ });
+ intervals.sort((a,b) => a.from.localeCompare(b.from));
+
+ let hasOverlap = false;
+ for(let i=0; i<intervals.length; ++i) {
+ for(let j=i+1; j<intervals.length; ++j) {
+ if (intervals[i].to >= intervals[j].from) {
+ // Пересечение!
+ intervals[i].$row.addClass('table-danger');
+ intervals[j].$row.addClass('table-danger');
+ hasOverlap = true;
+ }
+ }
+ }
+ if (hasOverlap) {
+ if (!$('.interval-overlap-alert').length) {
+ $('<div class="alert alert-warning interval-overlap-alert mt-2">Пересекающиеся диапазоны по одному товару!</div>')
+ .insertBefore('#actuality-form');
+ }
+ } else {
+ $('.interval-overlap-alert').remove();
+ $('#actuality-form table tbody tr').removeClass('table-danger');
+ }
+ return hasOverlap;
+ }
+
+ $(document).on('change', '.from-month, .to-month', function(){
+ let $row = $(this).closest('tr');
+ let guid = $row.find('input[type=hidden][name*="[guid]"]').val();
+ checkIntervalsForGuid(guid);
+ });
+
+});