fix(marketplace): перераскладка склада обратима + штрих-код в строке

Раскладка по полкам теперь перераспределяет ВЕСЬ непромаркированный
RECEIVED-пул заказа, а не один кусок: оператор может собрать имущество
с полок обратно (merge), разложить иначе (re-split) или перенести —
в одном действии «Разложить». Промаркированные единицы (штрих-код)
фиксированы и в пул не входят.

Backend: splitInventory собирает пул по order_id (непромаркированные
RECEIVED), сверяет сумму долей с суммой пула, схлопывает лишние куски
(новый repo.deleteById). Сигнатура GraphQL не изменилась — codegen не нужен.

Desktop: штрих-код показывается в той же строке, что имущество и полка
(нижний печатный лист скрыт на экране, разворачивается только при печати);
диалог раскладки предзаполняется текущим раскладом заказа по полкам.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 17:14:02 +00:00
parent fe9bb86328
commit c89ca2784b
4 changed files with 113 additions and 37 deletions
@@ -88,69 +88,88 @@ export class MarketplaceInventoryLabelService {
}
/**
* Разложить позицию по нескольким полкам. Разрешено только до маркировки
* (RECEIVED): штрих-код относится к конкретной физической единице, дробить
* уже промаркированную позицию запрещено. Сумма долей == количество позиции.
* Перераскладка непромаркированного остатка заказа по полкам. Операция
* перераспределяет ВЕСЬ непромаркированный RECEIVED-пул заказа (а не только
* переданную позицию): это позволяет «собрать с полок обратно и разложить
* иначе» — merge, повторный split и перенос полок в одном действии.
*
* Промаркированные куски (штрих-код = конкретная физическая единица) в пул не
* входят и не трогаются: дробить/схлопывать промаркированное запрещено.
* Сумма долей == суммарное количество непромаркированного пула.
*/
async splitInventory(
input: MarketplaceSplitInventoryInputDto
): Promise<MarketplaceInventoryMutationResult> {
const item = await this.loadOwned(input.coopname, input.inventory_id);
if (item.status !== MarketplaceInventoryStatuses.RECEIVED) {
const target = await this.loadOwned(input.coopname, input.inventory_id);
if (target.status !== MarketplaceInventoryStatuses.RECEIVED || target.barcode_value) {
throw new ConflictException(
'Разложить по полкам можно только непромаркированную позицию (на складе, без штрих-кода).'
'Перераскладывать можно только непромаркированную позицию (на складе, без штрих-кода).'
);
}
if (!input.splits.length) {
throw new BadRequestException('Не указаны доли разбиения.');
throw new BadRequestException('Не указаны доли раскладки.');
}
const quantities = input.splits.map((s) => Math.trunc(s.quantity));
if (quantities.some((q) => q <= 0)) {
throw new BadRequestException('Количество в каждой доле должно быть положительным целым.');
}
// Пул перераскладки — все непромаркированные RECEIVED-куски того же заказа.
const pool = (
await this.inventoryRepo.list({
coopname: target.coopname,
order_id: target.order_id,
status: MarketplaceInventoryStatuses.RECEIVED,
})
).filter((p) => !p.barcode_value);
const poolTotal = pool.reduce((a, p) => a + p.quantity_per_label, 0);
const sum = quantities.reduce((a, q) => a + q, 0);
if (sum !== item.quantity_per_label) {
if (sum !== poolTotal) {
throw new BadRequestException(
`Сумма долей (${sum}) не равна количеству позиции (${item.quantity_per_label}).`
`Сумма долей (${sum}) не равна количеству позиции (${poolTotal}).`
);
}
const result: MarketplaceInventoryDomainEntity[] = [];
// Первая доля переиспользует исходную запись (сохраняем её id/историю),
// остальные — новые позиции с теми же снапшотами.
// target переиспользуем под первую долю (сохраняем id/историю), остальные
// куски пула удаляем (схлопываем), затем создаём новые доли с тем же снапшотом.
const first = input.splits[0];
result.push(
await this.inventoryRepo.resize(
item.id,
target.id,
quantities[0],
first.shelf?.trim() ? first.shelf.trim() : item.shelf
first.shelf?.trim() ? first.shelf.trim() : null
)
);
for (const piece of pool) {
if (piece.id !== target.id) await this.inventoryRepo.deleteById(piece.id);
}
for (let i = 1; i < input.splits.length; i++) {
const piece = input.splits[i];
const created = await this.inventoryRepo.create({
coopname: item.coopname,
order_id: item.order_id,
shipment_id: item.shipment_id,
braname: item.braname,
coopname: target.coopname,
order_id: target.order_id,
shipment_id: target.shipment_id,
braname: target.braname,
status: MarketplaceInventoryStatuses.RECEIVED,
product_name_snapshot: item.product_name_snapshot,
product_name_snapshot: target.product_name_snapshot,
quantity_per_label: quantities[i],
orderer_account_snapshot: item.orderer_account_snapshot,
orderer_account_snapshot: target.orderer_account_snapshot,
shelf: piece.shelf?.trim() ? piece.shelf.trim() : null,
received_at: item.received_at,
received_by_operator_account: item.received_by_operator_account,
received_at: target.received_at,
received_by_operator_account: target.received_by_operator_account,
barcode_value: null,
barcode_format: null,
labeled_at: null,
labeled_by_operator_account: null,
expiry_date: item.expiry_date,
expiry_date: target.expiry_date,
});
result.push(created);
}
this.logger.log(
`Inventory: позиция ${item.id} разложена на ${result.length} полок(и) (Order ${item.order_id}).`
`Inventory: заказ ${target.order_id} перераскладкой собран из ${pool.length} в ${result.length} полок(и).`
);
return { inventory: result };
}
@@ -76,4 +76,10 @@ export interface MarketplaceInventoryDomainRepository {
quantity_per_label: number,
shelf: string | null
): Promise<MarketplaceInventoryDomainEntity>;
/**
* Удалить позицию склада. Используется при перераскладке: лишние куски пула
* заказа схлопываются в одну запись (собрать с полок обратно).
*/
deleteById(id: string): Promise<void>;
}
@@ -119,4 +119,8 @@ export class MarketplaceInventoryRepositoryAdapter implements MarketplaceInvento
const row = await this.repo.findOneOrFail({ where: { id } });
return this.mapper.toDomain(row);
}
async deleteById(id: string): Promise<void> {
await this.repo.delete({ id });
}
}
@@ -50,6 +50,23 @@ const onWarehouse = computed(() =>
const labeledItems = computed(() => items.value.filter((i) => !!i.barcode_value))
// Непромаркированный RECEIVED-пул одного заказа — куски, которые можно собрать
// с полок и разложить заново (merge/split/перенос). Промаркированные единицы
// (со штрих-кодом) фиксированы и в пул не входят.
function orderPool(item: MarketplaceInventoryItemView): MarketplaceInventoryItemView[] {
return items.value.filter(
(i) => i.order_id === item.order_id && i.status === RECEIVED && !i.barcode_value,
)
}
function orderPoolTotal(item: MarketplaceInventoryItemView): number {
return orderPool(item).reduce((a, p) => a + p.quantity_per_label, 0)
}
// Перераскладка доступна для непромаркированной позиции, если в заказе ≥2 ед.
// (есть что распределить или собрать обратно).
function canRedistribute(item: MarketplaceInventoryItemView): boolean {
return !item.barcode_value && orderPoolTotal(item) >= 2
}
// Черновик полки на строку — чтобы сохранять только по явному действию.
const shelfDraft = reactive<Record<string, string>>({})
@@ -102,21 +119,29 @@ const splitRows = ref<{ quantity: number | null; shelf: string }[]>([])
const splitTotal = computed(() =>
splitRows.value.reduce((a, r) => a + (Number(r.quantity) || 0), 0),
)
// Цель раскладки — суммарное количество непромаркированного пула заказа.
const splitPoolTotal = computed(() =>
splitTarget.value ? orderPoolTotal(splitTarget.value) : 0,
)
const splitValid = computed(
() =>
!!splitTarget.value &&
splitRows.value.length >= 1 &&
splitRows.value.every((r) => Number(r.quantity) > 0) &&
splitTotal.value === splitTarget.value.quantity_per_label,
splitTotal.value === splitPoolTotal.value,
)
function openSplit(item: MarketplaceInventoryItemView): void {
splitTarget.value = item
// Предзаполняем двумя долями: текущая полка + пустая.
splitRows.value = [
{ quantity: item.quantity_per_label, shelf: item.shelf ?? '' },
{ quantity: null, shelf: '' },
]
// Предзаполняем строками из текущих кусков пула (кол-во + полка) — оператор
// видит, как заказ сейчас лежит на полках, и правит как нужно. Один кусок —
// добавляем пустую строку, чтобы предложить раскладку.
const pool = orderPool(item)
splitRows.value = pool.map((p) => ({
quantity: p.quantity_per_label as number | null,
shelf: p.shelf ?? '',
}))
if (splitRows.value.length === 1) splitRows.value.push({ quantity: null, shelf: '' })
splitDialogOpen.value = true
}
@@ -140,7 +165,11 @@ async function applySplit(): Promise<void> {
shelf: r.shelf.trim() || null,
})),
})
SuccessAlert(`Позиция разложена на ${splitRows.value.length} полок(и)`)
SuccessAlert(
splitRows.value.length > 1
? `Заказ разложен на ${splitRows.value.length} полок(и)`
: 'Заказ собран на одной полке',
)
await load()
} catch (e) {
FailAlert(e, 'Не удалось разложить позицию')
@@ -203,6 +232,9 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
| Принято {{ item.quantity_per_label }} ед. · {{ item.orderer_account_snapshot }}
BaseBadge(v-if='item.barcode_value', variant='pos') Промаркировано
BaseBadge(v-else, variant='neutral') Без штрих-кода
//- Штрих-код в той же строке, что и имущество с полкой — не оторван.
.place__item-barcode(v-if='item.barcode_value')
BarcodeDisplay(:code='item.barcode_value', size='sm')
.place__item-controls
BaseInput.place__shelf(
v-model='shelfDraft[item.id]',
@@ -213,9 +245,10 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
@keydown.enter='saveShelf(item)'
)
BaseButton(
v-if='!item.barcode_value',
variant='ghost',
size='sm',
:disabled='!!item.barcode_value || item.quantity_per_label < 2',
:disabled='!canRedistribute(item)',
@click='openSplit(item)'
)
template(#icon-left)
@@ -231,8 +264,8 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
q-icon(name='qr_code_2', size='16px')
| Этикетка
//- Печатная раскладка этикеток только промаркированные позиции.
.place__grid(v-if='labeledItems.length')
//- Печатный лист этикеток (только при печати; на экране штрих-код в строке).
.place__grid.print-only(v-if='labeledItems.length')
.place__label(v-for='item in labeledItems', :key='item.id')
.place__label-name.ellipsis {{ item.product_name_snapshot }}
.place__label-meta.ellipsis
@@ -243,7 +276,10 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
BaseDialog(v-model='splitDialogOpen', title='Разложить по полкам', size='md')
.place__split(v-if='splitTarget')
.place__split-head
| {{ splitTarget.product_name_snapshot || 'Товар' }} всего {{ splitTarget.quantity_per_label }} ед.
| {{ splitTarget.product_name_snapshot || 'Товар' }} всего {{ splitPoolTotal }} ед.
.place__split-note
| Распределите весь заказ по полкам. Чтобы собрать обратно на одну полку
| удалите лишние строки; чтобы разложить иначе измените количества и полки.
.place__split-row(v-for='(row, idx) in splitRows', :key='idx')
BaseInput.place__split-qty(
v-model.number='row.quantity',
@@ -272,8 +308,8 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
template(#icon-left)
q-icon(name='add', size='16px')
| Ещё полка
span.place__split-total(:class='{ "place__split-total--bad": splitTotal !== splitTarget.quantity_per_label }')
| Сумма: {{ splitTotal }} / {{ splitTarget.quantity_per_label }}
span.place__split-total(:class='{ "place__split-total--bad": splitTotal !== splitPoolTotal }')
| Сумма: {{ splitTotal }} / {{ splitPoolTotal }}
template(#footer)
BaseButton(variant='ghost', size='sm', @click='splitDialogOpen = false') Отмена
BaseButton(variant='primary', size='sm', :disabled='!splitValid', @click='applySplit') Разложить
@@ -346,6 +382,15 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
color: var(--p-ink);
}
&__split-note {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
}
&__item-barcode {
flex: 0 0 auto;
}
&__split-row {
display: flex;
align-items: flex-end;
@@ -377,8 +422,9 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
}
}
// Печатный лист этикеток — скрыт на экране, разворачивается только при печати.
&__grid {
display: grid;
display: none;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--p-4, 16px);
}
@@ -416,6 +462,7 @@ q-page.place(role='region', aria-label='Раскладка и маркировк
padding: 0;
}
.place__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}