This commit is contained in:
2026-11-02 08:10:00 +02:00
parent 504b01b244
commit b44378b899
14 changed files with 655 additions and 121 deletions
+6 -1
View File
@@ -298,7 +298,12 @@ export function procurementNavigation(
id: 'warehouse_replenishment',
label: 'Reposição automática',
path: '/stock/reposicao',
roles: ['admin', 'supply_read', 'supply_prepare', 'supply_review'],
roles: [
'admin',
'supply_read',
'supply_prepare',
'supply_review',
],
},
{
id: 'warehouse_requests',
+18 -3
View File
@@ -169,8 +169,17 @@ export default function Availability(props: Props) {
)
: 'Sem acesso ao planeamento'}
</td>
<td className="border-b p-3">{quantity(row.physical_commitment_milli)} / {quantity(row.free_milli)}</td>
<td className="border-b p-3">{quantity(row.minimum_milli)}</td>
<td className="border-b p-3">
{quantity(
row.physical_commitment_milli,
)}{' '}
/ {quantity(row.free_milli)}
</td>
<td className="border-b p-3">
{quantity(
row.minimum_milli,
)}
</td>
<td className="border-b p-3">
{!row.reconciled
? 'Reconciliar repartição dos lotes'
@@ -192,7 +201,13 @@ export default function Availability(props: Props) {
<p>Sem artigos nos filtros seleccionados.</p>
)}
<p className="mt-4 text-sm">
A compra automática usa a parametrização do artigo e armazém. Esta consulta não cria documentos. <Link href="/stock/reposicao">Consultar reposição e necessidades pendentes</Link>.
A compra automática usa a parametrização do
artigo e armazém. Esta consulta não cria
documentos.{' '}
<Link href="/stock/reposicao">
Consultar reposição e necessidades pendentes
</Link>
.
</p>
<div className="mt-4 flex gap-4">
{props.rows.prev_page_url && (
+431 -28
View File
@@ -1,32 +1,435 @@
import { Head, Link, useForm, router } from '@inertiajs/react';
import { PageTitle, Panel, FormErrors, controlClass } from '@/components/procurement';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
type RecordRow = { id:number; record_key:string; version:number; state:string; reason:string; created_at:string; data:{ policy_key?:string; requester_id?:number; cost_center_id?:number; unit_price?:string; tax_rate?:string; needed_days?:number; valid_until?:string; message?:string; purchases?:{id:number;number:string;quantity_milli:number}[] }; snapshot:{item:{id:number;code?:string;name?:string};warehouse:{id:number;name?:string};requester?:{name:string};cost_center?:{code:string;name:string};origins?:{warehouse_request_id:number;destination:string;equipment:{code:string;description:string}|null;remaining_milli:number}[]} };
type Props={ready:boolean;canPrepare:boolean;canReview:boolean;rules:RecordRow[];needs:RecordRow[];history:RecordRow[];policies:RecordRow[];requesters:{id:number;name:string;department_id:number}[];centers:{id:number;code:string;name:string;department_id:number}[]};
const states:Record<string,string>={draft:'Por activar',approved:'Activa',suspended:'Suspensa',pending:'Necessidade pendente',created:'Rascunho criado',covered:'Reposição em curso',satisfied:'Disponibilidade suficiente'};
function RuleForm({props,rule}:{props:Props;rule?:RecordRow}) {
const form=useForm({operation_key:crypto.randomUUID(),record_key:rule?.record_key??null,version:rule?.version??0,action:'save',reason:'',data:{policy_key:rule?.data.policy_key??'',requester_id:rule?.data.requester_id?.toString()??'',cost_center_id:rule?.data.cost_center_id?.toString()??'',unit_price:rule?.data.unit_price??'',tax_rate:rule?.data.tax_rate??'',needed_days:rule?.data.needed_days?.toString()??'',valid_until:rule?.data.valid_until??''}});
const requester=props.requesters.find(u=>u.id.toString()===form.data.data.requester_id);
const set=(key:keyof typeof form.data.data,value:string)=>form.setData('data',{...form.data.data,[key]:value,...(key==='requester_id'?{cost_center_id:''}:{})});
return <form className="grid gap-3 md:grid-cols-2" onSubmit={e=>{e.preventDefault();form.post('/stock/reposicao',{preserveState:'errors'});}}><FormErrors errors={form.errors}/>
<label>Política aprovada · artigo/armazém<select required className={controlClass} value={form.data.data.policy_key} onChange={e=>set('policy_key',e.target.value)}><option value="">Seleccione</option>{props.policies.map(p=><option key={p.record_key} value={p.record_key}>{p.snapshot.item.code} · {p.snapshot.item.name} · {p.snapshot.warehouse.name}</option>)}</select></label>
<label>Requisitante responsável<select required className={controlClass} value={form.data.data.requester_id} onChange={e=>set('requester_id',e.target.value)}><option value="">Seleccione</option>{props.requesters.map(u=><option key={u.id} value={u.id}>{u.name}</option>)}</select></label>
<label>Centro de custo<select required className={controlClass} value={form.data.data.cost_center_id} onChange={e=>set('cost_center_id',e.target.value)}><option value="">Seleccione</option>{props.centers.filter(c=>c.department_id===requester?.department_id).map(c=><option key={c.id} value={c.id}>{c.code} · {c.name}</option>)}</select></label>
<label>Preço estimado (MZN), se não existir no acordo<input inputMode="decimal" placeholder="Vazio: reutilizar acordo em MZN" className={controlClass} value={form.data.data.unit_price} onChange={e=>set('unit_price',e.target.value)}/></label>
<label>Imposto estimado (%), se diferente do parâmetro da empresa<input inputMode="decimal" placeholder="Vazio: reutilizar parâmetro da empresa" className={controlClass} value={form.data.data.tax_rate} onChange={e=>set('tax_rate',e.target.value)}/></label>
<label>Prazo pretendido após geração (dias)<input required type="number" min="0" max="366" className={controlClass} value={form.data.data.needed_days} onChange={e=>set('needed_days',e.target.value)}/></label>
<label>Validade desta parametrização e estimativa<input required type="date" className={controlClass} value={form.data.data.valid_until} onChange={e=>set('valid_until',e.target.value)}/></label>
<label>Fundamento<textarea required minLength={10} className={controlClass} value={form.data.reason} onChange={e=>form.setData('reason',e.target.value)}/></label>
<p className="text-sm md:col-span-2">Mínimo, alvo, máximo e embalagem são reutilizados da política aprovada. A activação exige outro responsável. Alterar uma regra exige nova activação e afecta apenas novas gerações; documentos existentes conservam a sua origem.</p><Button disabled={form.processing}>Guardar para activação</Button></form>;
type RecordRow = {
id: number;
record_key: string;
version: number;
state: string;
reason: string;
created_at: string;
data: {
policy_key?: string;
requester_id?: number;
cost_center_id?: number;
unit_price?: string;
tax_rate?: string;
needed_days?: number;
valid_until?: string;
message?: string;
purchases?: { id: number; number: string; quantity_milli: number }[];
};
snapshot: {
item: { id: number; code?: string; name?: string };
warehouse: { id: number; name?: string };
requester?: { name: string };
cost_center?: { code: string; name: string };
origins?: {
warehouse_request_id: number;
destination: string;
equipment: { code: string; description: string } | null;
remaining_milli: number;
}[];
};
};
type Props = {
ready: boolean;
canPrepare: boolean;
canReview: boolean;
rules: RecordRow[];
needs: RecordRow[];
history: RecordRow[];
policies: RecordRow[];
requesters: { id: number; name: string; department_id: number }[];
centers: {
id: number;
code: string;
name: string;
department_id: number;
}[];
};
const states: Record<string, string> = {
draft: 'Por activar',
approved: 'Activa',
suspended: 'Suspensa',
pending: 'Necessidade pendente',
created: 'Rascunho criado',
covered: 'Reposição em curso',
satisfied: 'Disponibilidade suficiente',
};
function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: rule?.record_key ?? null,
version: rule?.version ?? 0,
action: 'save',
reason: '',
data: {
policy_key: rule?.data.policy_key ?? '',
requester_id: rule?.data.requester_id?.toString() ?? '',
cost_center_id: rule?.data.cost_center_id?.toString() ?? '',
unit_price: rule?.data.unit_price ?? '',
tax_rate: rule?.data.tax_rate ?? '',
needed_days: rule?.data.needed_days?.toString() ?? '',
valid_until: rule?.data.valid_until ?? '',
},
});
const requester = props.requesters.find(
(u) => u.id.toString() === form.data.data.requester_id,
);
const set = (key: keyof typeof form.data.data, value: string) =>
form.setData('data', {
...form.data.data,
[key]: value,
...(key === 'requester_id' ? { cost_center_id: '' } : {}),
});
return (
<form
className="grid gap-3 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
form.post('/stock/reposicao', { preserveState: 'errors' });
}}
>
<FormErrors errors={form.errors} />
<label>
Política aprovada · artigo/armazém
<select
required
className={controlClass}
value={form.data.data.policy_key}
onChange={(e) => set('policy_key', e.target.value)}
>
<option value="">Seleccione</option>
{props.policies.map((p) => (
<option key={p.record_key} value={p.record_key}>
{p.snapshot.item.code} · {p.snapshot.item.name} ·{' '}
{p.snapshot.warehouse.name}
</option>
))}
</select>
</label>
<label>
Requisitante responsável
<select
required
className={controlClass}
value={form.data.data.requester_id}
onChange={(e) => set('requester_id', e.target.value)}
>
<option value="">Seleccione</option>
{props.requesters.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label>
Centro de custo
<select
required
className={controlClass}
value={form.data.data.cost_center_id}
onChange={(e) => set('cost_center_id', e.target.value)}
>
<option value="">Seleccione</option>
{props.centers
.filter(
(c) => c.department_id === requester?.department_id,
)
.map((c) => (
<option key={c.id} value={c.id}>
{c.code} · {c.name}
</option>
))}
</select>
</label>
<label>
Preço estimado (MZN), se não existir no acordo
<input
inputMode="decimal"
placeholder="Vazio: reutilizar acordo em MZN"
className={controlClass}
value={form.data.data.unit_price}
onChange={(e) => set('unit_price', e.target.value)}
/>
</label>
<label>
Imposto estimado (%), se diferente do parâmetro da empresa
<input
inputMode="decimal"
placeholder="Vazio: reutilizar parâmetro da empresa"
className={controlClass}
value={form.data.data.tax_rate}
onChange={(e) => set('tax_rate', e.target.value)}
/>
</label>
<label>
Prazo pretendido após geração (dias)
<input
required
type="number"
min="0"
max="366"
className={controlClass}
value={form.data.data.needed_days}
onChange={(e) => set('needed_days', e.target.value)}
/>
</label>
<label>
Validade desta parametrização e estimativa
<input
required
type="date"
className={controlClass}
value={form.data.data.valid_until}
onChange={(e) => set('valid_until', e.target.value)}
/>
</label>
<label>
Fundamento
<textarea
required
minLength={10}
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
</label>
<p className="text-sm md:col-span-2">
Mínimo, alvo, máximo e embalagem são reutilizados da política
aprovada. A activação exige outro responsável. Alterar uma regra
exige nova activação e afecta apenas novas gerações; documentos
existentes conservam a sua origem.
</p>
<Button disabled={form.processing}>Guardar para activação</Button>
</form>
);
}
function Decision({rule,action}:{rule:RecordRow;action:'approve'|'suspend'}) {
const form=useForm({operation_key:crypto.randomUUID(),record_key:rule.record_key,version:rule.version,action,reason:'',data:{}});
return <form className="mt-3 flex flex-wrap gap-3" onSubmit={e=>{e.preventDefault();form.post('/stock/reposicao',{preserveState:'errors'});}}><FormErrors errors={form.errors}/><input aria-label="Fundamento da decisão" required minLength={10} placeholder="Fundamento da decisão" className={controlClass} value={form.data.reason} onChange={e=>form.setData('reason',e.target.value)}/><Button disabled={form.processing}>{action==='approve'?'Activar parametrização':'Suspender parametrização'}</Button></form>;
function Decision({
rule,
action,
}: {
rule: RecordRow;
action: 'approve' | 'suspend';
}) {
const form = useForm({
operation_key: crypto.randomUUID(),
record_key: rule.record_key,
version: rule.version,
action,
reason: '',
data: {},
});
return (
<form
className="mt-3 flex flex-wrap gap-3"
onSubmit={(e) => {
e.preventDefault();
form.post('/stock/reposicao', { preserveState: 'errors' });
}}
>
<FormErrors errors={form.errors} />
<input
aria-label="Fundamento da decisão"
required
minLength={10}
placeholder="Fundamento da decisão"
className={controlClass}
value={form.data.reason}
onChange={(e) => form.setData('reason', e.target.value)}
/>
<Button disabled={form.processing}>
{action === 'approve'
? 'Activar parametrização'
: 'Suspender parametrização'}
</Button>
</form>
);
}
export default function Replenishment(props: Props) {
return (
<>
<Head title="Reposição automática" />
<div className="space-y-6 p-6">
<PageTitle
eyebrow="Armazém e compras"
title="Reposição automática"
description="Reutilizar a política aprovada e preparar a compra quando houver falta ou atingir o mínimo. As aprovações de compra mantêm-se."
/>
<div className="flex gap-4">
<Link href="/stock/disponibilidade">Disponibilidade</Link>
<Link href="/stock/requisicoes">
Requisições ao armazém
</Link>
<Link href="/compras/fornecimento?view=calendar">
Políticas de reposição
</Link>
</div>
{!props.ready ? (
<Panel title="Instalação pendente">
<p>
Instale a reserva e reposição para activar estas
regras.
</p>
</Panel>
) : (
<>
{props.canPrepare && (
<Panel title="Parametrizar artigo e armazém">
<RuleForm props={props} />
</Panel>
)}
<Panel title="Parametrizações">
{props.rules.length === 0 ? (
<p>
Sem parametrizações. Não são assumidos
requisitantes ou centros de custo.
</p>
) : (
props.rules.map((r) => (
<div
className="mb-4 rounded border p-4"
key={r.id}
>
<p className="font-medium">
{r.snapshot.item.code} ·{' '}
{r.snapshot.item.name} ·{' '}
{r.snapshot.warehouse.name}
</p>
<p>
{states[r.state]} · Versão{' '}
{r.version} ·{' '}
{r.snapshot.requester?.name} ·{' '}
{r.snapshot.cost_center?.code}
</p>
<p>
Preço estimado {r.data.unit_price}{' '}
MZN · Imposto {r.data.tax_rate}% ·
Validade {r.data.valid_until}
</p>
{props.canReview &&
r.state === 'draft' && (
<Decision
key={`approve:${r.id}`}
rule={r}
action="approve"
/>
)}
{props.canReview &&
r.state === 'approved' && (
<Decision
key={`suspend:${r.id}`}
rule={r}
action="suspend"
/>
)}
{props.canPrepare && (
<details className="mt-3">
<summary>
Rever parametrização
</summary>
<RuleForm
key={r.id}
props={props}
rule={r}
/>
</details>
)}
</div>
))
)}
</Panel>
<Panel title="Necessidades e compras associadas">
{props.needs.length === 0 ? (
<p>
Sem necessidades registadas após alterações
de stock ou activação de regras.
</p>
) : (
props.needs.map((n) => (
<div
key={n.id}
className="mb-4 rounded border p-4"
>
<p className="font-medium">
Artigo #{n.snapshot.item.id} ·
Armazém #{n.snapshot.warehouse.id} ·{' '}
{states[n.state]}
</p>
<p>{n.data.message}</p>
{n.snapshot.origins?.map((o, i) => (
<p key={i}>
<Link
href={`/stock/requisicoes/${o.warehouse_request_id}`}
>
RA-{o.warehouse_request_id}
</Link>{' '}
· {o.destination}
{o.equipment
? ` · ${o.equipment.code} · ${o.equipment.description}`
: ''}{' '}
· Pendente{' '}
{o.remaining_milli / 1000}
</p>
))}
{n.data.purchases?.map((p) => (
<p key={p.id}>
<Link
href={`/requisicoes/${p.id}`}
>
{p.number}
</Link>{' '}
· Quantidade original{' '}
{p.quantity_milli / 1000}
</p>
))}
{(props.canPrepare ||
props.canReview) && (
<Button
variant="outline"
className="mt-3"
onClick={() =>
router.post(
`/stock/reposicao/${n.id}/avaliar`,
{},
{
preserveState:
'errors',
},
)
}
>
Reavaliar necessidade
</Button>
)}
</div>
))
)}
</Panel>
<Panel title="Histórico">
<details>
<summary>
Consultar versões e fundamentos
</summary>
{props.history.map((h) => (
<p key={h.id} className="mt-2">
#{h.id} · {h.created_at} · Versão{' '}
{h.version} · {states[h.state]} ·{' '}
{h.reason}
</p>
))}
</details>
</Panel>
</>
)}
</div>
</>
);
}
export default function Replenishment(props:Props){return <><Head title="Reposição automática"/><div className="space-y-6 p-6"><PageTitle eyebrow="Armazém e compras" title="Reposição automática" description="Reutilizar a política aprovada e preparar a compra quando houver falta ou atingir o mínimo. As aprovações de compra mantêm-se."/><div className="flex gap-4"><Link href="/stock/disponibilidade">Disponibilidade</Link><Link href="/stock/requisicoes">Requisições ao armazém</Link><Link href="/compras/fornecimento?view=calendar">Políticas de reposição</Link></div>
{!props.ready?<Panel title="Instalação pendente"><p>Instale a reserva e reposição para activar estas regras.</p></Panel>:<>
{props.canPrepare&&<Panel title="Parametrizar artigo e armazém"><RuleForm props={props}/></Panel>}
<Panel title="Parametrizações">{props.rules.length===0?<p>Sem parametrizações. Não são assumidos requisitantes ou centros de custo.</p>:props.rules.map(r=><div className="mb-4 rounded border p-4" key={r.id}><p className="font-medium">{r.snapshot.item.code} · {r.snapshot.item.name} · {r.snapshot.warehouse.name}</p><p>{states[r.state]} · Versão {r.version} · {r.snapshot.requester?.name} · {r.snapshot.cost_center?.code}</p><p>Preço estimado {r.data.unit_price} MZN · Imposto {r.data.tax_rate}% · Validade {r.data.valid_until}</p>{props.canReview&&r.state==='draft'&&<Decision key={`approve:${r.id}`} rule={r} action="approve"/>}{props.canReview&&r.state==='approved'&&<Decision key={`suspend:${r.id}`} rule={r} action="suspend"/>}{props.canPrepare&&<details className="mt-3"><summary>Rever parametrização</summary><RuleForm key={r.id} props={props} rule={r}/></details>}</div>)}</Panel>
<Panel title="Necessidades e compras associadas">{props.needs.length===0?<p>Sem necessidades registadas após alterações de stock ou activação de regras.</p>:props.needs.map(n=><div key={n.id} className="mb-4 rounded border p-4"><p className="font-medium">Artigo #{n.snapshot.item.id} · Armazém #{n.snapshot.warehouse.id} · {states[n.state]}</p><p>{n.data.message}</p>{n.snapshot.origins?.map((o,i)=><p key={i}><Link href={`/stock/requisicoes/${o.warehouse_request_id}`}>RA-{o.warehouse_request_id}</Link> · {o.destination}{o.equipment?` · ${o.equipment.code} · ${o.equipment.description}`:''} · Pendente {o.remaining_milli/1000}</p>)}{n.data.purchases?.map(p=><p key={p.id}><Link href={`/requisicoes/${p.id}`}>{p.number}</Link> · Quantidade original {p.quantity_milli/1000}</p>)}{(props.canPrepare||props.canReview)&&<Button variant="outline" className="mt-3" onClick={()=>router.post(`/stock/reposicao/${n.id}/avaliar`,{}, {preserveState:'errors'})}>Reavaliar necessidade</Button>}</div>)}</Panel>
<Panel title="Histórico"><details><summary>Consultar versões e fundamentos</summary>{props.history.map(h=><p key={h.id} className="mt-2">#{h.id} · {h.created_at} · Versão {h.version} · {states[h.state]} · {h.reason}</p>)}</details></Panel></>}
</div></>}
+25 -7
View File
@@ -278,8 +278,14 @@ export default function WarehouseRequest(props: Props) {
<td className="border-b p-3">
{p.delivered[index] / 1000}
</td>
<td className="border-b p-3">{p.reservation_ready ? p.reserved[index] / 1000 : 'Por instalar'}</td>
<td className="border-b p-3">{p.cancelled[index] / 1000}</td>
<td className="border-b p-3">
{p.reservation_ready
? p.reserved[index] / 1000
: 'Por instalar'}
</td>
<td className="border-b p-3">
{p.cancelled[index] / 1000}
</td>
<td className="border-b p-3">
{p.remaining[index] / 1000}
</td>
@@ -289,7 +295,9 @@ export default function WarehouseRequest(props: Props) {
</table>
</div>
<p className="mt-4 text-sm">
{p.reservation_ready ? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.' : 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
{p.reservation_ready
? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.'
: 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
</p>
</Panel>
{!props.ready ? (
@@ -326,10 +334,20 @@ export default function WarehouseRequest(props: Props) {
action="deliver"
/>
)}
{props.canManageReservation && p.reservation_ready && <>
<Command key={`reserve:${p.version}`} props={props} action="reserve" />
<Command key={`cancel:${p.version}`} props={props} action="cancel" />
</>}
{props.canManageReservation && p.reservation_ready && (
<>
<Command
key={`reserve:${p.version}`}
props={props}
action="reserve"
/>
<Command
key={`cancel:${p.version}`}
props={props}
action="cancel"
/>
</>
)}
</div>
)}
<Panel title="Decisões, entregas e recepção">