FF&E schedule sheet for interior designers and home stagers: product links to SKU, finish, price, stock and a budget per room
What you get: one Google Sheet that turns the product links you pick for a client into a furniture, lighting and decor schedule (often called an FF&E schedule): one row per item with room, a thumbnail picture, product name, brand, SKU, the finish or size you chose, its price, whether it is in stock, quantity and line total. A rooms tab adds up each room against its budget, and a Check column tells you, before you order, which items went up in price or out of stock since the client approved them. Formulas are below. The example rows are made up.
The finished sheet
schedule tab (example rows are made up)
| Room | Picture | Item | Brand | SKU | Finish | Price | In stock | Qty | Line total | Approved at | Check |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Living | (thumbnail) | Example Linen Sofa 3-seat | Example Home | ELS-3-OAT | Oatmeal | 1290 | TRUE | 1 | 1290 | 1290 | ok to order |
| Living | (thumbnail) | Sample Rattan Pendant | Sample Light Co | SRP-45 | 45 cm | 189 | TRUE | 2 | 378 | 165 | price up 24 |
| Bedroom 1 | (thumbnail) | Demo Oak Bedside Table | Demo Furniture | DOB-01 | Natural oak | 240 | FALSE | 2 | 480 | 240 | out of stock: find alternative |
| Bedroom 1 | (thumbnail) | Test Wool Rug 160x230 | Test Rugs | TWR-160 | Charcoal | 310 | TRUE | 1 | 310 | send to client |
Who this is for
- Freelance interior designers and decorators who send a client a list of pieces to approve and then order them weeks later.
- Home stagers furnishing a property for sale and working to a fixed budget per room.
- Holiday-let and serviced-apartment owners furnishing several units with the same pieces, who need quantities and totals.
- Office managers choosing chairs, desks and lighting for a small office move.
The slow parts are copying the name, SKU, price and picture from each shop page, and then finding out on order day that a piece went up in price or sold out after the client said yes. This sheet does the copying from a list of links, and the Check column catches the changes.
Step 1: list the picks (tab picks)
Columns: A room, B product link, D qty, E budget note (optional). Paste the link of each product page you are considering, one per row. In C2, make a join key from each link (shop domain plus the last part of the address):
=ARRAYFORMULA(IF(B2:B="",,LOWER( REGEXEXTRACT(B2:B, "^(?:https?://)?(?:www\.)?([^/?#]+)") & "|" & REGEXEXTRACT(REGEXREPLACE(B2:B, "[?#].*$", ""), "([^/]+)/?$"))))
That turns https://www.example-home.com/collections/sofas/products/linen-sofa-3-seat?variant=123 into example-home.com|linen-sofa-3-seat. The same key is built from the scraper's url field in Step 3, so it does not matter whether you copied the link from a collection page or with a ?variant= on the end.
Step 2: get each product's details (tab scrape)
With the product scraper
Open the E-commerce Product Scraper: Shopify & Woo on the Apify Store, paste column B into Shop, category or product URLs and use this input (JSON tab):
{
"startUrls": [
{ "url": "https://example-home.com/products/linen-sofa-3-seat" },
{ "url": "https://sample-light.co.uk/products/rattan-pendant" }
],
"maxProductsPerShop": 50,
"includeDescription": false
}
- Give it product page links, not shop homepages. On a Shopify shop, a product link returns just that one product; a homepage returns the whole catalogue (up to
maxProductsPerShop), which you do not need here. includeDescriptionoff keeps the CSV small. Turn it on if you want dimensions or materials that the shop only writes in its description.- You pay only for products delivered. Shops that are skipped, blocked or return nothing are not charged.
- It reads Shopify, WooCommerce and shops that publish schema.org product data. Before it fetches anything it checks each shop's robots.txt and terms, and it skips any shop whose terms forbid scraping. Many Shopify shops keep Shopify's default terms, which do, so expect some shops to be skipped. Amazon, eBay, Etsy, Walmart and other marketplaces are never fetched. For those, copy the details by hand (below).
When the run finishes, export the dataset as CSV, then in your sheet use File → Import → Upload, "Insert new sheet", and name the tab scrape. Keep the headers. Apify flattens lists into numbered columns such as images/0, variants/0/title, variants/0/price, variants/0/sku and variants/0/available; the formulas below find columns by header name, so column order does not matter.
Free, without the scraper
Make a scrape tab with the headers url, title, brand, sku, price, currency, inStock and images/0 (right-click the product photo, "Copy image address"), and type one row per product. Slower, but the same formulas work. Leave the Finish column empty for these rows.
Step 3: the keys tab
Add a small tab named keys. Type key in A1 and this in A2; it builds the same key from the scraper's url column, row for row with the scrape tab:
=ARRAYFORMULA(LET(u, INDEX(scrape!A2:ZZ, 0, MATCH("url", scrape!A1:ZZ1, 0)),
IF(u="",, LOWER(REGEXEXTRACT(u, "^(?:https?://)?(?:www\.)?([^/?#]+)") & "|" &
REGEXEXTRACT(REGEXREPLACE(u, "[?#].*$", ""), "([^/]+)/?$")))))
Keeping the key on its own tab means you can replace the scrape tab with a fresh CSV later without losing it. Every lookup in the schedule then uses this one pattern; change only the header in quotes:
=IFERROR(INDEX(scrape!$A:$ZZ,
MATCH(picks!$C2, keys!$A:$A, 0),
MATCH("title", scrape!$1:$1, 0)), "")
Use it with title for Item, brand for Brand and currency in a spare column. If a link finds nothing, its shop was probably skipped: see the status table below.
Step 4: the schedule columns
Build the schedule tab with Room =picks!A2 and Qty =picks!D2, and the lookups above.
Finish (F). A Shopify product with several finishes or sizes comes back with each one as a variant: variants/0/title, variants/1/title and so on (for example Oatmeal, Charcoal). The top-level price is the cheapest variant, which may not be the one you chose. Add a column V with the number of the variant you chose (0, 1, 2...; check it against the titles in the scrape tab). Then in F2:
=IF($V2="", "", IFERROR(INDEX(scrape!$A:$ZZ,
MATCH(picks!$C2, keys!$A:$A, 0),
MATCH("variants/" & $V2 & "/title", scrape!$1:$1, 0)), ""))
SKU, Price and In stock use the variant's own values when V is set, and the product's otherwise. Price in G2 (swap price for sku to get the SKU, and use available / inStock for stock):
=IFERROR(INDEX(scrape!$A:$ZZ, MATCH(picks!$C2, keys!$A:$A, 0), MATCH(IF($V2="", "price", "variants/" & $V2 & "/price"), scrape!$1:$1, 0)), "")
Picture in B2 shows the first product photo inside the cell:
=IFERROR(IMAGE(INDEX(scrape!$A:$ZZ,
MATCH(picks!$C2, keys!$A:$A, 0),
MATCH("images/0", scrape!$1:$1, 0))), "")
Line total in J2: =IF(G2="", "", G2*I2). If you get a trade discount, add a column Trade % and use =IF(G2="", "", G2*I2*(1-N(W2))). The scraper reads the public price on the shop's page only; it never sees trade prices.
Step 5: the approval price and the Check column
When you send the schedule to the client, copy the Price column and paste it as values only (Ctrl+Shift+V) into Approved at (K). That freezes the price the client said yes to. On order day, run the scraper again with the same links, import the new CSV into the scrape tab with "Replace current sheet", and read the Check column in L2:
=IF(C2="", "", IF(H2=FALSE, "out of stock: find alternative", IF(K2="", "send to client", IF(G2>K2, "price up " & TEXT(G2-K2, "0.##"), IF(G2<K2, "price down " & TEXT(K2-G2, "0.##"), "ok to order")))))
Shops list prices in their own currency (the currency column). If your picks come from shops in different currencies, convert them in a spare column before adding totals.
Step 6: the rooms tab
Put each room in A and its budget in B. Then:
C2 (spent): =SUMIFS(schedule!J:J, schedule!A:A, A2) D2 (left): =B2-C2 E2 (items): =COUNTIFS(schedule!A:A, A2) F2 (to sort): =COUNTIFS(schedule!A:A, A2, schedule!L:L, "<>ok to order")
Add a total row with =SUM(B2:B) and =SUM(C2:C). Format D red when it is below zero (Format → Conditional formatting → Less than 0), and the client can see at a glance which room is over budget.
What the shop status values mean
Each run also saves a SHOPS_SUMMARY record (run → Storage → Key-value store) with one line per shop and its status:
| status | Meaning | What to do |
|---|---|---|
ok | Shop read | Use the rows |
blocked-by-terms | The shop's terms forbid scraping (clause in termsClause) | Copy the details by hand |
skipped-terms-unverifiable | The terms page could not be read because robots.txt hides it | Same |
blocked-by-robots | The shop asks bots not to crawl | Same |
blocked-by-tos-list | A marketplace such as Amazon or Etsy | Same |
no-products-found | No machine-readable product data (often a JavaScript-only shop) | Same |
rate-limited, failed | Too many requests, or the shop was unreachable | Run again later |
None of these are charged.
Check before you order
- Every row has a Finish (V set) where the product has more than one variant, and the SKU is the variant's SKU.
- The Check column says ok to order on every row, or the client has approved the new price.
- Out-of-stock items have an approved alternative.
- Delivery charges, lead times and returns terms are noted for each shop; the scraper does not read them.
- Dimensions have been checked on the shop page against your floor plan.
- The rooms tab shows no room over budget, or the client has agreed the overspend.
Partner programme opening soon
Partner programme opening soon: join the free waitlist. Planned terms are 50% of each referred sale (75% once you have 5+ paying customers), single level, paid through Gumroad only from revenue received.
Sources
- Product scraper input and output fields (
startUrls,maxProductsPerShop,includeDescription;url,title,brand,sku,price,currency,inStock,images,variantswithtitle,price,sku,available;SHOPS_SUMMARYstatus values): Apify Store page (input schema and README)
Related
- Wholesale line sheet from a Shopify or WooCommerce store
- Gift guide product shortlist sheet
- Office Christmas party venue sheet
Unadhi Guides are free, independent explainers. We correct errors as soon as we find them: write to mininglife7@gmail.com.